Skip to main content

hypersig_ui/
table.rs

1//! Data tables: rows of a list signal under typed columns, sorted,
2//! filtered, and paged in the browser or on the server.
3
4use std::fmt;
5use std::rc::Rc;
6use std::time::Duration;
7
8use hypersig::html::{
9    El, Event, Node, button, div, each_keyed, input, p, span, table, tbody, td, th, thead, tr,
10};
11use hypersig::style::pattern::stack;
12use hypersig::style::value::{
13    AlignItems, BorderBottomStyle, BorderCollapse, BorderStyle, CaptionSide, Color, Cursor,
14    Display, FlexDirection, FontVariantNumeric, FontWeight, JustifyContent, Length, OverflowX,
15    OverflowY, Position, TextAlign, VerticalAlign, WhiteSpace, ZIndex, pct, px, raw, rem,
16};
17use hypersig::style::{PseudoClass, Style};
18use hypersig::{
19    Comparable, Expr, IntoExpr, IntoStmt, Item, Number, NumberFormat, Segment, Sig, Signals, Stmt,
20    Textual, if_else,
21};
22use serde::{Deserialize, Serialize};
23
24use crate::button::{ButtonSize, ButtonVariant, button_style, icon_button};
25use crate::choice::{check_box, check_box_checked, check_mark};
26use crate::field::field_style;
27use crate::look::{self, icon};
28use crate::token;
29
30/// The signals a [`DataTable`] or [`ServerTable`] keeps: filter text, page, and sort.
31///
32/// Put one in the page's signals for each table. A [`ServerTable`] sends it
33/// to the server, which reads the sort with [`TableState::sorting`].
34///
35/// ```
36/// use hypersig::Signals;
37/// use hypersig_ui::TableState;
38/// use serde::{Deserialize, Serialize};
39///
40/// #[derive(Signals, Serialize, Deserialize)]
41/// struct Users {
42///     table: TableState,
43/// }
44///
45/// let users = Users { table: TableState::default() };
46/// assert_eq!(users.table.page, 0);
47/// ```
48#[derive(Signals, Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
49pub struct TableState {
50    /// What the filter field holds.
51    pub query: String,
52    /// The page shown, counted from 0.
53    pub page: i32,
54    /// The column sorted by, empty while unsorted. A [`DataTable`] writes
55    /// the column's number counted from 1, and a [`ServerTable`] the
56    /// column's key as a route segment writes it.
57    sort: String,
58    /// Whether the column is sorted descending.
59    descending: bool,
60}
61
62/// The words a [`DataTable`] or [`ServerTable`] shows or names its controls with.
63///
64/// They are English until replaced, so a page in another language passes
65/// its own:
66///
67/// ```
68/// use hypersig_ui::TableText;
69///
70/// let japanese = TableText {
71///     empty: "該当する行はありません。".into(),
72///     select_row: "行を選ぶ".into(),
73///     select_all: "すべての行を選ぶ".into(),
74///     previous_page: "前のページ".into(),
75///     next_page: "次のページ".into(),
76///     expand_row: "行の詳細を表示".into(),
77/// };
78/// assert_ne!(japanese, TableText::default());
79/// ```
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct TableText {
82    /// Shown in place of the rows when there are none, "No results.".
83    pub empty: String,
84    /// The name of the checkbox of a row, "Select row".
85    pub select_row: String,
86    /// The name of the checkbox in the header, "Select all rows".
87    pub select_all: String,
88    /// The name of the button that goes back a page, "Previous page".
89    pub previous_page: String,
90    /// The name of the button that goes forward a page, "Next page".
91    pub next_page: String,
92    /// The name of the button that shows or hides a row's details, "Show row details".
93    pub expand_row: String,
94}
95
96impl Default for TableText {
97    fn default() -> Self {
98        Self {
99            empty: "No results.".to_owned(),
100            select_row: "Select row".to_owned(),
101            select_all: "Select all rows".to_owned(),
102            previous_page: "Previous page".to_owned(),
103            next_page: "Next page".to_owned(),
104            expand_row: "Show row details".to_owned(),
105        }
106    }
107}
108
109/// The direction a [`TableState`] sorts its column in.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
111pub enum SortOrder {
112    /// Smallest first.
113    Ascending,
114    /// Largest first.
115    Descending,
116}
117
118impl TableState {
119    /// The key of the column a [`ServerTable`] is sorted by, and its order.
120    ///
121    /// Returns `None` while the table is unsorted, and where the sorted
122    /// column is not one of `K`, such as for a state a [`DataTable`] wrote.
123    ///
124    /// ```
125    /// use hypersig::Segment;
126    /// use hypersig_ui::{SortOrder, TableState};
127    /// use serde::{Deserialize, Serialize};
128    ///
129    /// #[derive(Segment, Serialize, Deserialize, Debug, PartialEq)]
130    /// enum By {
131    ///     Email,
132    ///     Amount,
133    /// }
134    ///
135    /// let state = TableState::default().with_sorting(&By::Amount, SortOrder::Descending);
136    /// assert_eq!(state.sorting::<By>(), Some((By::Amount, SortOrder::Descending)));
137    /// assert_eq!(TableState::default().sorting::<By>(), None);
138    /// ```
139    #[must_use]
140    pub fn sorting<K: Segment>(&self) -> Option<(K, SortOrder)> {
141        if self.sort.is_empty() {
142            return None;
143        }
144        let order = if self.descending {
145            SortOrder::Descending
146        } else {
147            SortOrder::Ascending
148        };
149        Some((K::parse(&self.sort)?, order))
150    }
151
152    /// This state sorted by the [`ServerTable`] column whose key is `key`, in `order`.
153    ///
154    /// Use it for the first state of a page whose server sends the rows
155    /// already sorted, so that the column's header says so.
156    #[must_use]
157    pub fn with_sorting<K: Segment>(mut self, key: &K, order: SortOrder) -> Self {
158        self.sort = key.write();
159        self.descending = order == SortOrder::Descending;
160        self
161    }
162}
163
164/// One column of a [`DataTable`] or a [`ServerTable`] whose rows are `T`.
165///
166/// A column shows a field of the row with [`Column::text`] or
167/// [`Column::number`], or any element with [`Column::custom`]. The table
168/// sorts by a column it receives through its `sortable_column`, which takes
169/// the key the way that table sorts.
170///
171/// ```
172/// use hypersig::Signals;
173/// use hypersig_ui::Column;
174/// use serde::{Deserialize, Serialize};
175///
176/// #[derive(Signals, Serialize, Deserialize)]
177/// struct User {
178///     id: i32,
179///     email: String,
180/// }
181///
182/// #[derive(Signals, Serialize, Deserialize)]
183/// struct Users {
184///     users: Vec<User>,
185/// }
186///
187/// let email: Column<Users, User> = Column::text("Email", User::email());
188/// # let _ = email;
189/// ```
190pub struct Column<S, T> {
191    header: String,
192    numeric: bool,
193    cell: Box<dyn Fn(Item<S, T>) -> El<S>>,
194    visible: Option<Expr<S, bool>>,
195}
196
197/// Renders the rows with the row template it receives.
198type Each<S, T> = Box<dyn FnOnce(Box<dyn FnOnce(Item<S, T>) -> El<S>>) -> Node<S>>;
199
200/// Whether a row has some property, such as being selected.
201type RowTest<S, T> = Box<dyn Fn(Item<S, T>) -> Expr<S, bool>>;
202
203/// A statement about a row, such as the one that selects it.
204type RowStmt<S, T> = Box<dyn Fn(Item<S, T>) -> Stmt<S>>;
205
206/// An element built from a row.
207type RowEl<S, T> = Box<dyn Fn(Item<S, T>) -> El<S>>;
208
209/// The key of a row, shared by the closures that read it.
210type RowKey<S, T, K> = Rc<dyn Fn(Item<S, T>) -> Expr<S, K>>;
211
212/// The text of a row that the filter searches.
213type RowText<S, T> = Box<dyn Fn(Item<S, T>) -> Expr<S, String>>;
214
215impl<S: 'static, T: 'static> Column<S, T> {
216    /// A column headed `header` that shows the text field `field` of each row.
217    #[must_use]
218    pub fn text(header: impl Into<String>, field: Sig<T, String>) -> Self {
219        Self::new(header.into(), false, move |row| {
220            span().bind_text(row.at(field))
221        })
222    }
223
224    /// A column headed `header` that shows the number field `field`, aligned to the end.
225    #[must_use]
226    pub fn number<U: Number + Textual + 'static>(
227        header: impl Into<String>,
228        field: Sig<T, U>,
229    ) -> Self {
230        Self::new(header.into(), true, move |row| {
231            span().bind_text(row.at(field).to_text())
232        })
233    }
234
235    /// A column headed `header` that shows the number field `field` written in `format`.
236    ///
237    /// ```
238    /// use hypersig::{NumberFormat, Signals};
239    /// use hypersig_ui::Column;
240    /// use serde::{Deserialize, Serialize};
241    ///
242    /// #[derive(Signals, Serialize, Deserialize)]
243    /// struct Payment {
244    ///     amount: f64,
245    /// }
246    ///
247    /// #[derive(Signals, Serialize, Deserialize)]
248    /// struct Ledger {
249    ///     payments: Vec<Payment>,
250    /// }
251    ///
252    /// const DOLLARS: NumberFormat = NumberFormat::new().digits(2).prefix("$");
253    /// let amount: Column<Ledger, Payment> = Column::formatted("Amount", Payment::amount(), DOLLARS);
254    /// # let _ = amount;
255    /// ```
256    #[must_use]
257    pub fn formatted<U: Number + 'static>(
258        header: impl Into<String>,
259        field: Sig<T, U>,
260        format: NumberFormat,
261    ) -> Self {
262        Self::new(header.into(), true, move |row| {
263            span().bind_text(row.at(field).format(&format))
264        })
265    }
266
267    /// A column headed `header` whose cells hold what `render` returns for each row.
268    #[must_use]
269    pub fn custom(
270        header: impl Into<String>,
271        render: impl Fn(Item<S, T>) -> El<S> + 'static,
272    ) -> Self {
273        Self::new(header.into(), false, render)
274    }
275
276    fn new(header: String, numeric: bool, cell: impl Fn(Item<S, T>) -> El<S> + 'static) -> Self {
277        Self {
278            header,
279            numeric,
280            cell: Box::new(cell),
281            visible: None,
282        }
283    }
284
285    /// Shows the column only while `when` is true.
286    #[must_use]
287    pub fn visible(mut self, when: impl IntoExpr<S, bool>) -> Self {
288        self.visible = Some(when.into_expr());
289        self
290    }
291}
292
293impl<S, T> fmt::Debug for Column<S, T> {
294    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295        f.debug_struct("Column")
296            .field("header", &self.header)
297            .field("numeric", &self.numeric)
298            .field("visible", &self.visible)
299            .finish_non_exhaustive()
300    }
301}
302
303/// A column and, for a sortable one, how its header sorts.
304struct Entry<S, T> {
305    column: Column<S, T>,
306    sort: Option<Sorting<S>>,
307}
308
309/// The text a sortable column writes into [`TableState`], and the
310/// statement that runs after the header writes it.
311struct Sorting<S> {
312    id: String,
313    then: Box<dyn Fn(SortOrder) -> Stmt<S>>,
314}
315
316impl<S, T> fmt::Debug for Entry<S, T> {
317    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318        f.debug_struct("Column")
319            .field("header", &self.column.header)
320            .field("sort", &self.sort.as_ref().map(|sort| &sort.id))
321            .finish_non_exhaustive()
322    }
323}
324
325/// A table of the rows of a list signal, sorted, filtered, and paged in the browser.
326///
327/// Each row is keyed by `key`, as in
328/// [`each_keyed`](hypersig::html::each_keyed), so a row keeps its element,
329/// and its checkbox its focus, when sorting moves it. Everything the table
330/// does runs in the browser on the list signal and the [`TableState`]:
331///
332/// - A column added with [`DataTable::sortable_column`] has a button in its
333///   header that sorts the rows, and the header's `aria-sort` says which way.
334/// - [`DataTable::filter`] adds a text field and shows the rows whose text
335///   contains what the visitor types, ignoring case.
336/// - [`DataTable::paginate`] shows a fixed number of the rows that pass the
337///   filter, with buttons for the previous and the next page.
338/// - [`DataTable::selectable`] adds a column of checkboxes that keep the
339///   keys of the selected rows in a list signal, under a header checkbox
340///   that selects or clears all of them. The header follows the WAI-ARIA
341///   checkbox pattern with `aria-checked="mixed"` while some rows are
342///   selected.
343///
344/// A page past the last row, which the list reaches by shrinking from
345/// outside the table, returns the table to its first page.
346///
347/// The header checkbox selects every row of the list, including rows the
348/// filter or the page hides. A selected key stays in the list after its
349/// row leaves the table, until the header clears the selection or the
350/// page removes the key. Rows are counted on each change of the list,
351/// the filter, or the page, and the work grows with the square of the
352/// number of rows while a filter and pagination are both on, so a list of
353/// thousands of rows belongs in a [`ServerTable`], which receives one page
354/// at a time from the server.
355///
356/// ```
357/// use hypersig::{Page, Signals};
358/// use hypersig_ui::{Column, DataTable, TableState};
359/// use serde::{Deserialize, Serialize};
360///
361/// #[derive(Signals, Serialize, Deserialize)]
362/// struct User {
363///     id: i32,
364///     email: String,
365/// }
366///
367/// #[derive(Signals, Serialize, Deserialize)]
368/// struct Users {
369///     users: Vec<User>,
370///     chosen: Vec<i32>,
371///     table: TableState,
372/// }
373///
374/// let table = DataTable::new("Users", Users::table(), Users::users(), |user| user.at(User::id()))
375///     .selectable(Users::chosen(), |user| user.at(User::id()))
376///     .filter("Filter emails", |user| user.at(User::email()))
377///     .paginate(10)
378///     .sortable_column(Column::text("Email", User::email()), |user| user.at(User::email()))
379///     .column(Column::number("Id", User::id()));
380/// let users = vec![User { id: 1, email: "ada@example.com".into() }];
381/// let html = Page::new("Users", Users { users, chosen: vec![1], table: TableState::default() })
382///     .child(table)
383///     .render();
384/// assert!(html.contains(r#"aria-sort="none""#));
385/// assert!(html.contains(r#"aria-checked="true""#));
386/// ```
387pub struct DataTable<S, T> {
388    label: String,
389    state: Sig<S, TableState>,
390    rows: Sig<S, Vec<T>>,
391    each: Each<S, T>,
392    columns: Vec<Entry<S, T>>,
393    selection: Option<Selection<S, T>>,
394    expansion: Option<Expansion<S, T>>,
395    filter: Option<(String, RowText<S, T>)>,
396    page_size: Option<i32>,
397    text: TableText,
398    max_height: Option<Length>,
399}
400
401impl<S: 'static, T: 'static> DataTable<S, T> {
402    /// An empty table named `label`, listing `rows` by `key`, with its state in `state`.
403    ///
404    /// The name is for assistive technology, and the table has no columns
405    /// until [`DataTable::column`] adds them.
406    #[must_use]
407    pub fn new<K: 'static, E: IntoExpr<S, K>>(
408        label: impl Into<String>,
409        state: Sig<S, TableState>,
410        rows: Sig<S, Vec<T>>,
411        key: impl FnOnce(Item<S, T>) -> E + 'static,
412    ) -> Self {
413        Self {
414            label: label.into(),
415            state,
416            rows,
417            each: Box::new(move |render| each_keyed(rows, key, render)),
418            columns: Vec::new(),
419            selection: None,
420            expansion: None,
421            filter: None,
422            page_size: None,
423            text: TableText::default(),
424            max_height: None,
425        }
426    }
427
428    /// Adds `column` after the columns added so far.
429    #[must_use]
430    pub fn column(mut self, column: Column<S, T>) -> Self {
431        self.columns.push(Entry { column, sort: None });
432        self
433    }
434
435    /// Adds `column`, whose header sorts the rows by `key`, after the columns added so far.
436    ///
437    /// The compiler requires the key to be a number or a string, because
438    /// the runtime orders only those. The first press on the header sorts
439    /// the rows ascending, and a press on a column sorted ascending sorts
440    /// it descending. Sorting reorders the list signal itself and keeps the
441    /// order of rows with equal keys, so sorting by one column and then by
442    /// another orders the rows by the second and then the first. The order
443    /// holds until the list changes. A list the server patches afterwards
444    /// arrives in the server's order, while the header still marks the
445    /// last sort.
446    #[must_use]
447    pub fn sortable_column<K: Comparable, E: IntoExpr<S, K>>(
448        mut self,
449        column: Column<S, T>,
450        key: impl Fn(Item<S, T>) -> E + 'static,
451    ) -> Self {
452        let rows = self.rows;
453        let sort = Sorting {
454            id: (self.columns.len() + 1).to_string(),
455            then: Box::new(move |order| match order {
456                SortOrder::Ascending => rows.sort_by(&key),
457                SortOrder::Descending => rows.sort_by_desc(&key),
458            }),
459        };
460        self.columns.push(Entry {
461            column,
462            sort: Some(sort),
463        });
464        self
465    }
466
467    /// Adds a first column of checkboxes that keep the `key` of each selected row in `selected`.
468    ///
469    /// The keys live in a signal of the page, apart from the rows, so a
470    /// selection needs no field in the row type and outlasts a patch of the
471    /// rows. The keys are compared as JSON, as `each_keyed` compares them.
472    #[must_use]
473    pub fn selectable<K: PartialEq + 'static, E: IntoExpr<S, K>>(
474        mut self,
475        selected: Sig<S, Vec<K>>,
476        key: impl Fn(Item<S, T>) -> E + 'static,
477    ) -> Self {
478        self.selection = Some(Selection::new(
479            self.rows,
480            selected,
481            Rc::new(move |row| key(row).into_expr()),
482        ));
483        self
484    }
485
486    /// Adds a first column of buttons that show `details` of a row under it, keeping the `key` of each open row in `expanded`.
487    ///
488    /// The button carries `aria-expanded`, as the WAI-ARIA disclosure
489    /// pattern asks, and the details fill a row of their own under the row,
490    /// across every column. Each row and its details are a `<tbody>` of
491    /// their own, so the two move together when the rows are sorted.
492    #[must_use]
493    pub fn expandable<K: PartialEq + 'static, E: IntoExpr<S, K>>(
494        mut self,
495        expanded: Sig<S, Vec<K>>,
496        key: impl Fn(Item<S, T>) -> E + 'static,
497        details: impl Fn(Item<S, T>) -> El<S> + 'static,
498    ) -> Self {
499        self.expansion = Some(Expansion {
500            keys: KeySet::new(expanded, Rc::new(move |row| key(row).into_expr())),
501            details: Box::new(details),
502        });
503        self
504    }
505
506    /// Adds a field named `label` that shows the rows whose `text` contains its text.
507    ///
508    /// The field sits above the table. The match ignores case, and typing
509    /// returns the table to its first page.
510    #[must_use]
511    pub fn filter<E: IntoExpr<S, String>>(
512        mut self,
513        label: impl Into<String>,
514        text: impl Fn(Item<S, T>) -> E + 'static,
515    ) -> Self {
516        self.filter = Some((label.into(), Box::new(move |row| text(row).into_expr())));
517        self
518    }
519
520    /// Shows `size` rows at a time, with buttons for the previous and next page.
521    ///
522    /// Below the table, the range of rows shown is written as `1–5 / 12`.
523    ///
524    /// # Panics
525    ///
526    /// Panics if `size` is less than 1.
527    #[must_use]
528    pub fn paginate(mut self, size: i32) -> Self {
529        assert!(size >= 1, "a page holds at least one row");
530        self.page_size = Some(size);
531        self
532    }
533
534    /// Replaces the words the table shows and names its controls with, English until set.
535    #[must_use]
536    pub fn text(mut self, text: TableText) -> Self {
537        self.text = text;
538        self
539    }
540
541    /// Keeps the table at most `height` high, scrolling its rows under a header that stays in place.
542    #[must_use]
543    pub fn max_height(mut self, height: Length) -> Self {
544        self.max_height = Some(height);
545        self
546    }
547
548    /// The filter field, the table, and the page buttons, in a `<div>`.
549    #[must_use]
550    pub fn render(self) -> El<S> {
551        let Self {
552            label,
553            state,
554            rows,
555            each,
556            columns,
557            selection,
558            expansion,
559            filter,
560            page_size,
561            text,
562            max_height,
563        } = self;
564        let query = state.at(TableState::query());
565        let page = state.at(TableState::page());
566        let (filter_label, row_text) = filter.unzip();
567        let filtered = row_text.is_some();
568        let matches = move |row: Item<S, T>| -> Expr<S, bool> {
569            match &row_text {
570                Some(row_text) => row_text(row)
571                    .to_lowercase()
572                    .contains(query.trim().to_lowercase()),
573                None => Expr::lit(&true),
574            }
575        };
576        let shown = if filtered {
577            rows.count(&matches)
578        } else {
579            rows.len()
580        };
581
582        let lead = lead_heads(rows, selection.as_ref(), expansion.is_some(), &text);
583        let (head, cells) = header_row(columns, lead, state);
584        let body = Body {
585            cells,
586            selection,
587            expansion,
588            text: text.clone(),
589        };
590        let (width, grouped) = (body.width(), body.expansion.is_some());
591
592        let row_shown = move |row: Item<S, T>| -> Expr<S, bool> {
593            let Some(size) = page_size else {
594                return matches(row);
595            };
596            let rank = if filtered {
597                rows.count(|other| other.index().lt(row.index()).and(matches(other)))
598            } else {
599                row.index()
600            };
601            let start = page * size;
602            matches(row)
603                .and(rank.clone().ge(start.clone()))
604                .and(rank.lt(start + size))
605        };
606        let body = each(Box::new(move |row| body.row(row, Some(row_shown(row)))));
607
608        let mut out = div().css(stack(rem(1)));
609        if let Some(label) = filter_label {
610            out = out.child(filter_field(label, query).on_input(page.set(0)));
611        }
612        out = out.child(frame(
613            label,
614            max_height,
615            grouped,
616            head,
617            body,
618            empty_row(shown.clone().equals(0), width, text.empty.clone()),
619        ));
620        if let Some(size) = page_size {
621            // The list can shrink from outside the table, through a patch or
622            // a statement of the page, and leave the page past its last row.
623            let past_end = page.gt(0).and((page * size).ge(shown.clone()));
624            out = out
625                .effect(Stmt::when(page.lt(0).or(past_end), page.set(0)))
626                .child(pager(page, shown, size, None, &text));
627        }
628        out
629    }
630}
631
632impl<S, T> fmt::Debug for DataTable<S, T> {
633    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
634        f.debug_struct("DataTable")
635            .field("label", &self.label)
636            .field("state", &self.state)
637            .field("rows", &self.rows)
638            .field("columns", &self.columns)
639            .field("selectable", &self.selection.is_some())
640            .field("expandable", &self.expansion.is_some())
641            .field("filter", &self.filter.as_ref().map(|(label, _)| label))
642            .field("page_size", &self.page_size)
643            .field("max_height", &self.max_height)
644            .finish_non_exhaustive()
645    }
646}
647
648impl<S: 'static, T: 'static> From<DataTable<S, T>> for El<S> {
649    fn from(table: DataTable<S, T>) -> Self {
650        table.render()
651    }
652}
653
654impl<S: 'static, T: 'static> From<DataTable<S, T>> for Node<S> {
655    fn from(table: DataTable<S, T>) -> Self {
656        table.render().into()
657    }
658}
659
660/// A table whose rows the server filters, sorts, and pages, one page at a time.
661///
662/// The list signal holds only the rows shown. The filter field, a sortable
663/// header, and the page buttons write the [`TableState`] and then run
664/// `load`, which is a call to an action that receives the state. The
665/// action answers with a patch of the rows and, for a paged table, of the
666/// total. The browser counts no rows, so the browser's work per change
667/// grows with the rows of one page, however many the server holds.
668///
669/// - [`ServerTable::sortable_column`] takes a key the server reads back
670///   with [`TableState::sorting`], so both sides name the column with the
671///   same enum.
672/// - [`ServerTable::filter`] runs `load` once typing pauses for 300 ms, and
673///   returns to the first page.
674/// - [`ServerTable::paginate`] shows the range of rows and moves between
675///   pages, out of the total that the server writes.
676///
677/// What the server does with the state is the application's code, which
678/// the compiler does not check against the table: it answers with the
679/// rows of the page the state names, and a total that counts every row
680/// that passes the filter. A page past the last row shows no rows until
681/// the visitor goes back.
682///
683/// ```
684/// use hypersig::{Action, Page, Patches, Segment, Signals};
685/// use hypersig_ui::{Column, ServerTable, SortOrder, TableState};
686/// use serde::{Deserialize, Serialize};
687///
688/// #[derive(Signals, Serialize, Deserialize, Clone)]
689/// struct User {
690///     id: i32,
691///     email: String,
692/// }
693///
694/// #[derive(Signals, Serialize, Deserialize)]
695/// struct Users {
696///     users: Vec<User>,
697///     total: i32,
698///     table: TableState,
699/// }
700///
701/// #[derive(Segment, Serialize, Deserialize)]
702/// enum By {
703///     Email,
704/// }
705///
706/// const LOAD: Action<Users, TableState> = Action::post("/users");
707///
708/// /// The page of users `state` names, and how many pass its filter.
709/// fn query(all: &[User], state: &TableState) -> (Vec<User>, i32) {
710///     let mut rows: Vec<User> = all
711///         .iter()
712///         .filter(|user| user.email.contains(&state.query.to_lowercase()))
713///         .cloned()
714///         .collect();
715///     if let Some((By::Email, order)) = state.sorting::<By>() {
716///         rows.sort_by(|a, b| a.email.cmp(&b.email));
717///         if order == SortOrder::Descending {
718///             rows.reverse();
719///         }
720///     }
721///     let total = i32::try_from(rows.len()).unwrap_or(i32::MAX);
722///     let start = usize::try_from(state.page * 10).unwrap_or(0);
723///     (rows.into_iter().skip(start).take(10).collect(), total)
724/// }
725///
726/// async fn load((): (), state: TableState) -> Patches<Users> {
727///     let all = vec![User { id: 1, email: "ada@example.com".into() }];
728///     let (rows, total) = query(&all, &state);
729///     Users::users().patch(rows).and(Users::total().patch(total)).into()
730/// }
731///
732/// let table = ServerTable::new(
733///     "Users",
734///     Users::table(),
735///     Users::users(),
736///     |user| user.at(User::id()),
737///     LOAD.call_with(Users::table()),
738/// )
739/// .filter("Filter emails")
740/// .paginate(10, Users::total())
741/// .sortable_column(Column::text("Email", User::email()), &By::Email);
742/// let all = vec![User { id: 1, email: "ada@example.com".into() }];
743/// let (users, total) = query(&all, &TableState::default());
744/// let html = Page::new("Users", Users { users, total, table: TableState::default() })
745///     .child(table)
746///     .render();
747/// assert!(html.contains("1–1 / 1"));
748/// # let _ = load;
749/// ```
750pub struct ServerTable<S, T> {
751    label: String,
752    state: Sig<S, TableState>,
753    rows: Sig<S, Vec<T>>,
754    each: Each<S, T>,
755    load: Stmt<S>,
756    columns: Vec<Entry<S, T>>,
757    selection: Option<Selection<S, T>>,
758    expansion: Option<Expansion<S, T>>,
759    filter: Option<String>,
760    pages: Option<(i32, Sig<S, i32>)>,
761    text: TableText,
762    max_height: Option<Length>,
763}
764
765impl<S: 'static, T: 'static> ServerTable<S, T> {
766    /// An empty table named `label`, listing `rows` by `key`, which runs `load` to fetch rows.
767    ///
768    /// `state` holds the filter text, the page, and the sort, and `load`
769    /// runs after the visitor changes one of them.
770    #[must_use]
771    pub fn new<K: 'static, E: IntoExpr<S, K>>(
772        label: impl Into<String>,
773        state: Sig<S, TableState>,
774        rows: Sig<S, Vec<T>>,
775        key: impl FnOnce(Item<S, T>) -> E + 'static,
776        load: impl IntoStmt<S>,
777    ) -> Self {
778        Self {
779            label: label.into(),
780            state,
781            rows,
782            each: Box::new(move |render| each_keyed(rows, key, render)),
783            load: load.into_stmt(),
784            columns: Vec::new(),
785            selection: None,
786            expansion: None,
787            filter: None,
788            pages: None,
789            text: TableText::default(),
790            max_height: None,
791        }
792    }
793
794    /// Adds `column` after the columns added so far.
795    #[must_use]
796    pub fn column(mut self, column: Column<S, T>) -> Self {
797        self.columns.push(Entry { column, sort: None });
798        self
799    }
800
801    /// Adds `column`, whose header asks the server to sort by `key`, after the columns added so far.
802    ///
803    /// The header writes `key` into the [`TableState`], returns to the
804    /// first page, and runs `load`. The server reads the key back with
805    /// [`TableState::sorting`]. An enum with `#[derive(Segment)]` keeps the
806    /// keys of the columns and the server's `match` in one type.
807    #[must_use]
808    pub fn sortable_column<K: Segment>(mut self, column: Column<S, T>, key: &K) -> Self {
809        let page = self.state.at(TableState::page());
810        let load = self.load.clone();
811        let sort = Sorting {
812            id: key.write(),
813            then: Box::new(move |_| page.set(0).then(load.clone())),
814        };
815        self.columns.push(Entry {
816            column,
817            sort: Some(sort),
818        });
819        self
820    }
821
822    /// Adds a first column of checkboxes that keep the `key` of each selected row in `selected`.
823    ///
824    /// The keys live in a signal of the page, so a row selected on one page
825    /// stays selected while the visitor moves to another, and the server
826    /// receives the keys as an argument of an action. The header checkbox
827    /// selects or clears the rows of the page shown.
828    #[must_use]
829    pub fn selectable<K: PartialEq + 'static, E: IntoExpr<S, K>>(
830        mut self,
831        selected: Sig<S, Vec<K>>,
832        key: impl Fn(Item<S, T>) -> E + 'static,
833    ) -> Self {
834        self.selection = Some(Selection::new(
835            self.rows,
836            selected,
837            Rc::new(move |row| key(row).into_expr()),
838        ));
839        self
840    }
841
842    /// Adds a first column of buttons that show `details` of a row under it, keeping the `key` of each open row in `expanded`.
843    ///
844    /// The button carries `aria-expanded`, as the WAI-ARIA disclosure
845    /// pattern asks, and the details fill a row of their own under the row,
846    /// across every column. Each row and its details are a `<tbody>` of
847    /// their own, so the two move together when the rows are sorted.
848    #[must_use]
849    pub fn expandable<K: PartialEq + 'static, E: IntoExpr<S, K>>(
850        mut self,
851        expanded: Sig<S, Vec<K>>,
852        key: impl Fn(Item<S, T>) -> E + 'static,
853        details: impl Fn(Item<S, T>) -> El<S> + 'static,
854    ) -> Self {
855        self.expansion = Some(Expansion {
856            keys: KeySet::new(expanded, Rc::new(move |row| key(row).into_expr())),
857            details: Box::new(details),
858        });
859        self
860    }
861
862    /// Adds a field named `label` whose text the server filters the rows by.
863    ///
864    /// The field sits above the table. Once typing pauses for 300 ms, the
865    /// table returns to its first page and runs `load`.
866    #[must_use]
867    pub fn filter(mut self, label: impl Into<String>) -> Self {
868        self.filter = Some(label.into());
869        self
870    }
871
872    /// Shows pages of `size` rows out of `total`, with buttons for the previous and next page.
873    ///
874    /// The server answers each `load` with at most `size` rows and writes
875    /// `total`, the number of rows that pass the filter. Below the table,
876    /// the range of rows shown is written as `11–20 / 42`.
877    ///
878    /// # Panics
879    ///
880    /// Panics if `size` is less than 1.
881    #[must_use]
882    pub fn paginate(mut self, size: i32, total: Sig<S, i32>) -> Self {
883        assert!(size >= 1, "a page holds at least one row");
884        self.pages = Some((size, total));
885        self
886    }
887
888    /// Replaces the words the table shows and names its controls with, English until set.
889    #[must_use]
890    pub fn text(mut self, text: TableText) -> Self {
891        self.text = text;
892        self
893    }
894
895    /// Keeps the table at most `height` high, scrolling its rows under a header that stays in place.
896    #[must_use]
897    pub fn max_height(mut self, height: Length) -> Self {
898        self.max_height = Some(height);
899        self
900    }
901
902    /// The filter field, the table, and the page buttons, in a `<div>`.
903    #[must_use]
904    pub fn render(self) -> El<S> {
905        let Self {
906            label,
907            state,
908            rows,
909            each,
910            load,
911            columns,
912            selection,
913            expansion,
914            filter,
915            pages,
916            text,
917            max_height,
918        } = self;
919        let query = state.at(TableState::query());
920        let page = state.at(TableState::page());
921        let lead = lead_heads(rows, selection.as_ref(), expansion.is_some(), &text);
922        let (head, cells) = header_row(columns, lead, state);
923        let body = Body {
924            cells,
925            selection,
926            expansion,
927            text: text.clone(),
928        };
929        let (width, grouped) = (body.width(), body.expansion.is_some());
930        let body = each(Box::new(move |row| body.row(row, None)));
931
932        let mut out = div().css(stack(rem(1)));
933        if let Some(label) = filter {
934            out = out.child(filter_field(label, query).on_debounced(
935                Event::Input,
936                Duration::from_millis(300),
937                page.set(0).then(load.clone()),
938            ));
939        }
940        out = out.child(frame(
941            label,
942            max_height,
943            grouped,
944            head,
945            body,
946            empty_row(rows.is_empty(), width, text.empty.clone()),
947        ));
948        if let Some((size, total)) = pages {
949            out = out.child(pager(page, total.into_expr(), size, Some(&load), &text));
950        }
951        out
952    }
953}
954
955impl<S, T> fmt::Debug for ServerTable<S, T> {
956    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
957        f.debug_struct("ServerTable")
958            .field("label", &self.label)
959            .field("state", &self.state)
960            .field("rows", &self.rows)
961            .field("load", &self.load)
962            .field("columns", &self.columns)
963            .field("selectable", &self.selection.is_some())
964            .field("expandable", &self.expansion.is_some())
965            .field("filter", &self.filter)
966            .field("pages", &self.pages)
967            .field("max_height", &self.max_height)
968            .finish_non_exhaustive()
969    }
970}
971
972impl<S: 'static, T: 'static> From<ServerTable<S, T>> for El<S> {
973    fn from(table: ServerTable<S, T>) -> Self {
974        table.render()
975    }
976}
977
978impl<S: 'static, T: 'static> From<ServerTable<S, T>> for Node<S> {
979    fn from(table: ServerTable<S, T>) -> Self {
980        table.render().into()
981    }
982}
983
984/// Whether a column is numeric, how it renders a row, and when it shows.
985type Cell<S, T> = (
986    bool,
987    Box<dyn Fn(Item<S, T>) -> El<S>>,
988    Option<Expr<S, bool>>,
989);
990
991/// The header row, after the cells of `lead`, and for each column the
992/// parts its cells need.
993fn header_row<S: 'static, T: 'static>(
994    columns: Vec<Entry<S, T>>,
995    lead: Vec<El<S>>,
996    state: Sig<S, TableState>,
997) -> (El<S>, Vec<Cell<S, T>>) {
998    let mut head = tr().children(lead);
999    let mut cells = Vec::with_capacity(columns.len());
1000    for Entry { column, sort } in columns {
1001        let heading = th().attr("scope", "col").css(head_cell(column.numeric));
1002        let mut heading = match sort {
1003            Some(sort) => sort_header(heading, column.header, column.numeric, sort, state),
1004            None => heading.text(column.header),
1005        };
1006        if let Some(visible) = &column.visible {
1007            heading = heading.show(visible.clone());
1008        }
1009        head = head.child(heading);
1010        cells.push((column.numeric, column.cell, column.visible));
1011    }
1012    (head, cells)
1013}
1014
1015/// `line` with a cell per column for `row`.
1016fn row_cells<S, T>(mut line: El<S>, cells: &[Cell<S, T>], row: Item<S, T>) -> El<S> {
1017    for (numeric, cell, visible) in cells {
1018        let mut data = td().css(body_cell(*numeric)).child(cell(row));
1019        if let Some(visible) = visible {
1020            data = data.show(visible.clone());
1021        }
1022        line = line.child(data);
1023    }
1024    line
1025}
1026
1027/// The rounded border around the `<table>` named `label`.
1028///
1029/// With a `max_height`, the border scrolls the rows and the header sticks
1030/// to its top.
1031///
1032/// Where the rows are `grouped`, each is a `<tbody>` of its own, and the
1033/// empty row gets one too.
1034fn frame<S>(
1035    label: String,
1036    max_height: Option<Length>,
1037    grouped: bool,
1038    head: El<S>,
1039    body: Node<S>,
1040    empty_row: El<S>,
1041) -> El<S> {
1042    let mut border = div().css(frame_style());
1043    let mut header = thead();
1044    if let Some(height) = max_height {
1045        border = border.css(Style::new().max_height(height).overflow_y(OverflowY::Auto));
1046        header = header.css(
1047            Style::new()
1048                .position(Position::Sticky)
1049                .top(px(0))
1050                .z_index(ZIndex::Integer(1))
1051                .background_color(token::background().var())
1052                // The border of a collapsed table stays with the scrolled
1053                // rows, so the line under the header is a shadow.
1054                .box_shadow(raw(format!("inset 0 -1px 0 {}", token::border().var()))),
1055        );
1056    }
1057    let table = table()
1058        .attr("aria-label", label)
1059        .css(table_style())
1060        .child(header.child(head));
1061    border.child(if grouped {
1062        table.child(body).child(tbody().child(empty_row))
1063    } else {
1064        table.child(tbody().child(body).child(empty_row))
1065    })
1066}
1067
1068/// The row that spans `width` columns and says `text` while `when` is true.
1069fn empty_row<S>(when: Expr<S, bool>, width: usize, text: String) -> El<S> {
1070    tr().show(when).child(
1071        td().attr("colspan", width.to_string())
1072            .css(empty_cell())
1073            .text(text),
1074    )
1075}
1076
1077/// The search field named `label` bound to `query`.
1078fn filter_field<S>(label: String, query: Sig<S, String>) -> El<S> {
1079    input()
1080        .attr("type", "search")
1081        .attr("aria-label", label.clone())
1082        .attr("placeholder", label)
1083        .css(field_style().max_width(rem(24)))
1084        .bind(query)
1085}
1086
1087/// `heading` with a button that writes `sort` into `state` and runs its
1088/// statement, and the `aria-sort` that follows `state`.
1089fn sort_header<S>(
1090    heading: El<S>,
1091    header: String,
1092    numeric: bool,
1093    sort: Sorting<S>,
1094    state: Sig<S, TableState>,
1095) -> El<S> {
1096    let column = state.at(TableState::sort());
1097    let descending = state.at(TableState::descending());
1098    let on = column.equals(sort.id.clone());
1099    let up = on.clone().and(!descending);
1100    let down = on.and(descending);
1101    let toggle = Stmt::if_else(
1102        up.clone(),
1103        descending
1104            .set(true)
1105            .then((sort.then)(SortOrder::Descending)),
1106        column
1107            .set(sort.id)
1108            .then(descending.set(false))
1109            .then((sort.then)(SortOrder::Ascending)),
1110    );
1111    heading
1112        .bind_attr(
1113            "aria-sort",
1114            if_else(
1115                up.clone(),
1116                "ascending",
1117                if_else(down.clone(), "descending", "none"),
1118            ),
1119        )
1120        .child(
1121            button()
1122                .attr("type", "button")
1123                .css(sort_button(numeric))
1124                .on_click(toggle)
1125                .child(span().text(header))
1126                .child(icon(icon::ARROW_UP, rem(1)).show(up.clone()))
1127                .child(icon(icon::ARROW_DOWN, rem(1)).show(down.clone()))
1128                .child(
1129                    icon(icon::ARROW_UP_DOWN, rem(1))
1130                        .show(!up.or(down))
1131                        .css(Style::new().color(token::muted_foreground().var())),
1132                ),
1133        )
1134}
1135
1136/// A list of row keys in a signal: whether a row's key is in it, and the
1137/// statement that puts the key in or takes it out.
1138struct KeySet<S, T> {
1139    has: RowTest<S, T>,
1140    toggle: RowStmt<S, T>,
1141}
1142
1143impl<S: 'static, T: 'static> KeySet<S, T> {
1144    fn new<K: PartialEq + 'static>(list: Sig<S, Vec<K>>, key: RowKey<S, T, K>) -> Self {
1145        let has = {
1146            let key = Rc::clone(&key);
1147            move |row: Item<S, T>| list.count(|kept| kept.value().equals(key(row))).gt(0)
1148        };
1149        let toggle = {
1150            let has = has.clone();
1151            move |row: Item<S, T>| {
1152                Stmt::if_else(
1153                    has(row),
1154                    list.retain(|kept| kept.value().not_equals(key(row))),
1155                    list.push(key(row)),
1156                )
1157            }
1158        };
1159        Self {
1160            has: Box::new(has),
1161            toggle: Box::new(toggle),
1162        }
1163    }
1164}
1165
1166/// Which rows are selected, and the statements of the header checkbox.
1167struct Selection<S, T> {
1168    keys: KeySet<S, T>,
1169    select_all: Stmt<S>,
1170    clear_all: Stmt<S>,
1171}
1172
1173impl<S: 'static, T: 'static> Selection<S, T> {
1174    /// The selection of `rows` by `key`, kept in `selected`.
1175    fn new<K: PartialEq + 'static>(
1176        rows: Sig<S, Vec<T>>,
1177        selected: Sig<S, Vec<K>>,
1178        key: RowKey<S, T, K>,
1179    ) -> Self {
1180        // Keeps the keys of rows outside `rows`, such as another page's.
1181        let clear_all =
1182            selected.retain(|chosen| rows.count(|row| key(row).equals(chosen.value())).equals(0));
1183        let select_all = clear_all
1184            .clone()
1185            .then(selected.append(rows.map(|row| key(row))));
1186        Self {
1187            keys: KeySet::new(selected, key),
1188            select_all,
1189            clear_all,
1190        }
1191    }
1192}
1193
1194/// Which rows show their details, and how the details of a row look.
1195struct Expansion<S, T> {
1196    keys: KeySet<S, T>,
1197    details: RowEl<S, T>,
1198}
1199
1200/// The parts a row of the body is built from, which both tables share.
1201struct Body<S, T> {
1202    cells: Vec<Cell<S, T>>,
1203    selection: Option<Selection<S, T>>,
1204    expansion: Option<Expansion<S, T>>,
1205    text: TableText,
1206}
1207
1208impl<S, T> Body<S, T> {
1209    /// The number of columns, those of the buttons and the checkboxes included.
1210    fn width(&self) -> usize {
1211        self.cells.len()
1212            + usize::from(self.selection.is_some())
1213            + usize::from(self.expansion.is_some())
1214    }
1215
1216    /// The row of `row`, shown while `shown` is true where there is one,
1217    /// with its details under it where the table expands rows.
1218    fn row(&self, row: Item<S, T>, shown: Option<Expr<S, bool>>) -> El<S> {
1219        let mut line = tr().css(body_row());
1220        if let Some(expansion) = &self.expansion {
1221            let open = (expansion.keys.has)(row);
1222            line = line.child(
1223                td().css(select_cell()).child(
1224                    icon_button(ButtonVariant::Ghost, self.text.expand_row.clone())
1225                        .bind_attr("aria-expanded", if_else(open.clone(), "true", "false"))
1226                        .on_click((expansion.keys.toggle)(row))
1227                        .child(
1228                            icon(icon::CHEVRON_RIGHT, rem(1))
1229                                .css(look::transition())
1230                                .css_when(Style::new().rotate(raw("90deg")), open),
1231                        ),
1232                ),
1233            );
1234        }
1235        if let Some(selection) = &self.selection {
1236            let checked = (selection.keys.has)(row);
1237            line = line.css_when(selected_row(), checked.clone()).child(
1238                td().css(select_cell()).child(check_mark(
1239                    input()
1240                        .attr("aria-label", self.text.select_row.clone())
1241                        .bind_checked(checked)
1242                        .on_change((selection.keys.toggle)(row)),
1243                )),
1244            );
1245        }
1246        let line = row_cells(line, &self.cells, row);
1247        let Some(expansion) = &self.expansion else {
1248            return match shown {
1249                Some(shown) => line.show(shown),
1250                None => line,
1251            };
1252        };
1253        let details = tr().show((expansion.keys.has)(row)).child(
1254            td().attr("colspan", self.width().to_string())
1255                .css(details_cell())
1256                .child((expansion.details)(row)),
1257        );
1258        let group = tbody().child(line).child(details);
1259        match shown {
1260            Some(shown) => group.show(shown),
1261            None => group,
1262        }
1263    }
1264}
1265
1266/// The header cells before the columns: an empty one over the buttons
1267/// that expand rows, and the checkbox over those that select them.
1268fn lead_heads<S, T>(
1269    rows: Sig<S, Vec<T>>,
1270    selection: Option<&Selection<S, T>>,
1271    expandable: bool,
1272    text: &TableText,
1273) -> Vec<El<S>> {
1274    let mut lead = Vec::new();
1275    if expandable {
1276        lead.push(td().css(select_cell()));
1277    }
1278    if let Some(selection) = selection {
1279        lead.push(
1280            th().attr("scope", "col")
1281                .css(select_cell())
1282                .child(select_all(rows, selection, &text.select_all)),
1283        );
1284    }
1285    lead
1286}
1287
1288/// The header checkbox: checked while every row of `rows` is selected,
1289/// mixed while some are, and a press selects every row unless all are
1290/// selected already.
1291fn select_all<S, T>(rows: Sig<S, Vec<T>>, selection: &Selection<S, T>, label: &str) -> El<S> {
1292    let count = rows.count(|row| (selection.keys.has)(row));
1293    let all = count.clone().equals(rows.len()).and(rows.len().gt(0));
1294    let some = count.gt(0);
1295    let mark = span()
1296        .attr("aria-hidden", "true")
1297        .css(check_box())
1298        .css_when(check_box_checked(), some.clone())
1299        .child(icon(icon::CHECK, rem(0.875)).show(all.clone()))
1300        .child(icon(icon::MINUS, rem(0.875)).show(some.clone().and(!all.clone())));
1301    button()
1302        .attr("type", "button")
1303        .attr("role", "checkbox")
1304        .attr("aria-label", label)
1305        .bind_attr(
1306            "aria-checked",
1307            if_else(all.clone(), "true", if_else(some, "mixed", "false")),
1308        )
1309        .on_click(Stmt::if_else(
1310            all,
1311            selection.clear_all.clone(),
1312            selection.select_all.clone(),
1313        ))
1314        .css(
1315            Style::new()
1316                .display(Display::InlineFlex)
1317                .padding(px(0))
1318                .border_width(px(0))
1319                .background_color(Color::TRANSPARENT)
1320                .cursor(Cursor::Pointer)
1321                .border_radius(px(4))
1322                .merge(look::focus_ring()),
1323        )
1324        .child(mark)
1325}
1326
1327/// The range of rows shown out of `shown`, and the buttons that move
1328/// between pages and then run `then`.
1329fn pager<S>(
1330    page: Sig<S, i32>,
1331    shown: Expr<S, i32>,
1332    size: i32,
1333    then: Option<&Stmt<S>>,
1334    text: &TableText,
1335) -> El<S> {
1336    let start = page * size;
1337    let end = (start.clone() + size).min(shown.clone());
1338    let first = if_else(shown.clone().equals(0), Expr::lit(&0), start.clone() + 1);
1339    let has_next = (start.clone() + size).lt(shown.clone());
1340    let go = |to: Expr<S, i32>| match then {
1341        Some(then) => page.set(to).then(then.clone()),
1342        None => page.set(to),
1343    };
1344    div()
1345        .css(
1346            Style::new()
1347                .display(Display::Flex)
1348                .align_items(AlignItems::Center)
1349                .justify_content(JustifyContent::End)
1350                .column_gap(rem(0.5)),
1351        )
1352        .child(
1353            p().css(
1354                Style::new()
1355                    .flex_grow(1.0)
1356                    .color(token::muted_foreground().var())
1357                    .font_variant_numeric(FontVariantNumeric::TabularNums)
1358                    .merge(look::small_text()),
1359            )
1360            .attr("aria-live", "polite")
1361            .bind_text(
1362                first
1363                    .to_text()
1364                    .concat("–")
1365                    .concat(end.to_text())
1366                    .concat(" / ")
1367                    .concat(shown.to_text()),
1368            ),
1369        )
1370        .child(
1371            icon_button(ButtonVariant::Outline, text.previous_page.clone())
1372                .disabled(page.le(0))
1373                .on_click(Stmt::when(page.gt(0), go(page - 1)))
1374                .child(icon(icon::CHEVRON_LEFT, rem(1))),
1375        )
1376        .child(
1377            icon_button(ButtonVariant::Outline, text.next_page.clone())
1378                .disabled(!has_next.clone())
1379                .on_click(Stmt::when(has_next, go(page + 1)))
1380                .child(icon(icon::CHEVRON_RIGHT, rem(1))),
1381        )
1382}
1383
1384/// The rounded border around the table, which scrolls sideways on a narrow screen.
1385fn frame_style() -> Style {
1386    Style::new()
1387        .overflow_x(OverflowX::Auto)
1388        .border_width(px(1))
1389        .border_style(BorderStyle::Solid)
1390        .border_color(token::border().var())
1391        .border_radius(token::radius().var())
1392}
1393
1394fn table_style() -> Style {
1395    Style::new()
1396        .width(pct(100))
1397        .border_collapse(BorderCollapse::Collapse)
1398        .caption_side(CaptionSide::Bottom)
1399        .merge(look::small_text())
1400}
1401
1402fn row_line() -> Style {
1403    Style::new()
1404        .border_bottom_width(px(1))
1405        .border_bottom_style(BorderBottomStyle::Solid)
1406        .border_bottom_color(token::border().var())
1407}
1408
1409fn head_cell(numeric: bool) -> Style {
1410    row_line()
1411        .height(rem(2.5))
1412        .padding_inline(rem(0.5))
1413        .text_align(if numeric {
1414            TextAlign::End
1415        } else {
1416            TextAlign::Start
1417        })
1418        .vertical_align(VerticalAlign::Middle)
1419        .font_weight(FontWeight::Number(500.0))
1420        .white_space(WhiteSpace::Nowrap)
1421        .color(token::muted_foreground().var())
1422}
1423
1424fn sort_button(numeric: bool) -> Style {
1425    button_style(ButtonVariant::Ghost, ButtonSize::Sm)
1426        .column_gap(rem(0.5))
1427        .margin_inline(rem(-0.75))
1428        .flex_direction(if numeric {
1429            FlexDirection::RowReverse
1430        } else {
1431            FlexDirection::Row
1432        })
1433}
1434
1435fn body_row() -> Style {
1436    row_line().merge(look::transition()).on(
1437        PseudoClass::Hover,
1438        Style::new().background_color(token::muted().var()),
1439    )
1440}
1441
1442/// The cell that holds a row's details, under the row.
1443fn details_cell() -> Style {
1444    row_line()
1445        .padding(rem(1))
1446        .background_color(token::muted().var().opacity(50))
1447}
1448
1449fn selected_row() -> Style {
1450    Style::new().background_color(token::muted().var())
1451}
1452
1453fn body_cell(numeric: bool) -> Style {
1454    Style::new()
1455        .padding(rem(0.5))
1456        .vertical_align(VerticalAlign::Middle)
1457        .text_align(if numeric {
1458            TextAlign::End
1459        } else {
1460            TextAlign::Start
1461        })
1462        .font_variant_numeric(if numeric {
1463            FontVariantNumeric::TabularNums
1464        } else {
1465            FontVariantNumeric::Normal
1466        })
1467}
1468
1469fn select_cell() -> Style {
1470    Style::new()
1471        .width(rem(2.5))
1472        .padding_inline(rem(0.5))
1473        .vertical_align(VerticalAlign::Middle)
1474}
1475
1476fn empty_cell() -> Style {
1477    Style::new()
1478        .height(rem(6))
1479        .text_align(TextAlign::Center)
1480        .color(token::muted_foreground().var())
1481}