Skip to main content

nightshade_api/web/
table.rs

1//! A data table component with sorting, filtering, resizable columns, and
2//! optional row virtualization.
3
4use std::cmp::Ordering;
5
6use leptos::html;
7use leptos::prelude::*;
8use wasm_bindgen::JsCast;
9
10use crate::web::visible_range;
11
12#[derive(Clone, Copy, PartialEq)]
13enum SortDirection {
14    Ascending,
15    Descending,
16}
17
18fn compare_cells(left: &str, right: &str) -> Ordering {
19    match (left.parse::<f64>(), right.parse::<f64>()) {
20        (Ok(left), Ok(right)) => left.partial_cmp(&right).unwrap_or(Ordering::Equal),
21        _ => left.cmp(right),
22    }
23}
24
25fn compare_rows(left: &[String], right: &[String], sort: &[(usize, SortDirection)]) -> Ordering {
26    for (column, direction) in sort {
27        let left_cell = left.get(*column).map(String::as_str).unwrap_or("");
28        let right_cell = right.get(*column).map(String::as_str).unwrap_or("");
29        let ordering = compare_cells(left_cell, right_cell);
30        let ordering = match direction {
31            SortDirection::Ascending => ordering,
32            SortDirection::Descending => ordering.reverse(),
33        };
34        if ordering != Ordering::Equal {
35            return ordering;
36        }
37    }
38    Ordering::Equal
39}
40
41/// A data table with multi-column sort (shift-click for additive sort),
42/// text filter, column resize and show/hide, sticky header, pagination, inline
43/// cell edit, and optional row virtualization. `rows` is a reactive signal of
44/// row data; `on_row_click`, `on_cell_edit`, and `selected_row` wire up
45/// selection and editing.
46#[component]
47pub fn Table(
48    headers: Vec<String>,
49    #[prop(into)] rows: Signal<Vec<Vec<String>>>,
50    #[prop(optional)] sortable: bool,
51    #[prop(optional)] filterable: bool,
52    #[prop(optional)] resizable: bool,
53    #[prop(optional)] virtualized: bool,
54    #[prop(optional)] columns_toggle: bool,
55    #[prop(optional)] page_size: Option<usize>,
56    #[prop(default = 34.0)] row_height: f64,
57    #[prop(default = 12)] overscan: usize,
58    #[prop(optional)] height: Option<f64>,
59    #[prop(optional)] on_row_click: Option<Callback<usize>>,
60    #[prop(optional)] on_cell_edit: Option<Callback<(usize, usize, String)>>,
61    #[prop(optional, into)] selected_row: Option<Signal<Option<usize>>>,
62) -> impl IntoView {
63    let column_count = headers.len();
64    let header_labels = StoredValue::new(headers.clone());
65    let sort = RwSignal::new(Vec::<(usize, SortDirection)>::new());
66    let filter = RwSignal::new(String::new());
67    let widths = RwSignal::new(vec![None::<f64>; column_count]);
68    let visible = RwSignal::new(vec![true; column_count]);
69    let columns_menu = RwSignal::new(false);
70    let page = RwSignal::new(0usize);
71    let editing = RwSignal::new(None::<(usize, usize)>);
72    let draft = RwSignal::new(String::new());
73    let scroll_top = RwSignal::new(0.0);
74    let viewport_height = RwSignal::new(height.unwrap_or(360.0));
75    let selected_row = selected_row.unwrap_or_else(|| Signal::derive(|| None));
76    let wrap_ref = NodeRef::<html::Div>::new();
77    let drag = StoredValue::new(None::<(usize, f64, f64)>);
78    let paginated = page_size.is_some();
79    let virtualized = virtualized && !paginated;
80
81    Effect::new(move |_| {
82        filter.track();
83        page.set(0);
84    });
85
86    let cell_input = NodeRef::<html::Input>::new();
87    Effect::new(move |_| {
88        if editing.get().is_some()
89            && let Some(input) = cell_input.get()
90        {
91            let _ = input.focus();
92        }
93    });
94
95    let is_visible =
96        move |column: usize| visible.with(|list| list.get(column).copied().unwrap_or(true));
97
98    let toggle_sort = move |column: usize, additive: bool| {
99        sort.update(|stack| {
100            let existing = stack.iter().position(|(active, _)| *active == column);
101            if additive {
102                match existing {
103                    Some(index) => match stack[index].1 {
104                        SortDirection::Ascending => {
105                            stack[index].1 = SortDirection::Descending;
106                        }
107                        SortDirection::Descending => {
108                            stack.remove(index);
109                        }
110                    },
111                    None => stack.push((column, SortDirection::Ascending)),
112                }
113            } else {
114                *stack = match existing {
115                    Some(index) if stack.len() == 1 => match stack[index].1 {
116                        SortDirection::Ascending => vec![(column, SortDirection::Descending)],
117                        SortDirection::Descending => Vec::new(),
118                    },
119                    _ => vec![(column, SortDirection::Ascending)],
120                };
121            }
122        });
123    };
124
125    let processed = Memo::new(move |_| {
126        let needle = filter.get().to_lowercase();
127        let mut data = rows
128            .get()
129            .into_iter()
130            .enumerate()
131            .filter(|(_, row)| {
132                needle.is_empty() || row.iter().any(|cell| cell.to_lowercase().contains(&needle))
133            })
134            .collect::<Vec<(usize, Vec<String>)>>();
135        let sort = sort.get();
136        if !sort.is_empty() {
137            data.sort_by(|(_, left), (_, right)| compare_rows(left, right, &sort));
138        }
139        data
140    });
141
142    let page_count = move || {
143        let Some(size) = page_size else {
144            return 1;
145        };
146        processed.with(|data| data.len()).div_ceil(size).max(1)
147    };
148
149    let on_pointermove = move |event: web_sys::PointerEvent| {
150        if let Some((column, start_x, start_width)) = drag.get_value() {
151            let delta = event.client_x() as f64 - start_x;
152            let next = (start_width + delta).max(48.0);
153            widths.update(|list| {
154                if let Some(slot) = list.get_mut(column) {
155                    *slot = Some(next);
156                }
157            });
158        }
159    };
160    let on_pointerup = move |_event: web_sys::PointerEvent| drag.set_value(None);
161
162    let header_cells = (0..column_count)
163        .map(|index| {
164            let label = header_labels.with_value(|list| list[index].clone());
165            let indicator = move || {
166                let stack = sort.get();
167                match stack.iter().find(|(active, _)| *active == index) {
168                    Some((_, SortDirection::Ascending)) => " \u{25b2}".to_string(),
169                    Some((_, SortDirection::Descending)) => " \u{25bc}".to_string(),
170                    None => String::new(),
171                }
172            };
173            let rank = move || {
174                let stack = sort.get();
175                if stack.len() > 1
176                    && let Some(position) = stack.iter().position(|(active, _)| *active == index)
177                {
178                    return format!("{}", position + 1);
179                }
180                String::new()
181            };
182            let aria_sort = move || {
183                let stack = sort.get();
184                match stack.iter().find(|(active, _)| *active == index) {
185                    Some((_, SortDirection::Ascending)) => "ascending",
186                    Some((_, SortDirection::Descending)) => "descending",
187                    _ if sortable => "none",
188                    _ => "",
189                }
190            };
191            let on_click = move |event: web_sys::MouseEvent| {
192                if sortable {
193                    toggle_sort(index, event.shift_key());
194                }
195            };
196            let on_grip_down = move |event: web_sys::PointerEvent| {
197                event.stop_propagation();
198                event.prevent_default();
199                let start_width = widths.with_untracked(|list| list.get(index).copied().flatten());
200                let start_width = start_width.unwrap_or_else(|| {
201                    event
202                        .target()
203                        .and_then(|target| target.dyn_into::<web_sys::HtmlElement>().ok())
204                        .and_then(|element| element.parent_element())
205                        .map(|parent| parent.get_bounding_client_rect().width())
206                        .unwrap_or(120.0)
207                });
208                drag.set_value(Some((index, event.client_x() as f64, start_width)));
209                if let Some(element) = event
210                    .target()
211                    .and_then(|target| target.dyn_into::<web_sys::Element>().ok())
212                {
213                    let _ = element.set_pointer_capture(event.pointer_id());
214                }
215            };
216            view! {
217                <Show when=move || is_visible(index) fallback=|| ()>
218                    <th
219                        scope="col"
220                        class="nightshade-th"
221                        class:sortable=sortable
222                        aria-sort=aria_sort
223                        on:click=on_click
224                    >
225                        <span class="nightshade-th-label">{label.clone()}{indicator}</span>
226                        <span class="nightshade-th-rank">{rank}</span>
227                        {resizable
228                            .then(|| {
229                                view! {
230                                    <span
231                                        class="nightshade-th-grip"
232                                        on:pointerdown=on_grip_down
233                                        on:pointermove=on_pointermove
234                                        on:pointerup=on_pointerup
235                                        on:click=|event| event.stop_propagation()
236                                    ></span>
237                                }
238                            })}
239                    </th>
240                </Show>
241            }
242        })
243        .collect_view();
244
245    let colgroup = move || {
246        (0..column_count)
247            .map(|index| {
248                let width = widths.with(|list| list.get(index).copied().flatten());
249                let style = width
250                    .map(|value| format!("width:{value}px"))
251                    .unwrap_or_default();
252                view! {
253                    <Show when=move || is_visible(index) fallback=|| ()>
254                        <col style=style.clone() />
255                    </Show>
256                }
257            })
258            .collect_view()
259    };
260
261    let visible_range = move |total: usize| {
262        if !virtualized {
263            return (0usize, total);
264        }
265        visible_range(
266            scroll_top.get(),
267            viewport_height.get(),
268            row_height,
269            overscan,
270            total,
271        )
272    };
273
274    let render_cell = move |original_index: usize, column: usize, cell: String| {
275        let editable = on_cell_edit.is_some();
276        let is_editing = move || editing.get() == Some((original_index, column));
277        let cell = StoredValue::new(cell);
278        let start_edit = move |_: web_sys::MouseEvent| {
279            if editable {
280                draft.set(cell.get_value());
281                editing.set(Some((original_index, column)));
282            }
283        };
284        let commit = move || {
285            if let Some(callback) = on_cell_edit {
286                callback.run((original_index, column, draft.get_untracked()));
287            }
288            editing.set(None);
289        };
290        view! {
291            <Show when=move || is_visible(column) fallback=|| ()>
292                <td class:editable=editable on:dblclick=start_edit>
293                    {move || {
294                        if is_editing() {
295                            view! {
296                                <input
297                                    node_ref=cell_input
298                                    class="nightshade-cell-input"
299                                    prop:value=move || draft.get()
300                                    on:click=|event| event.stop_propagation()
301                                    on:input=move |event| draft.set(event_target_value(&event))
302                                    on:keydown=move |event| {
303                                        match event.key().as_str() {
304                                            "Enter" => {
305                                                event.prevent_default();
306                                                commit();
307                                            }
308                                            "Escape" => {
309                                                event.prevent_default();
310                                                editing.set(None);
311                                            }
312                                            _ => {}
313                                        }
314                                    }
315                                    on:blur=move |_| commit()
316                                />
317                            }
318                                .into_any()
319                        } else {
320                            view! { <span>{cell.get_value()}</span> }.into_any()
321                        }
322                    }}
323                </td>
324            </Show>
325        }
326    };
327
328    let body = move || {
329        let page = page.get();
330        processed.with(|data| {
331            let total = data.len();
332            let (page_start, page_end) = if let Some(size) = page_size {
333                let start = (page * size).min(total);
334                let end = (start + size).min(total);
335                (start, end)
336            } else {
337                (0, total)
338            };
339            let visible_total = page_end - page_start;
340            let (start, end) = visible_range(visible_total);
341            let top_pad = start as f64 * row_height;
342            let bottom_pad = visible_total.saturating_sub(end) as f64 * row_height;
343            let visible_rows = data[page_start..page_end]
344                .iter()
345                .skip(start)
346                .take(end - start)
347                .cloned()
348                .map(|(original_index, row)| {
349                    let is_selected = move || selected_row.get() == Some(original_index);
350                    let on_click = move |_| {
351                        if let Some(callback) = on_row_click {
352                            callback.run(original_index);
353                        }
354                    };
355                    let style = virtualized.then(|| format!("height:{row_height}px"));
356                    let cells = row
357                        .into_iter()
358                        .enumerate()
359                        .map(|(column, cell)| render_cell(original_index, column, cell))
360                        .collect_view();
361                    view! {
362                        <tr
363                            class:selected=is_selected
364                            class:clickable=on_row_click.is_some()
365                            style=style
366                            aria-selected=move || is_selected().to_string()
367                            on:click=on_click
368                        >
369                            {cells}
370                        </tr>
371                    }
372                })
373                .collect_view();
374            let spacer = move |pixels: f64| {
375                (pixels > 0.5).then(|| {
376                view! {
377                    <tr class="nightshade-spacer-row" aria-hidden="true">
378                        <td colspan=column_count style=format!("height:{pixels}px;padding:0")></td>
379                    </tr>
380                }
381            })
382            };
383            view! {
384                {spacer(top_pad)}
385                {visible_rows}
386                {spacer(bottom_pad)}
387            }
388        })
389    };
390
391    let on_scroll = move |event: web_sys::Event| {
392        if let Some(element) = event
393            .target()
394            .and_then(|target| target.dyn_into::<web_sys::HtmlElement>().ok())
395        {
396            scroll_top.set(element.scroll_top() as f64);
397            viewport_height.set(element.client_height() as f64);
398        }
399    };
400
401    Effect::new(move |_| {
402        if let Some(element) = wrap_ref.get() {
403            viewport_height.set(element.client_height() as f64);
404        }
405    });
406
407    let wrap_style = height.map(|value| format!("max-height:{value}px;overflow:auto"));
408
409    view! {
410        <div class="nightshade-table-shell">
411            <div class="nightshade-table-toolbar">
412                {filterable
413                    .then(|| {
414                        view! {
415                            <input
416                                class="nightshade-table-filter"
417                                type="search"
418                                placeholder="Filter rows…"
419                                prop:value=move || filter.get()
420                                on:input=move |event| filter.set(event_target_value(&event))
421                            />
422                        }
423                    })}
424                {columns_toggle
425                    .then(|| {
426                        view! {
427                            <div class="nightshade-table-columns">
428                                <button
429                                    class="nightshade-button"
430                                    on:click=move |_| columns_menu.update(|open| *open = !*open)
431                                >
432                                    "Columns"
433                                </button>
434                                <Show when=move || columns_menu.get() fallback=|| ()>
435                                    <div class="nightshade-table-columns-menu">
436                                        {(0..column_count)
437                                            .map(|index| {
438                                                let label = header_labels
439                                                    .with_value(|list| list[index].clone());
440                                                view! {
441                                                    <label class="nightshade-table-column-row">
442                                                        <input
443                                                            type="checkbox"
444                                                            prop:checked=move || is_visible(index)
445                                                            on:change=move |_| {
446                                                                visible
447                                                                    .update(|list| {
448                                                                        if let Some(slot) = list.get_mut(index) {
449                                                                            *slot = !*slot;
450                                                                        }
451                                                                    })
452                                                            }
453                                                        />
454                                                        {label}
455                                                    </label>
456                                                }
457                                            })
458                                            .collect_view()}
459                                    </div>
460                                </Show>
461                            </div>
462                        }
463                    })}
464            </div>
465            <div
466                class="nightshade-table-wrap"
467                class:sticky=virtualized || height.is_some()
468                node_ref=wrap_ref
469                style=wrap_style
470                on:scroll=on_scroll
471            >
472                <table class="nightshade-table">
473                    <colgroup>{colgroup}</colgroup>
474                    <thead>
475                        <tr>{header_cells}</tr>
476                    </thead>
477                    <tbody>{body}</tbody>
478                </table>
479            </div>
480            {paginated
481                .then(|| {
482                    view! {
483                        <div class="nightshade-table-pager">
484                            <button
485                                class="nightshade-button"
486                                disabled=move || page.get() == 0
487                                on:click=move |_| page.update(|value| *value = value.saturating_sub(1))
488                            >
489                                "Prev"
490                            </button>
491                            <span class="nightshade-table-page">
492                                {move || format!("Page {} / {}", page.get() + 1, page_count())}
493                            </span>
494                            <button
495                                class="nightshade-button"
496                                disabled=move || page.get() + 1 >= page_count()
497                                on:click=move |_| {
498                                    let last = page_count() - 1;
499                                    page.update(|value| *value = (*value + 1).min(last))
500                                }
501                            >
502                                "Next"
503                            </button>
504                        </div>
505                    }
506                })}
507        </div>
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::{SortDirection, compare_cells, compare_rows};
514    use std::cmp::Ordering;
515
516    #[test]
517    fn numeric_cells_compare_by_value_not_lexically() {
518        assert_eq!(compare_cells("9", "10"), Ordering::Less);
519        assert_eq!(compare_cells("100", "20"), Ordering::Greater);
520    }
521
522    #[test]
523    fn non_numeric_cells_compare_lexically() {
524        assert_eq!(compare_cells("alpha", "beta"), Ordering::Less);
525        assert_eq!(compare_cells("zeta", "alpha"), Ordering::Greater);
526    }
527
528    #[test]
529    fn multi_column_sort_breaks_ties_with_secondary_key() {
530        let left = vec!["group".to_string(), "1".to_string()];
531        let right = vec!["group".to_string(), "2".to_string()];
532        let sort = vec![
533            (0, SortDirection::Ascending),
534            (1, SortDirection::Descending),
535        ];
536        assert_eq!(compare_rows(&left, &right, &sort), Ordering::Greater);
537    }
538}