dui/command.rs
1//! CommandPalette — Cmd+K style command menu with fuzzy search, keyboard
2//! navigation, grouped results, and full ARIA support.
3
4use leptos::prelude::*;
5use leptos::callback::{Callback, Callable};
6use wasm_bindgen::prelude::*;
7use wasm_bindgen::JsCast;
8
9// ---------------------------------------------------------------------------
10// Data types
11// ---------------------------------------------------------------------------
12
13/// A single actionable item in the command palette.
14#[derive(Debug, Clone, PartialEq)]
15pub struct CommandItem {
16 /// Unique identifier — passed to `on_select` when chosen.
17 pub id: String,
18 /// Primary display text.
19 pub label: String,
20 /// Optional secondary description shown beneath / beside the label.
21 pub description: Option<String>,
22 /// Optional SVG `<path d="...">` data for a 20x20 viewBox icon.
23 pub icon: Option<String>,
24 /// Optional keyboard shortcut hint (e.g. `"⌘ K"`).
25 /// Multiple keys separated by spaces each get their own keycap.
26 pub shortcut: Option<String>,
27 /// Optional group heading. Items sharing the same group value are rendered
28 /// together under a single heading.
29 pub group: Option<String>,
30 /// Extra search terms that do not appear in the UI but improve findability.
31 pub keywords: Vec<String>,
32}
33
34// ---------------------------------------------------------------------------
35// Kbd inline styling (avoids cross-component import issues)
36// ---------------------------------------------------------------------------
37
38/// Inline keycap styling string, matching `kbd.rs`.
39const KBD_CLASS: &str = "inline-flex items-center justify-center \
40 min-w-[20px] h-5 px-1.5 text-[11px] font-mono font-medium leading-none \
41 rounded border bg-dm-elevated text-dm-muted border-dm \
42 shadow-[0_1px_0_1px_var(--dm-bg)] select-none";
43
44// ---------------------------------------------------------------------------
45// Component
46// ---------------------------------------------------------------------------
47
48/// A full-screen command palette overlay with search, keyboard navigation,
49/// and grouped results.
50///
51/// Uses the same CSS-visibility-toggle pattern as `Modal` — children are
52/// rendered once, and the palette is shown/hidden via class swaps.
53///
54/// # Features
55/// - **Search**: filters items by label, description, and keywords (case-insensitive).
56/// - **Keyboard navigation**: Arrow Up/Down, Enter to select, Escape to close.
57/// - **Grouping**: items with a `group` field are rendered under headings.
58/// - **ARIA**: `role="dialog"`, combobox, listbox, option, and group roles.
59///
60/// # Example
61/// ```rust
62/// let open = RwSignal::new(false);
63/// let items = Signal::derive(|| vec![
64/// CommandItem {
65/// id: "save".into(),
66/// label: "Save file".into(),
67/// description: Some("Save the current document".into()),
68/// icon: None,
69/// shortcut: Some("⌘ S".into()),
70/// group: Some("File".into()),
71/// keywords: vec!["write".into(), "persist".into()],
72/// },
73/// ]);
74/// view! {
75/// <CommandPalette
76/// open=open
77/// items=items
78/// on_select=Callback::new(move |id: String| { /* handle */ })
79/// />
80/// }
81/// ```
82#[component]
83pub fn CommandPalette(
84 /// Controls visibility (writable so the palette can close itself).
85 open: RwSignal<bool>,
86 /// The full set of command items (filtering happens internally).
87 #[prop(into)]
88 items: Signal<Vec<CommandItem>>,
89 /// Called with the selected item's `id` when the user picks one.
90 on_select: Callback<String>,
91 /// Placeholder text for the search input.
92 #[prop(default = "Type a command or search\u{2026}")]
93 placeholder: &'static str,
94) -> impl IntoView {
95 // -- Local state ---------------------------------------------------------
96 let query = RwSignal::new(String::new());
97 let active_index = RwSignal::new(0usize);
98
99 // Unique ids for ARIA linkage.
100 let input_id = "dm-cmd-input";
101 let listbox_id = "dm-cmd-listbox";
102
103 // -- Derived: filtered items ---------------------------------------------
104 let filtered = Memo::new(move |_| {
105 let q = query.get().to_lowercase();
106 let all = items.get();
107 if q.is_empty() {
108 return all;
109 }
110 all.into_iter()
111 .filter(|item| {
112 item.label.to_lowercase().contains(&q)
113 || item
114 .description
115 .as_ref()
116 .map_or(false, |d| d.to_lowercase().contains(&q))
117 || item.keywords.iter().any(|k| k.to_lowercase().contains(&q))
118 })
119 .collect::<Vec<_>>()
120 });
121
122 // -- Helpers -------------------------------------------------------------
123 // Clamp active_index whenever filtered list changes.
124 Effect::new(move |_| {
125 let len = filtered.get().len();
126 if len == 0 {
127 active_index.set(0);
128 } else if active_index.get() >= len {
129 active_index.set(len - 1);
130 }
131 });
132
133 // Reset state when palette opens and focus the search input.
134 Effect::new(move |_| {
135 if open.get() {
136 query.set(String::new());
137 active_index.set(0);
138
139 // Focus after DOM settles.
140 request_animation_frame(move || {
141 if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
142 if let Some(el) = doc.get_element_by_id(input_id) {
143 if let Some(html) = el.dyn_ref::<web_sys::HtmlElement>() {
144 let _ = html.focus();
145 }
146 }
147 }
148 });
149 }
150 });
151
152 // Scroll the active item into view.
153 let scroll_active_into_view = move || {
154 if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
155 let selector = format!("[data-dm-cmd-idx=\"{}\"]", active_index.get_untracked());
156 if let Ok(Some(el)) = doc.query_selector(&selector) {
157 el.scroll_into_view();
158 }
159 }
160 };
161
162 // Close the palette.
163 let close = move || {
164 open.set(false);
165 };
166
167 // Fire on_select for the currently active item, then close.
168 let do_select = move || {
169 let list = filtered.get_untracked();
170 let idx = active_index.get_untracked();
171 if let Some(item) = list.get(idx) {
172 on_select.run(item.id.clone());
173 }
174 close();
175 };
176
177 // -- Global Escape key listener (same pattern as Modal) ------------------
178 Effect::new(move |_| {
179 let window = match web_sys::window() {
180 Some(w) => w,
181 None => return,
182 };
183 let cb = Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(move |ev: web_sys::KeyboardEvent| {
184 if ev.key() == "Escape" && open.get_untracked() {
185 open.set(false);
186 }
187 });
188 let _ = window.add_event_listener_with_callback("keydown", cb.as_ref().unchecked_ref());
189 cb.forget();
190 });
191
192 // -- View ----------------------------------------------------------------
193 view! {
194 <div
195 class=move || {
196 if open.get() {
197 "fixed inset-0 z-50 flex items-center justify-center animate-dm-fade-in"
198 } else {
199 "hidden"
200 }
201 }
202 style="background: rgba(0,0,0,0.60);"
203 role="dialog"
204 aria-modal="true"
205 aria-label="Command palette"
206 on:mousedown=move |ev| {
207 // Close on backdrop click (not on panel)
208 if let Some(target) = ev.target() {
209 if let Some(el) = target.dyn_ref::<web_sys::HtmlElement>() {
210 if el.class_list().contains("fixed") {
211 close();
212 }
213 }
214 }
215 }
216 >
217 // Panel
218 <div class="bg-dm-panel border border-dm rounded-xl shadow-2xl \
219 w-full max-w-lg mx-4 flex flex-col overflow-hidden \
220 animate-dm-scale-in">
221
222 // ---- Search input section ----
223 <div class="flex items-center gap-3 px-4 py-3 border-b border-dm">
224 // Magnifying glass icon
225 <svg
226 class="w-5 h-5 text-dm-muted shrink-0"
227 xmlns="http://www.w3.org/2000/svg"
228 fill="none"
229 viewBox="0 0 24 24"
230 stroke-width="2"
231 stroke="currentColor"
232 >
233 <path
234 stroke-linecap="round"
235 stroke-linejoin="round"
236 d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
237 />
238 </svg>
239
240 <input
241 id=input_id
242 type="text"
243 placeholder=placeholder
244 autocomplete="off"
245 spellcheck="false"
246 role="combobox"
247 aria-expanded="true"
248 aria-controls=listbox_id
249 aria-autocomplete="list"
250 aria-activedescendant=move || format!("dm-cmd-opt-{}", active_index.get())
251 class="flex-1 bg-transparent text-dm-text text-base \
252 placeholder:text-dm-dim outline-none"
253 prop:value=move || query.get()
254 on:input=move |ev| {
255 query.set(event_target_value(&ev));
256 active_index.set(0);
257 }
258 on:keydown=move |ev: web_sys::KeyboardEvent| {
259 let key = ev.key();
260 match key.as_str() {
261 "ArrowDown" => {
262 ev.prevent_default();
263 let len = filtered.get_untracked().len();
264 if len > 0 {
265 active_index.update(|i| *i = (*i + 1) % len);
266 scroll_active_into_view();
267 }
268 }
269 "ArrowUp" => {
270 ev.prevent_default();
271 let len = filtered.get_untracked().len();
272 if len > 0 {
273 active_index.update(|i| {
274 *i = if *i == 0 { len - 1 } else { *i - 1 };
275 });
276 scroll_active_into_view();
277 }
278 }
279 "Enter" => {
280 ev.prevent_default();
281 do_select();
282 }
283 "Escape" => {
284 ev.prevent_default();
285 close();
286 }
287 _ => {}
288 }
289 }
290 />
291 </div>
292
293 // ---- Results list ----
294 <div
295 id=listbox_id
296 role="listbox"
297 aria-label="Commands"
298 class="overflow-y-auto overscroll-contain py-2 px-2"
299 style="max-height: 300px;"
300 >
301 {move || {
302 let list = filtered.get();
303
304 if list.is_empty() {
305 return view! {
306 <div class="px-4 py-8 text-center text-sm text-dm-muted select-none">
307 "No results found."
308 </div>
309 }.into_any();
310 }
311
312 // Group items: collect (group_name, Vec<(global_idx, item)>)
313 let mut groups: Vec<(Option<String>, Vec<(usize, CommandItem)>)> = Vec::new();
314 for (idx, item) in list.into_iter().enumerate() {
315 let group_key = item.group.clone();
316 if let Some(last) = groups.last_mut() {
317 if last.0 == group_key {
318 last.1.push((idx, item));
319 continue;
320 }
321 }
322 groups.push((group_key, vec![(idx, item)]));
323 }
324
325 view! {
326 <div>
327 {groups.into_iter().map(|(group_name, members)| {
328 let group_heading_id = group_name
329 .as_ref()
330 .map(|g| format!("dm-cmd-grp-{}", g.to_lowercase().replace(' ', "-")));
331 let heading_id_attr = group_heading_id.clone().unwrap_or_default();
332
333 view! {
334 <div
335 role="group"
336 aria-labelledby=heading_id_attr.clone()
337 >
338 // Group heading
339 {group_name.map(|name| {
340 let gid = group_heading_id.clone().unwrap_or_default();
341 view! {
342 <div
343 id=gid
344 class="text-xs font-semibold text-dm-muted \
345 uppercase tracking-wider px-2 py-1.5 \
346 select-none"
347 >
348 {name}
349 </div>
350 }
351 })}
352
353 // Items in this group
354 {members.into_iter().map(|(idx, item)| {
355 let option_dom_id = format!("dm-cmd-opt-{}", idx);
356 let item_id_click = item.id.clone();
357
358 view! {
359 <div
360 id=option_dom_id
361 role="option"
362 aria-selected=move || {
363 if active_index.get() == idx { "true" } else { "false" }
364 }
365 data-dm-cmd-idx=idx.to_string()
366 class=move || format!(
367 "px-2 py-2 flex items-center gap-3 rounded-md \
368 cursor-pointer text-sm transition-colors duration-75 {}",
369 if active_index.get() == idx {
370 "bg-dm-hover"
371 } else {
372 ""
373 }
374 )
375 on:mouseenter=move |_| {
376 active_index.set(idx);
377 }
378 on:click={
379 let id = item_id_click.clone();
380 move |_| {
381 on_select.run(id.clone());
382 close();
383 }
384 }
385 >
386 // Icon
387 {item.icon.as_ref().map(|path_d| {
388 let d = path_d.clone();
389 view! {
390 <svg
391 class="w-5 h-5 text-dm-muted shrink-0"
392 xmlns="http://www.w3.org/2000/svg"
393 fill="none"
394 viewBox="0 0 20 20"
395 stroke-width="1.5"
396 stroke="currentColor"
397 >
398 <path
399 stroke-linecap="round"
400 stroke-linejoin="round"
401 d=d
402 />
403 </svg>
404 }
405 })}
406
407 // Label + description
408 <div class="flex-1 min-w-0">
409 <div class="text-dm-text truncate">
410 {item.label.clone()}
411 </div>
412 {item.description.as_ref().map(|desc| {
413 let d = desc.clone();
414 view! {
415 <div class="text-xs text-dm-dim truncate mt-0.5">
416 {d}
417 </div>
418 }
419 })}
420 </div>
421
422 // Shortcut badge(s)
423 {item.shortcut.as_ref().map(|sc| {
424 let parts: Vec<String> = sc
425 .split_whitespace()
426 .map(|s| s.to_string())
427 .collect();
428 view! {
429 <span class="inline-flex items-center gap-0.5 shrink-0 ml-auto">
430 {parts.into_iter().map(|k| {
431 view! {
432 <kbd class=KBD_CLASS>{k}</kbd>
433 }
434 }).collect::<Vec<_>>()}
435 </span>
436 }
437 })}
438 </div>
439 }
440 }).collect::<Vec<_>>()}
441 </div>
442 }
443 }).collect::<Vec<_>>()}
444 </div>
445 }.into_any()
446 }}
447 </div>
448
449 // ---- Footer: keyboard hints ----
450 <div class="flex items-center gap-4 px-4 py-2.5 border-t border-dm \
451 text-xs text-dm-dim select-none">
452 <span class="inline-flex items-center gap-1">
453 <kbd class=KBD_CLASS>{"\u{2191}\u{2193}"}</kbd>
454 " Navigate"
455 </span>
456 <span class="inline-flex items-center gap-1">
457 <kbd class=KBD_CLASS>{"\u{21B5}"}</kbd>
458 " Select"
459 </span>
460 <span class="inline-flex items-center gap-1">
461 <kbd class=KBD_CLASS>{"Esc"}</kbd>
462 " Close"
463 </span>
464 </div>
465 </div>
466 </div>
467 }
468}
469
470// ---------------------------------------------------------------------------
471// Utility: requestAnimationFrame helper
472// ---------------------------------------------------------------------------
473
474fn request_animation_frame(f: impl FnOnce() + 'static) {
475 let closure = Closure::once_into_js(f);
476 if let Some(window) = web_sys::window() {
477 let _ = window.request_animation_frame(closure.as_ref().unchecked_ref());
478 }
479}