orbital_base_components/tree/
item.rs1use 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}