adui_dioxus/components/
table.rs

1//! Table component aligned with Ant Design 6.0.
2//!
3//! Features:
4//! - Custom column render functions
5//! - Row selection (checkbox column)
6//! - Sorting and filtering
7//! - Fixed header/columns via scroll
8//! - Pagination integration
9
10use crate::components::checkbox::Checkbox;
11use crate::components::config_provider::ComponentSize;
12use crate::components::empty::Empty;
13use crate::components::icon::{Icon, IconKind};
14use crate::components::pagination::Pagination;
15use crate::components::spin::Spin;
16use crate::foundation::{
17    ClassListExt, StyleStringExt, TableClassNames, TableSemantic, TableStyles,
18};
19use dioxus::prelude::*;
20use serde_json::Value;
21use std::collections::HashMap;
22use std::rc::Rc;
23
24/// Horizontal alignment for table cells.
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26pub enum ColumnAlign {
27    #[default]
28    Left,
29    Center,
30    Right,
31}
32
33impl ColumnAlign {
34    fn as_class(&self) -> &'static str {
35        match self {
36            ColumnAlign::Left => "adui-table-align-left",
37            ColumnAlign::Center => "adui-table-align-center",
38            ColumnAlign::Right => "adui-table-align-right",
39        }
40    }
41}
42
43/// Sort direction for a column.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum SortOrder {
46    Ascend,
47    Descend,
48}
49
50impl SortOrder {
51    fn as_class(&self) -> &'static str {
52        match self {
53            SortOrder::Ascend => "adui-table-column-sort-ascend",
54            SortOrder::Descend => "adui-table-column-sort-descend",
55        }
56    }
57}
58
59/// Fixed position for a column.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum ColumnFixed {
62    Left,
63    Right,
64}
65
66/// Type alias for custom render function.
67/// Takes (value, record, index) and returns Element.
68pub type ColumnRenderFn = fn(Option<&Value>, &Value, usize) -> Element;
69
70/// Filter item for column filters.
71#[derive(Clone, Debug, PartialEq)]
72pub struct ColumnFilter {
73    pub text: String,
74    pub value: String,
75}
76
77/// Type alias for filter function.
78/// Takes (value, record) and returns whether to show the row.
79pub type ColumnFilterFn = fn(&str, &Value) -> bool;
80
81/// Type alias for sort compare function.
82/// Takes (a, b) and returns ordering.
83pub type ColumnSorterFn = fn(&Value, &Value) -> std::cmp::Ordering;
84
85/// Column definition for the Table component.
86#[derive(Clone)]
87pub struct TableColumn {
88    pub key: String,
89    pub title: String,
90    pub data_index: Option<String>,
91    pub width: Option<f32>,
92    pub align: Option<ColumnAlign>,
93    pub fixed: Option<ColumnFixed>,
94    /// Whether this column can be sorted.
95    pub sortable: bool,
96    /// Default sort order.
97    pub default_sort_order: Option<SortOrder>,
98    /// Custom sorter function.
99    #[allow(clippy::type_complexity)]
100    pub sorter: Option<ColumnSorterFn>,
101    /// Filter options.
102    pub filters: Option<Vec<ColumnFilter>>,
103    /// Custom filter function.
104    #[allow(clippy::type_complexity)]
105    pub on_filter: Option<ColumnFilterFn>,
106    /// Custom render function.
107    #[allow(clippy::type_complexity)]
108    pub render: Option<ColumnRenderFn>,
109    /// Whether column is hidden.
110    pub hidden: bool,
111    /// Ellipsis text overflow.
112    pub ellipsis: bool,
113}
114
115impl PartialEq for TableColumn {
116    fn eq(&self, other: &Self) -> bool {
117        self.key == other.key
118            && self.title == other.title
119            && self.data_index == other.data_index
120            && self.width == other.width
121            && self.align == other.align
122            && self.fixed == other.fixed
123            && self.sortable == other.sortable
124            && self.default_sort_order == other.default_sort_order
125            && self.filters == other.filters
126            && self.hidden == other.hidden
127            && self.ellipsis == other.ellipsis
128    }
129}
130
131impl TableColumn {
132    pub fn new(key: impl Into<String>, title: impl Into<String>) -> Self {
133        let key_str = key.into();
134        Self {
135            key: key_str.clone(),
136            title: title.into(),
137            data_index: Some(key_str),
138            width: None,
139            align: None,
140            fixed: None,
141            sortable: false,
142            default_sort_order: None,
143            sorter: None,
144            filters: None,
145            on_filter: None,
146            render: None,
147            hidden: false,
148            ellipsis: false,
149        }
150    }
151
152    /// Set data index for this column.
153    pub fn data_index(mut self, index: impl Into<String>) -> Self {
154        self.data_index = Some(index.into());
155        self
156    }
157
158    /// Set width for this column.
159    pub fn width(mut self, width: f32) -> Self {
160        self.width = Some(width);
161        self
162    }
163
164    /// Set alignment for this column.
165    pub fn align(mut self, align: ColumnAlign) -> Self {
166        self.align = Some(align);
167        self
168    }
169
170    /// Set this column as sortable.
171    pub fn sortable(mut self) -> Self {
172        self.sortable = true;
173        self
174    }
175
176    /// Set custom render function.
177    pub fn render(mut self, render: ColumnRenderFn) -> Self {
178        self.render = Some(render);
179        self
180    }
181
182    /// Set fixed position.
183    pub fn fixed(mut self, fixed: ColumnFixed) -> Self {
184        self.fixed = Some(fixed);
185        self
186    }
187
188    /// Set ellipsis overflow.
189    pub fn ellipsis(mut self) -> Self {
190        self.ellipsis = true;
191        self
192    }
193}
194
195/// Row selection configuration.
196#[derive(Clone, Default, PartialEq)]
197pub struct RowSelection {
198    /// Selected row keys.
199    pub selected_row_keys: Vec<String>,
200    /// Callback when selection changes.
201    pub on_change: Option<EventHandler<Vec<String>>>,
202    /// Selection type (checkbox or radio).
203    pub selection_type: SelectionType,
204    /// Whether to preserve selection when data changes.
205    pub preserve_selected_row_keys: bool,
206}
207
208/// Selection type for row selection.
209#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
210pub enum SelectionType {
211    #[default]
212    Checkbox,
213    Radio,
214}
215
216/// Expandable row configuration.
217#[derive(Clone)]
218pub struct ExpandableConfig {
219    /// Whether rows are expandable by default.
220    pub expanded_row_keys: Vec<String>,
221    /// Callback when expanded rows change.
222    pub on_expand: Option<EventHandler<(bool, String)>>,
223    /// Custom expand icon.
224    pub expand_icon: Option<Element>,
225    /// Custom expanded row render function: (record, index, indent, expanded) -> Element
226    pub expanded_row_render: Option<Rc<dyn Fn(&Value, usize, usize, bool) -> Element>>,
227    /// Whether to show expand icon for all rows (even if expanded_row_render returns None).
228    pub show_expand_icon: bool,
229}
230
231impl Default for ExpandableConfig {
232    fn default() -> Self {
233        Self {
234            expanded_row_keys: Vec::new(),
235            on_expand: None,
236            expand_icon: None,
237            expanded_row_render: None,
238            show_expand_icon: true,
239        }
240    }
241}
242
243impl PartialEq for ExpandableConfig {
244    fn eq(&self, other: &Self) -> bool {
245        self.expanded_row_keys == other.expanded_row_keys
246            && self.show_expand_icon == other.show_expand_icon
247        // Functions cannot be compared
248    }
249}
250
251/// Scroll configuration for fixed header/columns.
252#[derive(Clone, Debug, Default, PartialEq)]
253pub struct TableScroll {
254    /// Fixed table height (for virtual scrolling or fixed header).
255    pub y: Option<f32>,
256    /// Fixed table width (for horizontal scrolling).
257    pub x: Option<f32>,
258    /// Whether to scroll to top when data changes.
259    pub scroll_to_first_row_on_change: bool,
260}
261
262/// Sticky configuration for table header.
263#[derive(Clone, Debug, Default, PartialEq)]
264pub struct StickyConfig {
265    /// Offset from top when sticky.
266    pub offset_top: Option<f32>,
267    /// Offset from bottom when sticky.
268    pub offset_bottom: Option<f32>,
269    /// Container element selector for sticky calculation.
270    pub get_container: Option<String>,
271}
272
273/// Summary row configuration.
274#[derive(Clone)]
275pub struct SummaryConfig {
276    /// Custom summary row render function: (columns, data) -> Element
277    pub render: Option<Rc<dyn Fn(&[TableColumn], &[Value]) -> Element>>,
278    /// Fixed position for summary row.
279    pub fixed: Option<ColumnFixed>,
280}
281
282impl PartialEq for SummaryConfig {
283    fn eq(&self, other: &Self) -> bool {
284        self.fixed == other.fixed
285        // Functions cannot be compared
286    }
287}
288
289/// Type alias for row class name function: (record, index) -> Option<String>
290pub type RowClassNameFn = Rc<dyn Fn(&Value, usize) -> Option<String>>;
291
292/// Type alias for row props function: (record, index) -> HashMap<String, String>
293pub type RowPropsFn = Rc<dyn Fn(&Value, usize) -> HashMap<String, String>>;
294
295/// Locale configuration for table text.
296#[derive(Clone, Debug, Default, PartialEq)]
297pub struct TableLocale {
298    /// Text for filter title.
299    pub filter_title: Option<String>,
300    /// Text for filter confirm button.
301    pub filter_confirm: Option<String>,
302    /// Text for filter reset button.
303    pub filter_reset: Option<String>,
304    /// Text when filter is empty.
305    pub filter_empty_text: Option<String>,
306    /// Text for "select all".
307    pub select_all: Option<String>,
308    /// Text for "select none".
309    pub select_none: Option<String>,
310    /// Text for "select invert".
311    pub select_invert: Option<String>,
312    /// Text for sort title.
313    pub sort_title: Option<String>,
314    /// Text for expand.
315    pub expand: Option<String>,
316    /// Text for collapse.
317    pub collapse: Option<String>,
318    /// Text for empty table.
319    pub empty_text: Option<String>,
320}
321
322/// Event payload for table changes (pagination, filters, sorter).
323#[derive(Clone, Debug, Default, PartialEq)]
324pub struct TableChangeEvent {
325    pub pagination: Option<TablePaginationState>,
326    pub sorter: Option<TableSorterState>,
327    pub filters: HashMap<String, Vec<String>>,
328}
329
330/// Pagination state in change event.
331#[derive(Clone, Debug, Default, PartialEq)]
332pub struct TablePaginationState {
333    pub current: u32,
334    pub page_size: u32,
335    pub total: u32,
336}
337
338/// Sorter state in change event.
339#[derive(Clone, Debug, Default, PartialEq)]
340pub struct TableSorterState {
341    pub column_key: Option<String>,
342    pub order: Option<SortOrder>,
343}
344
345/// Props for the Table component.
346#[derive(Props, Clone)]
347pub struct TableProps {
348    /// Column definitions.
349    pub columns: Vec<TableColumn>,
350    /// Row data as JSON values.
351    pub data: Vec<Value>,
352    /// Field used as row key.
353    #[props(optional)]
354    pub row_key_field: Option<String>,
355    /// Extra class applied to each row (static).
356    #[props(optional)]
357    pub row_class_name: Option<String>,
358    /// Dynamic row class name function: (record, index) -> Option<String>
359    #[props(optional)]
360    pub row_class_name_fn: Option<RowClassNameFn>,
361    /// Dynamic row props function: (record, index) -> HashMap<String, String>
362    #[props(optional)]
363    pub row_props_fn: Option<RowPropsFn>,
364    /// Whether to show outer borders.
365    #[props(default)]
366    pub bordered: bool,
367    /// Visual density.
368    #[props(optional)]
369    pub size: Option<ComponentSize>,
370    /// Loading state.
371    #[props(default)]
372    pub loading: bool,
373    /// Whether the table is currently empty.
374    #[props(optional)]
375    pub is_empty: Option<bool>,
376    /// Custom empty node.
377    #[props(optional)]
378    pub empty: Option<Element>,
379    /// Row selection configuration.
380    #[props(optional)]
381    pub row_selection: Option<RowSelection>,
382    /// Scroll configuration.
383    #[props(optional)]
384    pub scroll: Option<TableScroll>,
385    /// Sticky header configuration.
386    #[props(optional)]
387    pub sticky: Option<StickyConfig>,
388    /// Expandable row configuration.
389    #[props(optional)]
390    pub expandable: Option<ExpandableConfig>,
391    /// Summary row configuration.
392    #[props(optional)]
393    pub summary: Option<SummaryConfig>,
394    /// Called when pagination, filters or sorter changes.
395    #[props(optional)]
396    pub on_change: Option<EventHandler<TableChangeEvent>>,
397    /// Custom container for popups (dropdowns, filters, etc.).
398    /// Function that takes trigger node and returns container element.
399    /// In Rust, this is simplified to a container selector string.
400    #[props(optional)]
401    pub get_popup_container: Option<String>,
402    /// Enable virtual scrolling for large datasets.
403    #[props(default)]
404    pub r#virtual: bool,
405    /// Locale configuration for table text.
406    #[props(optional)]
407    pub locale: Option<TableLocale>,
408    /// Show table header.
409    #[props(default = true)]
410    pub show_header: bool,
411    #[props(optional)]
412    pub class: Option<String>,
413    #[props(optional)]
414    pub style: Option<String>,
415    /// Semantic class names.
416    #[props(optional)]
417    pub class_names: Option<TableClassNames>,
418    /// Semantic styles.
419    #[props(optional)]
420    pub styles: Option<TableStyles>,
421    // Pagination props (simplified)
422    #[props(optional)]
423    pub pagination_total: Option<u32>,
424    #[props(optional)]
425    pub pagination_current: Option<u32>,
426    #[props(optional)]
427    pub pagination_page_size: Option<u32>,
428    #[props(optional)]
429    pub pagination_on_change: Option<EventHandler<(u32, u32)>>,
430}
431
432impl PartialEq for TableProps {
433    fn eq(&self, other: &Self) -> bool {
434        // Compare all fields except function pointers
435        self.columns == other.columns
436            && self.data == other.data
437            && self.row_key_field == other.row_key_field
438            && self.row_class_name == other.row_class_name
439            && self.bordered == other.bordered
440            && self.size == other.size
441            && self.loading == other.loading
442            && self.is_empty == other.is_empty
443            && self.empty == other.empty
444            && self.row_selection == other.row_selection
445            && self.scroll == other.scroll
446            && self.sticky == other.sticky
447            && self.expandable == other.expandable
448            && self.summary == other.summary
449            && self.on_change == other.on_change
450            && self.show_header == other.show_header
451            && self.class == other.class
452            && self.style == other.style
453            && self.class_names == other.class_names
454            && self.styles == other.styles
455            && self.get_popup_container == other.get_popup_container
456            && self.r#virtual == other.r#virtual
457            && self.locale == other.locale
458            && self.pagination_total == other.pagination_total
459            && self.pagination_current == other.pagination_current
460            && self.pagination_page_size == other.pagination_page_size
461        // Function pointers cannot be compared for equality
462    }
463}
464
465/// Ant Design flavored Table.
466#[component]
467pub fn Table(props: TableProps) -> Element {
468    let TableProps {
469        columns,
470        data,
471        row_key_field,
472        row_class_name,
473        row_class_name_fn: _,
474        row_props_fn: _,
475        bordered,
476        size,
477        loading,
478        is_empty,
479        empty,
480        row_selection,
481        scroll,
482        sticky: _,
483        expandable: _,
484        summary: _,
485        on_change,
486        show_header,
487        class,
488        style,
489        class_names,
490        styles,
491        get_popup_container: _,
492        r#virtual: _virtual_scrolling,
493        locale: _,
494        pagination_total,
495        pagination_current,
496        pagination_page_size,
497        pagination_on_change,
498    } = props;
499
500    // Internal sort state
501    let sort_state: Signal<Option<(String, SortOrder)>> = use_signal(|| None);
502
503    // Filter visible columns
504    let visible_columns: Vec<&TableColumn> = columns.iter().filter(|c| !c.hidden).collect();
505
506    // Build table classes
507    let mut class_list = vec!["adui-table".to_string()];
508    if bordered {
509        class_list.push("adui-table-bordered".into());
510    }
511    if let Some(sz) = size {
512        match sz {
513            ComponentSize::Small => class_list.push("adui-table-sm".into()),
514            ComponentSize::Middle => {}
515            ComponentSize::Large => class_list.push("adui-table-lg".into()),
516        }
517    }
518    if row_selection.is_some() {
519        class_list.push("adui-table-selection".into());
520    }
521    class_list.push_semantic(&class_names, TableSemantic::Root);
522    if let Some(extra) = class {
523        class_list.push(extra);
524    }
525    let class_attr = class_list
526        .into_iter()
527        .filter(|s| !s.is_empty())
528        .collect::<Vec<_>>()
529        .join(" ");
530
531    let mut style_attr = style.unwrap_or_default();
532    style_attr.append_semantic(&styles, TableSemantic::Root);
533
534    // Scroll styles
535    let scroll_style = if let Some(ref sc) = scroll {
536        let mut s = String::new();
537        if let Some(y) = sc.y {
538            s.push_str(&format!("max-height: {}px; overflow-y: auto;", y));
539        }
540        if let Some(x) = sc.x {
541            s.push_str(&format!("overflow-x: auto; min-width: {}px;", x));
542        }
543        s
544    } else {
545        String::new()
546    };
547
548    let show_empty = !loading && is_empty.unwrap_or(data.is_empty());
549
550    // Row key helper
551    let get_row_key = |row: &Value, idx: usize| -> String {
552        if let Some(field) = &row_key_field {
553            get_cell_text(row, field)
554        } else {
555            idx.to_string()
556        }
557    };
558
559    // Selection helpers
560    let has_selection = row_selection.is_some();
561    let selection_type = row_selection
562        .as_ref()
563        .map(|r| r.selection_type)
564        .unwrap_or_default();
565    let selected_keys = row_selection
566        .as_ref()
567        .map(|r| r.selected_row_keys.clone())
568        .unwrap_or_default();
569
570    let all_keys: Vec<String> = data
571        .iter()
572        .enumerate()
573        .map(|(idx, row)| get_row_key(row, idx))
574        .collect();
575
576    let all_selected = !all_keys.is_empty() && all_keys.iter().all(|k| selected_keys.contains(k));
577    let some_selected = !selected_keys.is_empty() && !all_selected;
578
579    // Selection change handler
580    let on_select_change = row_selection.as_ref().and_then(|r| r.on_change);
581
582    // Sort handler
583    let on_change_cb = on_change;
584    let pagination_total_for_change = pagination_total;
585    let pagination_current_for_change = pagination_current;
586    let pagination_page_size_for_change = pagination_page_size;
587
588    rsx! {
589        div { class: "{class_attr}", style: "{style_attr}",
590            if show_header {
591                div { class: "adui-table-header",
592                    div { class: "adui-table-row adui-table-row-header",
593                        // Selection column header
594                        if has_selection {
595                            div { class: "adui-table-cell adui-table-cell-selection",
596                                if matches!(selection_type, SelectionType::Checkbox) {
597                                    Checkbox {
598                                        checked: all_selected,
599                                        indeterminate: some_selected,
600                                        on_change: move |_| {
601                                            if let Some(cb) = on_select_change {
602                                                if all_selected {
603                                                    cb.call(Vec::new());
604                                                } else {
605                                                    cb.call(all_keys.clone());
606                                                }
607                                            }
608                                        }
609                                    }
610                                }
611                            }
612                        }
613                        // Data columns
614                        {visible_columns.iter().map(|col| {
615                            let mut cell_classes = vec!["adui-table-cell".to_string(), "adui-table-cell-header".to_string()];
616                            if let Some(align) = col.align {
617                                cell_classes.push(align.as_class().to_string());
618                            }
619                            if col.sortable {
620                                cell_classes.push("adui-table-column-sortable".into());
621                            }
622                            if let Some((ref key, order)) = *sort_state.read() {
623                                if key == &col.key {
624                                    cell_classes.push(order.as_class().to_string());
625                                }
626                            }
627                            if col.ellipsis {
628                                cell_classes.push("adui-table-cell-ellipsis".into());
629                            }
630
631                            let width_style = col.width.map(|w| format!("width: {}px;", w)).unwrap_or_default();
632                            let title = col.title.clone();
633                            let cell_class = cell_classes.join(" ");
634
635                            let col_key = col.key.clone();
636                            let sortable = col.sortable;
637                            let mut sort_signal = sort_state;
638
639                            rsx! {
640                                div {
641                                    class: "{cell_class}",
642                                    style: "{width_style}",
643                                    onclick: move |_| {
644                                        if sortable {
645                                            let current = sort_signal.read().clone();
646                                            let new_order = match current {
647                                                Some((ref k, SortOrder::Ascend)) if k == &col_key => {
648                                                    Some((col_key.clone(), SortOrder::Descend))
649                                                }
650                                                Some((ref k, SortOrder::Descend)) if k == &col_key => {
651                                                    None
652                                                }
653                                                _ => Some((col_key.clone(), SortOrder::Ascend))
654                                            };
655                                            sort_signal.set(new_order.clone());
656
657                                            // Emit change event
658                                            if let Some(cb) = on_change_cb {
659                                                cb.call(TableChangeEvent {
660                                                    pagination: pagination_total_for_change.map(|t| TablePaginationState {
661                                                        total: t,
662                                                        current: pagination_current_for_change.unwrap_or(1),
663                                                        page_size: pagination_page_size_for_change.unwrap_or(10),
664                                                    }),
665                                                    sorter: Some(TableSorterState {
666                                                        column_key: new_order.as_ref().map(|(k, _)| k.clone()),
667                                                        order: new_order.as_ref().map(|(_, o)| *o),
668                                                    }),
669                                                    filters: HashMap::new(),
670                                                });
671                                            }
672                                        }
673                                    },
674                                    span { class: "adui-table-column-title", "{title}" }
675                                    if sortable {
676                                        span { class: "adui-table-column-sorter",
677                                            Icon { kind: IconKind::ArrowUp, size: 10.0 }
678                                            Icon { kind: IconKind::ArrowDown, size: 10.0 }
679                                        }
680                                    }
681                                }
682                            }
683                        })}
684                    }
685                }
686            }
687
688            // Table body
689            div {
690                class: "adui-table-body",
691                style: "{scroll_style}",
692                if loading {
693                    Spin {
694                        spinning: Some(true),
695                        tip: Some("加载中...".to_string()),
696                        div { class: "adui-table-body-inner",
697                            {render_rows(&visible_columns, &data, &row_key_field, &row_class_name, has_selection, selection_type, &selected_keys, on_select_change)}
698                        }
699                    }
700                } else if show_empty {
701                    div { class: "adui-table-empty",
702                        if let Some(node) = empty {
703                            {node}
704                        } else {
705                            Empty {}
706                        }
707                    }
708                } else {
709                    div { class: "adui-table-body-inner",
710                        {render_rows(&visible_columns, &data, &row_key_field, &row_class_name, has_selection, selection_type, &selected_keys, on_select_change)}
711                    }
712                }
713            }
714
715            if let Some(total) = pagination_total {
716                div { class: "adui-table-pagination",
717                    Pagination {
718                        total: total,
719                        current: pagination_current,
720                        page_size: pagination_page_size,
721                        show_total: false,
722                        show_size_changer: false,
723                        on_change: move |(page, size)| {
724                            if let Some(cb) = pagination_on_change {
725                                cb.call((page, size));
726                            }
727                            // Also emit to on_change
728                            if let Some(cb) = on_change_cb {
729                                cb.call(TableChangeEvent {
730                                    pagination: Some(TablePaginationState {
731                                        total,
732                                        current: page,
733                                        page_size: size,
734                                    }),
735                                    sorter: None,
736                                    filters: HashMap::new(),
737                                });
738                            }
739                        },
740                    }
741                }
742            }
743        }
744    }
745}
746
747fn render_rows(
748    columns: &[&TableColumn],
749    data: &[Value],
750    row_key_field: &Option<String>,
751    row_class_name: &Option<String>,
752    has_selection: bool,
753    selection_type: SelectionType,
754    selected_keys: &[String],
755    on_select_change: Option<EventHandler<Vec<String>>>,
756) -> Element {
757    rsx! {
758        {data.iter().enumerate().map(|(idx, row)| {
759            let key = if let Some(field) = row_key_field {
760                get_cell_text(row, field)
761            } else {
762                idx.to_string()
763            };
764            let row_class = row_class_name.clone().unwrap_or_default();
765            let is_selected = selected_keys.contains(&key);
766            let key_for_select = key.clone();
767            let selected_keys_clone = selected_keys.to_vec();
768
769            rsx! {
770                div {
771                    key: "{key}",
772                    class: "adui-table-row {row_class}",
773                    class: if is_selected { "adui-table-row-selected" } else { "" },
774                    // Selection cell
775                    if has_selection {
776                        div { class: "adui-table-cell adui-table-cell-selection",
777                            Checkbox {
778                                checked: is_selected,
779                                on_change: move |_checked| {
780                                    if let Some(cb) = on_select_change {
781                                        let mut new_keys = selected_keys_clone.clone();
782                                        if is_selected {
783                                            new_keys.retain(|k| k != &key_for_select);
784                                        } else {
785                                            if matches!(selection_type, SelectionType::Radio) {
786                                                new_keys.clear();
787                                            }
788                                            new_keys.push(key_for_select.clone());
789                                        }
790                                        cb.call(new_keys);
791                                    }
792                                }
793                            }
794                        }
795                    }
796                    // Data cells
797                    {columns.iter().map(|col| {
798                        let mut cell_classes = vec!["adui-table-cell".to_string()];
799                        if let Some(align) = col.align {
800                            cell_classes.push(align.as_class().to_string());
801                        }
802                        if col.ellipsis {
803                            cell_classes.push("adui-table-cell-ellipsis".into());
804                        }
805                        let cell_class = cell_classes.join(" ");
806
807                        let data_index = col.data_index.as_ref().unwrap_or(&col.key);
808                        let cell_value = row.get(data_index);
809
810                        // Use custom render if provided, otherwise default text
811                        let content = if let Some(render_fn) = col.render {
812                            render_fn(cell_value, row, idx)
813                        } else {
814                            let text = get_cell_text(row, data_index);
815                            rsx! { "{text}" }
816                        };
817
818                        rsx! {
819                            div { class: "{cell_class}", {content} }
820                        }
821                    })}
822                }
823            }
824        })}
825    }
826}
827
828/// Helper to extract a cell value as string.
829fn get_cell_text(row: &Value, key: &str) -> String {
830    match row.get(key) {
831        Some(Value::String(s)) => s.clone(),
832        Some(Value::Number(n)) => n.to_string(),
833        Some(Value::Bool(b)) => b.to_string(),
834        _ => "".to_string(),
835    }
836}
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841
842    #[test]
843    fn column_builder_works() {
844        let col = TableColumn::new("id", "ID")
845            .width(100.0)
846            .align(ColumnAlign::Center)
847            .sortable();
848
849        assert_eq!(col.key, "id");
850        assert_eq!(col.title, "ID");
851        assert_eq!(col.width, Some(100.0));
852        assert_eq!(col.align, Some(ColumnAlign::Center));
853        assert!(col.sortable);
854    }
855
856    #[test]
857    fn column_builder_with_all_options() {
858        let mut col = TableColumn::new("name", "Name")
859            .width(200.0)
860            .align(ColumnAlign::Right)
861            .fixed(ColumnFixed::Left)
862            .sortable()
863            .ellipsis();
864        col.default_sort_order = Some(SortOrder::Ascend);
865        col.hidden = false;
866
867        assert_eq!(col.key, "name");
868        assert_eq!(col.title, "Name");
869        assert_eq!(col.width, Some(200.0));
870        assert_eq!(col.align, Some(ColumnAlign::Right));
871        assert_eq!(col.fixed, Some(ColumnFixed::Left));
872        assert!(col.sortable);
873        assert_eq!(col.default_sort_order, Some(SortOrder::Ascend));
874        assert!(col.ellipsis);
875        assert!(!col.hidden);
876    }
877
878    #[test]
879    fn column_align_all_variants() {
880        assert_eq!(ColumnAlign::Left.as_class(), "adui-table-align-left");
881        assert_eq!(ColumnAlign::Center.as_class(), "adui-table-align-center");
882        assert_eq!(ColumnAlign::Right.as_class(), "adui-table-align-right");
883    }
884
885    #[test]
886    fn column_align_default() {
887        assert_eq!(ColumnAlign::default(), ColumnAlign::Left);
888    }
889
890    #[test]
891    fn sort_order_classes() {
892        assert_eq!(
893            SortOrder::Ascend.as_class(),
894            "adui-table-column-sort-ascend"
895        );
896        assert_eq!(
897            SortOrder::Descend.as_class(),
898            "adui-table-column-sort-descend"
899        );
900    }
901
902    #[test]
903    fn sort_order_equality() {
904        assert_eq!(SortOrder::Ascend, SortOrder::Ascend);
905        assert_eq!(SortOrder::Descend, SortOrder::Descend);
906        assert_ne!(SortOrder::Ascend, SortOrder::Descend);
907    }
908
909    #[test]
910    fn column_fixed_variants() {
911        assert_eq!(ColumnFixed::Left, ColumnFixed::Left);
912        assert_eq!(ColumnFixed::Right, ColumnFixed::Right);
913        assert_ne!(ColumnFixed::Left, ColumnFixed::Right);
914    }
915
916    #[test]
917    fn get_cell_text_from_string() {
918        let mut row = serde_json::Map::new();
919        row.insert("name".to_string(), Value::String("John".to_string()));
920        let row_value = Value::Object(row);
921        assert_eq!(get_cell_text(&row_value, "name"), "John");
922    }
923
924    #[test]
925    fn get_cell_text_from_number() {
926        let mut row = serde_json::Map::new();
927        row.insert("age".to_string(), Value::Number(serde_json::Number::from(25)));
928        let row_value = Value::Object(row);
929        assert_eq!(get_cell_text(&row_value, "age"), "25");
930    }
931
932    #[test]
933    fn get_cell_text_from_bool() {
934        let mut row = serde_json::Map::new();
935        row.insert("active".to_string(), Value::Bool(true));
936        let row_value = Value::Object(row);
937        assert_eq!(get_cell_text(&row_value, "active"), "true");
938    }
939
940    #[test]
941    fn get_cell_text_missing_key() {
942        let mut row = serde_json::Map::new();
943        row.insert("name".to_string(), Value::String("John".to_string()));
944        let row_value = Value::Object(row);
945        assert_eq!(get_cell_text(&row_value, "missing"), "");
946    }
947}