1
9import * as React from "react";
10import { clsx } from "clsx";
11import {
12 commandNav,
13 filterCommands,
14 type CommandItem,
15 type CommandNavKey,
16 type CommandNavState,
17} from "../command-filter.js";
18import { trapFocus } from "../focus.js";
19
20export interface CommandPaletteProps {
21 items: readonly CommandItem[];
22
23 onSelect?: (item: CommandItem) => void;
24 placeholder?: string;
25 open?: boolean;
26 onClose?: () => void;
27 className?: string;
28}
29
30const INITIAL_NAV: CommandNavState = { active: -1, count: 0, open: false };
31
32
37export function useCommandPalette(items: readonly CommandItem[], query: string) {
38 const results = React.useMemo(() => filterCommands(items, query), [items, query]);
39 const [nav, dispatch] = React.useReducer(
40 (s: CommandNavState, a: Parameters<typeof commandNav>[1]) => commandNav(s, a),
41 INITIAL_NAV,
42 );
43 React.useEffect(() => {
44 dispatch({ type: "results", count: results.length });
45 }, [results.length]);
46 return { results, nav, dispatch } as const;
47}
48
49export function CommandPalette({
50 items,
51 onSelect,
52 placeholder = "Type a command…",
53 open = true,
54 onClose,
55 className,
56}: CommandPaletteProps) {
57 const [query, setQuery] = React.useState("");
58 const { results, nav, dispatch } = useCommandPalette(items, query);
59 const containerRef = React.useRef<HTMLDivElement>(null);
60 const inputRef = React.useRef<HTMLInputElement>(null);
61 const trapRef = React.useRef<ReturnType<typeof trapFocus> | null>(null);
62
63 React.useEffect(() => {
64 if (!open) return;
65 const trap = trapFocus({ container: containerRef.current, returnFocus: true });
66 trapRef.current = trap;
67 trap.activate();
68 inputRef.current?.focus();
69 return () => trap.deactivate();
70 }, [open]);
71
72 if (!open) return null;
73
74 const activeItem = nav.active >= 0 ? results[nav.active]?.item : undefined;
75
76 const onKeyDown = (e: React.KeyboardEvent): void => {
77 const key = e.key as CommandNavKey;
78 if (key === "Escape") {
79 e.preventDefault();
80 dispatch({ type: "close" });
81 onClose?.();
82 return;
83 }
84 if (key === "Enter" && activeItem !== undefined) {
85 e.preventDefault();
86 onSelect?.(activeItem);
87 dispatch({ type: "close" });
88 onClose?.();
89 return;
90 }
91 if (key === "ArrowUp" || key === "ArrowDown" || key === "Home" || key === "End") {
92 e.preventDefault();
93
94 const nextNav = commandNav(nav, { type: "key", key });
95 dispatch({ type: "key", key });
96 const el = containerRef.current?.querySelector<HTMLElement>(`[data-cmd="${nextNav.active}"]`);
97 el?.focus();
98 }
99 };
100
101 return (
102 <div
103 ref={containerRef}
104 role="dialog"
105 aria-label="Command palette"
106 onKeyDown={onKeyDown}
107 className={clsx(
108 "fixed left-1/2 top-24 -translate-x-1/2 w-full max-w-lg rounded-lg border border-border",
109 "bg-popover p-2 shadow-xl text-popover-foreground",
110 className,
111 )}
112 >
113 <input
114 ref={inputRef}
115 value={query}
116 onChange={(e) => setQuery(e.target.value)}
117 placeholder={placeholder}
118 aria-label="Search commands"
119 className="w-full rounded-md bg-transparent px-3 py-2 text-sm text-foreground outline-none"
120 />
121 <ul role="listbox" aria-label="Commands" className="mt-1 max-h-72 overflow-y-auto">
122 {results.length === 0 && (
123 <li className="px-3 py-2 text-sm text-muted-foreground">No matching commands.</li>
124 )}
125 {results.map((r, i) => (
126 <li
127 key={r.item.id}
128 data-cmd={i}
129 role="option"
130 aria-selected={i === nav.active}
131 tabIndex={-1}
132 onClick={() => {
133 onSelect?.(r.item);
134 dispatch({ type: "close" });
135 onClose?.();
136 }}
137 className={clsx(
138 "flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm outline-none",
139 i === nav.active && "bg-accent text-accent-foreground",
140 )}
141 >
142 <span>{r.item.title}</span>
143 {r.item.hint !== undefined && (
144 <span className="text-xs text-muted-foreground">{r.item.hint}</span>
145 )}
146 </li>
147 ))}
148 </ul>
149 </div>
150 );
151}
152