Skip to main content

fc_dioxus/components/
nav.rs

1//! The navigation sidebar and its icon rail, from nav.css. Collapsing ports upstream's
2//! `nav.ts`.
3use crate::{
4    core::{
5        assets,
6        attrs::{availability_attributes, component_attributes},
7    },
8    prelude::*,
9    theme::use_stylesheets,
10};
11const BEM: Bem = Bem::new("fc-nav");
12const LIST: Bem = Bem::new("fc-nav__list");
13fc_modifier! { enum ListDepth for LIST { Top => None, Nested => "nested" } }
14
15#[derive(Clone, Copy)]
16struct Rail(Signal<bool>);
17
18/// A sidebar of links that can collapse to an icon rail.
19///
20/// The toggle's `aria-expanded` is the single state: the stylesheet reads it, so what is
21/// announced and what is drawn cannot disagree. Collapsing hides labels visually but
22/// keeps them as each link's name, and gives each link a `title` so a mouse user can
23/// read what an icon stands for.
24#[component]
25pub fn NavSidebar(
26    label: AccessibleName,
27    title: Element,
28    /// Names the collapse toggle; "Sidebar" when omitted. Without `collapsible` there is
29    /// no toggle.
30    #[props(default)]
31    toggle_label: Option<AccessibleName>,
32    #[props(default = true)] collapsible: bool,
33    #[props(default)] expanded: Option<ReadSignal<bool>>,
34    #[props(default = true)] default_expanded: bool,
35    #[props(default)] ontoggle: Option<EventHandler<bool>>,
36    #[props(default)] class: Option<String>,
37    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
38    children: Element,
39) -> Element {
40    let state = use_controllable(expanded, || default_expanded, ontoggle);
41    let id = use_id();
42    let mut rail = use_context_provider(|| Rail(Signal::new(!default_expanded)));
43    let open = state.get();
44    if *rail.0.peek() == open {
45        rail.0.set(!open);
46    }
47    let attributes = component_attributes(
48        attributes,
49        ClassList::new(BEM.block()).extend(class.as_deref()),
50        &["id", "aria-label"],
51    );
52    let toggle_label = toggle_label.map_or_else(|| "Sidebar".to_owned(), |l| l.to_string());
53    rsx! {
54        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_NAV_CSS } }
55        nav { id: "{id}", aria_label: label.to_string(), ..attributes,
56            div { class: BEM.element("header"),
57                span { class: BEM.element("title"), {title} }
58                if collapsible {
59                    button { class: BEM.element("toggle"), r#type: "button", aria_label: toggle_label,
60                        aria_expanded: open.to_string(), aria_controls: "{id}",
61                        onclick: move |_| state.set(!open),
62                    }
63                }
64            }
65            {children}
66        }
67    }
68}
69
70/// A small heading above a [`NavList`].
71#[component]
72pub fn NavHeading(children: Element) -> Element {
73    rsx! { p { class: BEM.element("heading"), {children} } }
74}
75
76/// A list of [`NavLink`]s. `nested` indents it under the link it belongs to.
77#[component]
78pub fn NavList(#[props(default)] nested: bool, children: Element) -> Element {
79    let class = ClassList::new(LIST.block())
80        .with_modifier(
81            LIST,
82            if nested {
83                ListDepth::Nested
84            } else {
85                ListDepth::Top
86            },
87        )
88        .to_string();
89    rsx! { ul { class, {children} } }
90}
91
92/// What a link has to do with where the user is.
93#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
94pub enum NavCurrent {
95    /// Elsewhere.
96    #[default]
97    No,
98    /// The current page (`aria-current="page"`).
99    Page,
100    /// Contains the current page, e.g. its section (`aria-current="true"`).
101    Section,
102}
103
104/// A destination. The label stays the accessible name in the rail.
105#[component]
106pub fn NavLink(
107    label: String,
108    #[props(default)] href: Option<String>,
109    #[props(default)] icon: Option<Element>,
110    /// A count or note after the label.
111    #[props(default)]
112    meta: Option<Element>,
113    #[props(default)] current: NavCurrent,
114    #[props(default)] availability: Availability,
115    /// A nested [`NavList`] under this link.
116    #[props(default)]
117    nested: Option<Element>,
118) -> Element {
119    let rail = try_consume_context::<Rail>().is_some_and(|rail| *rail.0.read());
120    let blocked = availability != Availability::Enabled;
121    let state = match &availability {
122        Availability::Disabled => {
123            crate::spread![Attribute::new("aria-disabled", "true", None, false)]
124        }
125        other => availability_attributes(other),
126    };
127    let link = crate::core::link::use_link(href.filter(|_| !blocked));
128    let href = link.href.clone();
129    let current = match current {
130        // Inside a router, the link to the route being shown is current on its own.
131        NavCurrent::No if link.current => Some("page"),
132        NavCurrent::No => None,
133        NavCurrent::Page => Some("page"),
134        NavCurrent::Section => Some("true"),
135    };
136    rsx! {
137        li {
138            a { class: BEM.element("link"), href, aria_current: current,
139                onclick: move |event| { link.follow(&event); },
140                // In the rail the label is hidden visually, so the title names the icon.
141                title: rail.then(|| label.clone()),
142                ..state,
143                {icon}
144                span { class: BEM.element("label"), "{label}" }
145                if let Some(meta) = meta { span { class: BEM.element("meta"), {meta} } }
146            }
147            {nested}
148        }
149    }
150}
151
152/// A 20-unit stroke icon for a [`NavLink`]. Children are SVG shapes such as `path`.
153#[component]
154pub fn NavIcon(children: Element) -> Element {
155    rsx! {
156        svg { class: BEM.element("icon"), view_box: "0 0 20 20", fill: "none", stroke: "currentColor",
157            stroke_width: "1.6", "aria-hidden": "true", {children}
158        }
159    }
160}
161
162/// A collapsible group of links under a label.
163#[component]
164pub fn NavGroup(label: Element, #[props(default)] open: bool, children: Element) -> Element {
165    rsx! {
166        details { class: BEM.element("group"), open,
167            summary { class: BEM.element("group-label"), {label} }
168            NavList { {children} }
169        }
170    }
171}
172
173/// Links pinned to the bottom of the sidebar, such as settings.
174#[component]
175pub fn NavFooter(children: Element) -> Element {
176    rsx! { div { class: BEM.element("footer"), NavList { {children} } } }
177}