Skip to main content

fc_dioxus/components/
table.rs

1//! Data tables with sortable columns, from table.css. Sorting ports upstream's
2//! `table.ts`; see [`crate::behavior::sort`].
3use crate::{
4    behavior::sort::{self, SortDirection},
5    core::{
6        assets,
7        attrs::{availability_attributes, component_attributes},
8    },
9    prelude::*,
10    theme::use_stylesheets,
11};
12const BEM: Bem = Bem::new("fc-table");
13const WRAP: Bem = Bem::new("fc-table-wrap");
14const NUM: Bem = Bem::new("fc-table__cell--num");
15const HIDDEN: Bem = Bem::new("fc-u-visually-hidden");
16
17/// One column.
18#[derive(Debug, Clone, PartialEq)]
19pub struct TableColumn {
20    /// Header text.
21    pub header: String,
22    /// Right-align figures so they compare at a glance.
23    pub numeric: bool,
24    /// Cells in this column are row headers (`<th scope="row">`).
25    pub row_header: bool,
26    /// Whether the header offers sorting, and whether it can be used now.
27    pub sort: Option<Availability>,
28}
29impl TableColumn {
30    /// A plain, unsortable column.
31    #[must_use]
32    pub fn new(header: impl Into<String>) -> Self {
33        Self {
34            header: header.into(),
35            numeric: false,
36            row_header: false,
37            sort: None,
38        }
39    }
40    /// A column that can be sorted.
41    #[must_use]
42    pub fn sortable(mut self) -> Self {
43        self.sort = Some(Availability::Enabled);
44        self
45    }
46    /// Right-aligned figures.
47    #[must_use]
48    pub fn numeric(mut self) -> Self {
49        self.numeric = true;
50        self
51    }
52    /// Cells are row headers.
53    #[must_use]
54    pub fn row_header(mut self) -> Self {
55        self.row_header = true;
56        self
57    }
58}
59
60/// One cell: what sorts, and optionally richer content to show.
61#[derive(Debug, Clone, PartialEq)]
62pub struct TableCell {
63    /// The value sorting compares. What is shown is not always what sorts: "3 min ago"
64    /// or "12.5 KB" need their raw value here.
65    pub sort_key: String,
66    /// Shown instead of `sort_key` when given.
67    pub content: Option<Element>,
68}
69impl TableCell {
70    /// A cell showing and sorting by the same text.
71    #[must_use]
72    pub fn text(text: impl Into<String>) -> Self {
73        Self {
74            sort_key: text.into(),
75            content: None,
76        }
77    }
78    /// A cell that shows `content` and sorts by `sort_key`.
79    #[must_use]
80    pub fn rich(sort_key: impl Into<String>, content: Element) -> Self {
81        Self {
82            sort_key: sort_key.into(),
83            content: Some(content),
84        }
85    }
86}
87
88/// One row.
89#[derive(Debug, Clone, PartialEq)]
90pub struct TableRow {
91    /// Stable identity, so a row keeps its state when sorting moves it.
92    pub key: String,
93    /// Cells, one per column.
94    pub cells: Vec<TableCell>,
95    /// The row is selected (`aria-selected`).
96    pub selected: bool,
97}
98
99/// Who reorders the rows when the sort changes.
100#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
101pub enum SortMode {
102    /// The table sorts its rows.
103    #[default]
104    Client,
105    /// Only `aria-sort` moves; `onsort` asks the owner for new rows, e.g. from a server.
106    Manual,
107}
108
109fc_modifier! { enum Density for BEM { Comfortable => None, Compact => "compact" } }
110fc_modifier! { enum Bands for BEM { Plain => None, Striped => "striped" } }
111fc_modifier! { enum Header for BEM { Scrolls => None, Sticky => "sticky" } }
112
113/// A table in a focusable, named scroll region, with optional sorting.
114///
115/// Only the sorted column carries `aria-sort`, so screen readers do not announce "not
116/// sorted" on every other header; the arrow and emphasis come from the same attribute.
117#[component]
118pub fn Table(
119    /// The caption, which also names the scroll region.
120    caption: Element,
121    /// Hide the caption visually; it still names the table and region.
122    #[props(default)]
123    caption_hidden: bool,
124    columns: Vec<TableColumn>,
125    rows: Vec<TableRow>,
126    #[props(default)] sort: Option<ReadSignal<Option<(usize, SortDirection)>>>,
127    #[props(default)] default_sort: Option<(usize, SortDirection)>,
128    #[props(default)] onsort: Option<EventHandler<(usize, SortDirection)>>,
129    #[props(default)] mode: SortMode,
130    #[props(default)] striped: bool,
131    #[props(default)] compact: bool,
132    /// Keep the header in view while the body scrolls.
133    #[props(default)]
134    sticky: bool,
135    /// Rows are loading.
136    #[props(default)]
137    busy: bool,
138    /// Shown across the table when there are no rows.
139    #[props(default)]
140    empty: Option<Element>,
141    #[props(default)] class: Option<String>,
142    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
143) -> Element {
144    let state = use_controllable(
145        sort,
146        || default_sort,
147        onsort.map(|onsort| {
148            EventHandler::new(move |value: Option<(usize, SortDirection)>| {
149                if let Some(value) = value {
150                    onsort.call(value);
151                }
152            })
153        }),
154    );
155    let id = use_id();
156    let caption_id = id.suffixed("caption");
157    let current = state.get();
158    let order: Vec<usize> = match (mode, current) {
159        (SortMode::Client, Some((column, direction))) => {
160            let keys: Vec<&str> = rows
161                .iter()
162                .map(|row| row.cells.get(column).map_or("", |c| c.sort_key.as_str()))
163                .collect();
164            sort::order(&keys, direction)
165        }
166        _ => (0..rows.len()).collect(),
167    };
168    let attributes = component_attributes(
169        attributes,
170        ClassList::new(BEM.block())
171            .with_modifier(
172                BEM,
173                if compact {
174                    Density::Compact
175                } else {
176                    Density::Comfortable
177                },
178            )
179            .with_modifier(
180                BEM,
181                if striped {
182                    Bands::Striped
183                } else {
184                    Bands::Plain
185                },
186            )
187            .with_modifier(
188                BEM,
189                if sticky {
190                    Header::Sticky
191                } else {
192                    Header::Scrolls
193                },
194            )
195            .extend(class.as_deref()),
196        &["aria-busy"],
197    );
198    let span = columns.len().max(1).to_string();
199    rsx! {
200        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_TABLE_CSS } }
201        div { class: WRAP.block(), role: "region", aria_labelledby: "{caption_id}", tabindex: "0",
202            table { aria_busy: busy.then_some("true"), ..attributes,
203                caption { id: "{caption_id}", class: caption_hidden.then(|| HIDDEN.block()), {caption} }
204                thead { tr {
205                    for (index, column) in columns.iter().enumerate() {
206                        th { key: "{index}", scope: "col", class: column.numeric.then(|| NUM.block()),
207                            aria_sort: current.filter(|(sorted, _)| *sorted == index).map(|(_, d)| d.as_str()),
208                            match &column.sort {
209                                Some(availability) => {
210                                    let enabled = *availability == Availability::Enabled;
211                                    let blocked = availability_attributes(availability);
212                                    rsx! {
213                                        button { class: BEM.element("sort"), r#type: "button",
214                                            onclick: move |_| if enabled { state.set(Some((index, sort::next_direction(state.get(), index)))); },
215                                            ..blocked, "{column.header}"
216                                        }
217                                    }
218                                }
219                                None => rsx! { "{column.header}" },
220                            }
221                        }
222                    }
223                } }
224                tbody {
225                    if rows.is_empty() {
226                        if let Some(empty) = empty { tr { td { class: BEM.element("empty"), colspan: "{span}", {empty} } } }
227                    }
228                    for index in order {
229                        if let Some(row) = rows.get(index) {
230                            tr { key: "{row.key}", aria_selected: row.selected.then_some("true"),
231                                for (column_index, cell) in row.cells.iter().enumerate() {
232                                    {cell_view(columns.get(column_index), cell)}
233                                }
234                            }
235                        }
236                    }
237                }
238            }
239        }
240    }
241}
242
243fn cell_view(column: Option<&TableColumn>, cell: &TableCell) -> Element {
244    let numeric = column.is_some_and(|c| c.numeric).then(|| NUM.block());
245    let content = cell
246        .content
247        .clone()
248        .unwrap_or_else(|| rsx! { "{cell.sort_key}" });
249    if column.is_some_and(|c| c.row_header) {
250        rsx! { th { scope: "row", class: numeric, {content} } }
251    } else {
252        rsx! { td { class: numeric, {content} } }
253    }
254}