1use 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#[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}