CommandPalette
A dialog to jump to anything: a field on top and a list the arrow keys walk. It is a combobox in a dialog with the list always open, and shares the combobox's rows, groups and notes.
import { CommandPalette } from '@adecore/ui';import { useEffect, useState, type ReactNode } from 'react';
import { Bug, FolderGit2, GitBranch, LayoutDashboard, LogOut, Moon, Search, Settings } from 'lucide-react';
import { Button, CommandPalette, Icon, Kbd, KEY_SHORTCUTS, matchScore, shortcut, type Shortcut } from '@adecore/ui';
interface Command {
id: string;
label: string;
icon: ReactNode;
hint?: string;
shortcut?: Shortcut;
}
interface Group {
value: string;
items: Command[];
}
const icon = (glyph: typeof Bug): ReactNode => <Icon icon={glyph} size={14} />;
const GROUPS: Group[] = [
{
value: 'Go to',
items: [
{ id: 'overview', label: 'Overview', icon: icon(LayoutDashboard), shortcut: shortcut('Mod+1') },
{ id: 'branches', label: 'Branches', icon: icon(GitBranch), shortcut: shortcut('Mod+2') },
{ id: 'settings', label: 'Settings', icon: icon(Settings), shortcut: shortcut('Mod+,') }
]
},
{
value: 'Projects',
items: [
{ id: 'docs', label: 'Documentation site', icon: icon(FolderGit2), hint: '~/Projects/docs' },
{ id: 'desktop', label: 'Desktop app', icon: icon(FolderGit2), hint: '~/Projects/desktop' }
]
},
{
value: 'Actions',
items: [
{ id: 'theme', label: 'Switch to the dark theme', icon: icon(Moon) },
{ id: 'sign-out', label: 'Sign out', icon: icon(LogOut) }
]
}
];
const ISSUES: Command[] = [
{ id: 'issue-1', label: 'Sidebar loses its width after a restart', icon: icon(Bug), hint: 'DESK-118' },
{ id: 'issue-2', label: 'Settings dialog opens behind the window', icon: icon(Bug), hint: 'DESK-131' }
];
/* The rows of each group that match, best first; a group with none drops out. */
const rank = (groups: Group[], query: string): Group[] =>
groups
.map((group) => ({
...group,
items: group.items
.map((item) => ({ item, score: matchScore(`${item.label} ${item.hint ?? ''}`, query) }))
.filter((entry): entry is { item: Command; score: number } => entry.score !== null)
.sort((first, second) => second.score - first.score)
.map((entry) => entry.item)
}))
.filter((group) => group.items.length > 0);
/* Issues come from a request, a moment after the last key; the last answer stays until the next one lands. */
function useIssues(query: string): Command[] {
const [found, setFound] = useState<Command[]>([]);
useEffect(() => {
if (query.trim() === '') {
return;
}
const timer = window.setTimeout(() => setFound(ISSUES.filter((issue) => matchScore(`${issue.label} ${issue.hint}`, query) !== null)), 200);
return () => window.clearTimeout(timer);
}, [query]);
return query.trim() === '' ? [] : found;
}
export default function CommandPaletteDemo() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [ran, setRan] = useState<string | null>(null);
const issues = useIssues(query);
const groups = [...(issues.length > 0 ? [{ value: 'Issues', items: issues }] : []), ...rank(GROUPS, query)];
const run = (command: Command): void => {
setOpen(false);
setRan(command.label);
};
return (
<div className="flex items-center gap-3">
<Button onClick={() => setOpen(true)}>
<Icon icon={Search} size={14} />
Jump to
</Button>
{ran !== null && <span className="text-xs text-text-muted">Ran “{ran}”</span>}
<CommandPalette.Root
open={open}
onOpenChange={setOpen}
onOpenChangeComplete={(isOpen) => !isOpen && setQuery('')}
items={groups}
filter={null}
value={query}
onValueChange={setQuery}
itemToStringValue={(command: Command) => command.label}
>
<CommandPalette.Popup>
<CommandPalette.Input aria-label="Jump to" placeholder="Jump to a screen, a project or an action" />
<CommandPalette.Empty>Nothing matches</CommandPalette.Empty>
<CommandPalette.List>
{(group: Group) => (
<CommandPalette.Group key={group.value} items={group.items}>
<CommandPalette.GroupLabel>{group.value}</CommandPalette.GroupLabel>
<CommandPalette.Collection>
{(command: Command) => (
<CommandPalette.Item
key={command.id}
value={command}
icon={command.icon}
hint={command.hint}
shortcut={command.shortcut}
onClick={() => run(command)}
>
{command.label}
</CommandPalette.Item>
)}
</CommandPalette.Collection>
</CommandPalette.Group>
)}
</CommandPalette.List>
<CommandPalette.Footer>
<span className="flex items-center gap-1.5">
<Kbd variant="inline">↑</Kbd>
<Kbd variant="inline">↓</Kbd> Move
</span>
<span className="flex items-center gap-1.5">
<Kbd shortcut={KEY_SHORTCUTS.enter} variant="inline" /> Run
</span>
</CommandPalette.Footer>
</CommandPalette.Popup>
</CommandPalette.Root>
</div>
);
}The app supplies the rows and binds the shortcut that opens the palette, and holds open in its state.
Parts
| Part | What it is |
|---|---|
CommandPalette.Root | The dialog and the combobox in one. open, defaultOpen, onOpenChange and onOpenChangeComplete belong to the dialog; every other prop is Base UI's autocomplete (value/onValueChange for the query, items, filter, or filter={null} to rank yourself). The first row is always highlighted, so Enter always has a row to run. |
CommandPalette.Popup | The surface, 576 pixels wide or 760 with size="lg". It hangs near the top of the window, so it stays put while the list changes height, and focuses the field on open. Its accessible name is "Command palette" (palette.label) unless you pass aria-label. |
CommandPalette.Input | The field, with a magnifier in front and an Escape hint at the end. leading replaces the magnifier, for a mode mark or a back button. trailing goes after the text. escapeHint={false} hides the hint, mono uses the code font. className goes to the row around the field; ref stays on the input. |
CommandPalette.List | The rows, which scroll while field and footer stay. Accessible name "Results" (palette.results). |
CommandPalette.Item | One row, with icon, hint, description, trailing, shortcut and unstyled as in a combobox row. onClick runs it on click and Enter. |
CommandPalette.Group, CommandPalette.GroupLabel, CommandPalette.Collection | Rows under a label, as in a combobox. |
CommandPalette.Empty | Shows its children while the list is empty. |
CommandPalette.Status | A live line, such as a search still running. |
CommandPalette.Footer | A row under the list for key hints, a count or a button. |
The palette closes on Escape and on a click outside. A row closes it by setting open to false in its onClick.
Ranking
matchScore(text, query) scores how well a label matches the query, ignoring case:
| Score | When |
|---|---|
5 | The text starts with the query. |
4 | A word of the text starts with it. A space, a slash, a dot, a dash, an underscore or a colon starts a word. |
3 | Every typed word is somewhere in the text, and the text starts with the first one. |
2 | Every typed word is somewhere in the text, in any order. |
1 | The letters of the query are in the text in order. |
null | None of these. |
An empty query scores 0 for everything. Sort within a group to keep the groups in order, or across the list for one ranking. To match more than the label, join it with a hint or keywords.
Modes and keys of its own
A focused row becomes highlighted and focus returns to CommandPalette.Input.
For modes, such as a file search beside the commands, keep the mode in app state and swap the leading mark, items and footer. Extra keys go in onKeyDown on CommandPalette.Input, which runs before Base UI's handlers:
- Tab can complete the field to the highlighted row.
- Backspace in an empty field can step back out of a mode.
- Cmd+Enter can run the highlighted row another way. Track the row with
onItemHighlightedon the root and callevent.preventBaseUIHandler()for keys the app takes.
For a step where Enter should use the typed text until a row is picked, pass autoHighlight={false}.
The highlight returns to the first row, and the list to the top, on open, on typing and when the app sets value. Rows that change under the same query keep the highlight. The highlight cannot start on another row, so put the row that should open highlighted first.
Types
CommandPaletteRootProps, CommandPalettePopupProps, CommandPaletteInputProps and CommandPaletteFooterProps are exported types.
Large lists
For a large catalog, use CommandPalette.VirtualList instead of CommandPalette.List and set virtualized on the root. It mounts only the visible rows of the filtered items; rows may vary in height, and the arrow keys still reach every item.
<CommandPalette.Root virtualized items={items}>
<CommandPalette.Popup>
<CommandPalette.Input aria-label="Find a command" />
<CommandPalette.Empty>No matching command</CommandPalette.Empty>
<CommandPalette.VirtualList<Command>>
{(command, itemProps) => (
<CommandPalette.Item {...itemProps} value={command} onClick={command.run}>
{command.label}
</CommandPalette.Item>
)}
</CommandPalette.VirtualList>
</CommandPalette.Popup>
</CommandPalette.Root>Spread itemProps onto each item; they carry its position in the whole filtered list. Grouped catalogs pass groupLabel={(group) => group.label} and the group type as the second type parameter. It takes the same element props and ref as CommandPalette.List.
CommandPaletteVirtualListProps and CommandPaletteVirtualItemProps are exported types.