adui_dioxus/components/
tabs.rs

1//! Tabs component aligned with Ant Design 6.0.
2//!
3//! Features:
4//! - Multiple visual types (line/card/editable-card)
5//! - Tab placement (top/right/bottom/left)
6//! - Centered tabs
7//! - Editable tabs with add/remove functionality
8
9use crate::components::config_provider::ComponentSize;
10use crate::components::icon::{Icon, IconKind};
11use crate::foundation::{ClassListExt, StyleStringExt, TabsClassNames, TabsSemantic, TabsStyles};
12use dioxus::prelude::*;
13
14/// Visual type for Tabs.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub enum TabsType {
17    #[default]
18    Line,
19    Card,
20    EditableCard,
21}
22
23impl TabsType {
24    fn as_class(&self) -> &'static str {
25        match self {
26            TabsType::Line => "adui-tabs-line",
27            TabsType::Card => "adui-tabs-card",
28            TabsType::EditableCard => "adui-tabs-editable-card",
29        }
30    }
31}
32
33/// Tab placement position.
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
35pub enum TabPlacement {
36    #[default]
37    Top,
38    Right,
39    Bottom,
40    Left,
41}
42
43impl TabPlacement {
44    fn as_class(&self) -> &'static str {
45        match self {
46            TabPlacement::Top => "adui-tabs-top",
47            TabPlacement::Right => "adui-tabs-right",
48            TabPlacement::Bottom => "adui-tabs-bottom",
49            TabPlacement::Left => "adui-tabs-left",
50        }
51    }
52}
53
54/// Edit action for editable-card tabs.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub enum TabEditAction {
57    Add,
58    Remove(String),
59}
60
61/// Data model for a single tab in the Tabs component.
62#[derive(Clone, PartialEq)]
63pub struct TabItem {
64    pub key: String,
65    pub label: String,
66    pub disabled: bool,
67    /// Whether this tab can be closed (for editable-card type).
68    pub closable: bool,
69    /// Custom icon for the tab.
70    pub icon: Option<Element>,
71    /// Optional tab content. When None, the caller can render content nearby.
72    pub content: Option<Element>,
73}
74
75impl TabItem {
76    /// Create a new tab item with the given key, label and optional content.
77    pub fn new(key: impl Into<String>, label: impl Into<String>, content: Option<Element>) -> Self {
78        Self {
79            key: key.into(),
80            label: label.into(),
81            disabled: false,
82            closable: true,
83            icon: None,
84            content,
85        }
86    }
87
88    /// Create a disabled tab without content.
89    pub fn disabled(key: impl Into<String>, label: impl Into<String>) -> Self {
90        Self {
91            key: key.into(),
92            label: label.into(),
93            disabled: true,
94            closable: false,
95            icon: None,
96            content: None,
97        }
98    }
99
100    /// Set closable state.
101    pub fn closable(mut self, closable: bool) -> Self {
102        self.closable = closable;
103        self
104    }
105
106    /// Set custom icon.
107    pub fn icon(mut self, icon: Element) -> Self {
108        self.icon = Some(icon);
109        self
110    }
111}
112
113/// Resolve the initial active key for uncontrolled Tabs.
114fn resolve_initial_active_key(default_key: Option<String>, items: &[TabItem]) -> String {
115    default_key
116        .or_else(|| items.first().map(|t| t.key.clone()))
117        .unwrap_or_default()
118}
119
120/// Props for the Tabs component.
121#[derive(Props, Clone, PartialEq)]
122pub struct TabsProps {
123    /// Tab items with key/label/content.
124    pub items: Vec<TabItem>,
125    /// Controlled active key. When set, Tabs becomes controlled.
126    #[props(optional)]
127    pub active_key: Option<String>,
128    /// Default active key used in uncontrolled mode.
129    #[props(optional)]
130    pub default_active_key: Option<String>,
131    /// Called when the active tab changes.
132    #[props(optional)]
133    pub on_change: Option<EventHandler<String>>,
134    /// Visual type (line/card/editable-card).
135    #[props(default)]
136    pub r#type: TabsType,
137    /// Tab placement position.
138    #[props(default)]
139    pub tab_placement: TabPlacement,
140    /// Whether to center tabs.
141    #[props(default)]
142    pub centered: bool,
143    /// Whether to hide the add button (for editable-card).
144    #[props(default)]
145    pub hide_add: bool,
146    /// Called when tabs are added or removed (for editable-card).
147    #[props(optional)]
148    pub on_edit: Option<EventHandler<TabEditAction>>,
149    /// Custom add icon.
150    #[props(optional)]
151    pub add_icon: Option<Element>,
152    /// Custom close icon.
153    #[props(optional)]
154    pub remove_icon: Option<Element>,
155    /// Custom more icon (for overflow).
156    #[props(optional)]
157    pub more_icon: Option<Element>,
158    /// Visual density for tab height and typography.
159    #[props(optional)]
160    pub size: Option<ComponentSize>,
161    /// Whether to destroy inactive tab panels.
162    #[props(default)]
163    pub destroy_inactive_tab_pane: bool,
164    #[props(optional)]
165    pub class: Option<String>,
166    #[props(optional)]
167    pub style: Option<String>,
168    /// Semantic class names.
169    #[props(optional)]
170    pub class_names: Option<TabsClassNames>,
171    /// Semantic styles.
172    #[props(optional)]
173    pub styles: Option<TabsStyles>,
174}
175
176/// Ant Design flavored Tabs.
177#[component]
178pub fn Tabs(props: TabsProps) -> Element {
179    let TabsProps {
180        items,
181        active_key,
182        default_active_key,
183        on_change,
184        r#type,
185        tab_placement,
186        centered,
187        hide_add,
188        on_edit,
189        add_icon,
190        remove_icon,
191        more_icon: _more_icon,
192        size,
193        destroy_inactive_tab_pane,
194        class,
195        style,
196        class_names,
197        styles,
198    } = props;
199
200    // Determine initial active key for uncontrolled mode.
201    let initial_key = resolve_initial_active_key(default_active_key.clone(), &items);
202
203    let active_internal: Signal<String> = use_signal(|| initial_key);
204
205    let is_controlled = active_key.is_some();
206    let current_key = active_key
207        .clone()
208        .unwrap_or_else(|| active_internal.read().clone());
209
210    // Root classes.
211    let mut class_list = vec!["adui-tabs".to_string()];
212    class_list.push(r#type.as_class().to_string());
213    class_list.push(tab_placement.as_class().to_string());
214    if centered {
215        class_list.push("adui-tabs-centered".into());
216    }
217    if let Some(sz) = size {
218        match sz {
219            ComponentSize::Small => class_list.push("adui-tabs-sm".into()),
220            ComponentSize::Middle => {}
221            ComponentSize::Large => class_list.push("adui-tabs-lg".into()),
222        }
223    }
224    class_list.push_semantic(&class_names, TabsSemantic::Root);
225    if let Some(extra) = class {
226        class_list.push(extra);
227    }
228    let class_attr = class_list
229        .into_iter()
230        .filter(|s| !s.is_empty())
231        .collect::<Vec<_>>()
232        .join(" ");
233
234    let mut style_attr = style.unwrap_or_default();
235    style_attr.append_semantic(&styles, TabsSemantic::Root);
236
237    // Event handler snapshot.
238    let on_change_cb = on_change;
239    let on_edit_cb = on_edit;
240    let is_editable = matches!(r#type, TabsType::EditableCard);
241
242    // Default icons
243    let add_icon_element = add_icon.unwrap_or_else(|| {
244        rsx! { Icon { kind: IconKind::Plus, size: 14.0 } }
245    });
246
247    let close_icon_element = remove_icon.unwrap_or_else(|| {
248        rsx! { Icon { kind: IconKind::Close, size: 12.0 } }
249    });
250
251    rsx! {
252        div { class: "{class_attr}", style: "{style_attr}",
253            div { class: "adui-tabs-nav",
254                div { class: "adui-tabs-nav-wrap",
255                    div { class: "adui-tabs-nav-list",
256                        {items.iter().map(|item| {
257                            let key = item.key.clone();
258                            let key_for_change = key.clone();
259                            let key_for_close = key.clone();
260                            let label = item.label.clone();
261                            let disabled = item.disabled;
262                            let closable = item.closable;
263                            let icon = item.icon.clone();
264                            let is_active = key == current_key;
265                            let active_internal_for_tab = active_internal;
266                            let on_change_for_tab = on_change_cb;
267                            let on_edit_for_close = on_edit_cb;
268
269                            rsx! {
270                                div {
271                                    class: {
272                                        let mut classes = vec!["adui-tabs-tab".to_string()];
273                                        if is_active {
274                                            classes.push("adui-tabs-tab-active".into());
275                                        }
276                                        if disabled {
277                                            classes.push("adui-tabs-tab-disabled".into());
278                                        }
279                                        classes.join(" ")
280                                    },
281                                    role: "tab",
282                                    aria_selected: "{is_active}",
283                                    button {
284                                        r#type: "button",
285                                        class: "adui-tabs-tab-btn",
286                                        disabled: disabled,
287                                        onclick: move |_| {
288                                            if disabled {
289                                                return;
290                                            }
291                                            if !is_controlled {
292                                                let mut signal = active_internal_for_tab;
293                                                signal.set(key_for_change.clone());
294                                            }
295                                            if let Some(cb) = on_change_for_tab {
296                                                cb.call(key_for_change.clone());
297                                            }
298                                        },
299                                        if let Some(icon_el) = icon {
300                                            span { class: "adui-tabs-tab-icon", {icon_el} }
301                                        }
302                                        "{label}"
303                                    }
304                                    if is_editable && closable {
305                                        button {
306                                            r#type: "button",
307                                            class: "adui-tabs-tab-remove",
308                                            onclick: move |evt| {
309                                                evt.stop_propagation();
310                                                if let Some(cb) = on_edit_for_close {
311                                                    cb.call(TabEditAction::Remove(key_for_close.clone()));
312                                                }
313                                            },
314                                            {close_icon_element.clone()}
315                                        }
316                                    }
317                                }
318                            }
319                        })}
320                    }
321                }
322                if is_editable && !hide_add {
323                    button {
324                        r#type: "button",
325                        class: "adui-tabs-nav-add",
326                        onclick: move |_| {
327                            if let Some(cb) = on_edit_cb {
328                                cb.call(TabEditAction::Add);
329                            }
330                        },
331                        {add_icon_element}
332                    }
333                }
334            }
335
336            div { class: "adui-tabs-content-holder",
337                div { class: "adui-tabs-content",
338                    {items.iter().map(|item| {
339                        let key = item.key.clone();
340                        let content = item.content.clone();
341                        let is_active = key == current_key;
342
343                        // If destroy_inactive_tab_pane, only render active content
344                        if destroy_inactive_tab_pane && !is_active {
345                            return rsx! {};
346                        }
347
348                        let pane_class = if is_active {
349                            "adui-tabs-tabpane adui-tabs-tabpane-active"
350                        } else {
351                            "adui-tabs-tabpane adui-tabs-tabpane-hidden"
352                        };
353
354                        rsx! {
355                            div {
356                                key: "{key}",
357                                class: "{pane_class}",
358                                role: "tabpanel",
359                                hidden: !is_active,
360                                if let Some(node) = content { {node} }
361                            }
362                        }
363                    })}
364                }
365            }
366        }
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn tab_item_new_and_disabled_work() {
376        let t = TabItem::new("k1", "Label", None);
377        assert_eq!(t.key, "k1");
378        assert_eq!(t.label, "Label");
379        assert!(!t.disabled);
380
381        let t2 = TabItem::disabled("k2", "Other");
382        assert_eq!(t2.key, "k2");
383        assert_eq!(t2.label, "Other");
384        assert!(t2.disabled);
385        assert!(t2.content.is_none());
386    }
387
388    #[test]
389    fn resolve_initial_active_key_prefers_default_then_first_item() {
390        let items = vec![TabItem::new("a", "A", None), TabItem::new("b", "B", None)];
391
392        assert_eq!(resolve_initial_active_key(Some("x".into()), &items), "x");
393        assert_eq!(resolve_initial_active_key(None, &items), "a");
394        assert_eq!(resolve_initial_active_key(None, &[]), "");
395    }
396
397    #[test]
398    fn tabs_type_classes() {
399        assert_eq!(TabsType::Line.as_class(), "adui-tabs-line");
400        assert_eq!(TabsType::Card.as_class(), "adui-tabs-card");
401        assert_eq!(TabsType::EditableCard.as_class(), "adui-tabs-editable-card");
402    }
403
404    #[test]
405    fn tab_placement_classes() {
406        assert_eq!(TabPlacement::Top.as_class(), "adui-tabs-top");
407        assert_eq!(TabPlacement::Left.as_class(), "adui-tabs-left");
408    }
409}