use dioxus::prelude::*;
use crate::cn;
const GROUP_BASE: &str = "flex h-full w-full data-[panel-group-direction=vertical]:flex-col";
const HANDLE_BASE: &str = "bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute \
after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 \
focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px \
data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 \
data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full \
data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 \
[&[data-panel-group-direction=vertical]>div]:rotate-90";
const GRIP_BASE: &str = "bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border";
#[derive(strum::AsRefStr, Clone, Copy, Default, PartialEq)]
#[strum(serialize_all = "kebab-case")]
pub enum ResizableDirection {
#[default]
Horizontal,
Vertical,
}
#[component]
pub fn ResizablePanelGroup(#[props(default)] direction: ResizableDirection, #[props(default = 10.0)] keyboard_step: f64, #[props(default)] class: String, children: Element) -> Element {
use_context_provider(|| ResizableCtx {
sizes: Signal::new(Vec::new()),
direction,
step: keyboard_step,
});
let cls = cn!(GROUP_BASE, class);
rsx! {
div {
class: cls,
"data-slot": "resizable-panel-group",
"data-panel-group-direction": direction.as_ref(),
{children}
}
}
}
#[component]
pub fn ResizablePanel(index: usize, #[props(default = 50.0)] default_size: f64, #[props(default)] class: String, children: Element) -> Element {
let ctx = use_context::<ResizableCtx>();
use_hook(|| {
let mut sizes = ctx.sizes;
let mut current = sizes.peek().clone();
if current.len() <= index {
current.resize(index + 1, 0.0);
}
current[index] = default_size;
sizes.set(current);
});
let basis = ctx.size_at(index);
let cls = cn!("relative", class);
rsx! {
div {
class: cls,
"data-slot": "resizable-panel",
style: "flex: {basis} 1 0%;",
{children}
}
}
}
#[component]
pub fn ResizableHandle(index: usize, #[props(default)] with_handle: bool, #[props(default)] class: String, children: Element) -> Element {
let ctx = use_context::<ResizableCtx>();
let dir: &str = ctx.direction.as_ref();
let on_key = move |e: KeyboardEvent| {
let delta = match (ctx.direction, e.key()) {
(ResizableDirection::Horizontal, Key::ArrowLeft) | (ResizableDirection::Vertical, Key::ArrowUp) => -ctx.step,
(ResizableDirection::Horizontal, Key::ArrowRight) | (ResizableDirection::Vertical, Key::ArrowDown) => ctx.step,
_ => return,
};
e.prevent_default();
ctx.resize(index, delta);
};
let cls = cn!(HANDLE_BASE, class);
rsx! {
div {
class: cls,
"data-slot": "resizable-handle",
"data-panel-group-direction": dir,
role: "separator",
"aria-orientation": if ctx.direction == ResizableDirection::Horizontal { "vertical" } else { "horizontal" },
tabindex: "0",
onkeydown: on_key,
if with_handle {
div { class: GRIP_BASE,
svg {
class: "size-2.5",
xmlns: "http://www.w3.org/2000/svg",
width: "15",
height: "15",
view_box: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
circle { cx: "9", cy: "12", r: "1" }
circle { cx: "9", cy: "5", r: "1" }
circle { cx: "9", cy: "19", r: "1" }
circle { cx: "15", cy: "12", r: "1" }
circle { cx: "15", cy: "5", r: "1" }
circle { cx: "15", cy: "19", r: "1" }
}
}
}
{children}
}
}
}
#[derive(Clone, Copy)]
struct ResizableCtx {
sizes: Signal<Vec<f64>>,
direction: ResizableDirection,
step: f64,
}
impl ResizableCtx {
fn size_at(&self, index: usize) -> f64 {
self.sizes.peek().get(index).copied().unwrap_or(0.0)
}
fn resize(&self, index: usize, delta: f64) {
let mut sizes = self.sizes;
let mut next = sizes.peek().clone();
if index + 1 >= next.len() {
return;
}
let moved = delta.clamp(-next[index], next[index + 1]);
next[index] += moved;
next[index + 1] -= moved;
sizes.set(next);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::uikit::test_util::render;
#[test]
fn group_carries_slot_and_direction() {
fn app() -> Element {
rsx! {
ResizablePanelGroup {
ResizablePanel { index: 0, default_size: 50.0, "a" }
ResizableHandle { index: 0 }
ResizablePanel { index: 1, default_size: 50.0, "b" }
}
}
}
let html = render(app);
assert!(html.contains("data-slot=\"resizable-panel-group\""), "{html}");
assert!(html.contains("data-panel-group-direction=\"horizontal\""), "{html}");
}
#[test]
fn panels_render_flex_basis_from_default_size() {
fn app() -> Element {
rsx! {
ResizablePanelGroup {
ResizablePanel { index: 0, default_size: 30.0, "a" }
ResizableHandle { index: 0 }
ResizablePanel { index: 1, default_size: 70.0, "b" }
}
}
}
let html = render(app);
assert!(html.contains("data-slot=\"resizable-panel\""), "{html}");
assert!(html.contains("flex: 30 1 0%"), "{html}");
assert!(html.contains("flex: 70 1 0%"), "{html}");
}
#[test]
fn handle_is_a_separator_with_orientation() {
fn app() -> Element {
rsx! {
ResizablePanelGroup {
ResizablePanel { index: 0, "a" }
ResizableHandle { index: 0 }
ResizablePanel { index: 1, "b" }
}
}
}
let html = render(app);
assert!(html.contains("data-slot=\"resizable-handle\""), "{html}");
assert!(html.contains("role=\"separator\""), "{html}");
assert!(html.contains("aria-orientation=\"vertical\""), "{html}");
}
#[test]
fn vertical_group_flips_orientation() {
fn app() -> Element {
rsx! {
ResizablePanelGroup { direction: ResizableDirection::Vertical,
ResizablePanel { index: 0, "a" }
ResizableHandle { index: 0 }
ResizablePanel { index: 1, "b" }
}
}
}
let html = render(app);
assert!(html.contains("data-panel-group-direction=\"vertical\""), "{html}");
assert!(html.contains("aria-orientation=\"horizontal\""), "{html}");
}
#[test]
fn with_handle_renders_grip() {
fn app() -> Element {
rsx! {
ResizablePanelGroup {
ResizablePanel { index: 0, "a" }
ResizableHandle { index: 0, with_handle: true }
ResizablePanel { index: 1, "b" }
}
}
}
let html = render(app);
assert!(html.contains("rounded-xs"), "{html}");
assert!(html.contains("<svg"), "{html}");
assert!(html.contains("<circle"), "{html}");
}
#[test]
fn class_override_merges_on_group() {
fn app() -> Element {
rsx! {
ResizablePanelGroup { class: "h-40",
ResizablePanel { index: 0, "a" }
}
}
}
let html = render(app);
assert!(html.contains("h-40"), "{html}");
assert!(!html.contains("h-full"), "override should drop base h-full: {html}");
}
}