Skip to main content

nightshade_api/web/
tree.rs

1//! A hierarchical tree view with keyboard navigation, inline rename, drag and
2//! drop reordering, and lazy branch expansion.
3
4use std::collections::HashSet;
5
6use leptos::html;
7use leptos::prelude::*;
8use wasm_bindgen::JsCast;
9use web_sys::{HtmlElement, KeyboardEvent};
10
11/// A single node in a [`Tree`], carrying its stable `id`, display `label`, an
12/// optional `icon` glyph, a `lazy` flag for branches whose children load on
13/// expand, and its `children`.
14#[derive(Clone)]
15pub struct TreeItem {
16    /// Stable identifier used for selection, rename, move, and expand callbacks.
17    pub id: String,
18    /// Text shown for the node.
19    pub label: String,
20    /// Optional leading icon glyph.
21    pub icon: Option<String>,
22    /// Whether this branch loads its children lazily on first expand.
23    pub lazy: bool,
24    /// Child nodes nested beneath this one.
25    pub children: Vec<TreeItem>,
26}
27
28impl TreeItem {
29    /// Builds a childless leaf node from an `id` and `label`.
30    pub fn leaf(id: impl Into<String>, label: impl Into<String>) -> Self {
31        Self {
32            id: id.into(),
33            label: label.into(),
34            icon: None,
35            lazy: false,
36            children: Vec::new(),
37        }
38    }
39
40    /// Builds a branch node with the given `children`.
41    pub fn branch(
42        id: impl Into<String>,
43        label: impl Into<String>,
44        children: Vec<TreeItem>,
45    ) -> Self {
46        Self {
47            id: id.into(),
48            label: label.into(),
49            icon: None,
50            lazy: false,
51            children,
52        }
53    }
54
55    /// Sets the node's leading icon glyph, returning the updated node.
56    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
57        self.icon = Some(icon.into());
58        self
59    }
60
61    /// Marks the node as a lazily loaded branch, overriding its `id` and `label`.
62    pub fn lazy(mut self, id: impl Into<String>, label: impl Into<String>) -> Self {
63        self.id = id.into();
64        self.label = label.into();
65        self.lazy = true;
66        self
67    }
68}
69
70#[derive(Clone, Copy)]
71struct TreeApi {
72    on_select: Callback<String>,
73    selected: Signal<Option<String>>,
74    selection: Option<RwSignal<HashSet<String>>>,
75    on_rename: Option<Callback<(String, String)>>,
76    on_move: Option<Callback<(String, String)>>,
77    on_expand: Option<Callback<String>>,
78    editing: RwSignal<Option<String>>,
79    draft: RwSignal<String>,
80    dragging: RwSignal<Option<String>>,
81    drop_target: RwSignal<Option<String>>,
82}
83
84/// A hierarchical tree view built from `items`, with arrow-key navigation,
85/// F2 inline rename, drag and drop moves, and single or multi selection. The
86/// optional `on_select`, `selected`, `selection`, `on_rename`, `on_move`, and
87/// `on_expand` props wire up interaction; `default_expanded` opens every branch
88/// initially.
89#[component]
90pub fn Tree(
91    items: Vec<TreeItem>,
92    #[prop(optional)] on_select: Option<Callback<String>>,
93    #[prop(optional, into)] selected: Option<Signal<Option<String>>>,
94    #[prop(optional)] selection: Option<RwSignal<HashSet<String>>>,
95    #[prop(optional)] on_rename: Option<Callback<(String, String)>>,
96    #[prop(optional)] on_move: Option<Callback<(String, String)>>,
97    #[prop(optional)] on_expand: Option<Callback<String>>,
98    #[prop(optional)] default_expanded: bool,
99) -> impl IntoView {
100    let initial = if default_expanded {
101        collect_branch_ids(&items)
102    } else {
103        HashSet::new()
104    };
105    let expanded = RwSignal::new(initial);
106    let api = TreeApi {
107        on_select: on_select.unwrap_or_else(|| Callback::new(|_| {})),
108        selected: selected.unwrap_or_else(|| Signal::derive(|| None)),
109        selection,
110        on_rename,
111        on_move,
112        on_expand,
113        editing: RwSignal::new(None),
114        draft: RwSignal::new(String::new()),
115        dragging: RwSignal::new(None),
116        drop_target: RwSignal::new(None),
117    };
118    let tree_ref = NodeRef::<html::Div>::new();
119
120    let on_key = move |event: KeyboardEvent| {
121        let Some(container) = tree_ref.get() else {
122            return;
123        };
124        let rows = rows_within(&container);
125        let Some(active) = active_element() else {
126            return;
127        };
128        let Some(current) = rows.iter().position(|row| same_element(row, &active)) else {
129            return;
130        };
131        let row = &rows[current];
132        let id = row.get_attribute("data-tree-id").unwrap_or_default();
133        let is_branch = row.get_attribute("data-tree-branch").as_deref() == Some("1");
134        let is_open = expanded.with_untracked(|set| set.contains(&id));
135        match event.key().as_str() {
136            "ArrowDown" => {
137                event.prevent_default();
138                if let Some(next) = rows.get(current + 1) {
139                    let _ = next.focus();
140                }
141            }
142            "ArrowUp" => {
143                event.prevent_default();
144                if current > 0 {
145                    let _ = rows[current - 1].focus();
146                }
147            }
148            "ArrowRight" => {
149                event.prevent_default();
150                if is_branch && !is_open {
151                    if let Some(callback) = api.on_expand {
152                        callback.run(id.clone());
153                    }
154                    expanded.update(|set| {
155                        set.insert(id);
156                    });
157                } else if let Some(next) = rows.get(current + 1) {
158                    let _ = next.focus();
159                }
160            }
161            "ArrowLeft" => {
162                event.prevent_default();
163                if is_branch && is_open {
164                    expanded.update(|set| {
165                        set.remove(&id);
166                    });
167                } else if current > 0 {
168                    let _ = rows[current - 1].focus();
169                }
170            }
171            "F2" => {
172                event.prevent_default();
173                if api.on_rename.is_some() {
174                    api.draft
175                        .set(row.get_attribute("data-tree-label").unwrap_or_default());
176                    api.editing.set(Some(id));
177                }
178            }
179            "Enter" | " " => {
180                event.prevent_default();
181                api.on_select.run(id);
182            }
183            _ => {}
184        }
185    };
186
187    view! {
188        <div class="nightshade-tree" role="tree" node_ref=tree_ref on:keydown=on_key>
189            {items
190                .into_iter()
191                .map(|item| {
192                    view! { <Branch item=item expanded=expanded api=api depth=0 /> }
193                })
194                .collect_view()}
195        </div>
196    }
197}
198
199#[component]
200fn Branch(
201    item: TreeItem,
202    expanded: RwSignal<HashSet<String>>,
203    api: TreeApi,
204    depth: usize,
205) -> impl IntoView {
206    let expandable = !item.children.is_empty() || item.lazy;
207    let label = item.label.clone();
208    let icon = item.icon.clone();
209    let children = item.children.clone();
210    let indent = format!("padding-left:{}px", 6 + depth * 14);
211    let id = StoredValue::new(item.id.clone());
212    let label_store = StoredValue::new(item.label.clone());
213
214    let is_selected = move || {
215        let id = id.get_value();
216        api.selected.get().as_deref() == Some(id.as_str())
217            || api
218                .selection
219                .is_some_and(|selection| selection.with(|set| set.contains(&id)))
220    };
221    let is_editing = move || api.editing.get().as_deref() == Some(id.get_value().as_str());
222    let rename_ref = NodeRef::<html::Input>::new();
223    Effect::new(move |_| {
224        if is_editing()
225            && let Some(input) = rename_ref.get()
226        {
227            let _ = input.focus();
228        }
229    });
230    let is_drop_target = move || api.drop_target.get().as_deref() == Some(id.get_value().as_str());
231    let is_open_chevron = move || expanded.get().contains(&id.get_value());
232    let is_open_show = move || expanded.get().contains(&id.get_value());
233    let aria_expanded = move || {
234        if expandable {
235            expanded.get().contains(&id.get_value()).to_string()
236        } else {
237            String::new()
238        }
239    };
240
241    let on_row = move |event: web_sys::MouseEvent| {
242        let key = id.get_value();
243        if let Some(selection) = api.selection {
244            if event.ctrl_key() || event.meta_key() {
245                selection.update(|set| {
246                    if !set.remove(&key) {
247                        set.insert(key.clone());
248                    }
249                });
250                return;
251            }
252            selection.update(|set| {
253                set.clear();
254                set.insert(key.clone());
255            });
256        }
257        api.on_select.run(key);
258    };
259    let on_chevron = move |event: web_sys::MouseEvent| {
260        event.stop_propagation();
261        let key = id.get_value();
262        let opening = !expanded.with_untracked(|set| set.contains(&key));
263        if opening && let Some(callback) = api.on_expand {
264            callback.run(key.clone());
265        }
266        expanded.update(|set| {
267            if !set.remove(&key) {
268                set.insert(key);
269            }
270        });
271    };
272    let on_double = move |_event: web_sys::MouseEvent| {
273        if api.on_rename.is_some() {
274            api.draft.set(label_store.get_value());
275            api.editing.set(Some(id.get_value()));
276        }
277    };
278
279    let commit_rename = move || {
280        if let Some(callback) = api.on_rename {
281            callback.run((id.get_value(), api.draft.get_untracked()));
282        }
283        api.editing.set(None);
284    };
285    let on_edit_key = move |event: KeyboardEvent| match event.key().as_str() {
286        "Enter" => {
287            event.prevent_default();
288            commit_rename();
289        }
290        "Escape" => {
291            event.prevent_default();
292            api.editing.set(None);
293        }
294        _ => {}
295    };
296
297    let draggable = api.on_move.is_some();
298    let on_dragstart = move |event: web_sys::DragEvent| {
299        let key = id.get_value();
300        api.dragging.set(Some(key.clone()));
301        if let Some(transfer) = event.data_transfer() {
302            let _ = transfer.set_data("text/plain", &key);
303        }
304    };
305    let on_dragover = move |event: web_sys::DragEvent| {
306        event.prevent_default();
307        api.drop_target.set(Some(id.get_value()));
308    };
309    let on_dragleave = move |_event: web_sys::DragEvent| {
310        if api.drop_target.get_untracked().as_deref() == Some(id.get_value().as_str()) {
311            api.drop_target.set(None);
312        }
313    };
314    let on_drop = move |event: web_sys::DragEvent| {
315        event.prevent_default();
316        let target = id.get_value();
317        let source = api.dragging.get_untracked();
318        api.drop_target.set(None);
319        api.dragging.set(None);
320        if let (Some(source), Some(callback)) = (source, api.on_move)
321            && source != target
322        {
323            callback.run((source, target));
324        }
325    };
326    let on_dragend = move |_event: web_sys::DragEvent| {
327        api.dragging.set(None);
328        api.drop_target.set(None);
329    };
330
331    view! {
332        <div
333            class="nightshade-tree-row"
334            class:selected=is_selected
335            class:drop-target=is_drop_target
336            role="treeitem"
337            tabindex="0"
338            draggable=draggable.then_some("true")
339            data-tree-id=item.id.clone()
340            data-tree-label=label_store.get_value()
341            data-tree-branch=if expandable { "1" } else { "0" }
342            aria-selected=move || is_selected().to_string()
343            aria-expanded=aria_expanded
344            style=indent
345            on:click=on_row
346            on:dblclick=on_double
347            on:dragstart=on_dragstart
348            on:dragover=on_dragover
349            on:dragleave=on_dragleave
350            on:drop=on_drop
351            on:dragend=on_dragend
352        >
353            {if expandable {
354                view! {
355                    <span class="nightshade-tree-chevron" class:open=is_open_chevron on:click=on_chevron>
356                        "\u{25b8}"
357                    </span>
358                }
359                    .into_any()
360            } else {
361                view! { <span class="nightshade-tree-spacer"></span> }.into_any()
362            }}
363            {icon.map(|glyph| view! { <span class="nightshade-tree-icon">{glyph}</span> })}
364            <Show
365                when=is_editing
366                fallback=move || view! { <span class="nightshade-tree-label">{label.clone()}</span> }
367            >
368                <input
369                    node_ref=rename_ref
370                    class="nightshade-tree-rename"
371                    prop:value=move || api.draft.get()
372                    on:click=|event| event.stop_propagation()
373                    on:input=move |event| api.draft.set(event_target_value(&event))
374                    on:keydown=on_edit_key
375                    on:blur=move |_| commit_rename()
376                />
377            </Show>
378        </div>
379        {expandable
380            .then(move || {
381                view! {
382                    <Show when=is_open_show fallback=|| ()>
383                        {children
384                            .clone()
385                            .into_iter()
386                            .map(|child| {
387                                view! {
388                                    <Branch
389                                        item=child
390                                        expanded=expanded
391                                        api=api
392                                        depth=depth + 1
393                                    />
394                                }
395                            })
396                            .collect_view()}
397                    </Show>
398                }
399            })}
400    }
401    .into_any()
402}
403
404fn collect_branch_ids(items: &[TreeItem]) -> HashSet<String> {
405    fn walk(items: &[TreeItem], set: &mut HashSet<String>) {
406        for item in items {
407            if !item.children.is_empty() {
408                set.insert(item.id.clone());
409                walk(&item.children, set);
410            }
411        }
412    }
413    let mut set = HashSet::new();
414    walk(items, &mut set);
415    set
416}
417
418fn rows_within(container: &HtmlElement) -> Vec<HtmlElement> {
419    let Ok(list) = container.query_selector_all(".nightshade-tree-row") else {
420        return Vec::new();
421    };
422    (0..list.length())
423        .filter_map(|index| list.item(index))
424        .filter_map(|node| node.dyn_into::<HtmlElement>().ok())
425        .collect()
426}
427
428fn active_element() -> Option<HtmlElement> {
429    web_sys::window()?
430        .document()?
431        .active_element()
432        .and_then(|element| element.dyn_into::<HtmlElement>().ok())
433}
434
435fn same_element(left: &HtmlElement, right: &HtmlElement) -> bool {
436    let left: &wasm_bindgen::JsValue = left.as_ref();
437    let right: &wasm_bindgen::JsValue = right.as_ref();
438    left == right
439}