Skip to main content

maud_ui/primitives/
command.rs

1//! Command component — Cmd+K command palette with search, grouped items, and keyboard navigation.
2use maud::{html, Markup};
3
4/// A single command item in the palette
5#[derive(Clone, Debug)]
6pub struct CommandItem {
7    /// Display label for the command
8    pub label: String,
9    /// Optional keyboard shortcut (e.g., "⌘N")
10    pub shortcut: Option<String>,
11    /// Optional group name for categorization
12    pub group: Option<String>,
13    /// Whether the item is disabled
14    pub disabled: bool,
15}
16
17/// Command palette rendering properties
18#[derive(Clone, Debug)]
19pub struct Props {
20    /// Unique identifier for the command palette dialog
21    pub id: String,
22    /// List of command items
23    pub items: Vec<CommandItem>,
24    /// Placeholder text for the search input
25    pub placeholder: String,
26}
27
28impl Default for Props {
29    fn default() -> Self {
30        Self {
31            id: "command".to_string(),
32            items: vec![],
33            placeholder: "Type a command or search\u{2026}".to_string(),
34        }
35    }
36}
37
38/// Render a trigger button that opens the command palette
39pub fn trigger(target_id: &str, label: &str) -> Markup {
40    html! {
41        button type="button"
42            class="mui-btn mui-btn--default mui-btn--md"
43            data-mui="command-trigger"
44            data-target=(target_id)
45        {
46            (label)
47        }
48    }
49}
50
51/// Render the command palette
52pub fn render(props: Props) -> Markup {
53    // Collect unique groups in insertion order
54    let mut groups: Vec<String> = Vec::new();
55    for item in &props.items {
56        let group_key = item.group.clone().unwrap_or_default();
57        let mut found = false;
58        for g in &groups {
59            if *g == group_key {
60                found = true;
61                break;
62            }
63        }
64        if !found {
65            groups.push(group_key);
66        }
67    }
68
69    html! {
70        dialog class="mui-command"
71            id=(props.id)
72            data-mui="command"
73            aria-label="Command palette"
74        {
75            div class="mui-command__search-wrap" {
76                span class="mui-command__search-icon" aria-hidden="true" { "\u{2315}" }
77                input type="text" class="mui-command__search"
78                    placeholder=(props.placeholder)
79                    autocomplete="off"
80                    aria-label="Search commands";
81            }
82            div class="mui-command__list" role="listbox" {
83                @for group_name in &groups {
84                    div class="mui-command__group" {
85                        @if !group_name.is_empty() {
86                            div class="mui-command__group-label" { (group_name) }
87                        }
88                        @for item in &props.items {
89                            @let item_group = item.group.clone().unwrap_or_default();
90                            @if item_group == *group_name {
91                                @if item.disabled {
92                                    div class="mui-command__item mui-command__item--disabled"
93                                        role="option"
94                                        tabindex="-1"
95                                        aria-disabled="true"
96                                        data-label=(item.label)
97                                    {
98                                        span class="mui-command__item-label" { (item.label) }
99                                        @if let Some(shortcut) = &item.shortcut {
100                                            kbd class="mui-kbd" { (shortcut) }
101                                        }
102                                    }
103                                } @else {
104                                    div class="mui-command__item"
105                                        role="option"
106                                        tabindex="-1"
107                                        data-label=(item.label)
108                                    {
109                                        span class="mui-command__item-label" { (item.label) }
110                                        @if let Some(shortcut) = &item.shortcut {
111                                            kbd class="mui-kbd" { (shortcut) }
112                                        }
113                                    }
114                                }
115                            }
116                        }
117                    }
118                }
119            }
120            div class="mui-command__empty" hidden { "No results found." }
121        }
122    }
123}
124
125/// Showcase the command palette
126pub fn showcase() -> Markup {
127    let items = vec![
128        CommandItem {
129            label: "Calendar".to_string(),
130            shortcut: None,
131            group: Some("Suggestions".to_string()),
132            disabled: false,
133        },
134        CommandItem {
135            label: "Search".to_string(),
136            shortcut: None,
137            group: Some("Suggestions".to_string()),
138            disabled: false,
139        },
140        CommandItem {
141            label: "Settings".to_string(),
142            shortcut: None,
143            group: Some("Suggestions".to_string()),
144            disabled: false,
145        },
146        CommandItem {
147            label: "New File".to_string(),
148            shortcut: Some("\u{2318}N".to_string()),
149            group: Some("Actions".to_string()),
150            disabled: false,
151        },
152        CommandItem {
153            label: "Save".to_string(),
154            shortcut: Some("\u{2318}S".to_string()),
155            group: Some("Actions".to_string()),
156            disabled: false,
157        },
158        CommandItem {
159            label: "Export".to_string(),
160            shortcut: None,
161            group: Some("Actions".to_string()),
162            disabled: false,
163        },
164    ];
165
166    html! {
167        div.mui-showcase__grid {
168            div {
169                p.mui-showcase__caption { "Command palette trigger" }
170                div.mui-showcase__row {
171                    (trigger("demo-command", "Open command palette"))
172                    span.mui-text-muted style="font-size: 0.875rem;" {
173                        "Press "
174                        kbd.mui-kbd { "\u{2318}K" }
175                    }
176                }
177            }
178            div {
179                (render(Props {
180                    id: "demo-command".to_string(),
181                    items,
182                    placeholder: "Type a command or search\u{2026}".to_string(),
183                }))
184            }
185        }
186    }
187}