orbital_base_components/tree/
item_layout.rs1use 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
90pub 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}