'use client'; import * as React from 'react'; import { SearchIcon } from '@/icons'; import { cn } from '@/lib/utils'; import { iconSizing } from '@/lib/cva-presets'; import { useControllableState } from '@/hooks/use-controllable-state'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/dialog/dialog'; /** * Scores an item against the query. Anything above 0 stays in the list. * * Matching returns a score rather than a boolean so a caller can plug in fuzzy * ranking; the list itself is never re-ordered, because a palette that * reshuffles under the cursor is easy to mis-click. */ export type CommandFilter = (value: string, search: string, keywords?: string[]) => number; const defaultFilter: CommandFilter = (value, search, keywords) => { const query = search.trim().toLowerCase(); if (!query) return 1; const haystack = [value, ...(keywords ?? [])].join(' ').toLowerCase(); const at = haystack.indexOf(query); if (at === -1) return 0; /* A hit at the start of a word beats one buried mid-token, so "set" ranks "Settings" above "Reset" for callers that do sort by score. */ const atWordStart = at === 0 || /\s/.test(haystack[at - 1]); return atWordStart ? 1 : 0.5; }; interface CommandItemMeta { keywords?: string[]; disabled?: boolean; } interface CommandContextValue { baseId: string; listId: string; search: string; setSearch: (search: string) => void; /** The highlighted item — what Enter would choose. */ active: string; setActive: (value: string) => void; isVisible: (value: string) => boolean; register: (value: string, meta: CommandItemMeta) => void; unregister: (value: string) => void; itemCount: number; visibleCount: number; idFor: (value: string) => string; } const CommandContext = React.createContext(null); const useCommand = () => { const context = React.useContext(CommandContext); if (!context) throw new Error('Command parts must be used inside .'); return context; }; /** * Registry of the items currently mounted, kept outside React state. * * Items can only announce themselves from an effect, which runs *after* their * parent has rendered — so the root cannot learn about them by rendering * harder. Treating the registry as an external store is the sanctioned way * round that: items mutate it, subscribers re-render, and the root computes the * visible set during render instead of correcting it afterwards in an effect. */ function createItemRegistry() { const items = new Map(); const listeners = new Set<() => void>(); let version = 0; const emit = () => { version += 1; listeners.forEach((listener) => listener()); }; return { items, subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; }, getVersion: () => version, register: (value: string, meta: CommandItemMeta) => { items.set(value, meta); emit(); }, unregister: (value: string) => { items.delete(value); emit(); }, }; } function useItemRegistry() { /* A plain object rather than a ref: it is external state this component subscribes to, not a render-time escape hatch, and reading it during render is the whole point. */ const [store] = React.useState(createItemRegistry); React.useSyncExternalStore(store.subscribe, store.getVersion, store.getVersion); return store; } export interface CommandProps extends Omit, 'onSelect'> { /** The highlighted item. Controlled — pair it with `onValueChange`. */ value?: string; defaultValue?: string; onValueChange?: (value: string) => void; /** Replace the matching logic. Return 0 to hide an item. */ filter?: CommandFilter; /** Turn off to filter the list yourself, e.g. against a server. */ shouldFilter?: boolean; /** Wrap around when arrowing past either end. */ loop?: boolean; /** Accessible name for the results list. */ label?: string; } /** * Filterable command list. * * Compose it inline for a search list, or wrap it in `CommandDialog` for a ⌘K * palette. Items declare their own searchable text through `value`, so the list * can be any shape — groups, separators, headings — and still filter correctly. * * ```tsx * * * * No results found. * * Profile * * * * ``` */ function Command({ value, defaultValue = '', onValueChange, filter = defaultFilter, shouldFilter = true, loop = false, label = 'Suggestions', className, children, onKeyDown, ...props }: CommandProps) { const baseId = React.useId(); const listId = `${baseId}-list`; const registry = useItemRegistry(); const [search, setSearch] = React.useState(''); const [requestedActive, setActive] = useControllableState({ value, defaultValue, onChange: onValueChange, }); /* Visibility is derived every render rather than stored, so a changed query, a new item and a removed one all land in one pass with nothing to re-sync. */ const visible = new Set(); const selectable: string[] = []; for (const [itemValue, meta] of registry.items) { const score = shouldFilter ? filter(itemValue, search, meta.keywords) : 1; if (score <= 0) continue; visible.add(itemValue); if (!meta.disabled) selectable.push(itemValue); } /* The highlight follows the same rule: when what the caller asked for is gone from the filtered list, the first selectable row stands in for it. */ const active = selectable.includes(requestedActive) ? requestedActive : (selectable[0] ?? ''); const idFor = (itemValue: string) => `${baseId}-${itemValue.replace(/[^\w-]+/g, '-')}`; const move = (delta: 1 | -1) => { if (selectable.length === 0) return; const at = selectable.indexOf(active); let next = at + delta; if (next < 0) next = loop ? selectable.length - 1 : 0; if (next >= selectable.length) next = loop ? 0 : selectable.length - 1; const target = selectable[next]; setActive(target); document.getElementById(idFor(target))?.scrollIntoView({ block: 'nearest' }); }; const handleKeyDown = (event: React.KeyboardEvent) => { onKeyDown?.(event); if (event.defaultPrevented) return; switch (event.key) { case 'ArrowDown': event.preventDefault(); move(1); break; case 'ArrowUp': event.preventDefault(); move(-1); break; case 'Home': if (selectable.length) { event.preventDefault(); setActive(selectable[0]); } break; case 'End': if (selectable.length) { event.preventDefault(); setActive(selectable[selectable.length - 1]); } break; case 'Enter': { /* Activating the row itself, rather than calling a handler held in the registry, keeps `onSelect` out of the store — a fresh closure on every parent render would otherwise churn it and re-render forever. */ if (selectable.includes(active)) { event.preventDefault(); document.getElementById(idFor(active))?.click(); } break; } default: break; } }; const context: CommandContextValue = { baseId, listId, search, setSearch, active, setActive, isVisible: (itemValue) => visible.has(itemValue), register: registry.register, unregister: registry.unregister, itemCount: registry.items.size, visibleCount: visible.size, idFor, }; return (
{label} {children}
); } export interface CommandDialogProps extends React.ComponentProps { /** Announced to screen readers; visually hidden by default. */ title?: string; description?: string; className?: string; showCloseButton?: boolean; } function CommandDialog({ title = 'Command Palette', description = 'Search for a command to run…', children, className, showCloseButton = true, ...props }: CommandDialogProps) { return ( {/* Inside the content, not beside it — a dialog's accessible name has to live within the portalled element to be announced. */} {title} {description} {children} ); } export type CommandInputProps = Omit, 'value' | 'onChange'> & { value?: string; onValueChange?: (search: string) => void; }; function CommandInput({ className, value, onValueChange, ...props }: CommandInputProps) { const { search, setSearch, listId, active, idFor, baseId } = useCommand(); return (
{ setSearch(event.target.value); onValueChange?.(event.target.value); }} {...props} />
); } function CommandList({ className, children, ...props }: React.ComponentProps<'div'>) { const { listId, baseId } = useCommand(); return (
{children}
); } /** Rendered when items exist but the query matches none of them. */ function CommandEmpty({ className, children, ...props }: React.ComponentProps<'div'>) { const { itemCount, visibleCount } = useCommand(); /* Waiting for at least one item avoids a flash of "no results" on the first render, before any item has had the chance to register. */ if (itemCount === 0 || visibleCount > 0) return null; return (
{children}
); } export interface CommandGroupProps extends React.ComponentProps<'div'> { heading?: React.ReactNode; } function CommandGroup({ className, heading, children, ...props }: CommandGroupProps) { const headingId = React.useId(); return (
{heading ? (
{heading}
) : null} {children}
); } function CommandSeparator({ className, ...props }: React.ComponentProps<'div'>) { return (
); } export interface CommandItemProps extends Omit, 'onSelect'> { /** The text this item is matched on, and its identity in the list. */ value: string; /** Extra terms that should also match — synonyms, shortcuts, ids. */ keywords?: string[]; disabled?: boolean; onSelect?: (value: string) => void; } function CommandItem({ className, value, keywords, disabled, onSelect, children, onClick, onPointerMove, ...props }: CommandItemProps) { const { active, setActive, isVisible, register, unregister, idFor } = useCommand(); /* `keywords` is almost always written inline, so its identity changes every render. Keying on its contents instead gives the effect below something stable to depend on — without it, registering would loop. */ const keywordKey = keywords?.join('\u0000'); const stableKeywords = React.useMemo( /* NUL as the separator, so a keyword that itself contains a space survives the round trip and a custom `filter` sees the array it was given. */ () => (keywordKey === undefined ? undefined : keywordKey.split('\u0000')), [keywordKey] ); React.useEffect(() => { register(value, { keywords: stableKeywords, disabled }); return () => unregister(value); }, [register, unregister, value, stableKeywords, disabled]); if (!isVisible(value)) return null; const isActive = active === value; return (
{ onClick?.(event); if (!disabled) onSelect?.(value); }} /* Hovering moves the highlight, so the mouse and the keyboard never disagree about what Enter would pick. */ onPointerMove={(event) => { onPointerMove?.(event); if (!disabled && !isActive) setActive(value); }} className={cn( 'relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none', 'data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground', 'data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50', iconSizing, "[&_svg:not([class*='text-'])]:text-muted-foreground", className )} {...props} > {children}
); } function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) { return ( ); } export { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, CommandSeparator, defaultFilter, };