Skip to main content

maud_ui/primitives/
data_table.rs

1//! Data Table component — composes Table + sorting + filtering + pagination.
2use maud::{html, Markup, PreEscaped};
3
4#[derive(Clone, Debug)]
5pub struct Column {
6    pub key: String,
7    pub label: String,
8    pub sortable: bool,
9}
10
11#[derive(Clone, Debug)]
12pub struct Props {
13    pub id: String,
14    pub columns: Vec<Column>,
15    pub rows: Vec<Vec<String>>,
16    pub page_size: usize,
17    pub searchable: bool,
18    pub search_placeholder: String,
19}
20
21impl Default for Props {
22    fn default() -> Self {
23        Self {
24            id: "data-table".to_string(),
25            columns: vec![],
26            rows: vec![],
27            page_size: 5,
28            searchable: false,
29            search_placeholder: "Filter...".to_string(),
30        }
31    }
32}
33
34/// Escape a string for safe embedding in a JSON array stored as an HTML attribute.
35fn escape_for_attr(s: &str) -> String {
36    s.replace('\\', "\\\\")
37        .replace('"', "&quot;")
38        .replace('<', "&lt;")
39        .replace('>', "&gt;")
40        .replace('&', "&amp;")
41}
42
43pub fn render(props: Props) -> Markup {
44    let total = props.rows.len();
45    let page_size = if props.page_size == 0 { 5 } else { props.page_size };
46    let end = std::cmp::min(page_size, total);
47    let start = if total == 0 { 0 } else { 1 };
48    let has_next = end < total;
49
50    html! {
51        div.mui-data-table data-mui="data-table" id=(props.id) data-page-size=(page_size) {
52            @if props.searchable {
53                div.mui-data-table__toolbar {
54                    input type="text" class="mui-input mui-data-table__search"
55                        placeholder=(props.search_placeholder);
56                }
57            }
58            div.mui-data-table__wrapper {
59                table.mui-table.mui-table--hoverable {
60                    thead {
61                        tr {
62                            @for col in &props.columns {
63                                th.mui-table__th.mui-data-table__th
64                                    data-key=(col.key)
65                                    data-sortable=(col.sortable) {
66                                    (col.label)
67                                    @if col.sortable {
68                                        span.mui-data-table__sort-icon {
69                                            (PreEscaped(r#"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>"#))
70                                        }
71                                    }
72                                }
73                            }
74                        }
75                    }
76                    tbody.mui-data-table__body {
77                        @for (i, row) in props.rows.iter().enumerate() {
78                            @let json_row = format!(
79                                "[{}]",
80                                row.iter()
81                                    .map(|c| format!("\"{}\"", escape_for_attr(c)))
82                                    .collect::<Vec<_>>()
83                                    .join(",")
84                            );
85                            tr.mui-table__row
86                                data-row-data=(json_row)
87                                hidden[i >= page_size] {
88                                @for cell in row {
89                                    td.mui-table__td { (cell) }
90                                }
91                            }
92                        }
93                    }
94                }
95            }
96            div.mui-data-table__footer {
97                span.mui-data-table__info {
98                    (format!("Showing {}-{} of {}", start, end, total))
99                }
100                div.mui-data-table__pagination {
101                    button type="button" class="mui-data-table__page-btn" data-action="prev" disabled {
102                        "Previous"
103                    }
104                    button type="button" class="mui-data-table__page-btn" data-action="next" disabled[!has_next] {
105                        "Next"
106                    }
107                }
108            }
109        }
110    }
111}
112
113pub fn showcase() -> Markup {
114    let columns = vec![
115        Column { key: "invoice".to_string(), label: "Invoice".to_string(), sortable: true },
116        Column { key: "status".to_string(), label: "Status".to_string(), sortable: true },
117        Column { key: "method".to_string(), label: "Method".to_string(), sortable: true },
118        Column { key: "amount".to_string(), label: "Amount".to_string(), sortable: true },
119    ];
120
121    let rows = vec![
122        vec!["INV001".to_string(), "Paid".to_string(), "Credit Card".to_string(), "$250.00".to_string()],
123        vec!["INV002".to_string(), "Pending".to_string(), "PayPal".to_string(), "$150.00".to_string()],
124        vec!["INV003".to_string(), "Unpaid".to_string(), "Bank Transfer".to_string(), "$350.00".to_string()],
125        vec!["INV004".to_string(), "Paid".to_string(), "Credit Card".to_string(), "$450.00".to_string()],
126        vec!["INV005".to_string(), "Paid".to_string(), "PayPal".to_string(), "$550.00".to_string()],
127        vec!["INV006".to_string(), "Pending".to_string(), "Bank Transfer".to_string(), "$200.00".to_string()],
128        vec!["INV007".to_string(), "Paid".to_string(), "Credit Card".to_string(), "$300.00".to_string()],
129        vec!["INV008".to_string(), "Unpaid".to_string(), "PayPal".to_string(), "$400.00".to_string()],
130        vec!["INV009".to_string(), "Paid".to_string(), "Bank Transfer".to_string(), "$500.00".to_string()],
131        vec!["INV010".to_string(), "Pending".to_string(), "Credit Card".to_string(), "$275.00".to_string()],
132    ];
133
134    html! {
135        div.mui-showcase__grid {
136            div {
137                p.mui-showcase__caption { "Searchable, sortable, paginated (5 per page)" }
138                (render(Props {
139                    id: "invoice-table".to_string(),
140                    columns,
141                    rows,
142                    page_size: 5,
143                    searchable: true,
144                    search_placeholder: "Filter invoices...".to_string(),
145                }))
146            }
147        }
148    }
149}