//! M3 data tables: a semantic `` scaffold and a generic server-paginated //! table (search + filter chips + prev/next). Both domain-free. use dioxus::prelude::*; /// An M3 data table: a horizontally scrollable semantic `
` with a quiet /// surface-container header row and tonal row dividers. `columns` are the header /// labels; pass the `` body rows as `children`. #[component] pub fn DataTable(columns: Vec, children: Element) -> Element { rsx! { div { class: "m3-data-table-wrap", table { class: "m3-data-table", thead { tr { for col in columns { th { key: "{col}", "{col}" } } } } tbody { {children} } } } } } /// A search field and a row of single-select filter chips. /// /// Extracted from [`PaginatedTable`] so a list can filter the same way a table /// does. It was worth doing the moment a second screen needed filtering: the two /// would otherwise have looked alike only for as long as someone kept them so. /// /// Owns nothing — the parent binds `search` and `filter` and decides what they /// mean. `on_change` fires after either, which is how the table resets its page /// without this knowing pages exist. `trailing` takes anything else the screen /// needs in the bar, such as a date range. #[component] pub fn FilterToolbar( search: Signal, filter: Signal, #[props(default)] filters: Vec<(String, String)>, search_placeholder: String, #[props(default)] on_change: EventHandler<()>, #[props(default)] trailing: Option, ) -> Element { let mut search = search; let mut filter = filter; let active = filter.read().clone(); rsx! { div { class: "paginated-table-toolbar", label { class: "search-field", span { class: "material-icons", "search" } input { r#type: "text", placeholder: "{search_placeholder}", value: "{search}", oninput: move |e| { search.set(e.value()); on_change.call(()); }, } } if !filters.is_empty() { div { class: "filter-chips", role: "group", for (value , label) in filters.iter().cloned() { { let selected = value == active; let v = value.clone(); rsx! { button { key: "{value}", r#type: "button", class: if selected { "m3-filter-chip selected" } else { "m3-filter-chip" }, "aria-pressed": if selected { "true" } else { "false" }, onclick: move |_| { filter.set(v.clone()); on_change.call(()); }, if selected { span { class: "material-icons", "check" } } "{label}" } } } } } } if let Some(trailing) = trailing { {trailing} } } } } /// A generic, wiki-agnostic paginated data table: a search field, an optional row /// of single-select filter chips, a [`DataTable`] body, and a footer with a range /// label and prev/next controls. It owns no data — the parent passes the current /// page's rows (`children`), the `total` matching count, and the bound `search` / /// `filter` / `page` signals, and (re)fetches server-side when they change. The /// search field and each filter chip reset `page` to 0. Reusable for any /// server-paginated list. #[component] pub fn PaginatedTable( columns: Vec, #[props(default)] filters: Vec<(String, String)>, search: Signal, filter: Signal, page: Signal, page_size: usize, total: usize, search_placeholder: String, prev_label: String, next_label: String, children: Element, ) -> Element { let mut page = page; let page_count = total.div_ceil(page_size).max(1); let cur = (*page.read()).min(page_count - 1); let first = if total == 0 { 0 } else { cur * page_size + 1 }; let last = ((cur + 1) * page_size).min(total); rsx! { div { class: "paginated-table", FilterToolbar { search, filter, filters, search_placeholder, // Any change starts the results again from the first page; the // toolbar itself knows nothing about paging. on_change: move |_| page.set(0), } DataTable { columns, {children} } div { class: "paginated-table-footer", span { class: "paginated-count body-medium", "{first}-{last} / {total}" } div { class: "pagination-controls", button { class: "btn-icon", r#type: "button", disabled: cur == 0, title: "{prev_label}", aria_label: "{prev_label}", onclick: move |_| page.set(cur.saturating_sub(1)), span { class: "material-icons", "chevron_left" } } span { class: "body-medium page-indicator", "{cur + 1} / {page_count}" } button { class: "btn-icon", r#type: "button", disabled: cur + 1 >= page_count, title: "{next_label}", aria_label: "{next_label}", onclick: move |_| page.set((cur + 1).min(page_count - 1)), span { class: "material-icons", "chevron_right" } } } } } } }