Skip to main content

dioxus_bootstrap_css/
nav.rs

1use dioxus::prelude::*;
2
3use crate::types::{Color, NavbarExpand};
4
5/// Bootstrap Nav component — standalone navigation (not inside a Navbar).
6///
7/// # Bootstrap HTML → Dioxus
8///
9/// ```html
10/// <!-- Bootstrap HTML -->
11/// <ul class="nav nav-pills nav-fill">
12///   <li class="nav-item"><a class="nav-link active" href="#">Home</a></li>
13///   <li class="nav-item"><a class="nav-link" href="#">Profile</a></li>
14///   <li class="nav-item"><a class="nav-link disabled">Disabled</a></li>
15/// </ul>
16/// ```
17///
18/// ```rust,no_run
19/// # use dioxus::prelude::*;
20/// # use dioxus_bootstrap_css::prelude::*;
21/// # fn _doctest() -> Element {
22/// rsx! {
23///     Nav { pills: true, fill: true,
24///         NavItem { NavLink { active: true, "Home" } }
25///         NavItem { NavLink { "Profile" } }
26///         NavItem { NavLink { disabled: true, "Disabled" } }
27///     }
28///     // Tabs style
29///     Nav { tabs: true, /* ... */ }
30///     // Underline style
31///     Nav { underline: true, /* ... */ }
32///     // Vertical with pills
33///     Nav { pills: true, vertical: true, /* ... */ }
34/// }
35/// # }
36/// ```
37///
38/// # Props
39///
40/// - `pills` — pill style
41/// - `tabs` — tab style
42/// - `underline` — underline style
43/// - `fill` — fill available width
44/// - `justified` — equal-width items
45/// - `vertical` — vertical layout
46#[derive(Clone, PartialEq, Props)]
47pub struct NavProps {
48    /// Use pill style.
49    #[props(default)]
50    pub pills: bool,
51    /// Use tab style.
52    #[props(default)]
53    pub tabs: bool,
54    /// Use underline style.
55    #[props(default)]
56    pub underline: bool,
57    /// Fill available width equally.
58    #[props(default)]
59    pub fill: bool,
60    /// Justify items to fill width (equal-width items).
61    #[props(default)]
62    pub justified: bool,
63    /// Vertical layout.
64    #[props(default)]
65    pub vertical: bool,
66    /// Additional CSS classes.
67    #[props(default)]
68    pub class: String,
69    /// Any additional HTML attributes.
70    #[props(extends = GlobalAttributes)]
71    attributes: Vec<Attribute>,
72    /// Child elements (NavItems).
73    pub children: Element,
74}
75
76#[component]
77pub fn Nav(props: NavProps) -> Element {
78    let mut classes = vec!["nav".to_string()];
79    if props.pills {
80        classes.push("nav-pills".to_string());
81    }
82    if props.tabs {
83        classes.push("nav-tabs".to_string());
84    }
85    if props.underline {
86        classes.push("nav-underline".to_string());
87    }
88    if props.fill {
89        classes.push("nav-fill".to_string());
90    }
91    if props.justified {
92        classes.push("nav-justified".to_string());
93    }
94    if props.vertical {
95        classes.push("flex-column".to_string());
96    }
97    if !props.class.is_empty() {
98        classes.push(props.class.clone());
99    }
100    let full_class = classes.join(" ");
101
102    rsx! {
103        ul { class: "{full_class}",
104            ..props.attributes,
105            {props.children}
106        }
107    }
108}
109
110/// Bootstrap Navbar component.
111///
112/// # Bootstrap HTML → Dioxus
113///
114/// ```html
115/// <!-- Bootstrap HTML -->
116/// <nav class="navbar navbar-expand-lg bg-dark" data-bs-theme="dark">
117///   <div class="container-fluid">
118///     <a class="navbar-brand" href="#">MyApp</a>
119///     <button class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#nav">
120///       <span class="navbar-toggler-icon"></span>
121///     </button>
122///     <div class="collapse navbar-collapse" id="nav">
123///       <ul class="navbar-nav"><li class="nav-item"><a class="nav-link" href="/">Home</a></li></ul>
124///     </div>
125///   </div>
126/// </nav>
127/// ```
128///
129/// ```rust,no_run
130/// # use dioxus::prelude::*;
131/// # use dioxus_bootstrap_css::prelude::*;
132/// # fn _doctest() -> Element {
133/// // Dioxus equivalent
134/// let collapsed = use_signal(|| true);
135/// rsx! {
136///     Navbar { expand: NavbarExpand::Lg, class: "bg-body sticky-top",
137///         brand: rsx! { a { class: "navbar-brand", href: "#", "MyApp" } },
138///         NavbarToggler { collapsed: collapsed }
139///         NavbarCollapse { collapsed: collapsed,
140///             NavbarNav {
141///                 NavItem { NavLink { href: "/", active: true, "Home" } }
142///                 NavItem { NavLink { href: "/about", "About" } }
143///             }
144///         }
145///     }
146/// }
147/// # }
148/// ```
149#[derive(Clone, PartialEq, Props)]
150pub struct NavbarProps {
151    /// Navbar color scheme.
152    #[props(default)]
153    pub color: Option<Color>,
154    /// Responsive expand breakpoint.
155    #[props(default)]
156    pub expand: NavbarExpand,
157    /// Brand element (logo, app name).
158    #[props(default)]
159    pub brand: Option<Element>,
160    /// Additional CSS classes.
161    #[props(default)]
162    pub class: String,
163    /// Any additional HTML attributes.
164    #[props(extends = GlobalAttributes)]
165    attributes: Vec<Attribute>,
166    /// Child elements (nav items, collapse, etc.).
167    pub children: Element,
168}
169
170#[component]
171pub fn Navbar(props: NavbarProps) -> Element {
172    let mut classes = vec!["navbar".to_string(), props.expand.to_string()];
173
174    let is_dark = matches!(props.color.as_ref(), Some(Color::Dark));
175
176    if let Some(ref color) = props.color {
177        match color {
178            Color::Dark => {
179                classes.push("bg-dark".to_string());
180            }
181            Color::Light => {
182                classes.push("bg-light".to_string());
183            }
184            c => {
185                classes.push(format!("bg-{c}"));
186            }
187        }
188    }
189
190    if !props.class.is_empty() {
191        classes.push(props.class.clone());
192    }
193
194    let full_class = classes.join(" ");
195
196    rsx! {
197        nav {
198            class: "{full_class}",
199            // Bootstrap 5.3: dark theme via HTML attribute, not CSS class
200            "data-bs-theme": if is_dark { "dark" } else { "" },
201            ..props.attributes,
202            div { class: "container-fluid",
203                if let Some(brand) = props.brand {
204                    {brand}
205                }
206                {props.children}
207            }
208        }
209    }
210}
211
212/// Navbar toggler button (hamburger menu) for responsive collapse.
213///
214/// # Bootstrap HTML → Dioxus
215///
216/// | HTML | Dioxus |
217/// |---|---|
218/// | `<button class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#nav">` | `NavbarToggler { collapsed: signal }` |
219///
220/// ```rust,no_run
221/// # use dioxus::prelude::*;
222/// # use dioxus_bootstrap_css::prelude::*;
223/// # fn _doctest() -> Element {
224/// # let collapsed_signal = use_signal(|| false);
225/// rsx! {
226///     NavbarToggler { collapsed: collapsed_signal }
227/// }
228/// # }
229/// ```
230#[derive(Clone, PartialEq, Props)]
231pub struct NavbarTogglerProps {
232    /// Signal to toggle — will invert the value on click.
233    pub collapsed: Signal<bool>,
234    /// Additional CSS classes.
235    #[props(default)]
236    pub class: String,
237    /// Any additional HTML attributes.
238    #[props(extends = GlobalAttributes)]
239    attributes: Vec<Attribute>,
240}
241
242#[component]
243pub fn NavbarToggler(props: NavbarTogglerProps) -> Element {
244    let is_collapsed = *props.collapsed.read();
245    let mut signal = props.collapsed;
246
247    let full_class = if props.class.is_empty() {
248        "navbar-toggler".to_string()
249    } else {
250        format!("navbar-toggler {}", props.class)
251    };
252
253    rsx! {
254        button {
255            class: "{full_class}",
256            r#type: "button",
257            "aria-expanded": if !is_collapsed { "true" } else { "false" },
258            "aria-label": "Toggle navigation",
259            onclick: move |_| signal.set(!is_collapsed),
260            ..props.attributes,
261            span { class: "navbar-toggler-icon" }
262        }
263    }
264}
265
266/// Navbar collapsible content area.
267///
268/// # Bootstrap HTML → Dioxus
269///
270/// | HTML | Dioxus |
271/// |---|---|
272/// | `<div class="collapse navbar-collapse" id="nav">` | `NavbarCollapse { collapsed: signal, ... }` |
273///
274/// ```rust,no_run
275/// # use dioxus::prelude::*;
276/// # use dioxus_bootstrap_css::prelude::*;
277/// # fn _doctest() -> Element {
278/// let collapsed = use_signal(|| true);
279/// rsx! {
280///     NavbarToggler { collapsed: collapsed }
281///     NavbarCollapse { collapsed: collapsed,
282///         NavbarNav {
283///             NavItem { NavLink { href: "/", "Home" } }
284///         }
285///     }
286/// }
287/// # }
288/// ```
289#[derive(Clone, PartialEq, Props)]
290pub struct NavbarCollapseProps {
291    /// Signal controlling collapsed state.
292    pub collapsed: Signal<bool>,
293    /// Additional CSS classes.
294    #[props(default)]
295    pub class: String,
296    /// Any additional HTML attributes.
297    #[props(extends = GlobalAttributes)]
298    attributes: Vec<Attribute>,
299    /// Child elements.
300    pub children: Element,
301}
302
303#[component]
304pub fn NavbarCollapse(props: NavbarCollapseProps) -> Element {
305    let is_collapsed = *props.collapsed.read();
306    let show = if !is_collapsed { " show" } else { "" };
307
308    let full_class = if props.class.is_empty() {
309        format!("collapse navbar-collapse{show}")
310    } else {
311        format!("collapse navbar-collapse{show} {}", props.class)
312    };
313
314    rsx! {
315        div { class: "{full_class}",
316            ..props.attributes,
317            {props.children}
318        }
319    }
320}
321
322/// Bootstrap navbar navigation list.
323///
324/// Use this inside [`NavbarCollapse`] to render Bootstrap's required
325/// `<ul class="navbar-nav">` wrapper around navbar [`NavItem`] children.
326///
327/// # Bootstrap HTML → Dioxus
328///
329/// | HTML | Dioxus |
330/// |---|---|
331/// | `<ul class="navbar-nav">...</ul>` | `NavbarNav { ... }` |
332/// | `<ul class="navbar-nav navbar-nav-scroll">...</ul>` | `NavbarNav { scroll: true, ... }` |
333///
334/// ```rust,no_run
335/// # use dioxus::prelude::*;
336/// # use dioxus_bootstrap_css::prelude::*;
337/// # fn _doctest() -> Element {
338/// rsx! {
339///     NavbarNav {
340///         NavItem { NavLink { active: true, href: "#", "Home" } }
341///         NavItem { NavLink { href: "#docs", "Docs" } }
342///     }
343/// }
344/// # }
345/// ```
346#[derive(Clone, PartialEq, Props)]
347pub struct NavbarNavProps {
348    /// Enable Bootstrap navbar scroll behavior.
349    #[props(default)]
350    pub scroll: bool,
351    /// Additional CSS classes.
352    #[props(default)]
353    pub class: String,
354    /// Any additional HTML attributes.
355    #[props(extends = GlobalAttributes)]
356    attributes: Vec<Attribute>,
357    /// Child elements (NavItem).
358    pub children: Element,
359}
360
361#[component]
362pub fn NavbarNav(props: NavbarNavProps) -> Element {
363    let full_class = navbar_nav_class(props.scroll, &props.class);
364
365    rsx! {
366        ul {
367            class: "{full_class}",
368            ..props.attributes,
369            {props.children}
370        }
371    }
372}
373
374fn navbar_nav_class(scroll: bool, class: &str) -> String {
375    let mut classes = vec!["navbar-nav".to_string()];
376    if scroll {
377        classes.push("navbar-nav-scroll".to_string());
378    }
379    if !class.is_empty() {
380        classes.push(class.to_string());
381    }
382    classes.join(" ")
383}
384
385/// Bootstrap NavItem component.
386///
387/// # Bootstrap HTML → Dioxus
388///
389/// | HTML | Dioxus |
390/// |---|---|
391/// | `<li class="nav-item">` | `NavItem { ... }` |
392#[derive(Clone, PartialEq, Props)]
393pub struct NavItemProps {
394    /// Additional CSS classes.
395    #[props(default)]
396    pub class: String,
397    /// Any additional HTML attributes.
398    #[props(extends = GlobalAttributes)]
399    attributes: Vec<Attribute>,
400    /// Child elements (NavLink).
401    pub children: Element,
402}
403
404#[component]
405pub fn NavItem(props: NavItemProps) -> Element {
406    let full_class = if props.class.is_empty() {
407        "nav-item".to_string()
408    } else {
409        format!("nav-item {}", props.class)
410    };
411
412    rsx! {
413        li { class: "{full_class}", ..props.attributes, {props.children} }
414    }
415}
416
417/// Bootstrap NavLink component.
418///
419/// # Bootstrap HTML → Dioxus
420///
421/// | HTML | Dioxus |
422/// |---|---|
423/// | `<a class="nav-link active" href="/">Home</a>` | `NavLink { href: "/", active: true, "Home" }` |
424/// | `<a class="nav-link disabled" aria-disabled="true" tabindex="-1">Disabled</a>` | `NavLink { disabled: true, "Disabled" }` |
425///
426/// For single-page apps that switch content client-side, set `prevent_default: true`
427/// so a click on a `#`-href link runs only your `onclick` handler and does not
428/// follow the anchor (no hash change, no scroll-to-top). For a JS-toggled tab
429/// button (`<button class="nav-link">`) use [`NavButton`] instead of an anchor.
430///
431/// ```rust,no_run
432/// # use dioxus::prelude::*;
433/// # use dioxus_bootstrap_css::prelude::*;
434/// # fn _doctest() -> Element {
435/// rsx! {
436///     NavLink { href: "/dashboard", active: true, "Dashboard" }
437///     // SPA tab link: handler runs, page does not navigate.
438///     NavLink { prevent_default: true, onclick: move |_| { /* switch section */ }, "Settings" }
439/// }
440/// # }
441/// ```
442#[derive(Clone, PartialEq, Props)]
443pub struct NavLinkProps {
444    /// Link href.
445    #[props(default = "#".to_string())]
446    pub href: String,
447    /// Active state.
448    #[props(default)]
449    pub active: bool,
450    /// Disabled state.
451    #[props(default)]
452    pub disabled: bool,
453    /// Call `event.prevent_default()` on click so the anchor is not followed
454    /// (SPA-safe: the `onclick` handler runs, but the URL/hash is untouched and
455    /// the page does not scroll to top). Matches what Bootstrap's own JS does
456    /// for `#`-href toggle links.
457    #[props(default)]
458    pub prevent_default: bool,
459    /// Click event handler.
460    #[props(default)]
461    pub onclick: Option<EventHandler<MouseEvent>>,
462    /// Additional CSS classes.
463    #[props(default)]
464    pub class: String,
465    /// Any additional HTML attributes.
466    #[props(extends = GlobalAttributes)]
467    attributes: Vec<Attribute>,
468    /// Child elements.
469    pub children: Element,
470}
471
472#[component]
473pub fn NavLink(props: NavLinkProps) -> Element {
474    let full_class = nav_link_class(props.active, props.disabled, &props.class);
475
476    rsx! {
477        a {
478            class: "{full_class}",
479            href: "{props.href}",
480            "aria-current": if props.active { "page" } else { "" },
481            "aria-disabled": if props.disabled { "true" } else { "" },
482            tabindex: if props.disabled { "-1" } else { "" },
483            onclick: move |evt| {
484                if props.prevent_default {
485                    evt.prevent_default();
486                }
487                if let Some(handler) = &props.onclick {
488                    handler.call(evt);
489                }
490            },
491            ..props.attributes,
492            {props.children}
493        }
494    }
495}
496
497/// Bootstrap nav-link rendered as a `<button>` — the JS-toggled tab / SPA
498/// navigation variant of [`NavLink`].
499///
500/// Bootstrap supports `button.nav-link` for nav components driven by script
501/// rather than by following an href (an in-page tab strip, a settings sidebar
502/// that swaps sections). A button never navigates, so there is nothing to
503/// prevent — use this instead of a `NavLink` with `prevent_default` when the
504/// item is not a real link.
505///
506/// This renders a plain nav button (`active` → `aria-current="page"`). It does
507/// **not** add `role="tab"`/`aria-selected`: those belong only inside a
508/// `role="tablist"`, which [`TabList`](crate::tabs::TabList) already provides.
509/// For a full ARIA tablist with managed panes, use `TabList`; use `NavButton`
510/// for a plain button-driven nav.
511///
512/// # Bootstrap HTML → Dioxus
513///
514/// | HTML | Dioxus |
515/// |---|---|
516/// | `<button class="nav-link active">Home</button>` | `NavButton { active: true, "Home" }` |
517/// | `<button class="nav-link" disabled>Off</button>` | `NavButton { disabled: true, "Off" }` |
518///
519/// ```rust,no_run
520/// # use dioxus::prelude::*;
521/// # use dioxus_bootstrap_css::prelude::*;
522/// # fn _doctest() -> Element {
523/// let mut section = use_signal(|| 0);
524/// rsx! {
525///     Nav {
526///         NavItem { NavButton { active: section() == 0, onclick: move |_| section.set(0), "General" } }
527///         NavItem { NavButton { active: section() == 1, onclick: move |_| section.set(1), "Account" } }
528///     }
529/// }
530/// # }
531/// ```
532#[derive(Clone, PartialEq, Props)]
533pub struct NavButtonProps {
534    /// Active state.
535    #[props(default)]
536    pub active: bool,
537    /// Disabled state. Rendered as the `<button>` `disabled` attribute (Bootstrap
538    /// parity), not the `.disabled` class used for anchors.
539    #[props(default)]
540    pub disabled: bool,
541    /// Click event handler.
542    #[props(default)]
543    pub onclick: Option<EventHandler<MouseEvent>>,
544    /// Additional CSS classes.
545    #[props(default)]
546    pub class: String,
547    /// Any additional HTML attributes.
548    #[props(extends = GlobalAttributes)]
549    attributes: Vec<Attribute>,
550    /// Child elements.
551    pub children: Element,
552}
553
554#[component]
555pub fn NavButton(props: NavButtonProps) -> Element {
556    // Button disabled uses the HTML attribute, so keep the class free of `disabled`.
557    let full_class = nav_link_class(props.active, false, &props.class);
558
559    rsx! {
560        button {
561            r#type: "button",
562            class: "{full_class}",
563            disabled: props.disabled,
564            "aria-current": if props.active { "page" } else { "" },
565            onclick: move |evt| {
566                if let Some(handler) = &props.onclick {
567                    handler.call(evt);
568                }
569            },
570            ..props.attributes,
571            {props.children}
572        }
573    }
574}
575
576/// Assemble the `nav-link` class list shared by [`NavLink`] and [`NavButton`].
577fn nav_link_class(active: bool, disabled: bool, extra: &str) -> String {
578    let mut classes = vec!["nav-link".to_string()];
579    if active {
580        classes.push("active".to_string());
581    }
582    if disabled {
583        classes.push("disabled".to_string());
584    }
585    if !extra.is_empty() {
586        classes.push(extra.to_string());
587    }
588    classes.join(" ")
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    #[test]
596    fn navbar_nav_class_base() {
597        assert_eq!(navbar_nav_class(false, ""), "navbar-nav");
598    }
599
600    #[test]
601    fn navbar_nav_class_scroll_and_extra_classes() {
602        assert_eq!(
603            navbar_nav_class(true, "ms-auto"),
604            "navbar-nav navbar-nav-scroll ms-auto"
605        );
606    }
607
608    #[test]
609    fn nav_link_class_base() {
610        assert_eq!(nav_link_class(false, false, ""), "nav-link");
611    }
612
613    #[test]
614    fn nav_link_class_active_disabled_and_extra() {
615        assert_eq!(
616            nav_link_class(true, true, "px-3"),
617            "nav-link active disabled px-3"
618        );
619    }
620
621    #[test]
622    fn nav_button_omits_disabled_class() {
623        // NavButton passes disabled=false to the class builder because a
624        // `<button>` uses the HTML `disabled` attribute, not the `.disabled`
625        // class. Only `active` should reach the class list.
626        assert_eq!(nav_link_class(true, false, ""), "nav-link active");
627    }
628}