Skip to main content

orbital_core_components/tree/
tree.rs

1use leptos::prelude::*;
2use orbital_base_components::BaseTreeRoot;
3use orbital_macros::component_doc;
4use orbital_style::inject_style;
5
6use super::api::{build_tree_state, install_tree_reorder_listeners, TreeApiHandle, TreeApiRef};
7use super::styles::tree_styles;
8use super::types::{TreeAppearance, TreeBehavior, TreeExpansion, TreeSelection};
9
10/// Hand-composed hierarchy for sidebars, pickers, and file lists where you control every row.
11///
12/// Wrap rows in nested [`TreeItem`] components with [`TreeItemLayout`] slots. Bind [`TreeExpansion`] and [`TreeSelection`] with Leptos signals for open and selected ids. When items come from an API, lazy loading, or virtualization, use [`RichTree`](super::rich_tree::RichTree) instead — same selection and expansion APIs, data adapters instead of nested components.
13///
14/// # When to use
15///
16/// - Hand-authored hierarchies with custom slots (icons, aside actions, motion) - Sidebars and pickers where item count is modest - Full control over row layout via [`TreeItemLayout`]
17///
18/// # Usage
19///
20/// 1. Wrap rows in [`Tree`] + nested [`TreeItem`] components. 2. Bind [`TreeExpansion::open_items`] for controlled expand/collapse. 3. Choose [`TreeSelection`] mode (`single`, `multi`, `checkbox`, or `none`).
21///
22/// # Best Practices
23///
24/// ## Do's
25///
26/// * Use [`TreeApiRef`](super::api::TreeApiRef) to focus items from external controls * Set `data-testid` on preview wrappers for E2E coverage * Prefer [`RichTree`](super::rich_tree::RichTree) when items come from an API
27///
28/// ## Don'ts
29///
30/// * Don't render thousands of static [`TreeItem`] nodes — use [`RichTree`](super::rich_tree::RichTree) with virtualization * Don't rely on mount order for keyboard range selection — the registry orders visible items
31///
32/// # Examples
33///
34/// ## Default nested tree
35/// Branch pre-expanded via `open_items`; nested leaf under Documents.
36/// <!-- preview -->
37/// ```rust
38/// use crate::{Tree, TreeExpansion, TreeItem, TreeItemConfig, TreeItemLayout, TreeSelection};
39/// use orbital_base_components::SignalModel;
40/// use std::collections::HashSet;
41/// let open = RwSignal::new(HashSet::from(["root".to_string()]));
42/// view! {
43///     <div data-testid="tree-view-preview">
44///         <Tree expansion=TreeExpansion::new(SignalModel::from(open)) selection=TreeSelection::none()>
45///             <TreeItem config=TreeItemConfig::branch("root")>
46///                 <TreeItemLayout slot>
47///                     <span data-testid="tree-node-docs">"Documents"</span>
48///                 </TreeItemLayout>
49///                 <TreeItem config=TreeItemConfig::leaf("readme")>
50///                     <TreeItemLayout slot>
51///                         <span data-testid="tree-node-readme">"readme.md"</span>
52///                     </TreeItemLayout>
53///                 </TreeItem>
54///             </TreeItem>
55///         </Tree>
56///     </div>
57/// }
58/// ```
59///
60/// ## Small size
61/// Compact row height for dense sidebars.
62/// <!-- preview -->
63/// ```rust
64/// use crate::{Tree, TreeAppearance, TreeExpansion, TreeItem, TreeItemConfig, TreeItemLayout, TreeSelection, TreeSize};
65/// use orbital_base_components::SignalModel;
66/// use std::collections::HashSet;
67/// let open = RwSignal::new(HashSet::new());
68/// let appearance = TreeAppearance::new(Signal::derive(|| TreeSize::Small));
69/// view! {
70///     <div data-testid="tree-small">
71///         <Tree expansion=TreeExpansion::new(SignalModel::from(open)) selection=TreeSelection::none() appearance=appearance>
72///             <TreeItem config=TreeItemConfig::leaf("a")>
73///                 <TreeItemLayout slot>"Item"</TreeItemLayout>
74///             </TreeItem>
75///         </Tree>
76///     </div>
77/// }
78/// ```
79///
80/// ## Deep nesting
81/// Three-level branch chain demonstrates nested subtrees.
82/// <!-- preview -->
83/// ```rust
84/// use crate::{Tree, TreeExpansion, TreeItem, TreeItemConfig, TreeItemLayout, TreeSelection};
85/// use orbital_base_components::SignalModel;
86/// use std::collections::HashSet;
87/// let open = RwSignal::new(HashSet::from(["l1".to_string(), "l2".to_string()]));
88/// view! {
89///     <div data-testid="tree-deep">
90///         <Tree expansion=TreeExpansion::new(SignalModel::from(open)) selection=TreeSelection::none()>
91///             <TreeItem config=TreeItemConfig::branch("l1")>
92///                 <TreeItemLayout slot>"Level 1"</TreeItemLayout>
93///                 <TreeItem config=TreeItemConfig::branch("l2")>
94///                     <TreeItemLayout slot>"Level 2"</TreeItemLayout>
95///                     <TreeItem config=TreeItemConfig::leaf("l3")>
96///                         <TreeItemLayout slot>"Level 3"</TreeItemLayout>
97///                     </TreeItem>
98///                 </TreeItem>
99///             </TreeItem>
100///         </Tree>
101///     </div>
102/// }
103/// ```
104///
105/// ## Click to expand
106/// Starts collapsed; clicking the branch toggles `open_items`.
107/// <!-- preview -->
108/// ```rust
109/// use crate::{Tree, TreeExpansion, TreeItem, TreeItemConfig, TreeItemLayout, TreeSelection};
110/// use orbital_base_components::SignalModel;
111/// use std::collections::HashSet;
112/// let open = RwSignal::new(HashSet::new());
113/// view! {
114///     <div data-testid="tree-toggle">
115///         <Tree expansion=TreeExpansion::new(SignalModel::from(open)) selection=TreeSelection::none()>
116///             <TreeItem config=TreeItemConfig::branch("toggle")>
117///                 <TreeItemLayout slot>
118///                     <span data-testid="tree-branch-toggle">"Expand me"</span>
119///                 </TreeItemLayout>
120///                 <TreeItem config=TreeItemConfig::leaf("child")>
121///                     <TreeItemLayout slot>"Hidden until expanded"</TreeItemLayout>
122///                 </TreeItem>
123///             </TreeItem>
124///         </Tree>
125///     </div>
126/// }
127/// ```
128///
129/// ## Flat leaf list
130/// All leaves — no chevrons.
131/// <!-- preview -->
132/// ```rust
133/// use crate::{Tree, TreeExpansion, TreeItem, TreeItemConfig, TreeItemLayout, TreeSelection};
134/// use orbital_base_components::SignalModel;
135/// use std::collections::HashSet;
136/// let open = RwSignal::new(HashSet::new());
137/// view! {
138///     <div data-testid="tree-flat">
139///         <Tree expansion=TreeExpansion::new(SignalModel::from(open)) selection=TreeSelection::none()>
140///             <TreeItem config=TreeItemConfig::leaf("1")>
141///                 <TreeItemLayout slot>"One"</TreeItemLayout>
142///             </TreeItem>
143///             <TreeItem config=TreeItemConfig::leaf("2")>
144///                 <TreeItemLayout slot>"Two"</TreeItemLayout>
145///             </TreeItem>
146///         </Tree>
147///     </div>
148/// }
149/// ```
150///
151/// ## Single selection
152/// Click rows to select one item at a time.
153/// <!-- preview -->
154/// ```rust
155/// use crate::{Tree, TreeExpansion, TreeItem, TreeItemConfig, TreeItemLayout, TreeSelection};
156/// use orbital_base_components::{SignalModel, TreeSelectionMode};
157/// use std::collections::HashSet;
158/// let open = RwSignal::new(HashSet::new());
159/// let selected = RwSignal::new(HashSet::new());
160/// view! {
161///     <div data-testid="tree-selection">
162///         <Tree
163///             expansion=TreeExpansion::new(SignalModel::from(open))
164///             selection=TreeSelection::single(SignalModel::from(selected))
165///         >
166///             <TreeItem config=TreeItemConfig::leaf("a")>
167///                 <TreeItemLayout slot>"Alpha"</TreeItemLayout>
168///             </TreeItem>
169///             <TreeItem config=TreeItemConfig::leaf("b")>
170///                 <TreeItemLayout slot>"Beta"</TreeItemLayout>
171///             </TreeItem>
172///         </Tree>
173///     </div>
174/// }
175/// ```
176///
177/// ## Theme surfaces
178/// Items use theme background tokens.
179/// <!-- preview -->
180/// ```rust
181/// use crate::{Tree, TreeExpansion, TreeItem, TreeItemConfig, TreeItemLayout, TreeSelection};
182/// use orbital_base_components::SignalModel;
183/// use std::collections::HashSet;
184/// let open = RwSignal::new(HashSet::from(["theme".to_string()]));
185/// view! {
186///     <div data-testid="tree-theme">
187///         <Tree expansion=TreeExpansion::new(SignalModel::from(open)) selection=TreeSelection::none()>
188///             <TreeItem config=TreeItemConfig::branch("theme")>
189///                 <TreeItemLayout slot>
190///                     <span data-testid="tree-theme-cell" style="background: var(--orb-color-surface-canvas); padding: 4px 8px;">"Themed row"</span>
191///                 </TreeItemLayout>
192///             </TreeItem>
193///         </Tree>
194///     </div>
195/// }
196/// ```
197///
198/// ## RichTree data-driven
199/// Pass structured data instead of composing nested items.
200/// <!-- code-only -->
201/// ```rust
202/// use crate::{RichTree, RichTreeData, TreeExpansion, TreeSelection};
203/// use orbital_base_components::SignalModel;
204/// use std::collections::HashSet;
205/// #[derive(Clone)]
206/// struct Node { id: String, label: String, children: Vec<Node> }
207/// let data = RichTreeData::new(vec![
208///     Node { id: "docs".into(), label: "Documents".into(), children: vec![
209///         Node { id: "readme".into(), label: "readme.md".into(), children: vec![] },
210///     ]},
211/// ]).get_id(|n| n.id.clone()).get_label(|n| n.label.clone()).get_children(|n| n.children.clone());
212/// let open = RwSignal::new(HashSet::from(["docs".to_string()]));
213/// view! {
214///     <div data-testid="tree-rich">
215///         <RichTree data=data expansion=TreeExpansion::new(SignalModel::from(open)) selection=TreeSelection::none() />
216///     </div>
217/// }
218/// ```
219///
220/// ## Icons and aside actions
221/// Leading icon, trailing badge, and hover-revealed aside actions.
222/// <!-- preview -->
223/// ```rust
224/// use crate::{Button, ButtonAppearance, Tree, TreeExpansion, TreeItem, TreeItemConfig, TreeItemIconAfter, TreeItemIconBefore, TreeItemAside, TreeItemLayout, TreeSelection};
225/// use orbital_base_components::SignalModel;
226/// use std::collections::HashSet;
227/// let open = RwSignal::new(HashSet::from(["docs".to_string()]));
228/// view! {
229///     <div data-testid="tree-icons-aside">
230///         <Tree expansion=TreeExpansion::new(SignalModel::from(open)) selection=TreeSelection::none()>
231///             <TreeItem config=TreeItemConfig::branch("docs")>
232///                 <TreeItemLayout slot>
233///                     <TreeItemIconBefore slot>
234///                         <span>"📁"</span>
235///                     </TreeItemIconBefore>
236///                     "Documents"
237///                     <TreeItemIconAfter slot>
238///                         <span>"3"</span>
239///                     </TreeItemIconAfter>
240///                     <TreeItemAside slot>
241///                         <Button appearance=ButtonAppearance::Subtle icon=icondata::AiEllipsisOutlined />
242///                     </TreeItemAside>
243///                 </TreeItemLayout>
244///                 <TreeItem config=TreeItemConfig::leaf("readme")>
245///                     <TreeItemLayout slot>"readme.md"</TreeItemLayout>
246///                 </TreeItem>
247///             </TreeItem>
248///         </Tree>
249///     </div>
250/// }
251/// ```
252///
253/// ## Multi selection (ctrl + shift)
254/// Ctrl/Meta toggles; Shift selects a visible range.
255/// <!-- preview -->
256/// ```rust
257/// use crate::{Tree, TreeExpansion, TreeItem, TreeItemConfig, TreeItemLayout, TreeSelection};
258/// use orbital_base_components::{SignalModel, TreeSelectionMode};
259/// use std::collections::HashSet;
260/// let open = RwSignal::new(HashSet::new());
261/// let selected = RwSignal::new(HashSet::new());
262/// view! {
263///     <div data-testid="tree-multi">
264///         <Tree expansion=TreeExpansion::new(SignalModel::from(open)) selection=TreeSelection::multi(SignalModel::from(selected))>
265///             <TreeItem config=TreeItemConfig::leaf("a")><TreeItemLayout slot>"Alpha"</TreeItemLayout></TreeItem>
266///             <TreeItem config=TreeItemConfig::leaf("b")><TreeItemLayout slot>"Beta"</TreeItemLayout></TreeItem>
267///             <TreeItem config=TreeItemConfig::leaf("c")><TreeItemLayout slot>"Gamma"</TreeItemLayout></TreeItem>
268///         </Tree>
269///     </div>
270/// }
271/// ```
272///
273/// ## Checkbox selection with cascade
274/// Parent checkbox selects descendants when `cascade` is enabled.
275/// <!-- preview -->
276/// ```rust
277/// use crate::{Tree, TreeExpansion, TreeItem, TreeItemConfig, TreeItemLayout, TreeSelection};
278/// use orbital_base_components::SignalModel;
279/// use std::collections::HashSet;
280/// let open = RwSignal::new(HashSet::from(["root".to_string()]));
281/// let selected = RwSignal::new(HashSet::new());
282/// let selection = TreeSelection::checkbox(SignalModel::from(selected)).with_cascade(true);
283/// view! {
284///     <div data-testid="tree-checkbox">
285///         <Tree expansion=TreeExpansion::new(SignalModel::from(open)) selection=selection>
286///             <TreeItem config=TreeItemConfig::branch("root")>
287///                 <TreeItemLayout slot>"Root"</TreeItemLayout>
288///                 <TreeItem config=TreeItemConfig::leaf("child")><TreeItemLayout slot>"Child"</TreeItemLayout></TreeItem>
289///             </TreeItem>
290///         </Tree>
291///     </div>
292/// }
293/// ```
294///
295/// ## Disabled items (focusable)
296/// Disabled rows remain focusable for screen-reader access.
297/// <!-- preview -->
298/// ```rust
299/// use crate::{Tree, TreeBehavior, TreeExpansion, TreeItem, TreeItemConfig, TreeItemLayout, TreeSelection};
300/// use orbital_base_components::SignalModel;
301/// use std::collections::HashSet;
302/// let open = RwSignal::new(HashSet::new());
303/// let behavior = TreeBehavior::new()
304///     .with_disabled_items(SignalModel::new(HashSet::from(["b".to_string()])))
305///     .with_disabled_items_focusable(Signal::from(true));
306/// view! {
307///     <div data-testid="tree-disabled">
308///         <Tree expansion=TreeExpansion::new(SignalModel::from(open)) selection=TreeSelection::none() behavior=behavior>
309///             <TreeItem config=TreeItemConfig::leaf("a")><TreeItemLayout slot>"Enabled"</TreeItemLayout></TreeItem>
310///             <TreeItem config=TreeItemConfig::leaf("b")><TreeItemLayout slot>"Disabled"</TreeItemLayout></TreeItem>
311///         </Tree>
312///     </div>
313/// }
314/// ```
315///
316/// ## Icon-container expansion
317/// Only the chevron toggles expansion; row click selects.
318/// <!-- preview -->
319/// ```rust
320/// use crate::{Tree, TreeExpansion, TreeItem, TreeItemConfig, TreeItemLayout, TreeSelection};
321/// use orbital_base_components::{ExpansionTrigger, SignalModel};
322/// use std::collections::HashSet;
323/// let open = RwSignal::new(HashSet::new());
324/// let selected = RwSignal::new(HashSet::new());
325/// let expansion = TreeExpansion::new(SignalModel::from(open)).with_expansion_trigger(ExpansionTrigger::IconContainer);
326/// view! {
327///     <div data-testid="tree-icon-expand">
328///         <Tree expansion=expansion selection=TreeSelection::single(SignalModel::from(selected))>
329///             <TreeItem config=TreeItemConfig::branch("branch")>
330///                 <TreeItemLayout slot><span data-testid="tree-icon-branch">"Branch"</span></TreeItemLayout>
331///                 <TreeItem config=TreeItemConfig::leaf("leaf")><TreeItemLayout slot>"Leaf"</TreeItemLayout></TreeItem>
332///             </TreeItem>
333///         </Tree>
334///     </div>
335/// }
336/// ```
337///
338/// ## Expand / collapse all
339/// External button uses [`TreeApiRef`](super::api::TreeApiRef) to toggle every branch.
340/// <!-- preview -->
341/// ```rust
342/// use crate::{Button, Tree, TreeApiRef, TreeExpansion, TreeItem, TreeItemConfig, TreeItemLayout, TreeSelection};
343/// use orbital_base_components::SignalModel;
344/// use std::collections::HashSet;
345/// let open = RwSignal::new(HashSet::new());
346/// let api = TreeApiRef::default();
347/// let api_for_button = api.clone();
348/// view! {
349///     <div data-testid="tree-expand-all">
350///         <Button on_click=Callback::new(move |_| {
351///             api_for_button.with(|handle| {
352///                 for (id, _) in handle.get_item_tree() {
353///                     handle.set_item_expansion(id, true);
354///                 }
355///             });
356///         })>"Expand all"</Button>
357///         <Tree api_ref=api expansion=TreeExpansion::new(SignalModel::from(open)) selection=TreeSelection::none()>
358///             <TreeItem config=TreeItemConfig::branch("a")>
359///                 <TreeItemLayout slot>"A"</TreeItemLayout>
360///                 <TreeItem config=TreeItemConfig::leaf("a1")><TreeItemLayout slot>"A1"</TreeItemLayout></TreeItem>
361///             </TreeItem>
362///         </Tree>
363///     </div>
364/// }
365/// ```
366///
367/// ## Connector borders
368/// Indentation guide lines between levels.
369/// <!-- preview -->
370/// ```rust
371/// use crate::{Tree, TreeAppearance, TreeExpansion, TreeItem, TreeItemConfig, TreeItemLayout, TreeSelection};
372/// use orbital_base_components::SignalModel;
373/// use std::collections::HashSet;
374/// let open = RwSignal::new(HashSet::from(["root".to_string()]));
375/// let appearance = TreeAppearance::new(Signal::from(orbital_base_components::TreeSize::Medium)).with_connectors(Signal::from(true));
376/// view! {
377///     <div data-testid="tree-connectors">
378///         <Tree appearance=appearance expansion=TreeExpansion::new(SignalModel::from(open)) selection=TreeSelection::none()>
379///             <TreeItem config=TreeItemConfig::branch("root")>
380///                 <TreeItemLayout slot>"Root"</TreeItemLayout>
381///                 <TreeItem config=TreeItemConfig::leaf("leaf")><TreeItemLayout slot>"Leaf"</TreeItemLayout></TreeItem>
382///             </TreeItem>
383///         </Tree>
384///     </div>
385/// }
386/// ```
387///
388/// ## Focus via button
389/// Imperative focus through [`TreeApiRef::focus_item`].
390/// <!-- preview -->
391/// ```rust
392/// use crate::{Button, Tree, TreeApiRef, TreeExpansion, TreeItem, TreeItemConfig, TreeItemLayout, TreeSelection};
393/// use orbital_base_components::SignalModel;
394/// use std::collections::HashSet;
395/// let open = RwSignal::new(HashSet::new());
396/// let api = TreeApiRef::default();
397/// let api_for_button = api.clone();
398/// view! {
399///     <div data-testid="tree-focus-button">
400///         <Button on_click=Callback::new(move |_| { api_for_button.with(|handle| handle.focus_item("target".into())); })>"Focus target"</Button>
401///         <Tree api_ref=api expansion=TreeExpansion::new(SignalModel::from(open)) selection=TreeSelection::none()>
402///             <TreeItem config=TreeItemConfig::leaf("target")><TreeItemLayout slot>"Target row"</TreeItemLayout></TreeItem>
403///         </Tree>
404///     </div>
405/// }
406/// ```
407///
408/// ## Keyboard navigation
409/// Arrow keys move focus; typeahead jumps to matching labels.
410/// <!-- preview -->
411/// ```rust
412/// use crate::{Tree, TreeExpansion, TreeItem, TreeItemConfig, TreeItemLayout, TreeSelection};
413/// use orbital_base_components::SignalModel;
414/// use std::collections::HashSet;
415/// let open = RwSignal::new(HashSet::new());
416/// view! {
417///     <div data-testid="tree-keyboard">
418///         <Tree expansion=TreeExpansion::new(SignalModel::from(open)) selection=TreeSelection::none()>
419///             <TreeItem config=TreeItemConfig::leaf("alpha")><TreeItemLayout slot>"Alpha"</TreeItemLayout></TreeItem>
420///             <TreeItem config=TreeItemConfig::leaf("beta")><TreeItemLayout slot>"Beta"</TreeItemLayout></TreeItem>
421///         </Tree>
422///     </div>
423/// }
424/// ```
425///
426/// ## Inline editing
427/// Double-click or Enter on a focused row edits the label.
428/// <!-- preview -->
429/// ```rust
430/// use crate::{Tree, TreeBehavior, TreeExpansion, TreeItem, TreeItemConfig, TreeItemLayout, TreeSelection};
431/// use orbital_base_components::{Handler, SignalModel};
432/// use std::collections::HashSet;
433/// let open = RwSignal::new(HashSet::new());
434/// let log = RwSignal::new(String::new());
435/// let behavior = TreeBehavior::new()
436///     .with_editable(Signal::from(true))
437///     .with_on_label_change(Handler::on(move |(id, label)| log.set(format!("{id}:{label}"))));
438/// view! {
439///     <div data-testid="tree-editing">
440///         <Tree behavior=behavior expansion=TreeExpansion::new(SignalModel::from(open)) selection=TreeSelection::none()>
441///             <TreeItem config=TreeItemConfig::leaf("edit")><TreeItemLayout slot>"Rename me"</TreeItemLayout></TreeItem>
442///         </Tree>
443///         <span data-testid="tree-edit-log">{move || log.get()}</span>
444///     </div>
445/// }
446/// ```
447///
448/// ## Drag reorder
449/// Pointer drag reorders siblings when `reorderable` is enabled.
450/// <!-- preview -->
451/// ```rust
452/// use crate::{Tree, TreeBehavior, TreeExpansion, TreeItem, TreeItemConfig, TreeItemLayout, TreeSelection};
453/// use orbital_base_components::SignalModel;
454/// use std::collections::HashSet;
455/// let open = RwSignal::new(HashSet::new());
456/// let behavior = TreeBehavior::new().with_reorderable(Signal::from(true));
457/// view! {
458///     <div data-testid="tree-reorder">
459///         <Tree behavior=behavior expansion=TreeExpansion::new(SignalModel::from(open)) selection=TreeSelection::none()>
460///             <TreeItem config=TreeItemConfig::leaf("one")><TreeItemLayout slot>"One"</TreeItemLayout></TreeItem>
461///             <TreeItem config=TreeItemConfig::leaf("two")><TreeItemLayout slot>"Two"</TreeItemLayout></TreeItem>
462///         </Tree>
463///     </div>
464/// }
465/// ```
466///
467/// ## Custom collapse motion
468/// Override the CSS transition name via [`TreeItemCollapse`].
469/// <!-- preview -->
470/// ```rust
471/// use crate::{Tree, TreeExpansion, TreeItem, TreeItemCollapse, TreeItemConfig, TreeItemLayout, TreeSelection};
472/// use orbital_base_components::SignalModel;
473/// use std::collections::HashSet;
474/// let open = RwSignal::new(HashSet::new());
475/// view! {
476///     <div data-testid="tree-custom-motion">
477///         <Tree expansion=TreeExpansion::new(SignalModel::from(open)) selection=TreeSelection::none()>
478///             <TreeItem config=TreeItemConfig::branch("motion")>
479///                 <TreeItemLayout slot>
480///                     "Animated branch"
481///                     <TreeItemCollapse slot motion_name="orbital-tree-custom-collapse".to_string() />
482///                 </TreeItemLayout>
483///                 <TreeItem config=TreeItemConfig::leaf("child")><TreeItemLayout slot>"Child"</TreeItemLayout></TreeItem>
484///             </TreeItem>
485///         </Tree>
486///     </div>
487/// }
488/// ```
489#[component_doc(
490    category = "Tree Views",
491    preview_slug = "tree-view",
492    preview_label = "Tree View",
493    preview_icon = icondata::AiApartmentOutlined,
494)]
495#[component]
496pub fn Tree(
497    /// Expansion state and triggers.
498    #[prop(optional, into)]
499    expansion: TreeExpansion,
500    /// Selection mode and selected item ids.
501    #[prop(optional, into)]
502    selection: TreeSelection,
503    /// Editing, reorder, and interaction callbacks.
504    #[prop(optional, into)]
505    behavior: TreeBehavior,
506    /// Visual density and motion settings.
507    #[prop(optional, into)]
508    appearance: TreeAppearance,
509    /// Imperative tree API handle.
510    #[prop(default = TreeApiRef::default())]
511    api_ref: TreeApiRef,
512    /// Extra CSS class names merged onto the tree root.
513    #[prop(optional, into)]
514    class: MaybeProp<String>,
515    /// [`TreeItem`] children forming the expandable hierarchy.
516    children: Children,
517) -> impl IntoView {
518    inject_style("orbital-tree", tree_styles());
519
520    let tree_state = StoredValue::new(build_tree_state(
521        expansion,
522        selection,
523        behavior,
524        appearance.clone(),
525    ));
526    let state = tree_state.get_value();
527    let drag_listeners = install_tree_reorder_listeners(state.clone());
528    on_cleanup(move || drag_listeners.remove());
529    api_ref.load(TreeApiHandle::from_state(state.clone()));
530
531    let connectors = appearance.connectors;
532    let merged_class = Signal::derive(move || {
533        let mut parts = Vec::new();
534        if connectors.get() {
535            parts.push("orbital-tree--connectors".to_string());
536        }
537        if let Some(extra) = class.get() {
538            if !extra.is_empty() {
539                parts.push(extra);
540            }
541        }
542        parts.join(" ")
543    });
544
545    view! {
546        <BaseTreeRoot class=merged_class tree_state=state>{children()}</BaseTreeRoot>
547    }
548}