Skip to main content

ui_storybook/components/menus/
menu_row.rs

1//! `MenuRow` — one row inside a popover menu (M-UI.3 / AUT-123).
2//!
3//! Supports the recurring shape across workspace switcher, device
4//! pickers, system-audio picker, and on-screen options: leading icon
5//! tile, title, optional subtitle, badges, optional trailing slot, and
6//! a kind-driven visual state.
7
8use leptos::prelude::*;
9
10use crate::components::primitives::{Badge, BadgeKind};
11
12/// Visual kind for a menu row.
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14pub enum MenuRowKind {
15    /// Normal row.
16    #[default]
17    Default,
18    /// Highlighted — currently selected.
19    Selected,
20    /// Action — slightly emphasized leading text. Used for "Add
21    /// workspace" / "Pair device".
22    Action,
23    /// Destructive — red text. Used for "Remove workspace" /
24    /// "Forget device".
25    Danger,
26    /// Visually disabled — non-interactive.
27    Disabled,
28}
29
30impl MenuRowKind {
31    /// CSS class for the kind.
32    #[must_use]
33    pub fn css(self) -> &'static str {
34        match self {
35            MenuRowKind::Default => "menu-row-default",
36            MenuRowKind::Selected => "menu-row-selected",
37            MenuRowKind::Action => "menu-row-action",
38            MenuRowKind::Danger => "menu-row-danger",
39            MenuRowKind::Disabled => "menu-row-disabled",
40        }
41    }
42}
43
44/// Badge attached to a menu row.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct MenuBadgeView {
47    /// Display text.
48    pub label: &'static str,
49    /// Visual kind.
50    pub kind: BadgeKind,
51}
52
53#[component]
54pub fn MenuRow(
55    #[prop(optional)] kind: MenuRowKind,
56    /// Optional leading slot — an `IconTile` / avatar / small glyph.
57    #[prop(optional)]
58    leading: Option<Children>,
59    /// Primary row text.
60    #[prop(into)]
61    title: String,
62    /// Optional secondary text under the title.
63    #[prop(optional, into)]
64    subtitle: Option<String>,
65    /// Badges rendered between the text and the trailing slot.
66    #[prop(optional)]
67    badges: Vec<MenuBadgeView>,
68    /// Optional trailing slot — kbd shortcut, count, or chevron.
69    #[prop(optional)]
70    trailing: Option<Children>,
71) -> impl IntoView {
72    let disabled = kind == MenuRowKind::Disabled;
73    let class = format!("menu-row {}", kind.css());
74    view! {
75        <li class="menu-row-item" role="none">
76            <button
77                class=class
78                role="menuitem"
79                aria-disabled=disabled
80                disabled=disabled
81            >
82                {leading.map(|l| view! { <span class="menu-row-leading">{l()}</span> })}
83                <span class="menu-row-text">
84                    <span class="menu-row-title">{title}</span>
85                    {subtitle.map(|s| view! { <span class="menu-row-subtitle">{s}</span> })}
86                </span>
87                {(!badges.is_empty()).then(|| view! {
88                    <span class="menu-row-badges">
89                        {badges.into_iter()
90                            .map(|b| view! { <Badge kind=b.kind>{b.label}</Badge> })
91                            .collect_view()}
92                    </span>
93                })}
94                {trailing.map(|t| view! { <span class="menu-row-trailing">{t()}</span> })}
95                {(kind == MenuRowKind::Selected).then(|| view! {
96                    <span class="menu-row-check" aria-hidden="true">"✓"</span>
97                })}
98            </button>
99        </li>
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn each_kind_has_unique_class() {
109        let classes = [
110            MenuRowKind::Default.css(),
111            MenuRowKind::Selected.css(),
112            MenuRowKind::Action.css(),
113            MenuRowKind::Danger.css(),
114            MenuRowKind::Disabled.css(),
115        ];
116        let mut sorted = classes.to_vec();
117        sorted.sort_unstable();
118        sorted.dedup();
119        assert_eq!(sorted.len(), classes.len());
120    }
121}