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}