Skip to main content

ui_storybook/components/shell/
workspace_menu.rs

1//! `WorkspaceSwitcherMenu` — popover anchored to the rail's
2//! `WorkspaceBadge` (M-UI.5 / AUT-125). Pure composition of UI-03 menu
3//! primitives.
4
5use leptos::prelude::*;
6
7use crate::components::menus::{
8    MenuBadgeView, MenuFooter, MenuList, MenuRow, MenuRowKind, MenuSection, PopoverPlacement,
9    PopoverSurface,
10};
11use crate::components::primitives::{BadgeKind, IconTile, IconTileKind};
12
13/// View-model for one workspace row.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct WorkspaceView {
16    /// Stable id.
17    pub id: &'static str,
18    /// 2-letter monogram.
19    pub initials: &'static str,
20    /// Display name.
21    pub name: &'static str,
22    /// Optional plan label rendered as a badge ("Pro", "Team", "Free").
23    pub plan_badge: Option<&'static str>,
24    /// Number of members. Rendered as "N members" / "1 member".
25    pub member_count: u32,
26    /// Optional CSS background color for the monogram tile.
27    pub color: Option<&'static str>,
28}
29
30#[component]
31pub fn WorkspaceSwitcherMenu(
32    /// All workspaces in display order.
33    workspaces: Vec<WorkspaceView>,
34    /// Currently-selected workspace id. Pass `""` for the
35    /// no-selection variant.
36    #[prop(into)]
37    selected_id: String,
38) -> impl IntoView {
39    // Pre-build the rows so the selected-id comparison runs once
40    // up-front and the resulting `View` values are `'static`.
41    let rows: Vec<_> = workspaces
42        .into_iter()
43        .map(|w| {
44            let is_selected = w.id == selected_id;
45            render_row(w, is_selected)
46        })
47        .collect();
48    view! {
49        <PopoverSurface
50            placement=PopoverPlacement::BottomLeft
51            width_px=320_u16
52            title="Workspaces".to_string()
53            description="Switch between your personal and team workspaces.".to_string()
54            footer=ToChildren::to_children(|| view! {
55                <MenuFooter>
56                    <span style="font-size:11px;color:var(--text-tertiary)">"Workspaces sync across devices."</span>
57                </MenuFooter>
58            })
59        >
60            <MenuList label="Workspaces">
61                <MenuSection heading="Your workspaces".to_string()>
62                    {rows}
63                </MenuSection>
64                <MenuSection heading="Actions".to_string()>
65                    <MenuRow
66                        kind=MenuRowKind::Action
67                        leading=ToChildren::to_children(|| view! { <IconTile kind=IconTileKind::Action>"+"</IconTile> })
68                        title="New workspace".to_string()
69                        subtitle="Invite teammates after creating".to_string()
70                    />
71                    <MenuRow
72                        leading=ToChildren::to_children(|| view! { <IconTile kind=IconTileKind::Action>"⚙"</IconTile> })
73                        title="Workspace settings".to_string()
74                    />
75                </MenuSection>
76            </MenuList>
77        </PopoverSurface>
78    }
79}
80
81fn render_row(w: WorkspaceView, is_selected: bool) -> impl IntoView {
82    let kind = if is_selected {
83        MenuRowKind::Selected
84    } else {
85        MenuRowKind::Default
86    };
87    let initials = w.initials;
88    let color_style = w
89        .color
90        .map(|c| format!("background:{c}"))
91        .unwrap_or_default();
92    let mut badges: Vec<MenuBadgeView> = Vec::new();
93    if let Some(plan) = w.plan_badge {
94        badges.push(MenuBadgeView {
95            label: plan,
96            kind: BadgeKind::Plan,
97        });
98    }
99    let member_count_label = leak_str(format_member_count(w.member_count));
100    view! {
101        <MenuRow
102            kind=kind
103            leading=ToChildren::to_children(move || view! {
104                <span class="icon-tile icon-tile-workspace" style=color_style.clone() aria-hidden="true">{initials}</span>
105            })
106            title=w.name.to_string()
107            subtitle=member_count_label.to_string()
108            badges=badges
109        />
110    }
111}
112
113/// Format the member-count subtitle. `1 member` (singular), `N
114/// members` (plural).
115#[must_use]
116pub fn format_member_count(n: u32) -> String {
117    if n == 1 {
118        "1 member".to_string()
119    } else {
120        format!("{n} members")
121    }
122}
123
124/// Leak a `String` to `&'static str` so the leptos view can hold the
125/// reference without extra cloning. Storybook stories are short-lived
126/// processes (the headless exporter runs once per build) — total leak
127/// bytes are negligible.
128fn leak_str(s: String) -> &'static str {
129    Box::leak(s.into_boxed_str())
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn member_count_singular() {
138        assert_eq!(format_member_count(1), "1 member");
139    }
140
141    #[test]
142    fn member_count_plural() {
143        assert_eq!(format_member_count(2), "2 members");
144        assert_eq!(format_member_count(14), "14 members");
145        assert_eq!(format_member_count(0), "0 members");
146    }
147}