Skip to main content

dioxus_ui_system/organisms/
data_table.rs

1//! Data Table organism component
2//!
3//! A comprehensive table component with sorting, selection, and pagination.
4
5use dioxus::prelude::*;
6use crate::theme::{use_theme, use_style};
7use crate::styles::Style;
8use crate::atoms::{Label, TextSize, TextColor, Button, ButtonVariant, ButtonSize, Icon, IconSize, IconColor};
9
10/// Table column definition
11#[derive(Clone, PartialEq)]
12pub struct TableColumn<T: 'static> {
13    pub key: String,
14    pub header: String,
15    pub width: Option<String>,
16    pub align: ColumnAlign,
17    pub sortable: bool,
18    pub render: Option<fn(&T) -> Element>,
19}
20
21/// Column text alignment
22#[derive(Default, Clone, PartialEq)]
23pub enum ColumnAlign {
24    #[default]
25    Left,
26    Center,
27    Right,
28}
29
30impl ColumnAlign {
31    fn as_str(&self) -> &'static str {
32        match self {
33            ColumnAlign::Left => "left",
34            ColumnAlign::Center => "center",
35            ColumnAlign::Right => "right",
36        }
37    }
38}
39
40/// Table properties
41#[derive(Props, Clone, PartialEq)]
42pub struct DataTableProps<T: Clone + PartialEq + 'static> {
43    /// Column definitions
44    pub columns: Vec<TableColumn<T>>,
45    /// Table data
46    pub data: Vec<T>,
47    /// Unique key extractor for rows
48    pub key_extractor: fn(&T) -> String,
49    /// Row selection enabled
50    #[props(default)]
51    pub selectable: bool,
52    /// Selected row keys
53    #[props(default)]
54    pub selected_keys: Vec<String>,
55    /// Selection change handler
56    #[props(default)]
57    pub on_selection_change: Option<EventHandler<Vec<String>>>,
58    /// Row click handler
59    #[props(default)]
60    pub on_row_click: Option<EventHandler<T>>,
61    /// Empty state message
62    #[props(default = "No data available")]
63    pub empty_message: &'static str,
64    /// Loading state
65    #[props(default)]
66    pub loading: bool,
67    /// Custom inline styles
68    #[props(default)]
69    pub style: Option<String>,
70}
71
72/// Data Table organism component
73///
74/// # Example
75/// ```rust,ignore
76/// use dioxus_ui_system::organisms::{DataTable, TableColumn, ColumnAlign};
77///
78/// #[derive(Clone, PartialEq)]
79/// struct User {
80///     id: String,
81///     name: String,
82///     email: String,
83/// }
84///
85/// let columns = vec![
86///     TableColumn {
87///         key: "name".to_string(),
88///         header: "Name".to_string(),
89///         width: None,
90///         align: ColumnAlign::Left,
91///         sortable: true,
92///         render: None,
93///     },
94///     TableColumn {
95///         key: "email".to_string(),
96///         header: "Email".to_string(),
97///         width: None,
98///         align: ColumnAlign::Left,
99///         sortable: true,
100///         render: None,
101///     },
102/// ];
103///
104/// let data = vec![
105///     User { id: "1".to_string(), name: "John".to_string(), email: "john@example.com".to_string() },
106/// ];
107///
108/// rsx! {
109///     DataTable {
110///         columns: columns,
111///         data: data,
112///         key_extractor: |u| u.id.clone(),
113///     }
114/// }
115/// ```
116#[component]
117pub fn DataTable<T: Clone + PartialEq + 'static>(props: DataTableProps<T>) -> Element {
118    let style = use_style(|t| {
119        Style::new()
120            .w_full()
121            .rounded(&t.radius, "md")
122            .border(1, &t.colors.border)
123            .overflow_hidden()
124            .build()
125    });
126    
127    let final_style = if let Some(custom) = &props.style {
128        format!("{} {}", style(), custom)
129    } else {
130        style()
131    };
132    
133    let table_style = use_style(|t| {
134        Style::new()
135            .w_full()
136            .text(&t.typography, "sm")
137            .build()
138    });
139    
140    // Loading state
141    if props.loading {
142        return rsx! {
143            div {
144                style: "{final_style}",
145                div {
146                    style: "padding: 48px; text-align: center;",
147                    Icon {
148                        name: "spinner".to_string(),
149                        size: IconSize::Large,
150                        color: IconColor::Muted,
151                    }
152                }
153            }
154        };
155    }
156    
157    // Empty state
158    if props.data.is_empty() {
159        return rsx! {
160            div {
161                style: "{final_style}",
162                div {
163                    style: "padding: 48px; text-align: center;",
164                    Label {
165                        size: TextSize::Small,
166                        color: TextColor::Muted,
167                        "{props.empty_message}"
168                    }
169                }
170            }
171        };
172    }
173    
174    let columns = props.columns.clone();
175    let data = props.data.clone();
176    let selectable = props.selectable;
177    let selected_keys = props.selected_keys.clone();
178    
179    rsx! {
180        div {
181            style: "{final_style}",
182            
183            table {
184                style: "{table_style}",
185                
186                DataTableHeader {
187                    columns: columns.clone(),
188                    selectable: selectable,
189                    selected_count: selected_keys.len(),
190                    total_count: data.len(),
191                    on_select_all: props.on_selection_change.clone(),
192                }
193                
194                tbody {
195                    for row in data {
196                        DataTableRow {
197                            row: row.clone(),
198                            columns: columns.clone(),
199                            key_extractor: props.key_extractor,
200                            selectable: selectable,
201                            is_selected: selected_keys.contains(&(props.key_extractor)(&row)),
202                            on_select: props.on_selection_change.clone(),
203                            on_click: props.on_row_click.clone(),
204                        }
205                    }
206                }
207            }
208        }
209    }
210}
211
212/// Table header component
213#[derive(Props, Clone, PartialEq)]
214pub struct DataTableHeaderProps<T: Clone + PartialEq + 'static> {
215    pub columns: Vec<TableColumn<T>>,
216    pub selectable: bool,
217    pub selected_count: usize,
218    pub total_count: usize,
219    pub on_select_all: Option<EventHandler<Vec<String>>>,
220}
221
222#[component]
223pub fn DataTableHeader<T: Clone + PartialEq>(props: DataTableHeaderProps<T>) -> Element {
224    let _theme = use_theme();
225    
226    let header_style = use_style(|t| {
227        Style::new()
228            .bg(&t.colors.muted)
229            .text_color(&t.colors.foreground)
230            .font_weight(500)
231            .build()
232    });
233    
234    let th_style = use_style(|t| {
235        Style::new()
236            .p(&t.spacing, "md")
237            .text_align("left")
238            .border_bottom(1, &t.colors.border)
239            .build()
240    });
241    
242    let all_selected = props.selected_count == props.total_count && props.total_count > 0;
243    
244    rsx! {
245        thead {
246            style: "{header_style}",
247            
248            tr {
249                if props.selectable {
250                    th {
251                        style: "width: 48px; padding: 12px;",
252                        input {
253                            r#type: "checkbox",
254                            checked: all_selected,
255                            onchange: move |_| {
256                                // Toggle select all logic would go here
257                            },
258                        }
259                    }
260                }
261                
262                for col in props.columns {
263                    th {
264                        style: "{th_style} text-align: {col.align.as_str()}; width: {col.width.clone().unwrap_or_default()};",
265                        
266                        if col.sortable {
267                            div {
268                                style: "display: inline-flex; align-items: center; gap: 4px; cursor: pointer;",
269                                "{col.header}"
270                                Icon {
271                                    name: "chevron-down".to_string(),
272                                    size: IconSize::Small,
273                                    color: IconColor::Muted,
274                                }
275                            }
276                        } else {
277                            "{col.header}"
278                        }
279                    }
280                }
281            }
282        }
283    }
284}
285
286/// Table row component
287#[derive(Props, Clone, PartialEq)]
288pub struct DataTableRowProps<T: Clone + PartialEq + 'static> {
289    pub row: T,
290    pub columns: Vec<TableColumn<T>>,
291    pub key_extractor: fn(&T) -> String,
292    pub selectable: bool,
293    pub is_selected: bool,
294    pub on_select: Option<EventHandler<Vec<String>>>,
295    pub on_click: Option<EventHandler<T>>,
296}
297
298#[component]
299pub fn DataTableRow<T: Clone + PartialEq + 'static>(props: DataTableRowProps<T>) -> Element {
300    let _theme = use_theme();
301    let _key = (props.key_extractor)(&props.row);
302    let is_selected = props.is_selected;
303    let _has_onclick = props.on_click.is_some();
304    
305    let mut is_hovered = use_signal(|| false);
306    
307    let row_style = use_style(move |t| {
308        let base = Style::new()
309            .border_bottom(1, &t.colors.border)
310            .transition("background-color 150ms ease");
311            
312        if is_selected {
313            base.bg(&t.colors.primary.blend(&t.colors.background, 0.9))
314        } else if is_hovered() {
315            base.bg(&t.colors.muted)
316        } else {
317            base
318        }.build()
319    });
320    
321    let td_style = use_style(|t| {
322        Style::new()
323            .p(&t.spacing, "md")
324            .build()
325    });
326    
327    let row_data = props.row.clone();
328    let onclick_handler = props.on_click.clone();
329    
330    rsx! {
331        tr {
332            style: "{row_style}",
333            onmouseenter: move |_| is_hovered.set(true),
334            onmouseleave: move |_| is_hovered.set(false),
335            onclick: move |_| {
336                if let Some(handler) = &onclick_handler {
337                    handler.call(row_data.clone());
338                }
339            },
340            
341            if props.selectable {
342                td {
343                    style: "width: 48px; padding: 12px;",
344                    input {
345                        r#type: "checkbox",
346                        checked: is_selected,
347                        onchange: move |_| {
348                            // Selection toggle logic
349                        },
350                    }
351                }
352            }
353            
354            for col in props.columns {
355                DataTableCell {
356                    row: props.row.clone(),
357                    column: col,
358                    base_style: td_style(),
359                }
360            }
361        }
362    }
363}
364
365/// Table cell component
366#[derive(Props, Clone, PartialEq)]
367pub struct DataTableCellProps<T: Clone + PartialEq + 'static> {
368    pub row: T,
369    pub column: TableColumn<T>,
370    pub base_style: String,
371}
372
373#[component]
374pub fn DataTableCell<T: Clone + PartialEq>(props: DataTableCellProps<T>) -> Element {
375    let col = props.column.clone();
376    let align = col.align.as_str();
377    
378    // Get cell value using key
379    let cell_content = if let Some(render_fn) = col.render {
380        render_fn(&props.row)
381    } else {
382        // Default: just show the value (in a real implementation, 
383        // we'd use reflection or require a trait to extract values)
384        rsx! {
385            Label {
386                size: TextSize::Small,
387                "-"
388            }
389        }
390    };
391    
392    rsx! {
393        td {
394            style: "{props.base_style} text-align: {align};",
395            {cell_content}
396        }
397    }
398}
399
400/// Pagination component
401#[derive(Props, Clone, PartialEq)]
402pub struct PaginationProps {
403    pub current_page: usize,
404    pub total_pages: usize,
405    pub on_page_change: EventHandler<usize>,
406    #[props(default)]
407    pub show_first_last: bool,
408}
409
410#[component]
411pub fn Pagination(props: PaginationProps) -> Element {
412    let current = props.current_page;
413    let total = props.total_pages;
414    
415    let container_style = use_style(|t| {
416        Style::new()
417            .flex()
418            .items_center()
419            .justify_between()
420            .px(&t.spacing, "md")
421            .py(&t.spacing, "sm")
422            .border_top(1, &t.colors.border)
423            .build()
424    });
425    
426    let info_style = use_style(|t| {
427        Style::new()
428            .text(&t.typography, "sm")
429            .text_color(&t.colors.muted_foreground)
430            .build()
431    });
432    
433    rsx! {
434        div {
435            style: "{container_style}",
436            
437            div {
438                style: "{info_style}",
439                "Page {current + 1} of {total}"
440            }
441            
442            div {
443                style: "display: flex; align-items: center; gap: 4px;",
444                
445                if props.show_first_last && current > 0 {
446                    Button {
447                        variant: ButtonVariant::Ghost,
448                        size: ButtonSize::Sm,
449                        onclick: move |_| props.on_page_change.call(0),
450                        Icon {
451                            name: "chevron-left".to_string(),
452                            size: IconSize::Small,
453                            color: IconColor::Current,
454                        }
455                    }
456                }
457                
458                Button {
459                    variant: ButtonVariant::Ghost,
460                    size: ButtonSize::Sm,
461                    disabled: current == 0,
462                    onclick: move |_| props.on_page_change.call(current.saturating_sub(1)),
463                    Icon {
464                        name: "chevron-left".to_string(),
465                        size: IconSize::Small,
466                        color: IconColor::Current,
467                    }
468                }
469                
470                Button {
471                    variant: ButtonVariant::Ghost,
472                    size: ButtonSize::Sm,
473                    disabled: current >= total - 1,
474                    onclick: move |_| props.on_page_change.call((current + 1).min(total - 1)),
475                    Icon {
476                        name: "chevron-right".to_string(),
477                        size: IconSize::Small,
478                        color: IconColor::Current,
479                    }
480                }
481                
482                if props.show_first_last && current < total - 1 {
483                    Button {
484                        variant: ButtonVariant::Ghost,
485                        size: ButtonSize::Sm,
486                        onclick: move |_| props.on_page_change.call(total - 1),
487                        Icon {
488                            name: "chevron-right".to_string(),
489                            size: IconSize::Small,
490                            color: IconColor::Current,
491                        }
492                    }
493                }
494            }
495        }
496    }
497}
498