1use 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#[derive(Debug, Clone, PartialEq)]
19pub struct TableColumn {
20 pub header: String,
22 pub numeric: bool,
24 pub row_header: bool,
26 pub sort: Option<Availability>,
28}
29impl TableColumn {
30 #[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 #[must_use]
42 pub fn sortable(mut self) -> Self {
43 self.sort = Some(Availability::Enabled);
44 self
45 }
46 #[must_use]
48 pub fn numeric(mut self) -> Self {
49 self.numeric = true;
50 self
51 }
52 #[must_use]
54 pub fn row_header(mut self) -> Self {
55 self.row_header = true;
56 self
57 }
58}
59
60#[derive(Debug, Clone, PartialEq)]
62pub struct TableCell {
63 pub sort_key: String,
66 pub content: Option<Element>,
68}
69impl TableCell {
70 #[must_use]
72 pub fn text(text: impl Into<String>) -> Self {
73 Self {
74 sort_key: text.into(),
75 content: None,
76 }
77 }
78 #[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#[derive(Debug, Clone, PartialEq)]
90pub struct TableRow {
91 pub key: String,
93 pub cells: Vec<TableCell>,
95 pub selected: bool,
97}
98
99#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
101pub enum SortMode {
102 #[default]
104 Client,
105 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#[component]
118pub fn Table(
119 caption: Element,
121 #[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 #[props(default)]
134 sticky: bool,
135 #[props(default)]
137 busy: bool,
138 #[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}