logicaffeine_web/ui/components/
tree.rs1use dioxus::prelude::*;
14use crate::ui::components::chevron::{Chevron, CHEVRON_STYLE};
15use crate::ui::hooks::{use_device_pixel_grid, PixelGrid};
16
17pub const TREE_STYLE: &str = r#"
28[data-tree] {
29 --dp: 1px;
30 --dp-nudge-x: 0px;
31 --dp-nudge-y: 0px;
32 --tree-step: round(nearest, 18px, var(--dp));
33 --tree-gap: round(nearest, 4px, var(--dp));
34 --tree-row: round(nearest, 22px, var(--dp));
35 --tree-hair: max(calc(2 * var(--dp)), round(nearest, 1.5px, var(--dp)));
36 --tree-mid: round(down, calc((var(--tree-row) - var(--tree-hair)) / 2), var(--dp));
37 --twistie: 12px;
38 --twistie-gap: 4px;
39 --tree-line: rgba(255, 255, 255, 0.22);
40 padding:
41 calc(round(nearest, var(--tree-pad-top, var(--studio-inset, 4px)), var(--dp)) + var(--dp-nudge-y))
42 0
43 round(nearest, var(--tree-pad-bottom, var(--studio-inset, 4px)), var(--dp))
44 var(--dp-nudge-x);
45}
46
47.tree-node {
48 position: relative;
49 -webkit-user-select: none;
50 user-select: none;
51}
52
53/* VS Code's explorer: 22px rows in its workbench font; a row at `--depth` starts its first glyph
54 (`--depth` + 1) steps and a gap in. */
55.tree-item {
56 position: relative;
57 display: flex;
58 align-items: center;
59 height: var(--tree-row);
60 padding: 0 var(--studio-inset, 8px) 0 calc(var(--tree-step) * (var(--depth) + 1) + var(--tree-gap));
61 font-family: -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", "Ubuntu", "Droid Sans", sans-serif;
62 font-size: 13px;
63 line-height: var(--tree-row);
64 color: rgba(255, 255, 255, 0.8);
65 transition: background 0.1s ease;
66}
67
68.tree-item.pressable {
69 cursor: pointer;
70}
71
72.tree-item:hover {
73 background: rgba(255, 255, 255, 0.04);
74}
75
76/* Under a finger every row is a finger tall, in the file tree and the Syntax Tree alike; the lines
77 and branches follow the row. */
78@media (pointer: coarse) {
79 [data-tree] {
80 --tree-row: round(nearest, 44px, var(--dp));
81 }
82}
83
84/* A row the keyboard reached shows a ring inside its edges; a row pressed with a pointer shows none. */
85.tree-item:focus {
86 outline: none;
87}
88
89.tree-item:focus-visible {
90 outline: 1px solid rgba(0, 212, 255, 0.6);
91 outline-offset: -1px;
92}
93
94.tree-item.selected {
95 background: rgba(0, 212, 255, 0.12);
96 color: #00d4ff;
97}
98
99/* A node's chevron cell, the first glyph of its row. The chevron fills it, so an open node's
100 chevron points down from the cell's left edge. */
101.tree-item .twistie {
102 flex: none;
103 width: var(--twistie);
104 margin-right: var(--twistie-gap);
105 height: var(--tree-row);
106 display: flex;
107 align-items: center;
108 justify-content: center;
109}
110
111/* Branches. A child at `--depth` hangs from the line `--depth` steps in; its branch leaves that
112 line at the row's middle and runs one step, to the line its own children hang from. The first
113 child's line starts a row up, at its parent's branch (the corner where the branch turns down),
114 and the last child's line stops at its own branch. */
115.tree-line,
116.tree-branch {
117 position: absolute;
118 left: calc(var(--tree-step) * var(--depth));
119 background: var(--tree-line);
120}
121
122.tree-line {
123 top: var(--line-top, 0px);
124 bottom: 0;
125 width: var(--tree-hair);
126}
127
128.tree-children > .tree-node:first-child > .tree-line {
129 --line-top: calc(var(--tree-mid) - var(--tree-row));
130}
131
132.tree-line.last {
133 bottom: auto;
134 height: calc(var(--tree-mid) + var(--tree-hair) - var(--line-top, 0px));
135}
136
137.tree-branch {
138 top: var(--tree-mid);
139 width: var(--tree-step);
140 height: var(--tree-hair);
141}
142"#;
143
144#[derive(Clone, Copy, PartialEq)]
146struct Kind(&'static str);
147
148#[component]
153pub fn Tree(
154 kind: &'static str,
155 #[props(default)]
157 label: &'static str,
158 children: Element,
159) -> Element {
160 use_context_provider(|| Kind(kind));
161 let (grid, on_mounted) = use_device_pixel_grid();
162 let PixelGrid { device_px, nudge_x, nudge_y } = grid();
163
164 rsx! {
165 div {
166 class: "{kind}",
167 "data-tree": "{kind}",
168 role: "tree",
169 "aria-label": (!label.is_empty()).then_some(label),
170 style: "--dp: {device_px}px; --dp-nudge-x: {nudge_x}px; --dp-nudge-y: {nudge_y}px;",
171 onmounted: move |event| on_mounted.call(event),
172 style { "{TREE_STYLE}" }
173 style { "{CHEVRON_STYLE}" }
174 {children}
175 }
176 }
177}
178
179#[component]
182pub fn TreeNode(
183 depth: usize,
185 last: bool,
187 #[props(default)]
189 expandable: bool,
190 #[props(default)]
192 open: bool,
193 #[props(default)]
194 selected: bool,
195 #[props(default)]
197 stop: bool,
198 #[props(default)]
200 title: Option<String>,
201 #[props(default)]
203 on_press: Option<EventHandler<()>>,
204 row: Element,
206 children: Element,
207) -> Element {
208 let Kind(kind) = use_context::<Kind>();
209 let row_class = format!(
210 "tree-item {kind}-item{}{}",
211 if selected { " selected" } else { "" },
212 if on_press.is_some() { " pressable" } else { "" }
213 );
214
215 rsx! {
216 div { class: "tree-node {kind}-node", style: "--depth: {depth};",
217 if depth > 0 {
218 span { class: if last { "tree-line last" } else { "tree-line" } }
219 }
220 div {
221 class: "{row_class}",
222 role: "treeitem",
223 "aria-level": "{depth + 1}",
224 "aria-selected": if selected { "true" } else { "false" },
225 "aria-expanded": expandable.then_some(if open { "true" } else { "false" }),
226 tabindex: if selected || stop { "0" } else { "-1" },
227 title: title,
228 onclick: move |_| {
229 if let Some(press) = on_press {
230 press.call(());
231 }
232 },
233 onkeydown: move |evt: KeyboardEvent| {
234 let Some(key) = tree_key(&evt.key().to_string()) else {
235 return;
236 };
237 evt.prevent_default();
238 if walk(&evt, key) == TreeMove::Press {
239 if let Some(press) = on_press {
240 press.call(());
241 }
242 }
243 },
244 if depth > 0 {
245 span { class: "tree-branch" }
246 }
247 if expandable {
248 span { class: "twistie",
249 Chevron { open }
250 }
251 }
252 {row}
253 }
254 if expandable && open {
255 div { class: "tree-children {kind}-children", role: "group", {children} }
256 }
257 }
258 }
259}
260
261#[derive(Clone, Copy, Debug, PartialEq, Eq)]
263pub enum TreeKey {
264 Down,
265 Up,
266 Home,
267 End,
268 Right,
269 Left,
270 Press,
272}
273
274pub fn tree_key(key: &str) -> Option<TreeKey> {
276 Some(match key {
277 "ArrowDown" => TreeKey::Down,
278 "ArrowUp" => TreeKey::Up,
279 "Home" => TreeKey::Home,
280 "End" => TreeKey::End,
281 "ArrowRight" => TreeKey::Right,
282 "ArrowLeft" => TreeKey::Left,
283 "Enter" | " " => TreeKey::Press,
284 _ => return None,
285 })
286}
287
288#[derive(Clone, Copy, Debug, PartialEq, Eq)]
290pub struct RowShape {
291 pub depth: usize,
292 pub expandable: bool,
293 pub open: bool,
294}
295
296#[derive(Clone, Copy, Debug, PartialEq, Eq)]
298pub enum TreeMove {
299 Focus(usize),
301 Press,
303 Stay,
304}
305
306pub fn next_row(rows: &[RowShape], at: usize, key: TreeKey) -> TreeMove {
308 let Some(row) = rows.get(at) else {
309 return TreeMove::Stay;
310 };
311 let focus = |to: Option<usize>| match to {
312 Some(to) if to != at && to < rows.len() => TreeMove::Focus(to),
313 _ => TreeMove::Stay,
314 };
315 match key {
316 TreeKey::Down => focus(Some(at + 1)),
317 TreeKey::Up => focus(at.checked_sub(1)),
318 TreeKey::Home => focus(Some(0)),
319 TreeKey::End => focus(rows.len().checked_sub(1)),
320 TreeKey::Right if row.expandable && !row.open => TreeMove::Press,
321 TreeKey::Right if row.expandable => focus(Some(at + 1).filter(|&next| rows.get(next).is_some_and(|r| r.depth == row.depth + 1))),
322 TreeKey::Right => TreeMove::Stay,
323 TreeKey::Left if row.expandable && row.open => TreeMove::Press,
324 TreeKey::Left => focus(row.depth.checked_sub(1).and_then(|parent| rows[..at].iter().rposition(|r| r.depth == parent))),
325 TreeKey::Press => TreeMove::Press,
326 }
327}
328
329#[cfg(target_arch = "wasm32")]
332fn walk(evt: &KeyboardEvent, key: TreeKey) -> TreeMove {
333 use wasm_bindgen::JsCast;
334 let row = evt
335 .data()
336 .downcast::<web_sys::KeyboardEvent>()
337 .and_then(|e| e.target())
338 .and_then(|target| target.dyn_into::<web_sys::Element>().ok())
339 .and_then(|element| element.closest(".tree-item").ok().flatten());
340 let Some(row) = row else {
341 return TreeMove::Stay;
342 };
343 let Some(list) = row.closest("[data-tree]").ok().flatten().and_then(|tree| tree.query_selector_all(".tree-item").ok()) else {
344 return TreeMove::Stay;
345 };
346 let rows: Vec<web_sys::Element> = (0..list.length()).filter_map(|i| list.item(i)?.dyn_into().ok()).collect();
347 let shapes: Vec<RowShape> = rows
348 .iter()
349 .map(|r| RowShape {
350 depth: r.get_attribute("aria-level").and_then(|l| l.parse::<usize>().ok()).unwrap_or(1).saturating_sub(1),
351 expandable: r.has_attribute("aria-expanded"),
352 open: r.get_attribute("aria-expanded").as_deref() == Some("true"),
353 })
354 .collect();
355 let Some(at) = rows.iter().position(|r| *r == row) else {
356 return TreeMove::Stay;
357 };
358 let step = next_row(&shapes, at, key);
359 if let TreeMove::Focus(to) = step {
360 if let Some(target) = rows.get(to).and_then(|r| r.dyn_ref::<web_sys::HtmlElement>()) {
361 let _ = target.focus();
362 }
363 }
364 step
365}
366
367#[cfg(not(target_arch = "wasm32"))]
369fn walk(_evt: &KeyboardEvent, key: TreeKey) -> TreeMove {
370 if key == TreeKey::Press {
371 TreeMove::Press
372 } else {
373 TreeMove::Stay
374 }
375}
376
377#[cfg(test)]
378#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/components/tree/tests.rs"]
379mod tests;
380
381#[cfg(test)]
382#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/components/tree/keys_tests.rs"]
383mod keys_tests;