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