/** * @factory/ui — CommandPalette (input + filtered list). * * Wiring only: filtering/ranking is `filterCommands` and keyboard navigation * is the `commandNav` reducer — both PURE in src/command-filter.ts. Focus * stays inside the palette via `trapFocus` (src/focus.ts, decision logic is * pure `nextFocusable`); Escape closes, Enter selects the highlighted item. */ import * as React from "react"; import { clsx } from "clsx"; import { commandNav, filterCommands, type CommandItem, type CommandNavKey, type CommandNavState, } from "../command-filter.js"; import { trapFocus } from "../focus.js"; export interface CommandPaletteProps { items: readonly CommandItem[]; /** Called with the selected item on Enter (or click). */ onSelect?: (item: CommandItem) => void; placeholder?: string; open?: boolean; onClose?: () => void; className?: string; } const INITIAL_NAV: CommandNavState = { active: -1, count: 0, open: false }; /** * Pure-ish palette state hook: exposes query + filtered results + nav state. * Kept separate from the .tsx so the behavior is easy to reason about; the * actual reducer is `commandNav` (unit-tested). */ export function useCommandPalette(items: readonly CommandItem[], query: string) { const results = React.useMemo(() => filterCommands(items, query), [items, query]); const [nav, dispatch] = React.useReducer( (s: CommandNavState, a: Parameters[1]) => commandNav(s, a), INITIAL_NAV, ); React.useEffect(() => { dispatch({ type: "results", count: results.length }); }, [results.length]); return { results, nav, dispatch } as const; } export function CommandPalette({ items, onSelect, placeholder = "Type a command…", open = true, onClose, className, }: CommandPaletteProps) { const [query, setQuery] = React.useState(""); const { results, nav, dispatch } = useCommandPalette(items, query); const containerRef = React.useRef(null); const inputRef = React.useRef(null); const trapRef = React.useRef | null>(null); React.useEffect(() => { if (!open) return; const trap = trapFocus({ container: containerRef.current, returnFocus: true }); trapRef.current = trap; trap.activate(); inputRef.current?.focus(); return () => trap.deactivate(); }, [open]); if (!open) return null; const activeItem = nav.active >= 0 ? results[nav.active]?.item : undefined; const onKeyDown = (e: React.KeyboardEvent): void => { const key = e.key as CommandNavKey; if (key === "Escape") { e.preventDefault(); dispatch({ type: "close" }); onClose?.(); return; } if (key === "Enter" && activeItem !== undefined) { e.preventDefault(); onSelect?.(activeItem); dispatch({ type: "close" }); onClose?.(); return; } if (key === "ArrowUp" || key === "ArrowDown" || key === "Home" || key === "End") { e.preventDefault(); // Decide with the PURE reducer, then mirror its state + move focus. const nextNav = commandNav(nav, { type: "key", key }); dispatch({ type: "key", key }); const el = containerRef.current?.querySelector(`[data-cmd="${nextNav.active}"]`); el?.focus(); } }; return (
setQuery(e.target.value)} placeholder={placeholder} aria-label="Search commands" className="w-full rounded-md bg-transparent px-3 py-2 text-sm text-foreground outline-none" />
    {results.length === 0 && (
  • No matching commands.
  • )} {results.map((r, i) => (
  • { onSelect?.(r.item); dispatch({ type: "close" }); onClose?.(); }} className={clsx( "flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm outline-none", i === nav.active && "bg-accent text-accent-foreground", )} > {r.item.title} {r.item.hint !== undefined && ( {r.item.hint} )}
  • ))}
); }