ui_storybook/components/shell/
navigation_rail.rs1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
15pub enum AppSection {
16 Record,
18 Library,
20 Editor,
22 Cursor,
24 Prefs,
26}
27
28impl AppSection {
29 #[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#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct NavItemView {
45 pub section: AppSection,
47 pub label: &'static str,
49 pub count: Option<u32>,
52 pub disabled: bool,
54}
55
56#[component]
57pub fn NavigationRail(
58 items: Vec<NavItemView>,
60 active: AppSection,
63 workspace: WorkspaceBadgeView,
65 #[prop(optional)]
67 user: Option<UserAvatarView>,
68 #[prop(optional)]
71 workspace_open: bool,
72 #[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 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}