Skip to main content

leptos_column_browser/ui/
column.rs

1use 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/// Configuration for column width sizing.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct ColumnSizeConfig {
12    /// Initial column width in pixels.
13    pub initial_width: f64,
14    /// Minimum column width in pixels (drag clamp).
15    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/// A single column in the browser layout.
28///
29/// Renders a `<ul role="tree">` of items with ARIA tree-widget semantics.
30/// Keyboard navigation (↑ / ↓ / Enter / Space) is handled via a single
31/// `keydown` listener on the `<ul>` (WAI-ARIA roving-tabindex pattern).
32///
33/// Cross-column navigation (→ / ← / Escape) is coordinated by the parent
34/// `BrowserView` via the `focus_request` signal.
35#[allow(
36    unreachable_pub,
37    clippy::too_many_lines,
38    clippy::needless_pass_by_value
39)]
40#[component]
41pub fn BrowserColumn(
42    /// The items to display in this column.
43    items: Vec<NodeView>,
44    /// The absolute column index into `DrillPath::segments`.
45    col_idx: usize,
46    /// The canonical id of the currently selected item (if any).
47    selected_id: Signal<Option<String>>,
48    /// Callback fired when a user clicks an item: `(col_idx, canonical_id)`.
49    on_select: Callback<(usize, String)>,
50    /// Callback fired when a user double-clicks a leaf item: `canonical_id`.
51    on_open: Callback<String>,
52    /// Icon renderer — maps `node_kind` to an SVG string.
53    icon_renderer: IconRenderer,
54    /// Column sizing configuration.
55    #[prop(default = ColumnSizeConfig::default())]
56    size_config: ColumnSizeConfig,
57    /// Shared signal for cross-column focus requests from `BrowserView`.
58    /// When set to `Some(col_idx)`, this column focuses its first item
59    /// and clears the signal.
60    focus_request: RwSignal<Option<usize>>,
61) -> impl IntoView {
62    // ── resize state ─────────────────────────────────────────────────────────
63    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    // ── keyboard / focus state ───────────────────────────────────────────────
96    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    // Pre-extract item IDs for the keydown handler (avoids borrowing `items`).
101    let item_ids: Vec<String> = items.iter().map(|n| n.id.clone()).collect();
102
103    // Helper: focus the <li> at `idx` within this column's <ul>.
104    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; // Column item counts are well below u32::MAX.
109            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    // Watch for cross-column focus requests targeting this column.
119    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                // ArrowRight / ArrowLeft / Escape are handled at BrowserView
148                // level for cross-column coordination.
149                _ => {}
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                        // aria-expanded: only on containers, true when drilled.
192                        // NOTE: This column browser uses separate <ul> columns
193                        // rather than nested <ul role="group">. The aria-expanded
194                        // attribute is technically for items with nested groups,
195                        // but we use it here to convey drill state to assistive
196                        // technology — an accepted deviation for the Miller
197                        // column pattern.
198                        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}