Skip to main content

orbital_base_components/tree/
item_layout.rs

1use std::sync::Mutex;
2
3use leptos::{children::ViewFnOnce, either::Either, html, prelude::*};
4
5use super::state::{checkbox_state, TreeCheckboxState, TreeSelectionMode, TreeStateInjection};
6use super::types::{TreeItemEditInjection, TreeItemInjection, TreeItemType};
7use crate::BaseIcon;
8use icondata::AiCaretRightOutlined;
9use icondata_core::Icon;
10use orbital_theme::{Direction, ThemeInjection};
11
12#[component]
13pub fn BaseTreeItemLayout(
14    #[prop(optional, into)] class: MaybeProp<String>,
15    #[prop(optional, into)] style: MaybeProp<String>,
16    #[prop(optional)] tree_item_icon_before: Option<BaseTreeItemIconBefore>,
17    #[prop(optional)] tree_item_icon_after: Option<BaseTreeItemIconAfter>,
18    #[prop(optional)] tree_item_aside: Option<BaseTreeItemAside>,
19    #[prop(optional)] tree_item_checkbox: Option<BaseTreeItemCheckbox>,
20    children: Children,
21) -> impl IntoView {
22    let tree_item_injection = TreeItemInjection::expect_context();
23    let tree_state = TreeStateInjection::use_context();
24    let is_branch = tree_item_injection.item_type == TreeItemType::Branch;
25    let show_default_checkbox = tree_state
26        .as_ref()
27        .is_some_and(|state| state.selection.mode == TreeSelectionMode::Checkbox)
28        && tree_item_checkbox.is_none();
29    let reorderable = tree_state
30        .as_ref()
31        .is_some_and(|state| state.reorderable.get());
32
33    view! {
34        <div class=move || {
35            let mut parts = vec!["orbital-tree-item-layout".to_string()];
36            if tree_item_injection.selected.get() {
37                parts.push("orbital-tree-item-layout--selected".to_string());
38            }
39            if let Some(extra) = class.get() {
40                if !extra.is_empty() {
41                    parts.push(extra);
42                }
43            }
44            parts.join(" ")
45        } style=move || style.get()>
46            {if reorderable {
47                Either::Left(view! {
48                    <div class="orbital-tree-item-layout__drag-handle" aria-hidden="true">"⋮⋮"</div>
49                })
50            } else {
51                Either::Right(())
52            }}
53            {if show_default_checkbox {
54                Either::Left(view! { <TreeItemCheckboxControl /> })
55            } else {
56                Either::Right(tree_item_checkbox.map(|slot| view! {
57                    <div class="orbital-tree-item-layout__checkbox">{(slot.children)()}</div>
58                }))
59            }}
60            {if is_branch {
61                Either::Left(view! {
62                    <div class="orbital-tree-item-layout__expand-icon">
63                        <TreeItemChevron />
64                    </div>
65                })
66            } else {
67                Either::Right(())
68            }}
69            {tree_item_icon_before.map(|slot| view! {
70                <div class="orbital-tree-item-layout__icon-before">{(slot.children)()}</div>
71            })}
72            <div class="orbital-tree-item-layout__main">
73                <TreeItemLabelRegion>{children()}</TreeItemLabelRegion>
74            </div>
75            {tree_item_icon_after.map(|slot| view! {
76                <div class="orbital-tree-item-layout__icon-after">{(slot.children)()}</div>
77            })}
78            {tree_item_aside.map(|slot| view! {
79                <div
80                    class="orbital-tree-item-layout__aside"
81                    on:click=|ev| ev.stop_propagation()
82                >
83                    {(slot.children)()}
84                </div>
85            })}
86        </div>
87    }
88}
89
90/// Renders [`BaseTreeItemLayout`] when slot props are assembled programmatically.
91pub fn base_tree_item_layout(
92    class: MaybeProp<String>,
93    style: MaybeProp<String>,
94    tree_item_icon_before: Option<BaseTreeItemIconBefore>,
95    tree_item_icon_after: Option<BaseTreeItemIconAfter>,
96    tree_item_aside: Option<BaseTreeItemAside>,
97    tree_item_checkbox: Option<BaseTreeItemCheckbox>,
98    children: Children,
99) -> impl IntoView {
100    BaseTreeItemLayout(BaseTreeItemLayoutProps {
101        class,
102        style,
103        tree_item_icon_before,
104        tree_item_icon_after,
105        tree_item_aside,
106        tree_item_checkbox,
107        children,
108    })
109}
110
111#[slot]
112pub struct BaseTreeItemIconBefore {
113    pub children: Children,
114}
115
116#[slot]
117pub struct BaseTreeItemIconAfter {
118    pub children: Children,
119}
120
121#[slot]
122pub struct BaseTreeItemAside {
123    pub children: Children,
124}
125
126#[slot]
127pub struct BaseTreeItemCheckbox {
128    pub children: Children,
129}
130
131#[component]
132fn TreeItemCheckboxControl() -> impl IntoView {
133    let tree_state = TreeStateInjection::expect_context();
134    let injection = TreeItemInjection::expect_context();
135    let item_id = injection.item_id.clone();
136
137    let tri_state = Memo::new({
138        let tree_state = tree_state.clone();
139        let item_id = item_id.clone();
140        move |_| checkbox_state(&tree_state.registry, &tree_state.selection, &item_id)
141    });
142
143    let aria_checked = move || match tri_state.get() {
144        TreeCheckboxState::Checked => "true".to_string(),
145        TreeCheckboxState::Unchecked => "false".to_string(),
146        TreeCheckboxState::Indeterminate => "mixed".to_string(),
147    };
148
149    let on_click = {
150        let tree_state = tree_state.clone();
151        let item_id = item_id.clone();
152        move |event: leptos::ev::MouseEvent| {
153            event.stop_propagation();
154            let selected = tri_state.get_untracked() == TreeCheckboxState::Checked;
155            tree_state.selection.select_item(
156                item_id.clone(),
157                true,
158                Some(!selected),
159                None,
160                Some(&tree_state.registry),
161            );
162        }
163    };
164
165    view! {
166        <div
167            class="orbital-tree-item-layout__checkbox"
168            role="checkbox"
169            aria-checked=aria_checked
170            tabindex="-1"
171            on:click=on_click
172        >
173            <span class=move || match tri_state.get() {
174                TreeCheckboxState::Checked => "orbital-tree-item-layout__checkbox-box orbital-tree-item-layout__checkbox-box--checked".to_string(),
175                TreeCheckboxState::Indeterminate => "orbital-tree-item-layout__checkbox-box orbital-tree-item-layout__checkbox-box--indeterminate".to_string(),
176                TreeCheckboxState::Unchecked => "orbital-tree-item-layout__checkbox-box".to_string(),
177            }></span>
178        </div>
179    }
180}
181
182#[component]
183fn TreeItemLabelRegion(children: Children) -> impl IntoView {
184    let edit = TreeItemEditInjection::expect_context();
185    let injection = TreeItemInjection::expect_context();
186    let tree_state = TreeStateInjection::expect_context();
187    let input_ref = NodeRef::<html::Input>::new();
188    let default_label = Mutex::new(Some(ViewFnOnce::from(move || {
189        view! {
190            <div class="orbital-tree-item-layout__label">{children()}</div>
191        }
192    })));
193
194    let display_label = Memo::new({
195        let item_id = injection.item_id.clone();
196        move |_| {
197            edit.label_override.get().or_else(|| {
198                tree_state
199                    .registry
200                    .get_entry(&item_id)
201                    .map(|entry| entry.label)
202                    .filter(|label| !label.is_empty())
203            })
204        }
205    });
206
207    Effect::new(move |_| {
208        if edit.editing.get() {
209            if let Some(input) = input_ref.get() {
210                let _ = input.focus();
211                input.select();
212            }
213        }
214    });
215
216    view! {
217        <Show
218            when=move || edit.editing.get()
219            fallback=move || {
220                if let Some(label) = display_label.get() {
221                    view! {
222                        <div class="orbital-tree-item-layout__label">{label}</div>
223                    }
224                    .into_any()
225                } else if let Some(default) = default_label.lock().ok().and_then(|mut slot| slot.take()) {
226                    default.run()
227                } else {
228                    view! {
229                        <div class="orbital-tree-item-layout__label"></div>
230                    }
231                    .into_any()
232                }
233            }
234        >
235            <input
236                class="orbital-tree-item-layout__label-input"
237                prop:value=move || edit.draft_label.get()
238                node_ref=input_ref
239                on:input=move |ev| edit.draft_label.set(event_target_value(&ev))
240                on:keydown=move |ev| {
241                    if ev.key() == "Enter" {
242                        if let Some(commit) = &edit.on_commit {
243                            commit.run(());
244                        }
245                    }
246                }
247                on:blur=move |_| {
248                    if let Some(commit) = &edit.on_commit {
249                        commit.run(());
250                    }
251                }
252            />
253        </Show>
254    }
255}
256
257#[component]
258fn TreeItemChevron() -> impl IntoView {
259    let tree_item_injection = TreeItemInjection::expect_context();
260    let open = tree_item_injection.open;
261    let theme_injection = use_context::<ThemeInjection>();
262
263    let style = Memo::new(move |_| {
264        let rtl = theme_injection
265            .and_then(|injection| {
266                injection
267                    .dir
268                    .map(|dir| dir.get_untracked() == Direction::Rtl)
269            })
270            .unwrap_or(false);
271
272        let rotation = if open.get() {
273            "transform: rotate(90deg)"
274        } else if rtl {
275            "transform: rotate(180deg)"
276        } else {
277            "transform: rotate(0deg)"
278        };
279
280        format!(
281            "{rotation}; transition: transform var(--orb-motion-duration-md) var(--orb-motion-ease-emphasis);"
282        )
283    });
284
285    view! {
286        <BaseIcon
287            icon=Icon::from(AiCaretRightOutlined)
288            width="12px"
289            height="12px"
290            style=style
291        />
292    }
293}