Skip to main content

dioxus_bootstrap_css/
nav.rs

1use dioxus::prelude::*;
2
3use crate::types::{Color, NavbarContainer, 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    /// Nav element. Empty (the default) renders `<ul>`, whose children are
67    /// `NavItem`s wrapping `NavLink`s; `"nav"` renders `<nav class="nav">` and
68    /// takes `NavLink`/`NavButton` children directly, with no `NavItem` layer.
69    ///
70    /// Bootstrap documents both forms, and the choice is not cosmetic: `.nav` is
71    /// a flex container, so the two put different elements in flow. A vertical
72    /// nav whose items carry their own spacing or an active-state border lays out
73    /// differently once an `<li>` sits between the container and the link.
74    /// Convert an existing `<nav class="nav">` to this, not to the `<ul>` default.
75    #[props(default)]
76    pub tag: String,
77    /// Additional CSS classes.
78    #[props(default)]
79    pub class: String,
80    /// Any additional HTML attributes.
81    #[props(extends = GlobalAttributes)]
82    attributes: Vec<Attribute>,
83    /// Child elements (NavItems, or NavLinks directly when `tag` is `"nav"`).
84    pub children: Element,
85}
86
87/// Which element a nav renders as. Bootstrap documents both the `<ul>`/`<li>`
88/// structure and the flat `<nav>` one; they are different flex containers, not
89/// two spellings of one thing.
90fn nav_element(tag: &str) -> &'static str {
91    if tag == "nav" { "nav" } else { "ul" }
92}
93
94#[component]
95pub fn Nav(props: NavProps) -> Element {
96    let mut classes = vec!["nav".to_string()];
97    if props.pills {
98        classes.push("nav-pills".to_string());
99    }
100    if props.tabs {
101        classes.push("nav-tabs".to_string());
102    }
103    if props.underline {
104        classes.push("nav-underline".to_string());
105    }
106    if props.fill {
107        classes.push("nav-fill".to_string());
108    }
109    if props.justified {
110        classes.push("nav-justified".to_string());
111    }
112    if props.vertical {
113        classes.push("flex-column".to_string());
114    }
115    if !props.class.is_empty() {
116        classes.push(props.class.clone());
117    }
118    let full_class = classes.join(" ");
119
120    if nav_element(&props.tag) == "nav" {
121        return rsx! {
122            nav { class: "{full_class}",
123                ..props.attributes,
124                {props.children}
125            }
126        };
127    }
128
129    rsx! {
130        ul { class: "{full_class}",
131            ..props.attributes,
132            {props.children}
133        }
134    }
135}
136
137/// Bootstrap Navbar component.
138///
139/// # Bootstrap HTML → Dioxus
140///
141/// ```html
142/// <!-- Bootstrap HTML -->
143/// <nav class="navbar navbar-expand-lg bg-dark" data-bs-theme="dark">
144///   <div class="container-fluid">
145///     <a class="navbar-brand" href="#">MyApp</a>
146///     <button class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#nav">
147///       <span class="navbar-toggler-icon"></span>
148///     </button>
149///     <div class="collapse navbar-collapse" id="nav">
150///       <ul class="navbar-nav"><li class="nav-item"><a class="nav-link" href="/">Home</a></li></ul>
151///     </div>
152///   </div>
153/// </nav>
154/// ```
155///
156/// ```rust,no_run
157/// # use dioxus::prelude::*;
158/// # use dioxus_bootstrap_css::prelude::*;
159/// # fn _doctest() -> Element {
160/// // Dioxus equivalent
161/// let collapsed = use_signal(|| true);
162/// rsx! {
163///     Navbar { expand: NavbarExpand::Lg, class: "bg-body sticky-top",
164///         brand: rsx! { a { class: "navbar-brand", href: "#", "MyApp" } },
165///         NavbarToggler { collapsed: collapsed }
166///         NavbarCollapse { collapsed: collapsed,
167///             NavbarNav {
168///                 NavItem { NavLink { href: "/", active: true, "Home" } }
169///                 NavItem { NavLink { href: "/about", "About" } }
170///             }
171///         }
172///     }
173/// }
174/// # }
175/// ```
176#[derive(Clone, PartialEq, Props)]
177pub struct NavbarProps {
178    /// Navbar color scheme.
179    #[props(default)]
180    pub color: Option<Color>,
181    /// Responsive expand breakpoint.
182    #[props(default)]
183    pub expand: NavbarExpand,
184    /// Brand element (logo, app name).
185    #[props(default)]
186    pub brand: Option<Element>,
187    /// The container wrapping brand and children. Defaults to
188    /// [`NavbarContainer::Fluid`] (`container-fluid`), which is Bootstrap's own
189    /// default and what this component emitted before the prop existed. Set
190    /// [`NavbarContainer::None`] when the navbar pads itself and must not gain
191    /// the container's gutter on top.
192    #[props(default)]
193    pub container: NavbarContainer,
194    /// Additional CSS classes.
195    #[props(default)]
196    pub class: String,
197    /// Any additional HTML attributes.
198    #[props(extends = GlobalAttributes)]
199    attributes: Vec<Attribute>,
200    /// Child elements (nav items, collapse, etc.).
201    pub children: Element,
202}
203
204#[component]
205pub fn Navbar(props: NavbarProps) -> Element {
206    let mut classes = vec!["navbar".to_string(), props.expand.to_string()];
207
208    let is_dark = matches!(props.color.as_ref(), Some(Color::Dark));
209
210    if let Some(ref color) = props.color {
211        match color {
212            Color::Dark => {
213                classes.push("bg-dark".to_string());
214            }
215            Color::Light => {
216                classes.push("bg-light".to_string());
217            }
218            c => {
219                classes.push(format!("bg-{c}"));
220            }
221        }
222    }
223
224    if !props.class.is_empty() {
225        classes.push(props.class.clone());
226    }
227
228    let full_class = classes.join(" ");
229
230    rsx! {
231        nav {
232            class: "{full_class}",
233            // Bootstrap 5.3: dark theme via HTML attribute, not CSS class
234            "data-bs-theme": if is_dark { "dark" } else { "" },
235            ..props.attributes,
236            // A container is Bootstrap's default, not a requirement: the gutter
237            // belongs to the container, so a navbar that pads itself must be
238            // able to omit the element rather than empty its class.
239            match props.container.class() {
240                Some(container) => rsx! {
241                    div { class: "{container}",
242                        if let Some(brand) = props.brand {
243                            {brand}
244                        }
245                        {props.children}
246                    }
247                },
248                None => rsx! {
249                    if let Some(brand) = props.brand {
250                        {brand}
251                    }
252                    {props.children}
253                },
254            }
255        }
256    }
257}
258
259/// Navbar toggler button (hamburger menu) for responsive collapse.
260///
261/// # Bootstrap HTML → Dioxus
262///
263/// | HTML | Dioxus |
264/// |---|---|
265/// | `<button class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#nav">` | `NavbarToggler { collapsed: signal }` |
266///
267/// ```rust,no_run
268/// # use dioxus::prelude::*;
269/// # use dioxus_bootstrap_css::prelude::*;
270/// # fn _doctest() -> Element {
271/// # let collapsed_signal = use_signal(|| false);
272/// rsx! {
273///     NavbarToggler { collapsed: collapsed_signal }
274/// }
275/// # }
276/// ```
277#[derive(Clone, PartialEq, Props)]
278pub struct NavbarTogglerProps {
279    /// Signal to toggle — will invert the value on click.
280    pub collapsed: Signal<bool>,
281    /// Additional CSS classes.
282    #[props(default)]
283    pub class: String,
284    /// Any additional HTML attributes.
285    #[props(extends = GlobalAttributes)]
286    attributes: Vec<Attribute>,
287}
288
289#[component]
290pub fn NavbarToggler(props: NavbarTogglerProps) -> Element {
291    let is_collapsed = *props.collapsed.read();
292    let mut signal = props.collapsed;
293
294    let full_class = if props.class.is_empty() {
295        "navbar-toggler".to_string()
296    } else {
297        format!("navbar-toggler {}", props.class)
298    };
299
300    rsx! {
301        button {
302            class: "{full_class}",
303            r#type: "button",
304            "aria-expanded": if !is_collapsed { "true" } else { "false" },
305            "aria-label": "Toggle navigation",
306            onclick: move |_| signal.set(!is_collapsed),
307            ..props.attributes,
308            span { class: "navbar-toggler-icon" }
309        }
310    }
311}
312
313/// Navbar collapsible content area.
314///
315/// # Bootstrap HTML → Dioxus
316///
317/// | HTML | Dioxus |
318/// |---|---|
319/// | `<div class="collapse navbar-collapse" id="nav">` | `NavbarCollapse { collapsed: signal, ... }` |
320///
321/// ```rust,no_run
322/// # use dioxus::prelude::*;
323/// # use dioxus_bootstrap_css::prelude::*;
324/// # fn _doctest() -> Element {
325/// let collapsed = use_signal(|| true);
326/// rsx! {
327///     NavbarToggler { collapsed: collapsed }
328///     NavbarCollapse { collapsed: collapsed,
329///         NavbarNav {
330///             NavItem { NavLink { href: "/", "Home" } }
331///         }
332///     }
333/// }
334/// # }
335/// ```
336#[derive(Clone, PartialEq, Props)]
337pub struct NavbarCollapseProps {
338    /// Signal controlling collapsed state.
339    pub collapsed: Signal<bool>,
340    /// Additional CSS classes.
341    #[props(default)]
342    pub class: String,
343    /// Any additional HTML attributes.
344    #[props(extends = GlobalAttributes)]
345    attributes: Vec<Attribute>,
346    /// Child elements.
347    pub children: Element,
348}
349
350#[component]
351pub fn NavbarCollapse(props: NavbarCollapseProps) -> Element {
352    let is_collapsed = *props.collapsed.read();
353    let show = if !is_collapsed { " show" } else { "" };
354
355    let full_class = if props.class.is_empty() {
356        format!("collapse navbar-collapse{show}")
357    } else {
358        format!("collapse navbar-collapse{show} {}", props.class)
359    };
360
361    rsx! {
362        div { class: "{full_class}",
363            ..props.attributes,
364            {props.children}
365        }
366    }
367}
368
369/// Bootstrap navbar navigation list.
370///
371/// Use this inside [`NavbarCollapse`] to render Bootstrap's required
372/// `<ul class="navbar-nav">` wrapper around navbar [`NavItem`] children.
373///
374/// # Bootstrap HTML → Dioxus
375///
376/// | HTML | Dioxus |
377/// |---|---|
378/// | `<ul class="navbar-nav">...</ul>` | `NavbarNav { ... }` |
379/// | `<ul class="navbar-nav navbar-nav-scroll">...</ul>` | `NavbarNav { scroll: true, ... }` |
380///
381/// ```rust,no_run
382/// # use dioxus::prelude::*;
383/// # use dioxus_bootstrap_css::prelude::*;
384/// # fn _doctest() -> Element {
385/// rsx! {
386///     NavbarNav {
387///         NavItem { NavLink { active: true, href: "#", "Home" } }
388///         NavItem { NavLink { href: "#docs", "Docs" } }
389///     }
390/// }
391/// # }
392/// ```
393#[derive(Clone, PartialEq, Props)]
394pub struct NavbarNavProps {
395    /// Enable Bootstrap navbar scroll behavior.
396    #[props(default)]
397    pub scroll: bool,
398    /// Additional CSS classes.
399    #[props(default)]
400    pub class: String,
401    /// Any additional HTML attributes.
402    #[props(extends = GlobalAttributes)]
403    attributes: Vec<Attribute>,
404    /// Child elements (NavItem).
405    pub children: Element,
406}
407
408#[component]
409pub fn NavbarNav(props: NavbarNavProps) -> Element {
410    let full_class = navbar_nav_class(props.scroll, &props.class);
411
412    rsx! {
413        ul {
414            class: "{full_class}",
415            ..props.attributes,
416            {props.children}
417        }
418    }
419}
420
421fn navbar_nav_class(scroll: bool, class: &str) -> String {
422    let mut classes = vec!["navbar-nav".to_string()];
423    if scroll {
424        classes.push("navbar-nav-scroll".to_string());
425    }
426    if !class.is_empty() {
427        classes.push(class.to_string());
428    }
429    classes.join(" ")
430}
431
432/// Bootstrap NavItem component.
433///
434/// # Bootstrap HTML → Dioxus
435///
436/// | HTML | Dioxus |
437/// |---|---|
438/// | `<li class="nav-item">` | `NavItem { ... }` |
439#[derive(Clone, PartialEq, Props)]
440pub struct NavItemProps {
441    /// Additional CSS classes.
442    #[props(default)]
443    pub class: String,
444    /// Any additional HTML attributes.
445    #[props(extends = GlobalAttributes)]
446    attributes: Vec<Attribute>,
447    /// Child elements (NavLink).
448    pub children: Element,
449}
450
451#[component]
452pub fn NavItem(props: NavItemProps) -> Element {
453    let full_class = if props.class.is_empty() {
454        "nav-item".to_string()
455    } else {
456        format!("nav-item {}", props.class)
457    };
458
459    rsx! {
460        li { class: "{full_class}", ..props.attributes, {props.children} }
461    }
462}
463
464/// Bootstrap NavLink component.
465///
466/// # Bootstrap HTML → Dioxus
467///
468/// | HTML | Dioxus |
469/// |---|---|
470/// | `<a class="nav-link active" href="/">Home</a>` | `NavLink { href: "/", active: true, "Home" }` |
471/// | `<a class="nav-link disabled" aria-disabled="true" tabindex="-1">Disabled</a>` | `NavLink { disabled: true, "Disabled" }` |
472///
473/// For single-page apps that switch content client-side, set `prevent_default: true`
474/// so a click on a `#`-href link runs only your `onclick` handler and does not
475/// follow the anchor (no hash change, no scroll-to-top). For a JS-toggled tab
476/// button (`<button class="nav-link">`) use [`NavButton`] instead of an anchor.
477///
478/// ```rust,no_run
479/// # use dioxus::prelude::*;
480/// # use dioxus_bootstrap_css::prelude::*;
481/// # fn _doctest() -> Element {
482/// rsx! {
483///     NavLink { href: "/dashboard", active: true, "Dashboard" }
484///     // SPA tab link: handler runs, page does not navigate.
485///     NavLink { prevent_default: true, onclick: move |_| { /* switch section */ }, "Settings" }
486/// }
487/// # }
488/// ```
489#[derive(Clone, PartialEq, Props)]
490pub struct NavLinkProps {
491    /// Link href.
492    #[props(default = "#".to_string())]
493    pub href: String,
494    /// Active state.
495    #[props(default)]
496    pub active: bool,
497    /// Disabled state.
498    #[props(default)]
499    pub disabled: bool,
500    /// Call `event.prevent_default()` on click so the anchor is not followed
501    /// (SPA-safe: the `onclick` handler runs, but the URL/hash is untouched and
502    /// the page does not scroll to top). Matches what Bootstrap's own JS does
503    /// for `#`-href toggle links.
504    #[props(default)]
505    pub prevent_default: bool,
506    /// Click event handler.
507    #[props(default)]
508    pub onclick: Option<EventHandler<MouseEvent>>,
509    /// Additional CSS classes.
510    #[props(default)]
511    pub class: String,
512    /// Any additional HTML attributes.
513    #[props(extends = GlobalAttributes)]
514    attributes: Vec<Attribute>,
515    /// Child elements.
516    pub children: Element,
517}
518
519#[component]
520pub fn NavLink(props: NavLinkProps) -> Element {
521    let full_class = nav_link_class(props.active, props.disabled, &props.class);
522
523    rsx! {
524        a {
525            class: "{full_class}",
526            href: "{props.href}",
527            "aria-current": if props.active { "page" } else { "" },
528            "aria-disabled": if props.disabled { "true" } else { "" },
529            tabindex: if props.disabled { "-1" } else { "" },
530            onclick: move |evt| {
531                if props.prevent_default {
532                    evt.prevent_default();
533                }
534                if let Some(handler) = &props.onclick {
535                    handler.call(evt);
536                }
537            },
538            ..props.attributes,
539            {props.children}
540        }
541    }
542}
543
544/// Bootstrap nav-link rendered as a `<button>` — the JS-toggled tab / SPA
545/// navigation variant of [`NavLink`].
546///
547/// Bootstrap supports `button.nav-link` for nav components driven by script
548/// rather than by following an href (an in-page tab strip, a settings sidebar
549/// that swaps sections). A button never navigates, so there is nothing to
550/// prevent — use this instead of a `NavLink` with `prevent_default` when the
551/// item is not a real link.
552///
553/// This renders a plain nav button (`active` → `aria-current="page"`). It does
554/// **not** add `role="tab"`/`aria-selected`: those belong only inside a
555/// `role="tablist"`, which [`TabList`](crate::tabs::TabList) already provides.
556/// For a full ARIA tablist with managed panes, use `TabList`; use `NavButton`
557/// for a plain button-driven nav.
558///
559/// # Bootstrap HTML → Dioxus
560///
561/// | HTML | Dioxus |
562/// |---|---|
563/// | `<button class="nav-link active">Home</button>` | `NavButton { active: true, "Home" }` |
564/// | `<button class="nav-link" disabled>Off</button>` | `NavButton { disabled: true, "Off" }` |
565///
566/// ```rust,no_run
567/// # use dioxus::prelude::*;
568/// # use dioxus_bootstrap_css::prelude::*;
569/// # fn _doctest() -> Element {
570/// let mut section = use_signal(|| 0);
571/// rsx! {
572///     Nav {
573///         NavItem { NavButton { active: section() == 0, onclick: move |_| section.set(0), "General" } }
574///         NavItem { NavButton { active: section() == 1, onclick: move |_| section.set(1), "Account" } }
575///     }
576/// }
577/// # }
578/// ```
579#[derive(Clone, PartialEq, Props)]
580pub struct NavButtonProps {
581    /// Active state.
582    #[props(default)]
583    pub active: bool,
584    /// Disabled state. Rendered as the `<button>` `disabled` attribute (Bootstrap
585    /// parity), not the `.disabled` class used for anchors.
586    #[props(default)]
587    pub disabled: bool,
588    /// Click event handler.
589    #[props(default)]
590    pub onclick: Option<EventHandler<MouseEvent>>,
591    /// Additional CSS classes.
592    #[props(default)]
593    pub class: String,
594    /// Any additional HTML attributes.
595    #[props(extends = GlobalAttributes)]
596    attributes: Vec<Attribute>,
597    /// Child elements.
598    pub children: Element,
599}
600
601#[component]
602pub fn NavButton(props: NavButtonProps) -> Element {
603    // Button disabled uses the HTML attribute, so keep the class free of `disabled`.
604    let full_class = nav_link_class(props.active, false, &props.class);
605
606    rsx! {
607        button {
608            r#type: "button",
609            class: "{full_class}",
610            disabled: props.disabled,
611            "aria-current": if props.active { "page" } else { "" },
612            onclick: move |evt| {
613                if let Some(handler) = &props.onclick {
614                    handler.call(evt);
615                }
616            },
617            ..props.attributes,
618            {props.children}
619        }
620    }
621}
622
623/// Assemble the `nav-link` class list shared by [`NavLink`] and [`NavButton`].
624fn nav_link_class(active: bool, disabled: bool, extra: &str) -> String {
625    let mut classes = vec!["nav-link".to_string()];
626    if active {
627        classes.push("active".to_string());
628    }
629    if disabled {
630        classes.push("disabled".to_string());
631    }
632    if !extra.is_empty() {
633        classes.push(extra.to_string());
634    }
635    classes.join(" ")
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    #[test]
643    fn nav_defaults_to_the_list_structure() {
644        assert_eq!(nav_element(""), "ul");
645    }
646
647    #[test]
648    fn nav_tag_selects_the_flat_form() {
649        // Not cosmetic: `.nav` is a flex container, so the `<li>` layer the list
650        // form adds becomes the flex item. A vertical nav whose links carry their
651        // own spacing or an active-state border lays out differently under the two.
652        assert_eq!(nav_element("nav"), "nav");
653    }
654
655    #[test]
656    fn navbar_container_defaults_to_fluid() {
657        // Unchanged behaviour is the requirement: every navbar rendered before
658        // this prop existed wrapped its contents in `container-fluid`.
659        assert_eq!(NavbarContainer::default().class(), Some("container-fluid"));
660    }
661
662    #[test]
663    fn navbar_container_fixed_is_the_responsive_container() {
664        assert_eq!(NavbarContainer::Fixed.class(), Some("container"));
665    }
666
667    #[test]
668    fn navbar_container_none_emits_no_wrapper_at_all() {
669        // `None`, not `Some("")`: an empty class still leaves a `<div>` in the
670        // layout, which is the gutter-free case failing in a way that looks
671        // like it worked.
672        assert_eq!(NavbarContainer::None.class(), None);
673    }
674
675    #[test]
676    fn navbar_nav_class_base() {
677        assert_eq!(navbar_nav_class(false, ""), "navbar-nav");
678    }
679
680    #[test]
681    fn navbar_nav_class_scroll_and_extra_classes() {
682        assert_eq!(
683            navbar_nav_class(true, "ms-auto"),
684            "navbar-nav navbar-nav-scroll ms-auto"
685        );
686    }
687
688    #[test]
689    fn nav_link_class_base() {
690        assert_eq!(nav_link_class(false, false, ""), "nav-link");
691    }
692
693    #[test]
694    fn nav_link_class_active_disabled_and_extra() {
695        assert_eq!(
696            nav_link_class(true, true, "px-3"),
697            "nav-link active disabled px-3"
698        );
699    }
700
701    #[test]
702    fn nav_button_omits_disabled_class() {
703        // NavButton passes disabled=false to the class builder because a
704        // `<button>` uses the HTML `disabled` attribute, not the `.disabled`
705        // class. Only `active` should reach the class list.
706        assert_eq!(nav_link_class(true, false, ""), "nav-link active");
707    }
708}