import React, { useEffect, useId, useMemo, useRef, useState } from 'react'; import { Dialog } from '@ark-ui/react/dialog'; export interface CommandPaletteItem { id: string; label: React.ReactNode; icon?: React.ReactNode; shortcut?: string; keywords?: string; } export interface CommandPaletteGroup { label: React.ReactNode; items: readonly CommandPaletteItem[]; } export interface CommandPaletteProps { open: boolean; onClose: () => void; onSelect: (id: string) => void; groups: readonly CommandPaletteGroup[]; placeholder?: string; /** Slot rendered when `groups` is empty (after filtering). */ emptyState?: React.ReactNode; /** Controlled search value. Omit for uncontrolled. */ value?: string; onValueChange?: (next: string) => void; className?: string; } interface FlatItem { readonly id: string; readonly label: React.ReactNode; } const searchString = (item: CommandPaletteItem): string => { const parts: string[] = []; if (typeof item.label === 'string') parts.push(item.label); if (item.keywords) parts.push(item.keywords); return parts.join(' ').toLowerCase(); }; const filterGroups = ( groups: readonly CommandPaletteGroup[], query: string ): readonly CommandPaletteGroup[] => { if (query.trim() === '') return groups; const needle = query.toLowerCase(); return groups .map(group => ({ label: group.label, items: group.items.filter(item => searchString(item).includes(needle)) })) .filter(group => group.items.length > 0); }; const flattenItems = ( groups: readonly CommandPaletteGroup[] ): readonly FlatItem[] => groups.flatMap(group => group.items.map(item => ({ id: item.id, label: item.label })) ); export const CommandPalette = ({ open, onClose, onSelect, groups, placeholder = 'Type a command…', emptyState = 'No commands found.', value, onValueChange, className = '' }: CommandPaletteProps): React.ReactElement | null => { const isControlled = value !== undefined; const listId = useId(); const [internal, setInternal] = useState(''); const query = isControlled ? value : internal; const setQuery = (next: string): void => { if (!isControlled) setInternal(next); onValueChange?.(next); }; const filtered = useMemo(() => filterGroups(groups, query), [groups, query]); const flat = useMemo(() => flattenItems(filtered), [filtered]); const [activeIndex, setActiveIndex] = useState(0); const activeRef = useRef(null); useEffect(() => { setActiveIndex(0); }, [flat.length]); useEffect(() => { if (!open) return; const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') { event.preventDefault(); onClose(); return; } if (event.key === 'ArrowDown') { event.preventDefault(); setActiveIndex(i => Math.min(i + 1, flat.length - 1)); return; } if (event.key === 'ArrowUp') { event.preventDefault(); setActiveIndex(i => Math.max(i - 1, 0)); return; } if (event.key === 'Enter') { const selected = flat[activeIndex]; if (selected) { event.preventDefault(); onSelect(selected.id); } } }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [open, flat, activeIndex, onClose, onSelect]); useEffect(() => { activeRef.current?.scrollIntoView?.({ block: 'nearest' }); }, [activeIndex]); const classes = ['command-palette', className].filter(Boolean).join(' '); const hasMatches = filtered.length > 0; let cursor = -1; return ( { if (!details.open) onClose(); }} unmountOnExit lazyMount > { if (event.target === event.currentTarget) onClose(); }} data-state='open' > setQuery(e.target.value)} aria-autocomplete='list' aria-label='Search commands' aria-controls={listId} aria-activedescendant={ flat[activeIndex] ? `${listId}-${activeIndex}` : undefined } />
    {!hasMatches && emptyState !== undefined && (
  • {emptyState}
  • )} {filtered.map((group, gi) => (
  • {group.label}
      {group.items.map(item => { cursor += 1; const isActive = cursor === activeIndex; const capturedCursor = cursor; return (
    • setActiveIndex(capturedCursor)} onClick={() => onSelect(item.id)} > {item.icon !== undefined && ( )} {item.label} {item.shortcut !== undefined && ( {item.shortcut} )}
    • ); })}
  • ))}
); }; CommandPalette.displayName = 'CommandPalette';