Skip to main content

dioxus_ui_system/organisms/
tabs.rs

1//! Tabs organism component
2//!
3//! A set of layered sections of content—known as tab panels—that are displayed one at a time.
4
5use crate::styles::Style;
6use crate::theme::{use_style, use_theme};
7use dioxus::prelude::*;
8
9/// Tab item definition
10#[derive(Clone, PartialEq)]
11pub struct TabItem {
12    /// Tab ID
13    pub id: String,
14    /// Tab label
15    pub label: String,
16    /// Tab icon (optional)
17    pub icon: Option<String>,
18    /// Whether tab is disabled
19    pub disabled: bool,
20}
21
22impl TabItem {
23    /// Create a new tab item
24    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
25        Self {
26            id: id.into(),
27            label: label.into(),
28            icon: None,
29            disabled: false,
30        }
31    }
32
33    /// Add an icon
34    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
35        self.icon = Some(icon.into());
36        self
37    }
38
39    /// Set disabled state
40    pub fn disabled(mut self, disabled: bool) -> Self {
41        self.disabled = disabled;
42        self
43    }
44}
45
46/// Tabs properties
47#[derive(Props, Clone, PartialEq)]
48pub struct TabsProps {
49    /// Tab items
50    pub tabs: Vec<TabItem>,
51    /// Currently active tab ID
52    pub active_tab: String,
53    /// Callback when tab changes
54    pub on_change: EventHandler<String>,
55    /// Tab content (rendered based on active tab)
56    pub children: Element,
57    /// Tabs variant
58    #[props(default)]
59    pub variant: TabsVariant,
60    /// Custom inline styles
61    #[props(default)]
62    pub style: Option<String>,
63}
64
65/// Tabs variant
66#[derive(Default, Clone, PartialEq)]
67pub enum TabsVariant {
68    /// Default underline style
69    #[default]
70    Default,
71    /// Enclosed/pill style
72    Enclosed,
73    /// Soft background style
74    Soft,
75}
76
77/// Tabs organism component
78#[component]
79pub fn Tabs(props: TabsProps) -> Element {
80    let _theme = use_theme();
81    let variant = props.variant.clone();
82
83    let tabs_container_style = use_style(move |t| match variant {
84        TabsVariant::Default => Style::new()
85            .w_full()
86            .border_bottom(1, &t.colors.border)
87            .flex()
88            .gap(&t.spacing, "md")
89            .build(),
90        TabsVariant::Enclosed => Style::new()
91            .w_full()
92            .bg(&t.colors.muted)
93            .rounded(&t.radius, "md")
94            .p(&t.spacing, "xs")
95            .flex()
96            .gap(&t.spacing, "xs")
97            .build(),
98        TabsVariant::Soft => Style::new().w_full().flex().gap(&t.spacing, "sm").build(),
99    });
100
101    rsx! {
102        div {
103            style: "{tabs_container_style} {props.style.clone().unwrap_or_default()}",
104
105            for tab in props.tabs.clone() {
106                TabButton {
107                    tab: tab.clone(),
108                    is_active: props.active_tab == tab.id,
109                    variant: props.variant.clone(),
110                    on_click: props.on_change.clone(),
111                }
112            }
113        }
114    }
115}
116
117#[derive(Props, Clone, PartialEq)]
118struct TabButtonProps {
119    tab: TabItem,
120    is_active: bool,
121    variant: TabsVariant,
122    on_click: EventHandler<String>,
123}
124
125#[component]
126fn TabButton(props: TabButtonProps) -> Element {
127    let _theme = use_theme();
128    let mut is_hovered = use_signal(|| false);
129
130    let is_active = props.is_active;
131    let is_disabled = props.tab.disabled;
132    let variant = props.variant.clone();
133
134    let button_style = use_style(move |t| {
135        let base = Style::new()
136            .inline_flex()
137            .items_center()
138            .gap(&t.spacing, "xs")
139            .px(&t.spacing, "md")
140            .py(&t.spacing, "sm")
141            .text(&t.typography, "sm")
142            .font_weight(500)
143            .cursor(if is_disabled {
144                "not-allowed"
145            } else {
146                "pointer"
147            })
148            .transition("all 150ms ease")
149            .border(0, &t.colors.border)
150            .bg_transparent()
151            .outline("none");
152
153        match variant {
154            TabsVariant::Default => {
155                let styled = if is_active {
156                    base.text_color(&t.colors.foreground)
157                        .border_bottom(2, &t.colors.primary)
158                        .mb_px(-1)
159                } else {
160                    base.text_color(&t.colors.muted_foreground)
161                };
162
163                if is_hovered() && !is_disabled && !is_active {
164                    styled.text_color(&t.colors.foreground)
165                } else {
166                    styled
167                }
168                .build()
169            }
170            TabsVariant::Enclosed => {
171                if is_active {
172                    base.bg(&t.colors.background)
173                        .text_color(&t.colors.foreground)
174                        .shadow(&t.shadows.sm)
175                        .rounded(&t.radius, "sm")
176                        .build()
177                } else if is_hovered() && !is_disabled {
178                    base.text_color(&t.colors.foreground).build()
179                } else {
180                    base.text_color(&t.colors.muted_foreground).build()
181                }
182            }
183            TabsVariant::Soft => {
184                if is_active {
185                    base.bg(&t.colors.secondary)
186                        .text_color(&t.colors.secondary_foreground)
187                        .rounded(&t.radius, "md")
188                        .build()
189                } else if is_hovered() && !is_disabled {
190                    base.bg(&t.colors.muted)
191                        .text_color(&t.colors.foreground)
192                        .rounded(&t.radius, "md")
193                        .build()
194                } else {
195                    base.text_color(&t.colors.muted_foreground).build()
196                }
197            }
198        }
199    });
200
201    let handle_click = move |_| {
202        if !is_disabled {
203            props.on_click.call(props.tab.id.clone());
204        }
205    };
206
207    let has_icon = props.tab.icon.is_some();
208
209    rsx! {
210        button {
211            style: "{button_style}",
212            disabled: is_disabled,
213            onclick: handle_click,
214            onmouseenter: move |_| if !is_disabled { is_hovered.set(true) },
215            onmouseleave: move |_| is_hovered.set(false),
216
217            if has_icon {
218                TabIcon { name: props.tab.icon.clone().unwrap() }
219            }
220
221            "{props.tab.label}"
222        }
223    }
224}
225
226#[derive(Props, Clone, PartialEq)]
227struct TabIconProps {
228    name: String,
229}
230
231#[component]
232fn TabIcon(props: TabIconProps) -> Element {
233    rsx! {
234        svg {
235            view_box: "0 0 24 24",
236            fill: "none",
237            stroke: "currentColor",
238            stroke_width: "2",
239            stroke_linecap: "round",
240            stroke_linejoin: "round",
241            style: "width: 16px; height: 16px;",
242
243            match props.name.as_str() {
244                "settings" => rsx! {
245                    path { d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" }
246                    circle { cx: "12", cy: "12", r: "3" }
247                },
248                _ => rsx! {
249                    circle { cx: "12", cy: "12", r: "10" }
250                },
251            }
252        }
253    }
254}
255
256/// Tab panel component for tab content
257#[derive(Props, Clone, PartialEq)]
258pub struct TabPanelProps {
259    /// Panel content
260    pub children: Element,
261    /// Custom inline styles
262    #[props(default)]
263    pub style: Option<String>,
264}
265
266/// Tab panel component
267#[component]
268pub fn TabPanel(props: TabPanelProps) -> Element {
269    let _theme = use_theme();
270
271    let panel_style = use_style(|t| Style::new().w_full().pt(&t.spacing, "md").build());
272
273    rsx! {
274        div {
275            role: "tabpanel",
276            style: "{panel_style} {props.style.clone().unwrap_or_default()}",
277            {props.children}
278        }
279    }
280}
281
282/// Vertical tabs variant
283#[derive(Props, Clone, PartialEq)]
284pub struct VerticalTabsProps {
285    /// Tab items
286    pub tabs: Vec<TabItem>,
287    /// Currently active tab ID
288    pub active_tab: String,
289    /// Callback when tab changes
290    pub on_change: EventHandler<String>,
291    /// Custom inline styles
292    #[props(default)]
293    pub style: Option<String>,
294}
295
296/// Vertical tabs component
297#[component]
298pub fn VerticalTabs(props: VerticalTabsProps) -> Element {
299    let _theme = use_theme();
300
301    let container_style = use_style(|t| Style::new().flex().gap(&t.spacing, "lg").build());
302
303    let sidebar_style = use_style(|t| {
304        Style::new()
305            .w_px(200)
306            .flex()
307            .flex_col()
308            .gap(&t.spacing, "xs")
309            .build()
310    });
311
312    rsx! {
313        div {
314            style: "{container_style} {props.style.clone().unwrap_or_default()}",
315
316            div {
317                style: "{sidebar_style}",
318
319                for tab in props.tabs.clone() {
320                    VerticalTabButton {
321                        tab: tab.clone(),
322                        is_active: props.active_tab == tab.id,
323                        on_click: props.on_change.clone(),
324                    }
325                }
326            }
327        }
328    }
329}
330
331#[derive(Props, Clone, PartialEq)]
332struct VerticalTabButtonProps {
333    tab: TabItem,
334    is_active: bool,
335    on_click: EventHandler<String>,
336}
337
338#[component]
339fn VerticalTabButton(props: VerticalTabButtonProps) -> Element {
340    let _theme = use_theme();
341    let mut is_hovered = use_signal(|| false);
342
343    let is_active = props.is_active;
344    let is_disabled = props.tab.disabled;
345
346    let button_style = use_style(move |t| {
347        let base = Style::new()
348            .w_full()
349            .flex()
350            .items_center()
351            .gap(&t.spacing, "sm")
352            .px(&t.spacing, "md")
353            .py(&t.spacing, "sm")
354            .rounded(&t.radius, "md")
355            .text(&t.typography, "sm")
356            .font_weight(500)
357            .cursor(if is_disabled {
358                "not-allowed"
359            } else {
360                "pointer"
361            })
362            .transition("all 150ms ease")
363            .border(0, &t.colors.border)
364            .bg_transparent()
365            .text_align_left();
366
367        if is_active {
368            base.bg(&t.colors.secondary)
369                .text_color(&t.colors.secondary_foreground)
370        } else if is_hovered() && !is_disabled {
371            base.bg(&t.colors.muted).text_color(&t.colors.foreground)
372        } else {
373            base.text_color(&t.colors.muted_foreground)
374        }
375        .build()
376    });
377
378    let handle_click = move |_| {
379        if !is_disabled {
380            props.on_click.call(props.tab.id.clone());
381        }
382    };
383
384    rsx! {
385        button {
386            style: "{button_style}",
387            disabled: is_disabled,
388            onclick: handle_click,
389            onmouseenter: move |_| if !is_disabled { is_hovered.set(true) },
390            onmouseleave: move |_| is_hovered.set(false),
391            "{props.tab.label}"
392        }
393    }
394}