Skip to main content

dioxus_bootstrap_css/
tabs.rs

1use dioxus::prelude::*;
2
3use crate::types::Color;
4
5/// Definition for a single tab.
6#[derive(Clone, PartialEq)]
7pub struct TabDef {
8    /// Tab button label.
9    pub label: String,
10    /// Optional Bootstrap icon name (without "bi-" prefix).
11    pub icon: Option<String>,
12    /// Tab content.
13    pub content: Element,
14}
15
16/// Bootstrap Tabs component — signal-driven, no JavaScript.
17///
18/// # Bootstrap HTML → Dioxus
19///
20/// ```html
21/// <!-- Bootstrap HTML (requires JavaScript) -->
22/// <ul class="nav nav-tabs">
23///   <li class="nav-item"><button class="nav-link active" data-bs-toggle="tab">Home</button></li>
24///   <li class="nav-item"><button class="nav-link" data-bs-toggle="tab">Profile</button></li>
25/// </ul>
26/// <div class="tab-content">
27///   <div class="tab-pane active">Home content</div>
28///   <div class="tab-pane">Profile content</div>
29/// </div>
30/// ```
31///
32/// ```rust,no_run
33/// // Dioxus equivalent — use Tab children or TabList with TabDef
34/// let active = use_signal(|| 0usize);
35/// rsx! {
36///     Tabs { active: active,
37///         Tab { label: "Home", index: 0, active: active,
38///             p { "Home content" }
39///         }
40///         Tab { label: "Profile", index: 1, active: active, icon: "person",
41///             p { "Profile content" }
42///         }
43///     }
44/// }
45/// ```
46///
47/// # Props
48///
49/// - `active` — `Signal<usize>` controlling active tab index
50/// - `pills` — pill style instead of tabs
51/// - `fill` — fill available width
52/// - `justified` — equal-width items
53/// - `vertical` — vertical tab layout
54#[derive(Clone, PartialEq, Props)]
55pub struct TabsProps {
56    /// Signal controlling the active tab index.
57    pub active: Signal<usize>,
58    /// Use pill style instead of tabs.
59    #[props(default)]
60    pub pills: bool,
61    /// Active tab color (for pills).
62    #[props(default)]
63    pub color: Option<Color>,
64    /// Fill available width.
65    #[props(default)]
66    pub fill: bool,
67    /// Justify items equally.
68    #[props(default)]
69    pub justified: bool,
70    /// Vertical tabs layout.
71    #[props(default)]
72    pub vertical: bool,
73    /// Additional CSS classes for the nav container.
74    #[props(default)]
75    pub class: String,
76    /// Child elements (Tab components).
77    pub children: Element,
78}
79
80#[component]
81pub fn Tabs(props: TabsProps) -> Element {
82    let style = if props.pills { "nav-pills" } else { "nav-tabs" };
83    let mut nav_classes = vec![format!("nav {style}")];
84    if props.fill {
85        nav_classes.push("nav-fill".to_string());
86    }
87    if props.justified {
88        nav_classes.push("nav-justified".to_string());
89    }
90    if props.vertical {
91        nav_classes.push("flex-column".to_string());
92    }
93    if !props.class.is_empty() {
94        nav_classes.push(props.class.clone());
95    }
96    let nav_class = nav_classes.join(" ");
97
98    rsx! {
99        div {
100            ul { class: "{nav_class}", role: "tablist",
101                {props.children}
102            }
103        }
104    }
105}
106
107/// A single Tab within a Tabs component.
108///
109/// Must be a direct child of Tabs.
110#[derive(Clone, PartialEq, Props)]
111pub struct TabProps {
112    /// Tab button label.
113    pub label: String,
114    /// Optional Bootstrap icon name (without "bi-" prefix).
115    #[props(default)]
116    pub icon: String,
117    /// Tab index (0-based). Set this to match the tab's position.
118    pub index: usize,
119    /// Signal controlling the active tab (shared with parent Tabs).
120    pub active: Signal<usize>,
121    /// Additional CSS classes for the tab pane.
122    #[props(default)]
123    pub class: String,
124    /// Tab content.
125    pub children: Element,
126}
127
128#[component]
129pub fn Tab(props: TabProps) -> Element {
130    let is_active = *props.active.read() == props.index;
131    let mut active_signal = props.active;
132
133    let btn_class = if is_active {
134        "nav-link active"
135    } else {
136        "nav-link"
137    };
138
139    let pane_class = if is_active {
140        "tab-pane fade show active"
141    } else {
142        "tab-pane fade"
143    };
144
145    let pane_class = if props.class.is_empty() {
146        pane_class.to_string()
147    } else {
148        format!("{pane_class} {}", props.class)
149    };
150
151    let index = props.index;
152
153    rsx! {
154        // Tab button
155        li { class: "nav-item", role: "presentation",
156            button {
157                class: "{btn_class}",
158                r#type: "button",
159                role: "tab",
160                "aria-selected": if is_active { "true" } else { "false" },
161                onclick: move |_| active_signal.set(index),
162                if !props.icon.is_empty() {
163                    i { class: "bi bi-{props.icon} me-1" }
164                }
165                "{props.label}"
166            }
167        }
168        // Tab pane (rendered but hidden when not active via CSS classes)
169        div { class: "{pane_class}", role: "tabpanel",
170            {props.children}
171        }
172    }
173}
174
175/// A simpler Tabs API using TabDef structs instead of child components.
176///
177/// ```rust
178/// let active = use_signal(|| 0usize);
179/// rsx! {
180///     TabList {
181///         active: active,
182///         tabs: vec![
183///             TabDef { label: "Home".into(), icon: None, content: rsx! { "Home" } },
184///             TabDef { label: "About".into(), icon: Some("info-circle".into()), content: rsx! { "About" } },
185///         ],
186///     }
187/// }
188/// ```
189#[derive(Clone, PartialEq, Props)]
190pub struct TabListProps {
191    /// Signal controlling the active tab index.
192    pub active: Signal<usize>,
193    /// Tab definitions.
194    pub tabs: Vec<TabDef>,
195    /// Use pill style.
196    #[props(default)]
197    pub pills: bool,
198    /// Additional CSS classes for the nav.
199    #[props(default)]
200    pub class: String,
201    /// Additional CSS classes for the tab content area.
202    #[props(default)]
203    pub content_class: String,
204}
205
206#[component]
207pub fn TabList(props: TabListProps) -> Element {
208    let current = *props.active.read();
209    let mut active_signal = props.active;
210    let style = if props.pills { "nav-pills" } else { "nav-tabs" };
211
212    let nav_class = if props.class.is_empty() {
213        format!("nav {style}")
214    } else {
215        format!("nav {style} {}", props.class)
216    };
217
218    let content_class = if props.content_class.is_empty() {
219        "tab-content".to_string()
220    } else {
221        format!("tab-content {}", props.content_class)
222    };
223
224    rsx! {
225        ul { class: "{nav_class}", role: "tablist",
226            for (i, tab) in props.tabs.iter().enumerate() {
227                li { class: "nav-item", role: "presentation",
228                    button {
229                        class: if current == i { "nav-link active" } else { "nav-link" },
230                        r#type: "button",
231                        role: "tab",
232                        "aria-selected": if current == i { "true" } else { "false" },
233                        onclick: move |_| active_signal.set(i),
234                        if let Some(ref icon) = tab.icon {
235                            i { class: "bi bi-{icon} me-1" }
236                        }
237                        "{tab.label}"
238                    }
239                }
240            }
241        }
242        div { class: "{content_class}",
243            for (i, tab) in props.tabs.iter().enumerate() {
244                div {
245                    class: if current == i { "tab-pane fade show active" } else { "tab-pane fade" },
246                    role: "tabpanel",
247                    if current == i {
248                        {tab.content.clone()}
249                    }
250                }
251            }
252        }
253    }
254}