/*! Strand UI | MIT License | dillingerstaffing.com */ import type { JSX } from "preact"; import { forwardRef } from "preact/compat"; import { useEffect, useId, useRef, useState } from "preact/hooks"; import { Dialog } from "../Dialog/index.js"; import { cx } from "../../internal/index.js"; export interface CommandPaletteItem { /** Stable identity, used for keys and the active-descendant id. */ id: string; label: string; sublabel?: string; /** Short trailing token, such as a category or shortcut hint. */ badge?: string; } export interface CommandPaletteProps extends Omit, "onSelect" | "onInput" | "open" | "title"> { open: boolean; onClose: () => void; /** Already filtered and ranked by the caller. */ items: CommandPaletteItem[]; query: string; onQueryChange: (query: string) => void; /** Chosen by Enter or click. */ onSelect: (item: CommandPaletteItem) => void; placeholder?: string; emptyLabel?: string; /** Accessible name. */ label?: string; } /** Wrap at both ends. */ function wrapIndex(index: number, delta: number, length: number): number { if (length <= 0) return 0; return (((index + delta) % length) + length) % length; } /** * Search-and-jump overlay driven from the keyboard: combobox, listbox, active descendant, wrapping arrows. Composes `Dialog`; filtering belongs to the caller. * * @example * navigate(item.id)} /> */ export const CommandPalette = forwardRef( ({ open, onClose, items, query, onQueryChange, onSelect, placeholder = "Search...", emptyLabel = "No matches", label = "Search", className = "", ...rest }, ref) => { const [active, setActive] = useState(0); const inputRef = useRef(null); const options = useRef(new Map()); const baseId = useId(); const listboxId = `${baseId}-listbox`; const optionId = (index: number) => `${baseId}-option-${index}`; // A new result set or a reopen starts at the top. // biome-ignore lint/correctness/useExhaustiveDependencies: reset on identity, not on contents useEffect(() => setActive(0), [items, open]); // Keep the highlighted row in view; scrollIntoView is absent outside a browser. useEffect(() => { const el = options.current.get(active); if (typeof el?.scrollIntoView === "function") el.scrollIntoView({ block: "nearest" }); }, [active]); const onKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowDown") setActive((i) => wrapIndex(i, 1, items.length)); else if (e.key === "ArrowUp") setActive((i) => wrapIndex(i, -1, items.length)); else if (e.key === "Home") setActive(0); else if (e.key === "End") setActive(Math.max(0, items.length - 1)); else if (e.key === "Enter") { const item = items[active]; if (!item) return; onSelect(item); } else return; e.preventDefault(); }; return (
onQueryChange((e.target as HTMLInputElement).value)} onKeyDown={onKeyDown} role="combobox" aria-expanded="true" aria-controls={listboxId} aria-activedescendant={items.length ? optionId(active) : undefined} aria-autocomplete="list" aria-label={label} autocomplete="off" spellcheck={false} />
{items.length === 0 &&

{emptyLabel}

} {items.map((item, index) => (
{ if (el) options.current.set(index, el); else options.current.delete(index); }} id={optionId(index)} role="option" tabIndex={-1} aria-selected={index === active} className={cx("strand-command-palette__option", index === active && "strand-command-palette__option--active")} onMouseMove={() => setActive(index)} onClick={() => onSelect(item)} > {item.label} {item.sublabel && {item.sublabel}} {item.badge && {item.badge}}
))}
); }, ); CommandPalette.displayName = "CommandPalette";