1use 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#[derive(Signals, Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
49pub struct TableState {
50 pub query: String,
52 pub page: i32,
54 sort: String,
58 descending: bool,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct TableText {
82 pub empty: String,
84 pub select_row: String,
86 pub select_all: String,
88 pub previous_page: String,
90 pub next_page: String,
92 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
111pub enum SortOrder {
112 Ascending,
114 Descending,
116}
117
118impl TableState {
119 #[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 #[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
164pub 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
197type Each<S, T> = Box<dyn FnOnce(Box<dyn FnOnce(Item<S, T>) -> El<S>>) -> Node<S>>;
199
200type RowTest<S, T> = Box<dyn Fn(Item<S, T>) -> Expr<S, bool>>;
202
203type RowStmt<S, T> = Box<dyn Fn(Item<S, T>) -> Stmt<S>>;
205
206type RowEl<S, T> = Box<dyn Fn(Item<S, T>) -> El<S>>;
208
209type RowKey<S, T, K> = Rc<dyn Fn(Item<S, T>) -> Expr<S, K>>;
211
212type RowText<S, T> = Box<dyn Fn(Item<S, T>) -> Expr<S, String>>;
214
215impl<S: 'static, T: 'static> Column<S, T> {
216 #[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 #[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 #[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 #[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 #[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
303struct Entry<S, T> {
305 column: Column<S, T>,
306 sort: Option<Sorting<S>>,
307}
308
309struct 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
325pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
536 pub fn text(mut self, text: TableText) -> Self {
537 self.text = text;
538 self
539 }
540
541 #[must_use]
543 pub fn max_height(mut self, height: Length) -> Self {
544 self.max_height = Some(height);
545 self
546 }
547
548 #[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 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
660pub 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 #[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 #[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 #[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 #[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 #[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 #[must_use]
867 pub fn filter(mut self, label: impl Into<String>) -> Self {
868 self.filter = Some(label.into());
869 self
870 }
871
872 #[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 #[must_use]
890 pub fn text(mut self, text: TableText) -> Self {
891 self.text = text;
892 self
893 }
894
895 #[must_use]
897 pub fn max_height(mut self, height: Length) -> Self {
898 self.max_height = Some(height);
899 self
900 }
901
902 #[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
984type Cell<S, T> = (
986 bool,
987 Box<dyn Fn(Item<S, T>) -> El<S>>,
988 Option<Expr<S, bool>>,
989);
990
991fn 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
1015fn 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
1027fn 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 .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
1068fn 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
1077fn 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
1087fn 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
1136struct 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
1166struct 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 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 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
1194struct Expansion<S, T> {
1196 keys: KeySet<S, T>,
1197 details: RowEl<S, T>,
1198}
1199
1200struct 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 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 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
1266fn 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
1288fn 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
1327fn 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
1384fn 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
1442fn 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}