"use client"; import {Dialog as BaseDialog} from "@base-ui/react/dialog"; import {Separator as BaseSeparator} from "@base-ui/react/separator"; import {Search} from "lucide-react"; import * as React from "react"; import {cn} from "@/lib/utilities"; import styles from "./command.module.css"; type CommandFilter = (value: string, search: string, keywords?: string[]) => number; interface CommandProps extends React.HTMLAttributes { /** * Command palette content. * @default undefined */ children?: React.ReactNode; /** * Accessible label announced for the command region. * @default undefined */ label?: string; /** * Whether items should be filtered automatically as the search value changes. * @default true */ shouldFilter?: boolean; /** * Custom scoring function used to determine whether an item matches the current search value. * @default undefined */ filter?: CommandFilter; /** * Deprecated uncontrolled search value placeholder retained for API compatibility. * @default undefined */ defaultValue?: string; /** * Deprecated controlled search value placeholder retained for API compatibility. * @default undefined */ value?: string; /** * Deprecated change callback retained for API compatibility. * @default undefined */ onValueChange?: (value: string) => void; /** * Whether keyboard navigation should wrap from the last item to the first. * @default false */ loop?: boolean; /** * Whether pointer hover should avoid changing the active item. * @default false */ disablePointerSelection?: boolean; /** * Deprecated Vim keybinding toggle retained for API compatibility. * @default undefined */ vimBindings?: boolean; } interface CommandDialogProps extends Omit, "children"> { /** * Command palette content rendered inside the dialog popup. * @default undefined */ children?: React.ReactNode; /** * Accessible dialog title announced to assistive technologies. * @default "Command menu" */ title?: React.ReactNode; } interface CommandInputProps extends Omit, "onChange" | "type" | "value"> { /** * Controlled search value. * @default undefined */ value?: string; /** * Native input change handler invoked before the command-specific callback. * @default undefined */ onChange?: React.ChangeEventHandler; /** * Callback fired when the command search value changes. * @default undefined */ onValueChange?: (search: string) => void; } interface CommandListProps extends React.HTMLAttributes { /** * Command items and groups. * @default undefined */ children?: React.ReactNode; /** * Accessible label for the listbox container. * @default undefined */ label?: string; } interface CommandGroupProps extends Omit, "value"> { /** * Group contents. * @default undefined */ children?: React.ReactNode; /** * Optional visual heading displayed above the group items. * @default undefined */ heading?: React.ReactNode; /** * Optional stable value retained for API compatibility. * @default undefined */ value?: string; /** * Whether the group should remain rendered even when it has no visible items. * @default false */ forceMount?: boolean; } interface CommandSeparatorProps extends React.ComponentPropsWithoutRef { /** * Whether the separator should remain visible while filtering is active. * @default false */ alwaysRender?: boolean; } interface CommandItemProps extends Omit, "onSelect"> { /** * Item contents. * @default undefined */ children?: React.ReactNode; /** * Whether the item is disabled and should be skipped by selection logic. * @default false */ disabled?: boolean; /** * Callback invoked when the item is selected. * @default undefined */ onSelect?: (value: string) => void; /** * Optional value used for filtering and selection callbacks. * @default undefined */ value?: string; /** * Additional search keywords included in the filter match set. * @default [] */ keywords?: string[]; /** * Whether the item should remain rendered even when filtered out. * @default false */ forceMount?: boolean; } interface CommandRegisteredItem { disabled: boolean; forceMount: boolean; groupId: string | null; id: string; keywords: string[]; order: number; ref: React.RefObject; textValue: string; value?: string; } interface CommandContextValue { activeItemId: string | null; disablePointerSelection: boolean; getVisibleItemCount: () => number; hasVisibleItemsInGroup: (groupId: string) => boolean; isFiltering: boolean; isItemVisible: (itemId: string) => boolean; listId: string; loop: boolean; registerItem: (item: Omit) => void; search: string; setSearch: (value: string) => void; selectNextItem: () => void; selectPreviousItem: () => void; selectSpecificItem: (itemId: string | null) => void; shouldFilter: boolean; triggerActiveItem: () => void; unregisterItem: (itemId: string) => void; } const CommandContext = React.createContext(null); const CommandGroupContext = React.createContext(null); function assignRef(ref: React.ForwardedRef, value: TValue): void { if (typeof ref === "function") { ref(value); return; } if (ref) { ref.current = value; } } function normalizeCommandValue(value: string): string { return value.trim().toLowerCase(); } function defaultCommandFilter(value: string, search: string, keywords: string[] = []): number { if (search.length === 0) { return 1; } const normalizedSearch = normalizeCommandValue(search); const normalizedValue = normalizeCommandValue([value, ...keywords].join(" ")); return normalizedValue.includes(normalizedSearch) ? 1 : 0; } function useCommandContext(componentName: string): CommandContextValue { const context = React.useContext(CommandContext); if (!context) { throw new Error(`${componentName} must be used within Command.`); } return context; } /** * Provides a lightweight, filterable command surface without depending on cmdk. * * @remarks * This wrapper preserves the existing compound-component API while replacing the * underlying implementation with a small context-driven registry. It supports * text filtering, arrow-key navigation, Enter-to-select, and pointer hover * selection for common command palette use cases. * * @example * ```tsx * * * * console.log("Open")}>Open * * * ``` * * @see {@link https://base-ui.com/react/components/dialog | Base UI Dialog Docs} */ const Command = React.forwardRef( ( { children, className, defaultValue: _defaultValue, disablePointerSelection = false, filter, label, loop = false, onKeyDown, onValueChange: _onValueChange, shouldFilter = true, value: _value, vimBindings: _vimBindings, ...props }, ref, ) => { const [activeItemId, setActiveItemId] = React.useState(null); const [search, setSearch] = React.useState(""); const itemOrderRef = React.useRef(0); const itemsRef = React.useRef(new Map()); const [itemsVersion, setItemsVersion] = React.useState(0); const listId = React.useId(); const registerItem = React.useCallback((item: Omit): void => { const existingItem = itemsRef.current.get(item.id); const nextItem: CommandRegisteredItem = { ...item, order: existingItem?.order ?? itemOrderRef.current++, }; const hasChanged = !existingItem || existingItem.disabled !== nextItem.disabled || existingItem.forceMount !== nextItem.forceMount || existingItem.groupId !== nextItem.groupId || existingItem.keywords.join("\u0000") !== nextItem.keywords.join("\u0000") || existingItem.ref !== nextItem.ref || existingItem.textValue !== nextItem.textValue || existingItem.value !== nextItem.value; if (!hasChanged) { return; } itemsRef.current.set(item.id, nextItem); setItemsVersion((currentVersion) => currentVersion + 1); }, []); const unregisterItem = React.useCallback((itemId: string): void => { if (!itemsRef.current.delete(itemId)) { return; } setItemsVersion((currentVersion) => currentVersion + 1); }, []); const items = React.useMemo(() => { return [...itemsRef.current.values()].toSorted((firstItem, secondItem) => firstItem.order - secondItem.order); // eslint-disable-next-line react-hooks/exhaustive-deps -- itemsVersion is an intentional change counter }, [itemsVersion]); const isFiltering = shouldFilter && search.trim().length > 0; const isItemVisible = React.useCallback( (itemId: string): boolean => { const item = itemsRef.current.get(itemId); if (!item) { return false; } if (item.forceMount || !shouldFilter || search.trim().length === 0) { return true; } const itemValue = item.value ?? item.textValue; const itemFilter = filter ?? defaultCommandFilter; return itemFilter(itemValue, search, item.keywords) > 0; }, [filter, search, shouldFilter], ); const visibleItems = React.useMemo(() => items.filter((item) => isItemVisible(item.id)), [isItemVisible, items]); const selectableItems = React.useMemo(() => visibleItems.filter((item) => !item.disabled), [visibleItems]); React.useEffect(() => { if (selectableItems.length === 0) { // eslint-disable-next-line react-x/set-state-in-effect -- clearing active item when no selectable items remain is the effect's purpose setActiveItemId(null); return; } if (!activeItemId || !selectableItems.some((item) => item.id === activeItemId)) { // eslint-disable-next-line react-x/set-state-in-effect -- resetting active item to the first selectable is the effect's purpose setActiveItemId(selectableItems[0].id); } }, [activeItemId, selectableItems]); React.useEffect(() => { if (!activeItemId) { return; } itemsRef.current.get(activeItemId)?.ref.current?.scrollIntoView({ block: "nearest", }); }, [activeItemId]); const selectSpecificItem = React.useCallback((itemId: string | null): void => { setActiveItemId(itemId); }, []); const selectNextItem = React.useCallback((): void => { if (selectableItems.length === 0) { return; } const currentIndex = selectableItems.findIndex((item) => item.id === activeItemId); if (currentIndex === -1) { setActiveItemId(selectableItems[0].id); return; } const nextIndex = currentIndex + 1; if (nextIndex >= selectableItems.length) { setActiveItemId(loop ? selectableItems[0].id : selectableItems[currentIndex].id); return; } setActiveItemId(selectableItems[nextIndex].id); }, [activeItemId, loop, selectableItems]); const selectPreviousItem = React.useCallback((): void => { if (selectableItems.length === 0) { return; } const currentIndex = selectableItems.findIndex((item) => item.id === activeItemId); if (currentIndex === -1) { setActiveItemId(selectableItems[0].id); return; } const previousIndex = currentIndex - 1; if (previousIndex < 0) { setActiveItemId(loop ? (selectableItems.at(-1)?.id ?? selectableItems[0].id) : selectableItems[currentIndex].id); return; } setActiveItemId(selectableItems[previousIndex].id); }, [activeItemId, loop, selectableItems]); const triggerActiveItem = React.useCallback((): void => { if (!activeItemId) { return; } itemsRef.current.get(activeItemId)?.ref.current?.click(); }, [activeItemId]); const hasVisibleItemsInGroup = React.useCallback( (groupId: string): boolean => visibleItems.some((item) => item.groupId === groupId), [visibleItems], ); const getVisibleItemCount = React.useCallback((): number => visibleItems.length, [visibleItems.length]); const contextValue = React.useMemo( () => ({ activeItemId, disablePointerSelection, getVisibleItemCount, hasVisibleItemsInGroup, isFiltering, isItemVisible, listId, loop, registerItem, search, setSearch, selectNextItem, selectPreviousItem, selectSpecificItem, shouldFilter, triggerActiveItem, unregisterItem, }), [ activeItemId, disablePointerSelection, getVisibleItemCount, hasVisibleItemsInGroup, isFiltering, isItemVisible, listId, loop, registerItem, search, setSearch, selectNextItem, selectPreviousItem, selectSpecificItem, shouldFilter, triggerActiveItem, unregisterItem, ], ); return (
{ onKeyDown?.(event); if (event.defaultPrevented) { return; } switch (event.key) { case "ArrowDown": { event.preventDefault(); selectNextItem(); break; } case "ArrowUp": { event.preventDefault(); selectPreviousItem(); break; } case "Home": { if (selectableItems.length === 0) { return; } event.preventDefault(); setActiveItemId(selectableItems[0].id); break; } case "End": { if (selectableItems.length === 0) { return; } event.preventDefault(); setActiveItemId(selectableItems.at(-1)?.id ?? selectableItems[0].id); break; } case "Enter": { if (event.nativeEvent.isComposing) { return; } event.preventDefault(); triggerActiveItem(); break; } default: { break; } } }} {...props}> {children}
); }, ); Command.displayName = "Command"; /** * Renders the command surface inside a modal dialog. * * @remarks * - Renders a Base UI dialog popup * - Built on Base UI Dialog primitives * * @example * ```tsx * * * * ``` * * @see {@link https://base-ui.com/react/components/dialog | Base UI Dialog Docs} */ function CommandDialog({children, open, onOpenChange, title = "Command menu", ...props}: Readonly): React.JSX.Element { return ( {title} {children} ); } CommandDialog.displayName = "CommandDialog"; /** * Provides the searchable input surface for a command palette. * * @remarks * - Renders an `` element inside a wrapper `
` * - Built on the shared command registry context * * @example * ```tsx * * ``` * * @see {@link https://developer.mozilla.org/docs/Web/Accessibility/ARIA/Roles/combobox_role | ARIA Combobox Role} */ const CommandInput = React.forwardRef(({className, onChange, onValueChange, value, ...props}, ref) => { const {activeItemId, listId, search, selectSpecificItem, setSearch} = useCommandContext("CommandInput"); const isControlled = value !== undefined; const inputValue = isControlled ? value : search; React.useEffect(() => { if (!isControlled) { return; } setSearch(value ?? ""); }, [isControlled, setSearch, value]); return (
{ onChange?.(event); const nextSearchValue = event.currentTarget.value; setSearch(nextSearchValue); onValueChange?.(nextSearchValue); selectSpecificItem(null); }} role='combobox' type='text' value={inputValue} {...props} />
); }); CommandInput.displayName = "CommandInput"; /** * Renders the listbox container that hosts command items and groups. * * @remarks * - Renders a `
` element with `role="listbox"` * - Built on the shared command registry context * * @example * ```tsx * * Settings * * ``` * * @see {@link https://developer.mozilla.org/docs/Web/Accessibility/ARIA/Roles/listbox_role | ARIA Listbox Role} */ const CommandList = React.forwardRef(({className, label, ...props}, ref) => { const {listId} = useCommandContext("CommandList"); return (
); }); CommandList.displayName = "CommandList"; /** * Renders a fallback empty-state message when no command items are visible. * * @remarks * - Renders a `
` element with `role="status"` * - Built on the shared command registry context * * @example * ```tsx * No results found. * ``` * * @see {@link https://developer.mozilla.org/docs/Web/Accessibility/ARIA/Roles/status_role | ARIA Status Role} */ const CommandEmpty = React.forwardRef>(({className, ...props}, ref) => { const {getVisibleItemCount} = useCommandContext("CommandEmpty"); if (getVisibleItemCount() > 0) { return null; } return (
); }); CommandEmpty.displayName = "CommandEmpty"; /** * Groups related command items under an optional heading. * * @remarks * - Renders a `
` element * - Built on the shared command registry context * * @example * ```tsx * * Profile * * ``` * * @see {@link https://developer.mozilla.org/docs/Web/Accessibility/ARIA/Roles/group_role | ARIA Group Role} */ const CommandGroup = React.forwardRef( ({children, className, forceMount = false, heading, value: _value, ...props}, ref) => { const groupId = React.useId(); const {hasVisibleItemsInGroup, isFiltering} = useCommandContext("CommandGroup"); if (!forceMount && isFiltering && !hasVisibleItemsInGroup(groupId)) { return null; } return (
{heading ?
{heading}
: null} {children}
); }, ); CommandGroup.displayName = "CommandGroup"; /** * Renders a separator between command groups or item sections. * * @remarks * - Renders the shared separator primitive * - Built on Base UI Separator * * @example * ```tsx * * ``` * * @see {@link https://base-ui.com/react/components/separator | Base UI Separator Docs} */ const CommandSeparator = React.forwardRef( ({alwaysRender = false, className, orientation = "horizontal", ...props}, ref) => { const {isFiltering} = useCommandContext("CommandSeparator"); if (isFiltering && !alwaysRender) { return null; } return ( ); }, ); CommandSeparator.displayName = "CommandSeparator"; /** * Renders a selectable command option with filtering metadata and keyboard support. * * @remarks * - Renders a `
` element with `role="option"` * - Built on the shared command registry context * * @example * ```tsx * Settings * ``` * * @see {@link https://developer.mozilla.org/docs/Web/Accessibility/ARIA/Roles/option_role | ARIA Option Role} */ const CommandItem = React.forwardRef( ({children, className, disabled = false, forceMount = false, keywords = [], onClick, onMouseEnter, onSelect, value, ...props}, ref) => { const {activeItemId, disablePointerSelection, isFiltering, isItemVisible, registerItem, selectSpecificItem, unregisterItem} = useCommandContext("CommandItem"); const groupId = React.useContext(CommandGroupContext); const generatedId = React.useId(); const itemRef = React.useRef(null); const keywordSignature = React.useMemo(() => keywords.join("\u0000"), [keywords]); React.useLayoutEffect(() => { const textValue = value ?? itemRef.current?.textContent?.trim() ?? ""; registerItem({ disabled, forceMount, groupId, id: generatedId, keywords, ref: itemRef, textValue, value, }); }, [children, disabled, forceMount, generatedId, groupId, keywordSignature, keywords, registerItem, value]); React.useEffect(() => { return () => { unregisterItem(generatedId); }; }, [generatedId, unregisterItem]); const isVisible = forceMount || !isFiltering || isItemVisible(generatedId); if (!isVisible) { return null; } const isSelected = activeItemId === generatedId; return (
{ itemRef.current = node; assignRef(ref, node); }} aria-disabled={disabled || undefined} aria-selected={isSelected} className={cn(styles.item, className)} data-disabled={disabled ? "true" : undefined} data-selected={isSelected ? "true" : undefined} id={generatedId} onClick={(event) => { if (disabled) { event.preventDefault(); return; } selectSpecificItem(generatedId); onSelect?.(value ?? itemRef.current?.textContent?.trim() ?? ""); onClick?.(event); }} onKeyDown={(event) => { if (disabled) { event.preventDefault(); return; } if (event.key === "Enter" || event.key === " ") { event.preventDefault(); selectSpecificItem(generatedId); onSelect?.(value ?? itemRef.current?.textContent?.trim() ?? ""); } }} onFocus={() => { if (disabled) { return; } selectSpecificItem(generatedId); }} onMouseEnter={(event) => { onMouseEnter?.(event); if (disabled || disablePointerSelection) { return; } selectSpecificItem(generatedId); }} role='option' tabIndex={disabled ? -1 : 0}> {children}
); }, ); CommandItem.displayName = "CommandItem"; /** * Renders auxiliary shortcut text aligned to the edge of a command item. * * @remarks * - Renders a `` element * - Built as a lightweight presentational helper for command menus * * @example * ```tsx * ⌘K * ``` * * @see {@link https://developer.mozilla.org/docs/Web/HTML/Element/span | HTML span element} */ const CommandShortcut = ({className, ...props}: Readonly>): React.JSX.Element => { return ( ); }; CommandShortcut.displayName = "CommandShortcut"; export {Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut};