dioxus_ui_system/organisms/
tree.rs1use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10#[derive(Clone, PartialEq)]
12pub struct TreeNodeData {
13 pub id: String,
15 pub label: String,
17 pub icon: Option<String>,
19 pub children: Vec<TreeNodeData>,
21 pub disabled: bool,
23}
24
25impl TreeNodeData {
26 pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
28 Self {
29 id: id.into(),
30 label: label.into(),
31 icon: None,
32 children: Vec::new(),
33 disabled: false,
34 }
35 }
36
37 pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
39 self.icon = Some(icon.into());
40 self
41 }
42
43 pub fn with_child(mut self, child: TreeNodeData) -> Self {
45 self.children.push(child);
46 self
47 }
48
49 pub fn with_children(mut self, children: Vec<TreeNodeData>) -> Self {
51 self.children = children;
52 self
53 }
54
55 pub fn disabled(mut self, disabled: bool) -> Self {
57 self.disabled = disabled;
58 self
59 }
60
61 pub fn is_leaf(&self) -> bool {
63 self.children.is_empty()
64 }
65}
66
67#[derive(Props, Clone, PartialEq)]
69pub struct TreeProps {
70 pub data: Vec<TreeNodeData>,
72 #[props(default)]
74 pub selected_id: Option<String>,
75 #[props(default)]
77 pub on_select: Option<EventHandler<String>>,
78 #[props(default)]
80 pub expanded_ids: Vec<String>,
81 #[props(default)]
83 pub on_toggle_expand: Option<EventHandler<String>>,
84 #[props(default)]
86 pub show_lines: bool,
87 #[props(default)]
89 pub style: Option<String>,
90}
91
92#[component]
123pub fn Tree(props: TreeProps) -> Element {
124 let _theme = use_theme();
125
126 let container_style = use_style(|t| {
127 Style::new()
128 .w_full()
129 .flex()
130 .flex_col()
131 .text(&t.typography, "sm")
132 .text_color(&t.colors.foreground)
133 .build()
134 });
135
136 rsx! {
137 div {
138 style: "{container_style} {props.style.clone().unwrap_or_default()}",
139 role: "tree",
140 aria_multiselectable: "false",
141
142 for node in &props.data {
143 TreeNode {
144 key: "{node.id}",
145 node: node.clone(),
146 depth: 0,
147 selected_id: props.selected_id.clone(),
148 on_select: props.on_select.clone(),
149 expanded_ids: props.expanded_ids.clone(),
150 on_toggle_expand: props.on_toggle_expand.clone(),
151 show_lines: props.show_lines,
152 }
153 }
154 }
155 }
156}
157
158#[derive(Props, Clone, PartialEq)]
160struct TreeNodeProps {
161 node: TreeNodeData,
163 depth: usize,
165 selected_id: Option<String>,
167 on_select: Option<EventHandler<String>>,
169 expanded_ids: Vec<String>,
171 on_toggle_expand: Option<EventHandler<String>>,
173 show_lines: bool,
175}
176
177#[component]
179fn TreeNode(props: TreeNodeProps) -> Element {
180 let _theme = use_theme();
181 let mut is_hovered = use_signal(|| false);
182
183 let node_id = props.node.id.clone();
184 let is_expanded = props.expanded_ids.contains(&node_id);
185 let is_selected = props.selected_id.as_ref() == Some(&node_id);
186 let is_disabled = props.node.disabled;
187 let has_children = !props.node.is_leaf();
188 let depth = props.depth;
189
190 const INDENT_SIZE: usize = 20;
192
193 let node_row_style = use_style(move |t| {
194 let indent = depth * INDENT_SIZE;
195 let mut base = Style::new()
196 .w_full()
197 .flex()
198 .items_center()
199 .gap_px(4)
200 .py(&t.spacing, "xs")
201 .px(&t.spacing, "sm")
202 .rounded(&t.radius, "md")
203 .cursor(if is_disabled {
204 "not-allowed"
205 } else {
206 "pointer"
207 })
208 .transition("all 150ms ease")
209 .opacity(if is_disabled { 0.5 } else { 1.0 });
210
211 base.margin_left = Some(format!("{}px", indent));
213
214 let base = if is_selected {
216 base.bg(&t.colors.primary)
217 .text_color(&t.colors.primary_foreground)
218 } else if is_hovered() && !is_disabled {
219 base.bg(&t.colors.muted).text_color(&t.colors.foreground)
220 } else {
221 base.bg_transparent().text_color(&t.colors.foreground)
222 };
223
224 base.build()
225 });
226
227 let children_container_style = use_style(|_| Style::new().w_full().flex().flex_col().build());
228
229 let handle_select = {
230 let node_id = node_id.clone();
231 let on_select = props.on_select.clone();
232 move |_| {
233 if is_disabled {
234 return;
235 }
236 if let Some(on_select) = &on_select {
237 on_select.call(node_id.clone());
238 }
239 }
240 };
241
242 let handle_toggle = {
243 let node_id = node_id.clone();
244 let on_toggle = props.on_toggle_expand.clone();
245 move |e: Event<MouseData>| {
246 e.stop_propagation();
247 if is_disabled {
248 return;
249 }
250 if let Some(on_toggle) = &on_toggle {
251 on_toggle.call(node_id.clone());
252 }
253 }
254 };
255
256 let chevron_rotation = if is_expanded { 90.0 } else { 0.0 };
258
259 rsx! {
260 div {
261 role: "treeitem",
262 aria_expanded: if has_children { Some(is_expanded.to_string()) } else { None },
263 aria_selected: is_selected.to_string(),
264 aria_disabled: is_disabled.to_string(),
265
266 div {
268 style: "{node_row_style}",
269 onclick: handle_select,
270 onmouseenter: move |_| if !is_disabled { is_hovered.set(true) },
271 onmouseleave: move |_| is_hovered.set(false),
272
273 if has_children {
275 button {
276 style: "display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0; border: none; background: transparent; cursor: pointer; flex-shrink: 0;",
277 type: "button",
278 aria_label: if is_expanded { "Collapse" } else { "Expand" },
279 onclick: handle_toggle,
280
281 span {
282 style: "display: inline-flex; transform: rotate({chevron_rotation}deg); transition: transform 200ms ease;",
283 TreeChevron {}
284 }
285 }
286 } else {
287 span { style: "width: 16px; flex-shrink: 0;" }
289 }
290
291 if props.show_lines && depth > 0 {
293 TreeConnector { is_last: false }
294 }
295
296 if let Some(icon_name) = &props.node.icon {
298 span {
299 style: "display: inline-flex; flex-shrink: 0;",
300 TreeIcon { name: icon_name.clone() }
301 }
302 }
303
304 span {
306 style: "user-select: none; flex: 1;",
307 "{props.node.label}"
308 }
309 }
310
311 if is_expanded && has_children {
313 div {
314 style: "{children_container_style}",
315 role: "group",
316
317 for child in &props.node.children {
318 TreeNode {
319 key: "{child.id}",
320 node: child.clone(),
321 depth: depth + 1,
322 selected_id: props.selected_id.clone(),
323 on_select: props.on_select.clone(),
324 expanded_ids: props.expanded_ids.clone(),
325 on_toggle_expand: props.on_toggle_expand.clone(),
326 show_lines: props.show_lines,
327 }
328 }
329 }
330 }
331 }
332 }
333}
334
335#[component]
337fn TreeChevron() -> Element {
338 rsx! {
339 svg {
340 view_box: "0 0 24 24",
341 fill: "none",
342 stroke: "currentColor",
343 stroke_width: "2",
344 stroke_linecap: "round",
345 stroke_linejoin: "round",
346 style: "width: 14px; height: 14px;",
347 polyline { points: "9 18 15 12 9 6" }
348 }
349 }
350}
351
352#[component]
354fn TreeConnector(is_last: bool) -> Element {
355 let _theme = use_theme();
356 let line_style = use_style(|t| Style::new().w_px(8).h_px(1).bg(&t.colors.border).build());
357
358 rsx! {
359 span { style: "{line_style}" }
360 }
361}
362
363#[derive(Props, Clone, PartialEq)]
365struct TreeIconProps {
366 name: String,
368}
369
370#[component]
371fn TreeIcon(props: TreeIconProps) -> Element {
372 let svg_content = get_tree_icon_svg(&props.name);
373
374 rsx! {
375 svg {
376 view_box: "0 0 24 24",
377 fill: "none",
378 stroke: "currentColor",
379 stroke_width: "2",
380 stroke_linecap: "round",
381 stroke_linejoin: "round",
382 style: "width: 16px; height: 16px;",
383 dangerous_inner_html: "{svg_content}",
384 }
385 }
386}
387
388fn get_tree_icon_svg(name: &str) -> String {
390 match name {
391 "folder" => r#"<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>"#,
392 "folder-open" => r#"<path d="M6 17h12l2-9H8l-2 9z"/><path d="M2 17h20"/><path d="M2 8h20"/>"#,
393 "file" => r#"<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/>"#,
394 "file-text" => r#"<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/>"#,
395 "document" => r#"<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/>"#,
396 "image" => r#"<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>"#,
397 "video" => r#"<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="17" x2="22" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/>"#,
398 "music" => r#"<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>"#,
399 "code" => r#"<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>"#,
400 "database" => r#"<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>"#,
401 "box" => r#"<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>"#,
402 "bookmark" => r#"<path d="m19 21-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/>"#,
403 "tag" => r#"<path d="M2 12V2h10l9 9-9 9-9-9z"/><circle cx="7" cy="7" r="2"/>"#,
404 "star" => r#"<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>"#,
405 "heart" => r#"<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>"#,
406 "user" => r#"<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>"#,
407 "users" => r#"<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>"#,
408 "home" => r#"<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>"#,
409 "settings" => r#"<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>"#,
410 _ => name,
412 }.to_string()
413}
414
415#[derive(Props, Clone, PartialEq)]
417pub struct TreeNodeBuilderProps {
418 #[props(default)]
420 pub initial_data: Vec<TreeNodeData>,
421 #[props(default)]
423 pub on_change: Option<EventHandler<Vec<TreeNodeData>>>,
424}
425
426#[component]
431pub fn TreeWithState(props: TreeProps) -> Element {
432 let internal_selected = use_signal(|| None::<String>);
434 let internal_expanded = use_signal(|| Vec::<String>::new());
435
436 let selected_id = props.selected_id.clone().or_else(|| internal_selected());
438
439 let expanded_ids = if props.expanded_ids.is_empty() {
440 internal_expanded()
441 } else {
442 props.expanded_ids.clone()
443 };
444
445 let on_select: EventHandler<String> = if let Some(handler) = props.on_select.clone() {
447 handler
448 } else {
449 let mut selected = internal_selected.clone();
450 EventHandler::new(move |id: String| {
451 selected.set(Some(id));
452 })
453 };
454
455 let on_toggle_expand: EventHandler<String> =
456 if let Some(handler) = props.on_toggle_expand.clone() {
457 handler
458 } else {
459 let mut expanded = internal_expanded.clone();
460 EventHandler::new(move |id: String| {
461 expanded.with_mut(|exp| {
462 if exp.contains(&id) {
463 exp.retain(|x| x != &id);
464 } else {
465 exp.push(id);
466 }
467 });
468 })
469 };
470
471 rsx! {
472 Tree {
473 data: props.data.clone(),
474 selected_id: selected_id,
475 on_select: on_select,
476 expanded_ids: expanded_ids,
477 on_toggle_expand: on_toggle_expand,
478 show_lines: props.show_lines,
479 style: props.style.clone(),
480 }
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn test_tree_node_data_builder() {
490 let node = TreeNodeData::new("1", "Root")
491 .with_icon("folder")
492 .with_child(TreeNodeData::new("1.1", "Child").with_icon("file"))
493 .disabled(false);
494
495 assert_eq!(node.id, "1");
496 assert_eq!(node.label, "Root");
497 assert_eq!(node.icon, Some("folder".to_string()));
498 assert_eq!(node.children.len(), 1);
499 assert!(!node.disabled);
500 }
501
502 #[test]
503 fn test_tree_node_is_leaf() {
504 let leaf = TreeNodeData::new("1", "Leaf");
505 assert!(leaf.is_leaf());
506
507 let parent = TreeNodeData::new("2", "Parent").with_child(TreeNodeData::new("2.1", "Child"));
508 assert!(!parent.is_leaf());
509 }
510}