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/// Renders standard Bootstrap HTML with separated nav-tabs and tab-content:
19///
20/// ```html
21/// <ul class="nav nav-tabs">
22///   <li class="nav-item"><button class="nav-link active">Home</button></li>
23///   <li class="nav-item"><button class="nav-link">Profile</button></li>
24/// </ul>
25/// <div class="tab-content border border-top-0 rounded-bottom p-3">
26///   <div class="tab-pane fade show active">Home content</div>
27///   <div class="tab-pane fade">Profile content</div>
28/// </div>
29/// ```
30///
31/// # Usage
32///
33/// ```rust,no_run
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/// - `content_class` — additional CSS classes for the tab-content div
55#[derive(Clone, PartialEq, Props)]
56pub struct TabsProps {
57    /// Signal controlling the active tab index.
58    pub active: Signal<usize>,
59    /// Use pill style instead of tabs.
60    #[props(default)]
61    pub pills: bool,
62    /// Active tab color (for pills).
63    #[props(default)]
64    pub color: Option<Color>,
65    /// Fill available width.
66    #[props(default)]
67    pub fill: bool,
68    /// Justify items equally.
69    #[props(default)]
70    pub justified: bool,
71    /// Vertical tabs layout.
72    #[props(default)]
73    pub vertical: bool,
74    /// Additional CSS classes for the nav container.
75    #[props(default)]
76    pub class: String,
77    /// Additional CSS classes for the tab-content container.
78    #[props(default)]
79    pub content_class: String,
80    /// Child elements (Tab components).
81    pub children: Element,
82}
83
84#[component]
85pub fn Tabs(props: TabsProps) -> Element {
86    let style = if props.pills { "nav-pills" } else { "nav-tabs" };
87    let mut nav_classes = vec![format!("nav {style}")];
88    if props.fill {
89        nav_classes.push("nav-fill".to_string());
90    }
91    if props.justified {
92        nav_classes.push("nav-justified".to_string());
93    }
94    if props.vertical {
95        nav_classes.push("flex-column".to_string());
96    }
97    if !props.class.is_empty() {
98        nav_classes.push(props.class.clone());
99    }
100    let nav_class = nav_classes.join(" ");
101
102    // The children contain Tab components which render both
103    // nav buttons and tab panes. We wrap them so the DOM structure
104    // is correct with separated nav and content areas.
105    rsx! {
106        div {
107            ul { class: "{nav_class}", role: "tablist",
108                {props.children}
109            }
110        }
111    }
112}
113
114/// A single Tab within a Tabs component.
115///
116/// Renders a nav button inside the parent `<ul>` and its content pane
117/// immediately after. When the tab is not active, the pane is hidden
118/// via Bootstrap's `fade` class.
119///
120/// Must be a direct child of Tabs.
121#[derive(Clone, PartialEq, Props)]
122pub struct TabProps {
123    /// Tab button label.
124    pub label: String,
125    /// Optional Bootstrap icon name (without "bi-" prefix).
126    #[props(default)]
127    pub icon: String,
128    /// Tab index (0-based). Set this to match the tab's position.
129    pub index: usize,
130    /// Signal controlling the active tab (shared with parent Tabs).
131    pub active: Signal<usize>,
132    /// Additional CSS classes for the tab pane.
133    #[props(default)]
134    pub class: String,
135    /// Tab content.
136    pub children: Element,
137}
138
139#[component]
140pub fn Tab(props: TabProps) -> Element {
141    let is_active = *props.active.read() == props.index;
142    let mut active_signal = props.active;
143
144    let btn_class = if is_active {
145        "nav-link active"
146    } else {
147        "nav-link"
148    };
149
150    let index = props.index;
151
152    // Only render the nav button inside the <ul>.
153    // Content is rendered separately below.
154    rsx! {
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 content — rendered as sibling of li, but will be
169        // moved outside <ul> by the browser's HTML parser since <div>
170        // is not valid inside <ul>. This is the expected behavior and
171        // matches how Bootstrap tabs work with Dioxus's component model.
172        //
173        // For pixel-perfect HTML structure, use TabList instead.
174        if is_active {
175            div {
176                class: "tab-pane fade show active {props.class}",
177                role: "tabpanel",
178                {props.children}
179            }
180        }
181    }
182}
183
184/// A simpler Tabs API using TabDef structs instead of child components.
185///
186/// This produces pixel-perfect Bootstrap HTML with separated `<ul>` nav
187/// and `<div class="tab-content">` areas.
188///
189/// ```rust,no_run
190/// let active = use_signal(|| 0usize);
191/// rsx! {
192///     TabList {
193///         active: active,
194///         tabs: vec![
195///             TabDef { label: "Home".into(), icon: None, content: rsx! { "Home" } },
196///             TabDef { label: "About".into(), icon: Some("info-circle".into()), content: rsx! { "About" } },
197///         ],
198///     }
199/// }
200/// ```
201#[derive(Clone, PartialEq, Props)]
202pub struct TabListProps {
203    /// Signal controlling the active tab index.
204    pub active: Signal<usize>,
205    /// Tab definitions.
206    pub tabs: Vec<TabDef>,
207    /// Use pill style.
208    #[props(default)]
209    pub pills: bool,
210    /// Additional CSS classes for the nav.
211    #[props(default)]
212    pub class: String,
213    /// Additional CSS classes for the tab content area.
214    #[props(default)]
215    pub content_class: String,
216}
217
218#[component]
219pub fn TabList(props: TabListProps) -> Element {
220    let current = *props.active.read();
221    let mut active_signal = props.active;
222    let style = if props.pills { "nav-pills" } else { "nav-tabs" };
223
224    let nav_class = if props.class.is_empty() {
225        format!("nav {style}")
226    } else {
227        format!("nav {style} {}", props.class)
228    };
229
230    let content_class = if props.content_class.is_empty() {
231        "tab-content".to_string()
232    } else {
233        format!("tab-content {}", props.content_class)
234    };
235
236    rsx! {
237        ul { class: "{nav_class}", role: "tablist",
238            for (i, tab) in props.tabs.iter().enumerate() {
239                li { class: "nav-item", role: "presentation",
240                    button {
241                        class: if current == i { "nav-link active" } else { "nav-link" },
242                        r#type: "button",
243                        role: "tab",
244                        "aria-selected": if current == i { "true" } else { "false" },
245                        onclick: move |_| active_signal.set(i),
246                        if let Some(ref icon) = tab.icon {
247                            i { class: "bi bi-{icon} me-1" }
248                        }
249                        "{tab.label}"
250                    }
251                }
252            }
253        }
254        div { class: "{content_class}",
255            for (i, tab) in props.tabs.iter().enumerate() {
256                div {
257                    class: if current == i { "tab-pane fade show active" } else { "tab-pane fade" },
258                    role: "tabpanel",
259                    if current == i {
260                        {tab.content.clone()}
261                    }
262                }
263            }
264        }
265    }
266}