Skip to main content

orbital_base_components/tree/
item.rs

1use leptos::{context::Provider, either::Either, ev, html, prelude::*};
2use wasm_bindgen::JsCast;
3
4use crate::collection::primitives::{
5    default_select_action, use_item_activation, use_item_keyboard, use_item_registration,
6    use_item_state, BaseCollectionItem,
7};
8use crate::collection::state::CollectionSelectionMode;
9
10use super::dnd::{use_tree_item_drag, TreeDropPosition};
11use super::edit::TreeEditCommit;
12use super::edit::{label_from_row, resolve_edit_label};
13use super::state::TreeStateInjection;
14use super::subtree::{BaseSubtree, BaseTreeCollapseSlot};
15use super::types::{SubtreeInjection, TreeItemEditInjection, TreeItemInjection, TreeItemType};
16
17#[slot]
18pub struct BaseTreeItemRow {
19    pub children: Children,
20}
21
22#[component]
23pub fn BaseTreeItem(
24    #[prop(optional, into)] class: MaybeProp<String>,
25    #[prop(optional)] item_type: TreeItemType,
26    #[prop(into, optional)] value: Option<String>,
27    #[prop(optional)] label: Option<String>,
28    #[prop(into, optional, default = String::new())] parent_id: String,
29    #[prop(optional, default = 0_usize)] order: usize,
30    base_tree_item_row: BaseTreeItemRow,
31    #[prop(optional)] base_tree_collapse: Option<BaseTreeCollapseSlot>,
32    #[prop(default = None)] children: Option<Children>,
33) -> impl IntoView {
34    let tree_state = TreeStateInjection::expect_context();
35    let collection = tree_state.collection();
36    let subtree_injection = SubtreeInjection::expect_context();
37
38    let item_id = StoredValue::new(value.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()));
39    let item_label = StoredValue::new(label.unwrap_or_default());
40    let subtree_ref = NodeRef::<html::Div>::new();
41    let row_ref = NodeRef::<html::Div>::new();
42    let editing = RwSignal::new(false);
43    let draft_label = RwSignal::new(String::new());
44    let label_override = RwSignal::new(None::<String>);
45
46    let resolved_type = item_type;
47    let is_branch = resolved_type == TreeItemType::Branch;
48    let resolved_parent_id = (!parent_id.is_empty())
49        .then(|| parent_id.clone())
50        .or(subtree_injection.parent_id.clone());
51
52    use_item_registration(
53        collection.clone(),
54        item_id,
55        item_label,
56        label_override,
57        resolved_parent_id.clone(),
58        is_branch,
59        subtree_injection.level,
60        order,
61        row_ref,
62    );
63
64    let signals = use_item_state(collection.clone(), item_id);
65
66    let item_id_signal = Signal::derive(move || item_id.get_value());
67    let (dragging, on_drag_pointerdown) = use_tree_item_drag(
68        item_id_signal,
69        tree_state.reorderable,
70        tree_state.drag_state.clone(),
71    );
72
73    let commit_label = {
74        let tree_state = tree_state.clone();
75        let item_id = item_id;
76        move || {
77            if let Some(commit) = TreeEditCommit::commit(item_id.get_value(), draft_label, editing)
78            {
79                label_override.set(Some(commit.label.clone()));
80                tree_state
81                    .registry
82                    .update_label(&commit.item_id, commit.label.clone());
83                if let Some(handler) = &tree_state.on_label_change {
84                    handler.run((commit.item_id, commit.label));
85                }
86            }
87        }
88    };
89
90    let on_commit = Callback::new({
91        let commit_label = commit_label.clone();
92        move |_| commit_label()
93    });
94
95    let should_ignore_click = {
96        let subtree_ref = subtree_ref;
97        move |event: ev::MouseEvent| {
98            let from_subtree = subtree_ref.with_untracked(|subtree| {
99                if let Some(subtree) = subtree {
100                    event
101                        .target()
102                        .and_then(|target| target.dyn_into::<web_sys::Node>().ok())
103                        .map(|node| subtree.contains(Some(&node)))
104                        .unwrap_or(false)
105                } else {
106                    false
107                }
108            });
109            if from_subtree {
110                return true;
111            }
112            event
113                .target()
114                .and_then(|target| target.dyn_into::<web_sys::Element>().ok())
115                .is_some_and(|element| {
116                    element
117                        .closest(".orbital-tree-item-layout__aside, .orbital-tree-item-layout__checkbox, .orbital-tree-item-layout__drag-handle")
118                        .ok()
119                        .flatten()
120                        .is_some()
121                })
122        }
123    };
124
125    let is_icon_container_click = move |event: ev::MouseEvent| {
126        event
127            .target()
128            .and_then(|target| target.dyn_into::<web_sys::Element>().ok())
129            .is_some_and(|element| {
130                element
131                    .closest(".orbital-tree-item-layout__expand-icon")
132                    .ok()
133                    .flatten()
134                    .is_some()
135            })
136    };
137
138    let on_click = use_item_activation(
139        collection.clone(),
140        item_id,
141        is_branch,
142        signals.clone(),
143        should_ignore_click,
144        is_icon_container_click,
145    );
146
147    let on_select = {
148        let collection = collection.clone();
149        let tree_state = tree_state.clone();
150        let item_id = item_id;
151        let row_ref = row_ref;
152        move || {
153            let id = item_id.get_value();
154            if tree_state.is_item_editable(&id) {
155                draft_label.set(resolve_edit_label(
156                    &id,
157                    &item_label,
158                    &label_override,
159                    tree_state.registry.get_entry(&id).map(|entry| entry.label),
160                    &row_ref,
161                ));
162                editing.set(true);
163            } else {
164                default_select_action(&collection, &id, is_branch);
165            }
166        }
167    };
168
169    let on_keydown_base = use_item_keyboard(
170        collection.clone(),
171        item_id,
172        is_branch,
173        signals.clone(),
174        on_select,
175    );
176
177    let on_keydown = {
178        let commit_label = commit_label.clone();
179        let tree_state = tree_state.clone();
180        let row_ref = row_ref;
181        move |event: ev::KeyboardEvent| {
182            on_keydown_base(event.clone());
183            if editing.get_untracked() {
184                let id = item_id.get_value();
185                let original = resolve_edit_label(
186                    &id,
187                    &item_label,
188                    &label_override,
189                    tree_state.registry.get_entry(&id).map(|entry| entry.label),
190                    &row_ref,
191                );
192                match event.key().as_str() {
193                    "Escape" => {
194                        TreeEditCommit::cancel(draft_label, editing, &original);
195                    }
196                    "Enter" => commit_label(),
197                    _ => {}
198                }
199            }
200        }
201    };
202
203    let on_dblclick = Callback::new({
204        let tree_state = tree_state.clone();
205        let row_ref = row_ref;
206        move |_| {
207            if !tree_state.is_item_editable(&item_id.get_value())
208                || signals.disabled.get_untracked()
209            {
210                return;
211            }
212            let id = item_id.get_value();
213            draft_label.set(resolve_edit_label(
214                &id,
215                &item_label,
216                &label_override,
217                tree_state.registry.get_entry(&id).map(|entry| entry.label),
218                &row_ref,
219            ));
220            editing.set(true);
221        }
222    });
223
224    Effect::new({
225        let tree_state = tree_state.clone();
226        let item_id = item_id;
227        let row_ref = row_ref;
228        move |_| {
229            row_ref.get();
230            if !item_label.get_value().is_empty() {
231                return;
232            }
233            if let Some(label) = label_from_row(&row_ref) {
234                tree_state
235                    .registry
236                    .update_label(&item_id.get_value(), label);
237            }
238        }
239    });
240
241    let style = Signal::derive({
242        let level = subtree_injection.level;
243        move || format!("--orbital-tree-item--level: {level}")
244    });
245
246    let aria_selected = Signal::derive({
247        let collection = collection.clone();
248        move || {
249            if collection.selection.mode == CollectionSelectionMode::None {
250                None
251            } else {
252                Some(
253                    if signals.selected.get() {
254                        "true"
255                    } else {
256                        "false"
257                    }
258                    .to_string(),
259                )
260            }
261        }
262    });
263
264    let aria_expanded = Signal::derive(move || {
265        if is_branch {
266            Some(if signals.open.get() { "true" } else { "false" }.to_string())
267        } else {
268            None
269        }
270    });
271
272    let drag_state = tree_state.drag_state.clone();
273    let tree_class = Signal::derive({
274        let drag_state = drag_state.clone();
275        move || {
276            let mut parts = vec![
277                "orbital-tree-item".to_string(),
278                format!("orbital-tree-item--{}", resolved_type.as_str()),
279            ];
280            if let Some(extra) = class.get() {
281                if !extra.is_empty() {
282                    parts.push(extra);
283                }
284            }
285            let item_id = item_id.get_value();
286            if drag_state
287                .drop_target_id
288                .get()
289                .is_some_and(|target_id| target_id == item_id)
290            {
291                match drag_state.drop_position.get() {
292                    Some(TreeDropPosition::Before) => {
293                        parts.push("orbital-tree-item--drop-before".to_string());
294                    }
295                    Some(TreeDropPosition::After) => {
296                        parts.push("orbital-tree-item--drop-after".to_string());
297                    }
298                    None => {}
299                }
300            }
301            parts.join(" ")
302        }
303    });
304
305    view! {
306        <BaseCollectionItem
307            class=tree_class
308            role="treeitem"
309            base_class="orbital-tree-item"
310            item_id=item_id.get_value()
311            style=style
312            signals=signals.clone()
313            aria_expanded=aria_expanded
314            aria_selected=aria_selected
315            on_click=Callback::new(on_click)
316            on_keydown=Callback::new(on_keydown)
317            on_dblclick=on_dblclick
318            on_pointerdown=Callback::new(move |ev| on_drag_pointerdown.run(ev))
319            dragging=dragging
320            row_ref=row_ref
321        >
322            <Provider value=TreeItemInjection {
323                open: signals.open,
324                item_type: resolved_type,
325                item_id: item_id.get_value(),
326                subtree_ref,
327                selected: signals.selected,
328                focused: signals.focused,
329                disabled: signals.disabled,
330            }>
331                <Provider value=TreeItemEditInjection { editing, draft_label, label_override, on_commit: Some(on_commit) }>
332                    {(base_tree_item_row.children)()}
333                    {match resolved_type {
334                        TreeItemType::Branch => {
335                            let level = subtree_injection.level + 1;
336                            Either::Left(match base_tree_collapse {
337                                Some(collapse) => view! {
338                                    <BaseSubtree
339                                        level=level
340                                        base_tree_collapse=collapse
341                                        subtree_children=children
342                                    />
343                                }
344                                .into_any(),
345                                None => view! {
346                                    <BaseSubtree level=level subtree_children=children />
347                                }
348                                .into_any(),
349                            })
350                        }
351                        TreeItemType::Leaf => Either::Right(()),
352                    }}
353                </Provider>
354            </Provider>
355        </BaseCollectionItem>
356    }
357}