Skip to main content

ui_storybook/components/shell/
navigation_rail.rs

1//! `NavigationRail` — left-edge nav (M-UI.2 / AUT-122).
2//!
3//! Structural component, not a router. The selected section is passed
4//! in from above via `active`; the component never owns that state.
5//! Items render with icon, label, and optional notification count.
6
7use leptos::prelude::*;
8
9use super::user_avatar::{UserAvatar, UserAvatarView};
10use super::workspace_badge::{WorkspaceBadge, WorkspaceBadgeView};
11use crate::components::primitives::{CircleDot, Folder, LayoutPanelTop, MousePointer2, Settings};
12
13/// Top-level app section the rail can route to.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
15pub enum AppSection {
16    /// Record setup + tray popover.
17    Record,
18    /// Recordings library.
19    Library,
20    /// Editor (drop zone + timeline + inspector).
21    Editor,
22    /// Cursor Studio.
23    Cursor,
24    /// Preferences.
25    Prefs,
26}
27
28impl AppSection {
29    /// Stable kebab-case slug used in CSS classes + data attributes.
30    #[must_use]
31    pub fn slug(self) -> &'static str {
32        match self {
33            AppSection::Record => "record",
34            AppSection::Library => "library",
35            AppSection::Editor => "editor",
36            AppSection::Cursor => "cursor",
37            AppSection::Prefs => "prefs",
38        }
39    }
40}
41
42/// View-model for one nav item.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct NavItemView {
45    /// Which section this item routes to.
46    pub section: AppSection,
47    /// Visible label under the icon.
48    pub label: &'static str,
49    /// Optional notification badge count (rendered when `Some` and
50    /// `> 0`).
51    pub count: Option<u32>,
52    /// `true` to render the item dimmed and non-clickable.
53    pub disabled: bool,
54}
55
56#[component]
57pub fn NavigationRail(
58    /// List of nav items in display order (top to bottom).
59    items: Vec<NavItemView>,
60    /// The currently-active section. The matching item gets the
61    /// `nav-rail-item-active` class.
62    active: AppSection,
63    /// Top-of-rail workspace marker.
64    workspace: WorkspaceBadgeView,
65    /// Optional bottom-of-rail user avatar.
66    #[prop(optional)]
67    user: Option<UserAvatarView>,
68    /// `true` when the workspace switcher menu is open — controls the
69    /// badge's `open` state.
70    #[prop(optional)]
71    workspace_open: bool,
72    /// Optional callback fired when the user clicks an enabled rail
73    /// item (M-TRAY.2 / AUT-251). The argument is the clicked item's
74    /// [`AppSection`]. M-TRAY.4 (AUT-253) wires this in
75    /// `crates/app-ui` to drive the active-surface signal; the
76    /// storybook stories leave it `None` so SSR snapshots stay
77    /// identical to the pre-callback baseline. Disabled items never
78    /// fire the callback.
79    #[prop(optional)]
80    on_select: Option<Callback<AppSection>>,
81) -> impl IntoView {
82    view! {
83        <nav class="nav-rail" aria-label="Primary">
84            <div class="nav-rail-top">
85                <WorkspaceBadge view=workspace open=workspace_open />
86            </div>
87            <ul class="nav-rail-items" role="tablist">
88                {items.into_iter()
89                    .map(|item| render_item(item, active, on_select))
90                    .collect_view()}
91            </ul>
92            <div class="nav-rail-bottom">
93                {user.map(|u| view! { <UserAvatar view=u /> })}
94            </div>
95        </nav>
96    }
97}
98
99fn render_item(
100    item: NavItemView,
101    active: AppSection,
102    on_select: Option<Callback<AppSection>>,
103) -> impl IntoView {
104    let is_active = item.section == active;
105    let mut class = String::from("nav-rail-item");
106    class.push_str(" nav-rail-item-");
107    class.push_str(item.section.slug());
108    if is_active {
109        class.push_str(" nav-rail-item-active");
110    }
111    if item.disabled {
112        class.push_str(" nav-rail-item-disabled");
113    }
114    let count_view = item
115        .count
116        .filter(|c| *c > 0)
117        .map(|c| view! { <span class="nav-rail-count">{c.to_string()}</span> });
118    // Click handler: fires the callback with the item's section when
119    // present + the item isn't disabled. `Callback<T>` is `Copy` so
120    // both `on_select` and `item_section` can be moved into the
121    // closure freely.
122    let item_section = item.section;
123    let item_disabled = item.disabled;
124    let on_click = move |_| {
125        if !item_disabled && let Some(cb) = on_select {
126            cb.run(item_section);
127        }
128    };
129    let icon_view = match item.section {
130        AppSection::Record => view! { <CircleDot /> }.into_any(),
131        AppSection::Library => view! { <Folder /> }.into_any(),
132        AppSection::Editor => view! { <LayoutPanelTop /> }.into_any(),
133        AppSection::Cursor => view! { <MousePointer2 /> }.into_any(),
134        AppSection::Prefs => view! { <Settings /> }.into_any(),
135    };
136    view! {
137        <li>
138            <button
139                class=class
140                role="tab"
141                aria-selected=is_active
142                aria-disabled=item.disabled
143                disabled=item.disabled
144                data-section=item.section.slug()
145                on:click=on_click
146            >
147                <span class="nav-rail-icon" aria-hidden="true">{icon_view}</span>
148                <span class="nav-rail-label">{item.label}</span>
149                {count_view}
150            </button>
151        </li>
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn each_section_has_a_unique_slug() {
161        let slugs = [
162            AppSection::Record.slug(),
163            AppSection::Library.slug(),
164            AppSection::Editor.slug(),
165            AppSection::Cursor.slug(),
166            AppSection::Prefs.slug(),
167        ];
168        let mut sorted: Vec<_> = slugs.to_vec();
169        sorted.sort_unstable();
170        sorted.dedup();
171        assert_eq!(sorted.len(), slugs.len());
172    }
173
174    #[test]
175    fn slugs_are_kebab_case_and_lowercase() {
176        for s in [
177            AppSection::Record,
178            AppSection::Library,
179            AppSection::Editor,
180            AppSection::Cursor,
181            AppSection::Prefs,
182        ] {
183            let slug = s.slug();
184            assert!(slug.chars().all(|c| c.is_ascii_lowercase() || c == '-'));
185            assert!(!slug.is_empty());
186        }
187    }
188}