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}