Skip to main content

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}