leptos_column_browser/ui/
column.rs1use leptos::prelude::*;
2use wasm_bindgen::JsCast;
3use web_sys::HtmlElement;
4
5use crate::topology::NodeView;
6use crate::ui::icons::{CHEVRON_RIGHT, IconRenderer};
7use crate::ui::style;
8
9#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct ColumnSizeConfig {
12 pub initial_width: f64,
14 pub min_width: f64,
16}
17
18impl Default for ColumnSizeConfig {
19 fn default() -> Self {
20 Self {
21 initial_width: 160.0,
22 min_width: 80.0,
23 }
24 }
25}
26
27#[allow(
36 unreachable_pub,
37 clippy::too_many_lines,
38 clippy::needless_pass_by_value
39)]
40#[component]
41pub fn BrowserColumn(
42 items: Vec<NodeView>,
44 col_idx: usize,
46 selected_id: Signal<Option<String>>,
48 on_select: Callback<(usize, String)>,
50 on_open: Callback<String>,
52 icon_renderer: IconRenderer,
54 #[prop(default = ColumnSizeConfig::default())]
56 size_config: ColumnSizeConfig,
57 focus_request: RwSignal<Option<usize>>,
61) -> impl IntoView {
62 let col_width: RwSignal<f64> = RwSignal::new(size_config.initial_width);
64 let drag_start_x: RwSignal<f64> = RwSignal::new(0.0);
65 let drag_start_w: RwSignal<f64> = RwSignal::new(0.0);
66 let dragging: RwSignal<bool> = RwSignal::new(false);
67 let min_width = size_config.min_width;
68
69 let on_handle_down = move |ev: leptos::ev::PointerEvent| {
70 ev.prevent_default();
71 dragging.set(true);
72 #[allow(clippy::cast_precision_loss)]
73 let x = f64::from(ev.client_x());
74 drag_start_x.set(x);
75 drag_start_w.set(col_width.get_untracked());
76 if let Some(target) = ev.target() {
77 let el: web_sys::Element = target.unchecked_into();
78 let _ = el.set_pointer_capture(ev.pointer_id());
79 }
80 };
81 let on_handle_move = move |ev: leptos::ev::PointerEvent| {
82 if !dragging.get_untracked() {
83 return;
84 }
85 #[allow(clippy::cast_precision_loss)]
86 let new_w = (drag_start_w.get_untracked() + f64::from(ev.client_x())
87 - drag_start_x.get_untracked())
88 .max(min_width);
89 col_width.set(new_w);
90 };
91 let on_handle_up = move |_: leptos::ev::PointerEvent| {
92 dragging.set(false);
93 };
94
95 let item_count = items.len();
97 let focus_idx: RwSignal<Option<usize>> = RwSignal::new(None);
98 let list_ref = NodeRef::<leptos::html::Ul>::new();
99
100 let item_ids: Vec<String> = items.iter().map(|n| n.id.clone()).collect();
102
103 let focus_item = move |idx: usize| {
105 if let Some(ul) = list_ref.get() {
106 let children = ul.children();
107 #[allow(clippy::cast_possible_truncation)]
108 let idx_u32 = idx as u32; if let Some(li) = children.item(idx_u32)
110 && let Ok(el) = li.dyn_into::<HtmlElement>()
111 {
112 let _ = el.focus();
113 }
114 }
115 focus_idx.set(Some(idx));
116 };
117
118 Effect::new(move |_| {
120 if focus_request.get() == Some(col_idx) {
121 focus_item(0);
122 focus_request.set(None);
123 }
124 });
125
126 let on_keydown = {
127 let item_ids = item_ids.clone();
128 move |ev: leptos::ev::KeyboardEvent| {
129 let current = focus_idx.get_untracked().unwrap_or(0);
130 match ev.key().as_str() {
131 "ArrowDown" => {
132 ev.prevent_default();
133 let next = (current + 1).min(item_count.saturating_sub(1));
134 focus_item(next);
135 }
136 "ArrowUp" => {
137 ev.prevent_default();
138 let prev = current.saturating_sub(1);
139 focus_item(prev);
140 }
141 "Enter" | " " => {
142 ev.prevent_default();
143 if let Some(id) = item_ids.get(current) {
144 on_select.run((col_idx, id.clone()));
145 }
146 }
147 _ => {}
150 }
151 }
152 };
153
154 view! {
155 <div
156 class={style::BROWSER_COLUMN}
157 style:width=move || format!("{}px", col_width.get())
158 style:min-width=move || format!("{}px", col_width.get())
159 >
160 <ul
161 node_ref=list_ref
162 class={style::BROWSER_LIST}
163 role="tree"
164 aria-label=format!("Column {}", col_idx + 1)
165 tabindex="0"
166 on:keydown=on_keydown
167 >
168 {items
169 .into_iter()
170 .enumerate()
171 .map(|(item_idx, item)| {
172 let item_id = item.id.clone();
173 let item_id_click = item_id.clone();
174 let item_id_dbl = item_id.clone();
175 let is_leaf = item.is_leaf();
176 let node_type_str = item.node_type.clone();
177 let kind_class = if is_leaf {
178 style::BROWSER_LEAF
179 } else {
180 style::BROWSER_CONTAINER
181 };
182 let icon_svg = icon_renderer(&node_type_str);
183
184 let is_selected = {
185 let item_id = item_id.clone();
186 Signal::derive(move || {
187 selected_id.get().as_deref() == Some(item_id.as_str())
188 })
189 };
190
191 let is_expanded = {
199 let item_id = item_id.clone();
200 Signal::derive(move || {
201 selected_id.get().as_deref() == Some(item_id.as_str())
202 })
203 };
204
205 view! {
206 <li
207 class=move || {
208 let mut cls = String::from(style::BROWSER_ITEM);
209 cls.push(' ');
210 cls.push_str(kind_class);
211 if is_selected.get() {
212 cls.push(' ');
213 cls.push_str(style::BROWSER_SELECTED);
214 }
215 cls
216 }
217 role="treeitem"
218 aria-selected=move || is_selected.get().to_string()
219 aria-expanded=move || {
220 if is_leaf { None } else { Some(is_expanded.get().to_string()) }
221 }
222 tabindex="-1"
223 on:click=move |_| {
224 focus_idx.set(Some(item_idx));
225 on_select.run((col_idx, item_id_click.clone()));
226 }
227 on:dblclick=move |_| {
228 if is_leaf { on_open.run(item_id_dbl.clone()); }
229 }
230 on:focus=move |_| { focus_idx.set(Some(item_idx)); }
231 >
232 <span class={style::BROWSER_ICON} aria-hidden="true" inner_html=icon_svg />
233 <span class={style::BROWSER_LABEL}>{item.label.clone()}</span>
234 {(!is_leaf).then(|| view! {
235 <span
236 class={style::BROWSER_CHEVRON}
237 aria-hidden="true"
238 inner_html=CHEVRON_RIGHT
239 />
240 })}
241 </li>
242 }
243 })
244 .collect::<Vec<_>>()}
245 </ul>
246 <div
247 class={style::BROWSER_RESIZE_HANDLE}
248 aria-hidden="true"
249 on:pointerdown=on_handle_down
250 on:pointermove=on_handle_move
251 on:pointerup=on_handle_up
252 on:lostpointercapture=on_handle_up
253 />
254 </div>
255 }
256}