Skip to main content

g3_ui/components/
nav.rs

1//! Persistent app navigation: bottom bars, side rails, and their items.
2use super::pressable::Destination;
3use crate::components::pressable::{Pressable, Target};
4use crate::theme::{ComponentMode, classes, merge_classes, use_component_mode, use_strings};
5use dioxus::prelude::*;
6
7#[cfg(feature = "transitions")]
8use crate::components::use_shell_size;
9#[cfg(feature = "transitions")]
10use g3_route_transitions::ROUTE_TRANSITION_PERSISTENT_CLASS;
11
12/// What an [`AdaptiveNav`] does on a compact shell (narrower than `48rem`).
13/// On wide shells it is always a rail.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
15pub enum AdaptiveNavCompact {
16    /// Show a bottom bar.
17    #[default]
18    Bar,
19    /// Show nothing. For full-screen routes, such as routed sheets, that cover
20    /// the bottom bar on phones but keep the rail beside them on wide shells.
21    Hidden,
22}
23
24/// Where a [`NavItem`] sits in a rail. Bottom bars keep declaration order.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
26pub enum NavItemGroup {
27    /// Main destinations, at the top of the rail.
28    #[default]
29    Primary,
30    /// Account and settings destinations, grouped at the bottom of the rail.
31    Secondary,
32}
33
34#[derive(Clone, Copy, PartialEq)]
35enum NavKind {
36    Bar,
37    Rail,
38    Adaptive(AdaptiveNavCompact),
39}
40
41#[component]
42fn NavContainer(
43    kind: NavKind,
44    aria_label: Option<String>,
45    mode: Option<ComponentMode>,
46    class: Option<String>,
47    children: Element,
48) -> Element {
49    let mode = use_component_mode(mode);
50    let aria_label = aria_label.unwrap_or_else(|| use_strings().primary_navigation);
51    let kind_cls = match kind {
52        NavKind::Bar => "",
53        NavKind::Rail => "g3-nav-rail",
54        NavKind::Adaptive(AdaptiveNavCompact::Bar) => "g3-nav-adaptive",
55        NavKind::Adaptive(AdaptiveNavCompact::Hidden) => "g3-nav-adaptive g3-nav-compact-hidden",
56    };
57    // A rail stays reachable while a routed sheet is up, so it is captured as
58    // persistent chrome and paints above the sheet. A bottom bar is not: on a
59    // phone a sheet is meant to cover it, so it stays inside the base region
60    // and rises under the sheet as before. An adaptive nav is a rail only on a
61    // wide shell, so it follows the measured shell size.
62    #[cfg(feature = "transitions")]
63    let persistent_cls = {
64        // Called unconditionally: a hook may not sit behind a match arm.
65        let wide = use_shell_size().is_wide();
66        let is_rail = match kind {
67            NavKind::Bar => false,
68            NavKind::Rail => true,
69            NavKind::Adaptive(_) => wide,
70        };
71        if is_rail {
72            ROUTE_TRANSITION_PERSISTENT_CLASS
73        } else {
74            ""
75        }
76    };
77    #[cfg(not(feature = "transitions"))]
78    let persistent_cls = "";
79    let cls = classes([
80        "g3-nav",
81        mode.pick("g3-nav-ios", "g3-nav-md"),
82        kind_cls,
83        persistent_cls,
84    ]);
85    rsx! {
86        nav { class: merge_classes(cls, class.as_deref()), aria_label, {children} }
87    }
88}
89
90/// Navigation that is a bottom bar on compact shells and a side rail on wide
91/// ones. Put it last inside a [`TabLayout`](crate::TabLayout).
92///
93/// With the `transitions` feature, the rail is persistent route-transition
94/// chrome inside an [`AppWrapper`](crate::AppWrapper): it stays still while
95/// pages slide, as long as the routes on both sides render it.
96#[component]
97pub fn AdaptiveNav(
98    /// What to show on compact shells. Defaults to a bottom bar.
99    compact: Option<AdaptiveNavCompact>,
100    /// Accessible name. Defaults to
101    /// [`Strings::primary_navigation`](crate::Strings::primary_navigation).
102    aria_label: Option<String>,
103    /// Platform look. Defaults to the ambient mode.
104    mode: Option<ComponentMode>,
105    /// Extra classes for the `nav` element.
106    class: Option<String>,
107    children: Element,
108) -> Element {
109    rsx! {
110        NavContainer {
111            kind: NavKind::Adaptive(compact.unwrap_or_default()),
112            aria_label,
113            mode,
114            class,
115            {children}
116        }
117    }
118}
119
120/// A bottom tab bar at every shell width. Put it last inside a
121/// [`TabLayout`](crate::TabLayout).
122#[component]
123pub fn NavBar(
124    /// Accessible name. Defaults to
125    /// [`Strings::primary_navigation`](crate::Strings::primary_navigation).
126    aria_label: Option<String>,
127    /// Platform look. Defaults to the ambient mode.
128    mode: Option<ComponentMode>,
129    /// Extra classes for the `nav` element.
130    class: Option<String>,
131    children: Element,
132) -> Element {
133    rsx! {
134        NavContainer { kind: NavKind::Bar, aria_label, mode, class, {children} }
135    }
136}
137
138/// A side navigation rail at every shell width. Put it inside a
139/// [`TabLayout`](crate::TabLayout).
140#[component]
141pub fn NavRail(
142    /// Accessible name. Defaults to
143    /// [`Strings::primary_navigation`](crate::Strings::primary_navigation).
144    aria_label: Option<String>,
145    /// Platform look. Defaults to the ambient mode.
146    mode: Option<ComponentMode>,
147    /// Extra classes for the `nav` element.
148    class: Option<String>,
149    children: Element,
150) -> Element {
151    rsx! {
152        NavContainer { kind: NavKind::Rail, aria_label, mode, class, {children} }
153    }
154}
155
156/// One destination in a [`NavBar`], [`NavRail`], or [`AdaptiveNav`].
157///
158/// Give it `to` for a router destination; it is then marked current
159/// automatically when its route is active. Use `href` for a plain link, or
160/// `onclick` with `selected` to manage the current destination yourself.
161#[component]
162pub fn NavItem(
163    /// Visible label. In a rail it appears as a tooltip.
164    label: String,
165    /// Icon shown above the label.
166    icon: Option<Element>,
167    /// Router destination.
168    #[props(default, into)]
169    to: Destination,
170    /// Plain link destination, used when `to` is not set.
171    href: Option<String>,
172    /// Mark this as the current destination. Router links mark themselves.
173    selected: Option<bool>,
174    /// Short badge text on the icon, such as an unread count. Hidden when
175    /// empty.
176    badge: Option<String>,
177    /// Where the item sits in a rail. Defaults to
178    /// [`NavItemGroup::Primary`].
179    group: Option<NavItemGroup>,
180    /// Disable the item.
181    disabled: Option<bool>,
182    /// Called on activation, before any navigation.
183    onclick: Option<EventHandler<MouseEvent>>,
184    /// Extra classes for the item.
185    class: Option<String>,
186) -> Element {
187    let target = Target::from_props(href, to, false);
188    let group_cls = match group.unwrap_or_default() {
189        NavItemGroup::Primary => "",
190        NavItemGroup::Secondary => "g3-nav-item-secondary",
191    };
192    let badge = badge.filter(|badge| !badge.is_empty());
193    // The rail hides the visible label, which also hides it from assistive
194    // technology, so the name is given directly.
195    let name = match &badge {
196        Some(badge) => format!("{label}, {badge}"),
197        None => label.clone(),
198    };
199    rsx! {
200        Pressable {
201            class: merge_classes(classes(["g3-nav-item", group_cls]), class.as_deref()),
202            target,
203            disabled: disabled.unwrap_or(false),
204            onclick,
205            aria_current: selected.unwrap_or(false).then_some("page"),
206            aria_label: name,
207            span { class: "g3-nav-item-icon", aria_hidden: "true",
208                {icon}
209                if let Some(badge) = &badge {
210                    span { class: "g3-nav-item-badge", "{badge}" }
211                }
212            }
213            span { class: "g3-nav-item-label", aria_hidden: "true", "{label}" }
214        }
215    }
216}
217
218#[cfg(feature = "playground")]
219#[component]
220fn NavPlaygroundDemo() -> Element {
221    use dioxus_icons::lucide::{CalendarDays, CircleUserRound, Trophy};
222    let mode = crate::use_component_mode(None);
223    let mut active = use_signal(|| 0_usize);
224    let layout = use_signal(|| 0_usize);
225    let items = rsx! {
226        NavItem {
227            label: "Games",
228            selected: active() == 0,
229            badge: "3",
230            icon: rsx! {
231                Trophy { size: 20 }
232            },
233            onclick: move |_| active.set(0),
234        }
235        NavItem {
236            label: "Tourneys",
237            selected: active() == 1,
238            icon: rsx! {
239                CalendarDays { size: 20 }
240            },
241            onclick: move |_| active.set(1),
242        }
243        NavItem {
244            label: "Account",
245            selected: active() == 2,
246            group: NavItemGroup::Secondary,
247            icon: rsx! {
248                CircleUserRound { size: 20 }
249            },
250            onclick: move |_| active.set(2),
251        }
252    };
253    rsx! {
254        crate::PlaygroundDemoFrame {
255            app: false,
256            controls: rsx! {
257                div {
258                    span { "Layout" }
259                    crate::SegmentGroup { value: layout,
260                        crate::SegmentButton { value: 0_usize, "Adaptive" }
261                        crate::SegmentButton { value: 1_usize, "Bar" }
262                        crate::SegmentButton { value: 2_usize, "Rail" }
263                    }
264                }
265            },
266            crate::AppWrapper { mode, class: "g3-playground-device-app",
267                crate::TabLayout {
268                    crate::Header { title: "Navigation" }
269                    crate::Content { footer_space: false,
270                        crate::Card { title: "Content", "Page content beside persistent navigation." }
271                    }
272                    match layout() {
273                        1 => rsx! {
274                            NavBar { {items} }
275                        },
276                        2 => rsx! {
277                            NavRail { {items} }
278                        },
279                        _ => rsx! {
280                            AdaptiveNav { {items} }
281                        },
282                    }
283                }
284            }
285        }
286    }
287}
288
289crate::g3_playground! {
290    name: "Navigation",
291    description: "Bottom bars and side rails, adaptive to the shell width.",
292    components: ["TabLayout", "AdaptiveNav", "NavBar", "NavRail", "NavItem"],
293    demo: NavPlaygroundDemo,
294    source: "src/components/nav.rs",
295}