Skip to main content

dioxus_ui_system/organisms/
tree.rs

1//! Tree organism component
2//!
3//! A hierarchical tree view for displaying nested data with expand/collapse,
4//! selection, and optional connector lines.
5
6use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10/// Tree node data structure for recursive tree representation
11#[derive(Clone, PartialEq)]
12pub struct TreeNodeData {
13    /// Unique identifier for the node
14    pub id: String,
15    /// Display label for the node
16    pub label: String,
17    /// Optional icon name for the node
18    pub icon: Option<String>,
19    /// Child nodes (empty if leaf node)
20    pub children: Vec<TreeNodeData>,
21    /// Whether the node is disabled
22    pub disabled: bool,
23}
24
25impl TreeNodeData {
26    /// Create a new tree node
27    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    /// Set the icon for the node
38    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
39        self.icon = Some(icon.into());
40        self
41    }
42
43    /// Add a child node
44    pub fn with_child(mut self, child: TreeNodeData) -> Self {
45        self.children.push(child);
46        self
47    }
48
49    /// Set multiple children at once
50    pub fn with_children(mut self, children: Vec<TreeNodeData>) -> Self {
51        self.children = children;
52        self
53    }
54
55    /// Set disabled state
56    pub fn disabled(mut self, disabled: bool) -> Self {
57        self.disabled = disabled;
58        self
59    }
60
61    /// Check if this is a leaf node (no children)
62    pub fn is_leaf(&self) -> bool {
63        self.children.is_empty()
64    }
65}
66
67/// Tree component properties
68#[derive(Props, Clone, PartialEq)]
69pub struct TreeProps {
70    /// Tree node data
71    pub data: Vec<TreeNodeData>,
72    /// Currently selected node ID
73    #[props(default)]
74    pub selected_id: Option<String>,
75    /// Callback when a node is selected
76    #[props(default)]
77    pub on_select: Option<EventHandler<String>>,
78    /// List of expanded node IDs
79    #[props(default)]
80    pub expanded_ids: Vec<String>,
81    /// Callback when a node's expand/collapse state changes
82    #[props(default)]
83    pub on_toggle_expand: Option<EventHandler<String>>,
84    /// Whether to show connector lines between nodes
85    #[props(default)]
86    pub show_lines: bool,
87    /// Custom inline styles
88    #[props(default)]
89    pub style: Option<String>,
90}
91
92/// Tree organism component
93///
94/// A hierarchical tree view for displaying nested data.
95///
96/// # Example
97/// ```rust,ignore
98/// use dioxus_ui_system::organisms::{Tree, TreeNodeData};
99///
100/// fn App() -> Element {
101///     let data = vec![
102///         TreeNodeData::new("1", "Documents")
103///             .with_icon("folder")
104///             .with_child(
105///                 TreeNodeData::new("1.1", "Work")
106///                     .with_icon("folder")
107///                     .with_child(TreeNodeData::new("1.1.1", "report.pdf").with_icon("file"))
108///             ),
109///     ];
110///     
111///     let expanded = vec!["1".to_string()];
112///     
113///     rsx! {
114///         Tree {
115///             data: data,
116///             expanded_ids: expanded,
117///             show_lines: true,
118///         }
119///     }
120/// }
121/// ```
122#[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/// Internal properties for TreeNode component
159#[derive(Props, Clone, PartialEq)]
160struct TreeNodeProps {
161    /// Node data
162    node: TreeNodeData,
163    /// Current depth level (for indentation)
164    depth: usize,
165    /// Currently selected node ID
166    selected_id: Option<String>,
167    /// Callback when node is selected
168    on_select: Option<EventHandler<String>>,
169    /// List of expanded node IDs
170    expanded_ids: Vec<String>,
171    /// Callback when expand/collapse is toggled
172    on_toggle_expand: Option<EventHandler<String>>,
173    /// Whether to show connector lines
174    show_lines: bool,
175}
176
177/// Individual tree node component
178#[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    // Indentation per level (in pixels)
191    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        // Apply indentation using inline style for margin-left
212        base.margin_left = Some(format!("{}px", indent));
213
214        // Selection styling
215        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    // Chevron rotation based on expanded state
257    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            // Node row
267            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                // Expand/collapse chevron (only for nodes with children)
274                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                    // Spacer for leaf nodes to align with expandable nodes
288                    span { style: "width: 16px; flex-shrink: 0;" }
289                }
290
291                // Connector line (optional)
292                if props.show_lines && depth > 0 {
293                    TreeConnector { is_last: false }
294                }
295
296                // Node icon (if provided)
297                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                // Node label
305                span {
306                    style: "user-select: none; flex: 1;",
307                    "{props.node.label}"
308                }
309            }
310
311            // Children container
312            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/// Chevron icon for expand/collapse
336#[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/// Connector line component for tree visualization
353#[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/// Tree icon component for node icons
364#[derive(Props, Clone, PartialEq)]
365struct TreeIconProps {
366    /// Icon name or SVG path
367    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
388/// Get SVG path data for preset tree icons
389fn 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        // Default: return as raw SVG path
411        _ => name,
412    }.to_string()
413}
414
415/// Tree node builder component with fluent API
416#[derive(Props, Clone, PartialEq)]
417pub struct TreeNodeBuilderProps {
418    /// Initial node data
419    #[props(default)]
420    pub initial_data: Vec<TreeNodeData>,
421    /// Callback when tree data changes
422    #[props(default)]
423    pub on_change: Option<EventHandler<Vec<TreeNodeData>>>,
424}
425
426/// Stateful tree component with built-in state management
427///
428/// This component manages its own state for selection and expansion,
429/// making it easier to use when you don't need external control.
430#[component]
431pub fn TreeWithState(props: TreeProps) -> Element {
432    // Use signals for internal state
433    let internal_selected = use_signal(|| None::<String>);
434    let internal_expanded = use_signal(|| Vec::<String>::new());
435
436    // Determine which state to use - external (props) or internal (signals)
437    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    // Create event handlers that update internal state if no external handler provided
446    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}