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 dioxus::prelude::*;
6use crate::theme::{use_theme, use_style};
7use crate::styles::Style;
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| {
84        match variant {
85            TabsVariant::Default => Style::new()
86                .w_full()
87                .border_bottom(1, &t.colors.border)
88                .flex()
89                .gap(&t.spacing, "md")
90                .build(),
91            TabsVariant::Enclosed => Style::new()
92                .w_full()
93                .bg(&t.colors.muted)
94                .rounded(&t.radius, "md")
95                .p(&t.spacing, "xs")
96                .flex()
97                .gap(&t.spacing, "xs")
98                .build(),
99            TabsVariant::Soft => Style::new()
100                .w_full()
101                .flex()
102                .gap(&t.spacing, "sm")
103                .build(),
104        }
105    });
106    
107    rsx! {
108        div {
109            style: "{tabs_container_style} {props.style.clone().unwrap_or_default()}",
110            
111            for tab in props.tabs.clone() {
112                TabButton {
113                    tab: tab.clone(),
114                    is_active: props.active_tab == tab.id,
115                    variant: props.variant.clone(),
116                    on_click: props.on_change.clone(),
117                }
118            }
119        }
120    }
121}
122
123#[derive(Props, Clone, PartialEq)]
124struct TabButtonProps {
125    tab: TabItem,
126    is_active: bool,
127    variant: TabsVariant,
128    on_click: EventHandler<String>,
129}
130
131#[component]
132fn TabButton(props: TabButtonProps) -> Element {
133    let _theme = use_theme();
134    let mut is_hovered = use_signal(|| false);
135    
136    let is_active = props.is_active;
137    let is_disabled = props.tab.disabled;
138    let variant = props.variant.clone();
139    
140    let button_style = use_style(move |t| {
141        let base = Style::new()
142            .inline_flex()
143            .items_center()
144            .gap(&t.spacing, "xs")
145            .px(&t.spacing, "md")
146            .py(&t.spacing, "sm")
147            .text(&t.typography, "sm")
148            .font_weight(500)
149            .cursor(if is_disabled { "not-allowed" } else { "pointer" })
150            .transition("all 150ms ease")
151            .border(0, &t.colors.border)
152            .bg_transparent()
153            .outline("none");
154        
155        match variant {
156            TabsVariant::Default => {
157                let styled = if is_active {
158                    base.text_color(&t.colors.foreground)
159                        .border_bottom(2, &t.colors.primary)
160                        .mb_px(-1)
161                } else {
162                    base.text_color(&t.colors.muted_foreground)
163                };
164                
165                if is_hovered() && !is_disabled && !is_active {
166                    styled.text_color(&t.colors.foreground)
167                } else {
168                    styled
169                }.build()
170            }
171            TabsVariant::Enclosed => {
172                if is_active {
173                    base.bg(&t.colors.background)
174                        .text_color(&t.colors.foreground)
175                        .shadow(&t.shadows.sm)
176                        .rounded(&t.radius, "sm")
177                        .build()
178                } else if is_hovered() && !is_disabled {
179                    base.text_color(&t.colors.foreground).build()
180                } else {
181                    base.text_color(&t.colors.muted_foreground).build()
182                }
183            }
184            TabsVariant::Soft => {
185                if is_active {
186                    base.bg(&t.colors.secondary)
187                        .text_color(&t.colors.secondary_foreground)
188                        .rounded(&t.radius, "md")
189                        .build()
190                } else if is_hovered() && !is_disabled {
191                    base.bg(&t.colors.muted)
192                        .text_color(&t.colors.foreground)
193                        .rounded(&t.radius, "md")
194                        .build()
195                } else {
196                    base.text_color(&t.colors.muted_foreground).build()
197                }
198            }
199        }
200    });
201    
202    let handle_click = move |_| {
203        if !is_disabled {
204            props.on_click.call(props.tab.id.clone());
205        }
206    };
207    
208    let has_icon = props.tab.icon.is_some();
209    
210    rsx! {
211        button {
212            style: "{button_style}",
213            disabled: is_disabled,
214            onclick: handle_click,
215            onmouseenter: move |_| if !is_disabled { is_hovered.set(true) },
216            onmouseleave: move |_| is_hovered.set(false),
217            
218            if has_icon {
219                TabIcon { name: props.tab.icon.clone().unwrap() }
220            }
221            
222            "{props.tab.label}"
223        }
224    }
225}
226
227#[derive(Props, Clone, PartialEq)]
228struct TabIconProps {
229    name: String,
230}
231
232#[component]
233fn TabIcon(props: TabIconProps) -> Element {
234    rsx! {
235        svg {
236            view_box: "0 0 24 24",
237            fill: "none",
238            stroke: "currentColor",
239            stroke_width: "2",
240            stroke_linecap: "round",
241            stroke_linejoin: "round",
242            style: "width: 16px; height: 16px;",
243            
244            match props.name.as_str() {
245                "settings" => rsx! {
246                    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" }
247                    circle { cx: "12", cy: "12", r: "3" }
248                },
249                _ => rsx! {
250                    circle { cx: "12", cy: "12", r: "10" }
251                },
252            }
253        }
254    }
255}
256
257/// Tab panel component for tab content
258#[derive(Props, Clone, PartialEq)]
259pub struct TabPanelProps {
260    /// Panel content
261    pub children: Element,
262    /// Custom inline styles
263    #[props(default)]
264    pub style: Option<String>,
265}
266
267/// Tab panel component
268#[component]
269pub fn TabPanel(props: TabPanelProps) -> Element {
270    let _theme = use_theme();
271    
272    let panel_style = use_style(|t| {
273        Style::new()
274            .w_full()
275            .pt(&t.spacing, "md")
276            .build()
277    });
278    
279    rsx! {
280        div {
281            role: "tabpanel",
282            style: "{panel_style} {props.style.clone().unwrap_or_default()}",
283            {props.children}
284        }
285    }
286}
287
288/// Vertical tabs variant
289#[derive(Props, Clone, PartialEq)]
290pub struct VerticalTabsProps {
291    /// Tab items
292    pub tabs: Vec<TabItem>,
293    /// Currently active tab ID
294    pub active_tab: String,
295    /// Callback when tab changes
296    pub on_change: EventHandler<String>,
297    /// Custom inline styles
298    #[props(default)]
299    pub style: Option<String>,
300}
301
302/// Vertical tabs component
303#[component]
304pub fn VerticalTabs(props: VerticalTabsProps) -> Element {
305    let _theme = use_theme();
306    
307    let container_style = use_style(|t| {
308        Style::new()
309            .flex()
310            .gap(&t.spacing, "lg")
311            .build()
312    });
313    
314    let sidebar_style = use_style(|t| {
315        Style::new()
316            .w_px(200)
317            .flex()
318            .flex_col()
319            .gap(&t.spacing, "xs")
320            .build()
321    });
322    
323    rsx! {
324        div {
325            style: "{container_style} {props.style.clone().unwrap_or_default()}",
326            
327            div {
328                style: "{sidebar_style}",
329                
330                for tab in props.tabs.clone() {
331                    VerticalTabButton {
332                        tab: tab.clone(),
333                        is_active: props.active_tab == tab.id,
334                        on_click: props.on_change.clone(),
335                    }
336                }
337            }
338        }
339    }
340}
341
342#[derive(Props, Clone, PartialEq)]
343struct VerticalTabButtonProps {
344    tab: TabItem,
345    is_active: bool,
346    on_click: EventHandler<String>,
347}
348
349#[component]
350fn VerticalTabButton(props: VerticalTabButtonProps) -> Element {
351    let _theme = use_theme();
352    let mut is_hovered = use_signal(|| false);
353    
354    let is_active = props.is_active;
355    let is_disabled = props.tab.disabled;
356    
357    let button_style = use_style(move |t| {
358        let base = Style::new()
359            .w_full()
360            .flex()
361            .items_center()
362            .gap(&t.spacing, "sm")
363            .px(&t.spacing, "md")
364            .py(&t.spacing, "sm")
365            .rounded(&t.radius, "md")
366            .text(&t.typography, "sm")
367            .font_weight(500)
368            .cursor(if is_disabled { "not-allowed" } else { "pointer" })
369            .transition("all 150ms ease")
370            .border(0, &t.colors.border)
371            .bg_transparent()
372            .text_align_left();
373        
374        if is_active {
375            base.bg(&t.colors.secondary)
376                .text_color(&t.colors.secondary_foreground)
377        } else if is_hovered() && !is_disabled {
378            base.bg(&t.colors.muted)
379                .text_color(&t.colors.foreground)
380        } else {
381            base.text_color(&t.colors.muted_foreground)
382        }.build()
383    });
384    
385    let handle_click = move |_| {
386        if !is_disabled {
387            props.on_click.call(props.tab.id.clone());
388        }
389    };
390    
391    rsx! {
392        button {
393            style: "{button_style}",
394            disabled: is_disabled,
395            onclick: handle_click,
396            onmouseenter: move |_| if !is_disabled { is_hovered.set(true) },
397            onmouseleave: move |_| is_hovered.set(false),
398            "{props.tab.label}"
399        }
400    }
401}