use crate::{
behavior::sort::{self, SortDirection},
core::{
assets,
attrs::{availability_attributes, component_attributes},
},
prelude::*,
theme::use_stylesheets,
};
const BEM: Bem = Bem::new("fc-table");
const WRAP: Bem = Bem::new("fc-table-wrap");
const NUM: Bem = Bem::new("fc-table__cell--num");
const HIDDEN: Bem = Bem::new("fc-u-visually-hidden");
#[derive(Debug, Clone, PartialEq)]
pub struct TableColumn {
pub header: String,
pub numeric: bool,
pub row_header: bool,
pub sort: Option<Availability>,
}
impl TableColumn {
#[must_use]
pub fn new(header: impl Into<String>) -> Self {
Self {
header: header.into(),
numeric: false,
row_header: false,
sort: None,
}
}
#[must_use]
pub fn sortable(mut self) -> Self {
self.sort = Some(Availability::Enabled);
self
}
#[must_use]
pub fn numeric(mut self) -> Self {
self.numeric = true;
self
}
#[must_use]
pub fn row_header(mut self) -> Self {
self.row_header = true;
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TableCell {
pub sort_key: String,
pub content: Option<Element>,
}
impl TableCell {
#[must_use]
pub fn text(text: impl Into<String>) -> Self {
Self {
sort_key: text.into(),
content: None,
}
}
#[must_use]
pub fn rich(sort_key: impl Into<String>, content: Element) -> Self {
Self {
sort_key: sort_key.into(),
content: Some(content),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TableRow {
pub key: String,
pub cells: Vec<TableCell>,
pub selected: bool,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SortMode {
#[default]
Client,
Manual,
}
fc_modifier! { enum Density for BEM { Comfortable => None, Compact => "compact" } }
fc_modifier! { enum Bands for BEM { Plain => None, Striped => "striped" } }
fc_modifier! { enum Header for BEM { Scrolls => None, Sticky => "sticky" } }
#[component]
pub fn Table(
caption: Element,
#[props(default)]
caption_hidden: bool,
columns: Vec<TableColumn>,
rows: Vec<TableRow>,
#[props(default)] sort: Option<ReadSignal<Option<(usize, SortDirection)>>>,
#[props(default)] default_sort: Option<(usize, SortDirection)>,
#[props(default)] onsort: Option<EventHandler<(usize, SortDirection)>>,
#[props(default)] mode: SortMode,
#[props(default)] striped: bool,
#[props(default)] compact: bool,
#[props(default)]
sticky: bool,
#[props(default)]
busy: bool,
#[props(default)]
empty: Option<Element>,
#[props(default)] class: Option<String>,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
) -> Element {
let state = use_controllable(
sort,
|| default_sort,
onsort.map(|onsort| {
EventHandler::new(move |value: Option<(usize, SortDirection)>| {
if let Some(value) = value {
onsort.call(value);
}
})
}),
);
let id = use_id();
let caption_id = id.suffixed("caption");
let current = state.get();
let order: Vec<usize> = match (mode, current) {
(SortMode::Client, Some((column, direction))) => {
let keys: Vec<&str> = rows
.iter()
.map(|row| row.cells.get(column).map_or("", |c| c.sort_key.as_str()))
.collect();
sort::order(&keys, direction)
}
_ => (0..rows.len()).collect(),
};
let attributes = component_attributes(
attributes,
ClassList::new(BEM.block())
.with_modifier(
BEM,
if compact {
Density::Compact
} else {
Density::Comfortable
},
)
.with_modifier(
BEM,
if striped {
Bands::Striped
} else {
Bands::Plain
},
)
.with_modifier(
BEM,
if sticky {
Header::Sticky
} else {
Header::Scrolls
},
)
.extend(class.as_deref()),
&["aria-busy"],
);
let span = columns.len().max(1).to_string();
rsx! {
if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_TABLE_CSS } }
div { class: WRAP.block(), role: "region", aria_labelledby: "{caption_id}", tabindex: "0",
table { aria_busy: busy.then_some("true"), ..attributes,
caption { id: "{caption_id}", class: caption_hidden.then(|| HIDDEN.block()), {caption} }
thead { tr {
for (index, column) in columns.iter().enumerate() {
th { key: "{index}", scope: "col", class: column.numeric.then(|| NUM.block()),
aria_sort: current.filter(|(sorted, _)| *sorted == index).map(|(_, d)| d.as_str()),
match &column.sort {
Some(availability) => {
let enabled = *availability == Availability::Enabled;
let blocked = availability_attributes(availability);
rsx! {
button { class: BEM.element("sort"), r#type: "button",
onclick: move |_| if enabled { state.set(Some((index, sort::next_direction(state.get(), index)))); },
..blocked, "{column.header}"
}
}
}
None => rsx! { "{column.header}" },
}
}
}
} }
tbody {
if rows.is_empty() {
if let Some(empty) = empty { tr { td { class: BEM.element("empty"), colspan: "{span}", {empty} } } }
}
for index in order {
if let Some(row) = rows.get(index) {
tr { key: "{row.key}", aria_selected: row.selected.then_some("true"),
for (column_index, cell) in row.cells.iter().enumerate() {
{cell_view(columns.get(column_index), cell)}
}
}
}
}
}
}
}
}
}
fn cell_view(column: Option<&TableColumn>, cell: &TableCell) -> Element {
let numeric = column.is_some_and(|c| c.numeric).then(|| NUM.block());
let content = cell
.content
.clone()
.unwrap_or_else(|| rsx! { "{cell.sort_key}" });
if column.is_some_and(|c| c.row_header) {
rsx! { th { scope: "row", class: numeric, {content} } }
} else {
rsx! { td { class: numeric, {content} } }
}
}