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, pagination, and filtering.
4
5#![allow(unpredictable_function_pointer_comparisons)]
6
7use crate::atoms::{
8    Button, ButtonSize, ButtonVariant, Icon, IconColor, IconSize, Label, TextColor, TextSize,
9};
10use crate::styles::Style;
11use crate::theme::{use_style, use_theme};
12use dioxus::prelude::*;
13
14/// Filter option for custom filters
15#[derive(Clone, PartialEq)]
16pub struct FilterOption {
17    pub label: String,
18    pub value: String,
19}
20
21/// Filter definition for custom filters
22#[derive(Clone, PartialEq)]
23pub struct TableFilter {
24    pub key: String,
25    pub label: String,
26    pub options: Vec<FilterOption>,
27}
28
29/// Table column definition
30#[derive(Clone, PartialEq)]
31pub struct TableColumn<T: 'static> {
32    pub key: String,
33    pub header: String,
34    pub width: Option<String>,
35    pub align: ColumnAlign,
36    pub sortable: bool,
37    pub render: Option<fn(&T) -> Element>,
38}
39
40/// Column text alignment
41#[derive(Default, Clone, PartialEq)]
42pub enum ColumnAlign {
43    #[default]
44    Left,
45    Center,
46    Right,
47}
48
49impl ColumnAlign {
50    fn as_str(&self) -> &'static str {
51        match self {
52            ColumnAlign::Left => "left",
53            ColumnAlign::Center => "center",
54            ColumnAlign::Right => "right",
55        }
56    }
57}
58
59/// Table properties
60#[derive(Props, Clone, PartialEq)]
61pub struct DataTableProps<T: Clone + PartialEq + 'static> {
62    /// Column definitions
63    pub columns: Vec<TableColumn<T>>,
64    /// Table data
65    pub data: Vec<T>,
66    /// Unique key extractor for rows
67    pub key_extractor: fn(&T) -> String,
68    /// Row selection enabled
69    #[props(default)]
70    pub selectable: bool,
71    /// Selected row keys
72    #[props(default)]
73    pub selected_keys: Vec<String>,
74    /// Selection change handler
75    #[props(default)]
76    pub on_selection_change: Option<EventHandler<Vec<String>>>,
77    /// Row click handler
78    #[props(default)]
79    pub on_row_click: Option<EventHandler<T>>,
80    /// Empty state message
81    #[props(default = "No data available")]
82    pub empty_message: &'static str,
83    /// Loading state
84    #[props(default)]
85    pub loading: bool,
86    /// Custom inline styles
87    #[props(default)]
88    pub style: Option<String>,
89    /// Search placeholder text
90    #[props(default = "Search...")]
91    pub search_placeholder: &'static str,
92    /// Search query for filtering
93    #[props(default)]
94    pub search_query: Option<String>,
95    /// Search change handler
96    #[props(default)]
97    pub on_search_change: Option<EventHandler<String>>,
98    /// Custom filter definitions
99    #[props(default)]
100    pub filters: Vec<TableFilter>,
101    /// Active filter values
102    #[props(default)]
103    pub active_filters: std::collections::HashMap<String, String>,
104    /// Filter change handler
105    #[props(default)]
106    pub on_filter_change: Option<EventHandler<(String, String)>>,
107    /// Show search input
108    #[props(default = true)]
109    pub show_search: bool,
110    /// Show filter controls
111    #[props(default = true)]
112    pub show_filters: bool,
113}
114
115/// Data Table organism component
116///
117/// # Example
118/// ```rust,ignore
119/// use dioxus_ui_system::organisms::{DataTable, TableColumn, ColumnAlign};
120///
121/// #[derive(Clone, PartialEq)]
122/// struct User {
123///     id: String,
124///     name: String,
125///     email: String,
126/// }
127///
128/// let columns = vec![
129///     TableColumn {
130///         key: "name".to_string(),
131///         header: "Name".to_string(),
132///         width: None,
133///         align: ColumnAlign::Left,
134///         sortable: true,
135///         render: None,
136///     },
137///     TableColumn {
138///         key: "email".to_string(),
139///         header: "Email".to_string(),
140///         width: None,
141///         align: ColumnAlign::Left,
142///         sortable: true,
143///         render: None,
144///     },
145/// ];
146///
147/// let data = vec![
148///     User { id: "1".to_string(), name: "John".to_string(), email: "john@example.com".to_string() },
149/// ];
150///
151/// rsx! {
152///     DataTable {
153///         columns: columns,
154///         data: data,
155///         key_extractor: |u| u.id.clone(),
156///     }
157/// }
158/// ```
159#[component]
160pub fn DataTable<T: Clone + PartialEq + 'static>(props: DataTableProps<T>) -> Element {
161    let style = use_style(|t| {
162        Style::new()
163            .w_full()
164            .rounded(&t.radius, "md")
165            .border(1, &t.colors.border)
166            .overflow_hidden()
167            .build()
168    });
169
170    let final_style = if let Some(custom) = &props.style {
171        format!("{} {}", style(), custom)
172    } else {
173        style()
174    };
175
176    let table_style = use_style(|t| Style::new().w_full().text(&t.typography, "sm").build());
177
178    let toolbar_style = use_style(|t| {
179        Style::new()
180            .flex()
181            .items_center()
182            .justify_between()
183            .px(&t.spacing, "md")
184            .py(&t.spacing, "sm")
185            .border_bottom(1, &t.colors.border)
186            .bg(&t.colors.background)
187            .gap(&t.spacing, "sm")
188            .build()
189    });
190
191    let search_container_style = use_style(|t| {
192        Style::new()
193            .flex()
194            .items_center()
195            .gap(&t.spacing, "sm")
196            .build()
197    });
198
199    let filters_container_style = use_style(|t| {
200        Style::new()
201            .flex()
202            .items_center()
203            .gap(&t.spacing, "sm")
204            .build()
205    });
206
207    let show_toolbar = (props.show_search && props.on_search_change.is_some())
208        || (props.show_filters && !props.filters.is_empty() && props.on_filter_change.is_some());
209
210    // Loading state (without toolbar)
211    if props.loading {
212        return rsx! {
213            div {
214                style: "{final_style}",
215                div {
216                    style: "padding: 48px; text-align: center;",
217                    Icon {
218                        name: "spinner".to_string(),
219                        size: IconSize::Large,
220                        color: IconColor::Muted,
221                    }
222                }
223            }
224        };
225    }
226
227    // Empty state (without toolbar)
228    if props.data.is_empty() {
229        return rsx! {
230            div {
231                style: "{final_style}",
232                div {
233                    style: "padding: 48px; text-align: center;",
234                    Label {
235                        size: TextSize::Small,
236                        color: TextColor::Muted,
237                        "{props.empty_message}"
238                    }
239                }
240            }
241        };
242    }
243
244    let columns = props.columns.clone();
245    let data = props.data.clone();
246    let selectable = props.selectable;
247    let selected_keys = props.selected_keys.clone();
248    let search_query = props.search_query.clone().unwrap_or_default();
249
250    rsx! {
251        div {
252            style: "{final_style}",
253
254            if show_toolbar {
255                div {
256                    style: "{toolbar_style}",
257
258                    if props.show_search && props.on_search_change.is_some() {
259                        div {
260                            style: "{search_container_style} flex: 1;",
261                            Icon {
262                                name: "search".to_string(),
263                                size: IconSize::Small,
264                                color: IconColor::Muted,
265                            }
266                            input {
267                                r#type: "text",
268                                placeholder: "{props.search_placeholder}",
269                                value: "{search_query}",
270                                style: "flex: 1; min-width: 200px; padding: 8px 12px; border: 1px solid rgb(226,232,240); border-radius: 6px; font-size: 14px; outline: none; &:focus {{ border-color: rgb(59,130,246); }}",
271                                oninput: move |e| {
272                                    if let Some(handler) = &props.on_search_change {
273                                        handler.call(e.value());
274                                    }
275                                },
276                            }
277                            if !search_query.is_empty() {
278                                Button {
279                                    variant: ButtonVariant::Ghost,
280                                    size: ButtonSize::Sm,
281                                    onclick: move |_| {
282                                        if let Some(handler) = &props.on_search_change {
283                                            handler.call("".to_string());
284                                        }
285                                    },
286                                    Icon {
287                                        name: "x".to_string(),
288                                        size: IconSize::Small,
289                                        color: IconColor::Muted,
290                                    }
291                                }
292                            }
293                        }
294                    }
295
296                    if props.show_filters && !props.filters.is_empty() && props.on_filter_change.is_some() {
297                        div {
298                            style: "{filters_container_style}",
299                            for filter in props.filters.clone() {
300                                DataTableFilter {
301                                    filter: filter.clone(),
302                                    active_value: props.active_filters.get(&filter.key).cloned().unwrap_or_default(),
303                                    on_change: props.on_filter_change.clone(),
304                                }
305                            }
306                        }
307                    }
308                }
309            }
310
311            table {
312                style: "{table_style}",
313
314                DataTableHeader {
315                    columns: columns.clone(),
316                    selectable: selectable,
317                    selected_count: selected_keys.len(),
318                    total_count: data.len(),
319                    on_select_all: props.on_selection_change.clone(),
320                }
321
322                tbody {
323                    for row in data {
324                        DataTableRow {
325                            row: row.clone(),
326                            columns: columns.clone(),
327                            key_extractor: props.key_extractor,
328                            selectable: selectable,
329                            is_selected: selected_keys.contains(&(props.key_extractor)(&row)),
330                            on_select: props.on_selection_change.clone(),
331                            on_click: props.on_row_click.clone(),
332                        }
333                    }
334                }
335            }
336        }
337    }
338}
339
340/// Filter dropdown component
341#[derive(Props, Clone, PartialEq)]
342pub struct DataTableFilterProps {
343    pub filter: TableFilter,
344    pub active_value: String,
345    pub on_change: Option<EventHandler<(String, String)>>,
346}
347
348#[component]
349pub fn DataTableFilter(props: DataTableFilterProps) -> Element {
350    let filter = props.filter.clone();
351    let active_value = props.active_value.clone();
352    let has_value = !active_value.is_empty();
353
354    rsx! {
355        select {
356            style: if has_value {
357                "padding: 8px 12px; border: 1px solid rgb(59,130,246); border-radius: 6px; font-size: 14px; background: white; cursor: pointer; outline: none; color: rgb(15,23,42);"
358            } else {
359                "padding: 8px 12px; border: 1px solid rgb(226,232,240); border-radius: 6px; font-size: 14px; background: white; cursor: pointer; outline: none; color: rgb(100,116,139);"
360            },
361            onchange: move |e| {
362                if let Some(handler) = &props.on_change {
363                    handler.call((filter.key.clone(), e.value()));
364                }
365            },
366            option {
367                value: "",
368                selected: active_value.is_empty(),
369                "{filter.label}"
370            }
371            for option in filter.options {
372                option {
373                    value: "{option.value}",
374                    selected: active_value == option.value,
375                    "{option.label}"
376                }
377            }
378        }
379    }
380}
381
382/// Table header component
383#[derive(Props, Clone, PartialEq)]
384pub struct DataTableHeaderProps<T: Clone + PartialEq + 'static> {
385    pub columns: Vec<TableColumn<T>>,
386    pub selectable: bool,
387    pub selected_count: usize,
388    pub total_count: usize,
389    pub on_select_all: Option<EventHandler<Vec<String>>>,
390}
391
392#[component]
393pub fn DataTableHeader<T: Clone + PartialEq>(props: DataTableHeaderProps<T>) -> Element {
394    let _theme = use_theme();
395
396    let header_style = use_style(|t| {
397        Style::new()
398            .bg(&t.colors.muted)
399            .text_color(&t.colors.foreground)
400            .font_weight(500)
401            .build()
402    });
403
404    let th_style = use_style(|t| {
405        Style::new()
406            .p(&t.spacing, "md")
407            .text_align("left")
408            .border_bottom(1, &t.colors.border)
409            .build()
410    });
411
412    let all_selected = props.selected_count == props.total_count && props.total_count > 0;
413
414    rsx! {
415        thead {
416            style: "{header_style}",
417
418            tr {
419                if props.selectable {
420                    th {
421                        style: "width: 48px; padding: 12px;",
422                        input {
423                            r#type: "checkbox",
424                            checked: all_selected,
425                            onchange: move |_| {
426                                // Toggle select all logic would go here
427                            },
428                        }
429                    }
430                }
431
432                for col in props.columns {
433                    th {
434                        style: "{th_style} text-align: {col.align.as_str()}; width: {col.width.clone().unwrap_or_default()};",
435
436                        if col.sortable {
437                            div {
438                                style: "display: inline-flex; align-items: center; gap: 4px; cursor: pointer;",
439                                "{col.header}"
440                                Icon {
441                                    name: "chevron-down".to_string(),
442                                    size: IconSize::Small,
443                                    color: IconColor::Muted,
444                                }
445                            }
446                        } else {
447                            "{col.header}"
448                        }
449                    }
450                }
451            }
452        }
453    }
454}
455
456/// Table row component
457#[derive(Props, Clone, PartialEq)]
458pub struct DataTableRowProps<T: Clone + PartialEq + 'static> {
459    pub row: T,
460    pub columns: Vec<TableColumn<T>>,
461    pub key_extractor: fn(&T) -> String,
462    pub selectable: bool,
463    pub is_selected: bool,
464    pub on_select: Option<EventHandler<Vec<String>>>,
465    pub on_click: Option<EventHandler<T>>,
466}
467
468#[component]
469pub fn DataTableRow<T: Clone + PartialEq + 'static>(props: DataTableRowProps<T>) -> Element {
470    let _theme = use_theme();
471    let _key = (props.key_extractor)(&props.row);
472    let is_selected = props.is_selected;
473    let _has_onclick = props.on_click.is_some();
474
475    let mut is_hovered = use_signal(|| false);
476
477    let row_style = use_style(move |t| {
478        let base = Style::new()
479            .border_bottom(1, &t.colors.border)
480            .transition("background-color 150ms ease");
481
482        if is_selected {
483            base.bg(&t.colors.primary.blend(&t.colors.background, 0.9))
484        } else if is_hovered() {
485            base.bg(&t.colors.muted)
486        } else {
487            base
488        }
489        .build()
490    });
491
492    let td_style = use_style(|t| Style::new().p(&t.spacing, "md").build());
493
494    let row_data = props.row.clone();
495    let onclick_handler = props.on_click.clone();
496
497    rsx! {
498        tr {
499            style: "{row_style}",
500            onmouseenter: move |_| is_hovered.set(true),
501            onmouseleave: move |_| is_hovered.set(false),
502            onclick: move |_| {
503                if let Some(handler) = &onclick_handler {
504                    handler.call(row_data.clone());
505                }
506            },
507
508            if props.selectable {
509                td {
510                    style: "width: 48px; padding: 12px;",
511                    input {
512                        r#type: "checkbox",
513                        checked: is_selected,
514                        onchange: move |_| {
515                            // Selection toggle logic
516                        },
517                    }
518                }
519            }
520
521            for col in props.columns {
522                DataTableCell {
523                    row: props.row.clone(),
524                    column: col,
525                    base_style: td_style(),
526                }
527            }
528        }
529    }
530}
531
532/// Table cell component
533#[derive(Props, Clone, PartialEq)]
534pub struct DataTableCellProps<T: Clone + PartialEq + 'static> {
535    pub row: T,
536    pub column: TableColumn<T>,
537    pub base_style: String,
538}
539
540#[component]
541pub fn DataTableCell<T: Clone + PartialEq>(props: DataTableCellProps<T>) -> Element {
542    let col = props.column.clone();
543    let align = col.align.as_str();
544
545    // Get cell value using key
546    let cell_content = if let Some(render_fn) = col.render {
547        render_fn(&props.row)
548    } else {
549        // Default: just show the value (in a real implementation,
550        // we'd use reflection or require a trait to extract values)
551        rsx! {
552            Label {
553                size: TextSize::Small,
554                "-"
555            }
556        }
557    };
558
559    rsx! {
560        td {
561            style: "{props.base_style} text-align: {align};",
562            {cell_content}
563        }
564    }
565}
566
567/// Pagination component
568#[derive(Props, Clone, PartialEq)]
569pub struct PaginationProps {
570    pub current_page: usize,
571    pub total_pages: usize,
572    pub on_page_change: EventHandler<usize>,
573    #[props(default)]
574    pub show_first_last: bool,
575}
576
577#[component]
578pub fn Pagination(props: PaginationProps) -> Element {
579    let current = props.current_page;
580    let total = props.total_pages;
581
582    let container_style = use_style(|t| {
583        Style::new()
584            .flex()
585            .items_center()
586            .justify_between()
587            .px(&t.spacing, "md")
588            .py(&t.spacing, "sm")
589            .border_top(1, &t.colors.border)
590            .build()
591    });
592
593    let info_style = use_style(|t| {
594        Style::new()
595            .text(&t.typography, "sm")
596            .text_color(&t.colors.muted_foreground)
597            .build()
598    });
599
600    rsx! {
601        div {
602            style: "{container_style}",
603
604            div {
605                style: "{info_style}",
606                "Page {current + 1} of {total}"
607            }
608
609            div {
610                style: "display: flex; align-items: center; gap: 4px;",
611
612                if props.show_first_last && current > 0 {
613                    Button {
614                        variant: ButtonVariant::Ghost,
615                        size: ButtonSize::Sm,
616                        onclick: move |_| props.on_page_change.call(0),
617                        Icon {
618                            name: "chevron-left".to_string(),
619                            size: IconSize::Small,
620                            color: IconColor::Current,
621                        }
622                    }
623                }
624
625                Button {
626                    variant: ButtonVariant::Ghost,
627                    size: ButtonSize::Sm,
628                    disabled: current == 0,
629                    onclick: move |_| props.on_page_change.call(current.saturating_sub(1)),
630                    Icon {
631                        name: "chevron-left".to_string(),
632                        size: IconSize::Small,
633                        color: IconColor::Current,
634                    }
635                }
636
637                Button {
638                    variant: ButtonVariant::Ghost,
639                    size: ButtonSize::Sm,
640                    disabled: current >= total - 1,
641                    onclick: move |_| props.on_page_change.call((current + 1).min(total - 1)),
642                    Icon {
643                        name: "chevron-right".to_string(),
644                        size: IconSize::Small,
645                        color: IconColor::Current,
646                    }
647                }
648
649                if props.show_first_last && current < total - 1 {
650                    Button {
651                        variant: ButtonVariant::Ghost,
652                        size: ButtonSize::Sm,
653                        onclick: move |_| props.on_page_change.call(total - 1),
654                        Icon {
655                            name: "chevron-right".to_string(),
656                            size: IconSize::Small,
657                            color: IconColor::Current,
658                        }
659                    }
660                }
661            }
662        }
663    }
664}