import { useEffect, useId, useMemo, useRef, useState, } from "react"; import { cn } from "../../lib/cn"; import { Button } from "../button"; import { EmptyState } from "../feedback"; import { fieldClassName, PrefixFieldShell, SearchGlyph, } from "../inputs/shared"; export type CommandPaletteCommand = { description?: string; group?: string; label: string; onSelect: () => void; shortcut?: string; }; export function CommandPalette({ commands, dismissable = true, isOpen, onOpenChange, placeholder = "Search commands", title = "Command palette", }: { commands: CommandPaletteCommand[]; dismissable?: boolean; isOpen: boolean; onOpenChange: (isOpen: boolean) => void; placeholder?: string; title?: string; }) { const generatedId = useId(); const panelRef = useRef(null); const inputRef = useRef(null); const [search, setSearch] = useState(""); const [activeIndex, setActiveIndex] = useState(0); const filteredCommands = useMemo(() => { const normalizedSearch = search.trim().toLowerCase(); if (!normalizedSearch) { return commands; } return commands.filter((command) => { const haystack = `${command.group ?? ""} ${command.label} ${command.description ?? ""}`.toLowerCase(); return haystack.includes(normalizedSearch); }); }, [commands, search]); const groupedCommands = useMemo(() => { return filteredCommands.reduce>( (groups, command) => { const lastGroup = groups[groups.length - 1]; if (lastGroup?.group === command.group) { lastGroup.commands.push(command); return groups; } groups.push({ group: command.group, commands: [command] }); return groups; }, [], ); }, [filteredCommands]); useEffect(() => { if (isOpen) { inputRef.current?.focus(); setActiveIndex(0); setSearch(""); } }, [isOpen]); useEffect(() => { function handlePointerDown(event: PointerEvent) { if (dismissable && !panelRef.current?.contains(event.target as Node)) { onOpenChange(false); } } function handleEscape(event: KeyboardEvent) { if (event.key === "Escape") { onOpenChange(false); } } if (isOpen) { window.addEventListener("pointerdown", handlePointerDown); window.addEventListener("keydown", handleEscape); } return () => { window.removeEventListener("pointerdown", handlePointerDown); window.removeEventListener("keydown", handleEscape); }; }, [dismissable, isOpen, onOpenChange]); function moveActive(step: 1 | -1) { if (filteredCommands.length === 0) { return; } setActiveIndex((current) => (current + step + filteredCommands.length) % filteredCommands.length); } return (

{title}

Type to search and press enter to run a command.

}> setSearch(event.target.value)} onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); moveActive(1); return; } if (event.key === "ArrowUp") { event.preventDefault(); moveActive(-1); return; } if (event.key === "Enter") { event.preventDefault(); const command = filteredCommands[activeIndex]; if (command) { command.onSelect(); onOpenChange(false); } } }} placeholder={placeholder} ref={inputRef} value={search} />
{filteredCommands.length > 0 ? (
{groupedCommands.map((group, groupIndex) => (
{group.group ? (

{group.group}

) : null}
{group.commands.map((command) => { const index = filteredCommands.findIndex((candidate) => candidate === command); const isActive = index === activeIndex; return ( ); })}
))}
) : (
)}
); }