Skip to main content

dioxus_element_plug/components/
table.rs

1use dioxus::prelude::*;
2
3// Table CSS class constants
4pub const TABLE: &str = "el-table";
5pub const TABLE_BORDERED: &str = "el-table--border";
6pub const TABLE_STRIPED: &str = "el-table--striped";
7pub const TABLE_HOVER: &str = "el-table--enable-row-hover";
8pub const TABLE_HEADER: &str = "el-table__header";
9pub const TABLE_BODY: &str = "el-table__body";
10pub const TABLE_ROW: &str = "el-table__row";
11pub const TABLE_CELL: &str = "el-table__cell";
12
13/// Sort direction for table columns
14#[derive(Clone, Copy, PartialEq, Debug)]
15pub enum SortOrder {
16    Ascending,
17    Descending,
18    None,
19}
20
21/// Table column definition
22#[derive(Clone, PartialEq)]
23pub struct TableColumn {
24    /// Column title
25    pub title: String,
26    /// Data key for the column
27    pub key: String,
28    /// Column width
29    pub width: Option<String>,
30    /// Whether the column is sortable
31    pub sortable: bool,
32    /// Whether the column is fixed (left/right)
33    pub fixed: Option<String>,
34}
35
36/// Table row data type
37pub type TableData = Vec<std::collections::HashMap<String, String>>;
38
39/// Table props
40#[derive(Props, Clone, PartialEq)]
41pub struct TableProps {
42    /// Table columns
43    pub columns: Vec<TableColumn>,
44    /// Table data
45    pub data: TableData,
46    /// Table height
47    #[props(default)]
48    pub height: Option<String>,
49    /// Table max height
50    #[props(default)]
51    pub max_height: Option<String>,
52    /// Whether to show header
53    #[props(default = true)]
54    pub show_header: bool,
55    /// Whether to show border
56    #[props(default = false)]
57    pub border: bool,
58    /// Whether to show stripe effect
59    #[props(default = false)]
60    pub stripe: bool,
61    /// Whether to highlight current row
62    #[props(default = false)]
63    pub highlight_current_row: bool,
64    /// Whether table is loading
65    #[props(default = false)]
66    pub loading: bool,
67    /// Empty state text
68    #[props(default = "No Data".to_string())]
69    pub empty_text: String,
70    /// Current sort column key
71    #[props(default)]
72    pub sort_key: Option<String>,
73    /// Current sort order
74    #[props(default = SortOrder::None)]
75    pub sort_order: SortOrder,
76    /// Current highlighted row index
77    #[props(default)]
78    pub current_row_index: Option<usize>,
79    /// Row click handler
80    #[props(default)]
81    pub on_row_click: Option<EventHandler<usize>>,
82    /// Sort change handler
83    #[props(default)]
84    pub on_sort_change: Option<EventHandler<(String, SortOrder)>>,
85    /// Additional CSS classes
86    #[props(default)]
87    pub class: Option<String>,
88    /// Inline styles
89    #[props(default)]
90    pub style: Option<String>,
91}
92
93/// A table component for displaying structured data
94///
95/// This component provides a table with features like sorting, row highlighting,
96/// loading state, and empty state.
97///
98/// ## Example
99///
100/// ```rust,ignore
101/// use dioxus_element_plug::components::table::{Table, TableColumn};
102/// use std::collections::HashMap;
103///
104/// let columns = vec![
105///     TableColumn {
106///         title: "Name".to_string(),
107///         key: "name".to_string(),
108///         width: Some("200px".to_string()),
109///         sortable: true,
110///         fixed: None,
111///     },
112/// ];
113///
114/// let mut row1 = HashMap::new();
115/// row1.insert("name".to_string(), "John".to_string());
116///
117/// let data = vec![row1];
118///
119/// rsx! {
120///     Table {
121///         columns: columns,
122///         data: data,
123///         stripe: true,
124///     }
125/// }
126/// ```
127#[component]
128pub fn Table(props: TableProps) -> Element {
129    let mut class_names = vec!["el-table".to_string()];
130
131    if props.border {
132        class_names.push("el-table--border".to_string());
133    }
134    if props.stripe {
135        class_names.push("el-table--striped".to_string());
136    }
137    if props.highlight_current_row {
138        class_names.push("el-table--highlight-current-row".to_string());
139    }
140    if let Some(ref custom_class) = props.class {
141        class_names.push(custom_class.to_string());
142    }
143
144    let class_string = class_names.join(" ");
145    let style_string = props.style.as_ref().cloned().unwrap_or_default();
146
147    let active_sort_key = props.sort_key.clone().unwrap_or_default();
148    let active_sort_order = props.sort_order.clone();
149
150    // Pre-compute sorted data
151    let sorted_rows: Vec<(usize, std::collections::HashMap<String, String>)> = {
152        if !active_sort_key.is_empty() && active_sort_order != SortOrder::None {
153            let mut indexed: Vec<(usize, &std::collections::HashMap<String, String>)> =
154                props.data.iter().enumerate().collect();
155            let sk = active_sort_key.clone();
156            indexed.sort_by(|a, b| {
157                let va = a.1.get(&sk).map(|s| s.as_str()).unwrap_or("");
158                let vb = b.1.get(&sk).map(|s| s.as_str()).unwrap_or("");
159                match active_sort_order {
160                    SortOrder::Ascending => va.cmp(vb),
161                    SortOrder::Descending => vb.cmp(va),
162                    SortOrder::None => std::cmp::Ordering::Equal,
163                }
164            });
165            indexed.into_iter().map(|(i, row)| (i, row.clone())).collect()
166        } else {
167            props.data.iter().enumerate().map(|(i, row)| (i, row.clone())).collect()
168        }
169    };
170
171    // Pre-compute header column data: (title, width_style, sortable, asc_class, desc_class, col_key, is_active, current_order)
172    let header_cols: Vec<(String, String, bool, String, String, String, bool, SortOrder)> = props
173        .columns
174        .iter()
175        .map(|col| {
176            let width_style = col
177                .width
178                .as_ref()
179                .map(|w| format!("width: {};", w))
180                .unwrap_or_default();
181            let is_active = active_sort_key == col.key;
182            let asc_class = if is_active {
183                match active_sort_order {
184                    SortOrder::Ascending => "sort-caret ascending is-active",
185                    _ => "sort-caret ascending",
186                }
187            } else {
188                "sort-caret ascending"
189            };
190            let desc_class = if is_active {
191                match active_sort_order {
192                    SortOrder::Descending => "sort-caret descending is-active",
193                    _ => "sort-caret descending",
194                }
195            } else {
196                "sort-caret descending"
197            };
198            let current_order = if is_active { active_sort_order } else { SortOrder::None };
199            (
200                col.title.clone(),
201                width_style,
202                col.sortable,
203                asc_class.to_string(),
204                desc_class.to_string(),
205                col.key.clone(),
206                is_active,
207                current_order,
208            )
209        })
210        .collect();
211
212    // Pre-compute row rendering data: (orig_idx, row_class, cells)
213    let row_render_data: Vec<(usize, String, Vec<(String, String)>)> = {
214        let cur = props.current_row_index;
215        let stripe = props.stripe;
216        let columns_keys: Vec<String> = props.columns.iter().map(|c| c.key.clone()).collect();
217        sorted_rows
218            .iter()
219            .map(|(orig_idx, row)| {
220                let is_current = cur.map_or(false, |r| r == *orig_idx);
221                let base_class = if *orig_idx % 2 == 1 && stripe {
222                    "el-table__row el-table__row--striped"
223                } else {
224                    "el-table__row"
225                };
226                let row_class = if is_current {
227                    format!("{} current-row", base_class)
228                } else {
229                    base_class.to_string()
230                };
231                let cells: Vec<(String, String)> = columns_keys
232                    .iter()
233                    .map(|key| {
234                        (
235                            "el-table__cell".to_string(),
236                            row.get(key).cloned().unwrap_or_default(),
237                        )
238                    })
239                    .collect();
240                (*orig_idx, row_class, cells)
241            })
242            .collect()
243    };
244
245    let has_data = !props.data.is_empty();
246    let col_count = props.columns.len();
247    let empty_text = props.empty_text.clone();
248    let show_header = props.show_header;
249    let on_row_click = props.on_row_click;
250    let on_sort_change = props.on_sort_change;
251    let loading = props.loading;
252
253    rsx! {
254        div {
255            class: "el-table__wrapper",
256            style: "{style_string}",
257
258            table {
259                class: "{class_string}",
260
261                if show_header {
262                    thead {
263                        class: "el-table__header",
264
265                        tr {
266                            class: "el-table__row",
267
268                            for (title, width_style, sortable, asc_class, desc_class, col_key, is_active, current_order) in header_cols.into_iter() {
269                                th {
270                                    class: "el-table__cell",
271                                    style: "{width_style}",
272
273                                    onclick: move |_| {
274                                        if sortable {
275                                            let new_order = if is_active {
276                                                match current_order {
277                                                    SortOrder::None => SortOrder::Ascending,
278                                                    SortOrder::Ascending => SortOrder::Descending,
279                                                    SortOrder::Descending => SortOrder::None,
280                                                }
281                                            } else {
282                                                SortOrder::Ascending
283                                            };
284                                            if let Some(handler) = on_sort_change.as_ref() {
285                                                handler.call((col_key.clone(), new_order));
286                                            }
287                                        }
288                                    },
289
290                                    div {
291                                        class: "cell",
292
293                                        if sortable {
294                                            span {
295                                                class: "el-table__column-label",
296                                                "{title}"
297                                            }
298                                            span {
299                                                class: "caret-wrapper",
300                                                i { class: "{asc_class}" }
301                                                i { class: "{desc_class}" }
302                                            }
303                                        } else {
304                                            "{title}"
305                                        }
306                                    }
307                                }
308                            }
309                        }
310                    }
311                }
312
313                if has_data {
314                    tbody {
315                        class: "el-table__body",
316
317                        for (orig_idx, row_class, cells) in row_render_data.into_iter() {
318                            tr {
319                                class: "{row_class}",
320
321                                onclick: move |_| {
322                                    if let Some(handler) = on_row_click.as_ref() {
323                                        handler.call(orig_idx);
324                                    }
325                                },
326
327                                for (cell_class, cell_value) in cells.into_iter() {
328                                    td {
329                                        class: "{cell_class}",
330                                        div {
331                                            class: "cell",
332                                            "{cell_value}"
333                                        }
334                                    }
335                                }
336                            }
337                        }
338                    }
339                } else {
340                    tbody {
341                        class: "el-table__body",
342                        tr {
343                            class: "el-table__empty-row",
344                            td {
345                                class: "el-table__cell",
346                                colspan: "{col_count}",
347                                div {
348                                    class: "el-table__empty-block",
349                                    div {
350                                        class: "el-table__empty-text",
351                                        "{empty_text}"
352                                    }
353                                }
354                            }
355                        }
356                    }
357                }
358            }
359
360            if loading {
361                div {
362                    class: "el-table__loading-mask",
363                    div {
364                        class: "el-loading-spinner",
365                        i { class: "el-icon-loading" }
366                        span { "Loading..." }
367                    }
368                }
369            }
370        }
371    }
372}
373
374/// Data list component for simpler data display
375#[derive(Props, Clone, PartialEq)]
376pub struct DataListProps {
377    /// List items
378    pub items: Vec<String>,
379    /// Whether to show loading state
380    #[props(default = false)]
381    pub loading: bool,
382    /// Whether to show empty state
383    #[props(default = true)]
384    pub show_empty: bool,
385    /// Empty state message
386    #[props(default = "No data".to_string())]
387    pub empty_text: String,
388    /// List direction (vertical/horizontal)
389    #[props(default = "vertical".to_string())]
390    pub direction: String,
391    /// Additional CSS classes
392    #[props(default)]
393    pub class: Option<String>,
394    /// Inline styles
395    #[props(default)]
396    pub style: Option<String>,
397    /// Item click handler
398    #[props(default)]
399    pub on_item_click: Option<EventHandler<usize>>,
400}
401
402/// A data list component for displaying item collections
403#[component]
404pub fn DataList(props: DataListProps) -> Element {
405    let mut class_names = vec!["el-data-list".to_string()];
406
407    class_names.push(format!("el-data-list--{}", props.direction));
408
409    if props.loading {
410        class_names.push("el-data-list--loading".to_string());
411    }
412    if props.items.is_empty() {
413        class_names.push("el-data-list--empty".to_string());
414    }
415    if let Some(ref custom_class) = props.class {
416        class_names.push(custom_class.to_string());
417    }
418
419    let class_string = class_names.join(" ");
420    let style_string = props.style.as_ref().cloned().unwrap_or_default();
421
422    if props.items.is_empty() && props.show_empty {
423        return rsx! {
424            div {
425                class: "{class_string} el-data-list--empty-state",
426                style: "{style_string}",
427
428                div {
429                    class: "el-empty",
430                    div {
431                        class: "el-empty__image",
432                        i { class: "el-icon-document" }
433                    }
434                    div {
435                        class: "el-empty__description",
436                        p { "{props.empty_text}" }
437                    }
438                }
439            }
440        };
441    }
442
443    rsx! {
444        div {
445            class: "{class_string}",
446            style: "{style_string}",
447
448            for (index, item) in props.items.iter().enumerate() {
449                div {
450                    class: "el-data-list__item",
451
452                    onclick: move |_| {
453                        if let Some(handler) = props.on_item_click {
454                            handler.call(index);
455                        }
456                    },
457
458                    "{item}"
459                }
460            }
461
462            if props.loading {
463                div {
464                    class: "el-data-list__loading",
465                    div {
466                        class: "el-loading-spinner",
467                        i { class: "el-icon-loading" }
468                        span { "Loading..." }
469                    }
470                }
471            }
472        }
473    }
474}