nightshade_api/web/
tree.rs1use std::collections::HashSet;
5
6use leptos::html;
7use leptos::prelude::*;
8use wasm_bindgen::JsCast;
9use web_sys::{HtmlElement, KeyboardEvent};
10
11#[derive(Clone)]
15pub struct TreeItem {
16 pub id: String,
18 pub label: String,
20 pub icon: Option<String>,
22 pub lazy: bool,
24 pub children: Vec<TreeItem>,
26}
27
28impl TreeItem {
29 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 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 pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
57 self.icon = Some(icon.into());
58 self
59 }
60
61 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#[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}