Skip to main content

nightshade_api/web/
dock.rs

1//! A resizable, optionally collapsible dock layout with a main area and edge panels.
2
3use leptos::html;
4use leptos::prelude::*;
5
6/// Whether a `DockLayout` arranges its children horizontally or vertically.
7#[derive(Clone, Copy, PartialEq, Eq)]
8pub enum SplitAxis {
9    /// Lay children out left to right.
10    Row,
11    /// Lay children out top to bottom.
12    Column,
13}
14
15/// Which edge a `DockPanel` sits against, which also flips its resize direction.
16#[derive(Clone, Copy, PartialEq, Eq)]
17pub enum DockSide {
18    /// The leading edge (left or top).
19    Start,
20    /// The trailing edge (right or bottom).
21    End,
22}
23
24#[derive(Clone, Copy)]
25struct DockAxis(SplitAxis);
26
27/// The dock container. Arranges its `DockMain` and `DockPanel` children along
28/// `axis` and provides that axis to them via context.
29#[component]
30pub fn DockLayout(
31    #[prop(default = SplitAxis::Row)] axis: SplitAxis,
32    #[prop(into, optional)] class: String,
33    children: Children,
34) -> impl IntoView {
35    provide_context(DockAxis(axis));
36    let orientation = match axis {
37        SplitAxis::Row => "row",
38        SplitAxis::Column => "column",
39    };
40    view! {
41        <div class=format!("nightshade-dock-layout {orientation} {class}")>{children()}</div>
42    }
43}
44
45/// The flexible central region of a `DockLayout` that fills the space left by the panels.
46#[component]
47pub fn DockMain(#[prop(into, optional)] class: String, children: Children) -> impl IntoView {
48    view! { <div class=format!("nightshade-dock-main {class}")>{children()}</div> }
49}
50
51/// An edge panel with a draggable handle. `size` holds its pixel extent along the
52/// layout axis and is updated as the handle is dragged, clamped between `min` and
53/// `max`; `side` picks the edge and drag direction. With `collapsible` set it
54/// shows a toggle in its header that drives the `collapsed` signal.
55#[component]
56pub fn DockPanel(
57    #[prop(into, optional)] title: String,
58    #[prop(default = DockSide::Start)] side: DockSide,
59    size: RwSignal<f64>,
60    #[prop(default = 140.0)] min: f64,
61    #[prop(default = 680.0)] max: f64,
62    #[prop(optional)] collapsible: bool,
63    #[prop(optional)] collapsed: Option<RwSignal<bool>>,
64    children: ChildrenFn,
65) -> impl IntoView {
66    let axis = use_context::<DockAxis>()
67        .map(|context| context.0)
68        .unwrap_or(SplitAxis::Row);
69    let horizontal = axis == SplitAxis::Row;
70    let collapsed = collapsed.unwrap_or_else(|| RwSignal::new(false));
71    let has_title = !title.is_empty();
72
73    let handle_ref = NodeRef::<html::Div>::new();
74    let drag = StoredValue::new(None::<(f64, f64)>);
75    let invert = matches!(side, DockSide::End);
76
77    let pointer_position = move |event: &web_sys::PointerEvent| {
78        if horizontal {
79            event.client_x() as f64
80        } else {
81            event.client_y() as f64
82        }
83    };
84
85    let on_pointerdown = move |event: web_sys::PointerEvent| {
86        event.prevent_default();
87        drag.set_value(Some((pointer_position(&event), size.get_untracked())));
88        if let Some(node) = handle_ref.get() {
89            let _ = node.set_pointer_capture(event.pointer_id());
90        }
91    };
92    let on_pointermove = move |event: web_sys::PointerEvent| {
93        if let Some((start_pointer, start_size)) = drag.get_value() {
94            let mut delta = pointer_position(&event) - start_pointer;
95            if invert {
96                delta = -delta;
97            }
98            size.set((start_size + delta).clamp(min, max));
99        }
100    };
101    let on_pointerup = move |event: web_sys::PointerEvent| {
102        drag.set_value(None);
103        if let Some(node) = handle_ref.get() {
104            let _ = node.release_pointer_capture(event.pointer_id());
105        }
106    };
107
108    let axis_class = if horizontal { "row" } else { "column" };
109    let side_class = match side {
110        DockSide::Start => "start",
111        DockSide::End => "end",
112    };
113
114    let style = move || {
115        if collapsed.get() {
116            "flex:0 0 auto".to_string()
117        } else {
118            format!("flex:0 0 {}px", size.get())
119        }
120    };
121
122    let children = StoredValue::new(children);
123
124    view! {
125        <div
126            class=format!("nightshade-dock-panel {axis_class} {side_class}")
127            class:collapsed=move || collapsed.get()
128            style=style
129        >
130            {(has_title || collapsible)
131                .then(|| {
132                    view! {
133                        <div class="nightshade-dock-header">
134                            <span class="nightshade-dock-title">{title.clone()}</span>
135                            {collapsible
136                                .then(|| {
137                                    view! {
138                                        <button
139                                            class="nightshade-dock-collapse"
140                                            aria-label="Toggle panel"
141                                            aria-expanded=move || (!collapsed.get()).to_string()
142                                            on:click=move |_| collapsed.update(|value| *value = !*value)
143                                        >
144                                            {move || if collapsed.get() { "+" } else { "-" }}
145                                        </button>
146                                    }
147                                })}
148                        </div>
149                    }
150                })}
151            <Show when=move || !collapsed.get() fallback=|| ()>
152                <div class="nightshade-dock-body">{children.with_value(|render| render())}</div>
153            </Show>
154            <Show when=move || !collapsed.get() fallback=|| ()>
155                <div
156                    node_ref=handle_ref
157                    class=format!("nightshade-dock-handle {axis_class} {side_class}")
158                    on:pointerdown=on_pointerdown
159                    on:pointermove=on_pointermove
160                    on:pointerup=on_pointerup
161                ></div>
162            </Show>
163        </div>
164    }
165}