Command
A command palette: a native <dialog> holding a search input and the actions that match it. Two APG patterns, a modal dialog and a combobox, and nothing invented beyond them.
Example (a11y tier)
Press the button or Cmd/Ctrl+K, then type to filter, Up and Down to move, Enter to choose, Escape to close. A choice is written under the button.
Import
import Command from 'abaabil/command' // structure and filtering, no ARIAimport Command from 'abaabil/command/styled' // + CSSimport Command from 'abaabil/command/a11y' // + showModal(), combobox semantics, shortcutTwo patterns, nothing invented
The dialog half is showModal(): focus containment, the inert background, Escape, the top layer, focus moving to the input on open (it is the first focusable child) and back to the opener on close. As with dialog, there is no focus trap and no focus-restore code, because both are the platform's.
The combobox half is the APG editable combobox with list autocomplete, the same keyboard model as combobox. Focus stays on the input and the active option is conveyed through aria-activedescendant. Up and Down wrap and skip disabled items, Home and End jump, Enter chooses. Groups are role="group" with a heading each.
Why every tier is a client component
Most of the library is server-renderable at the lower tiers. Command is not, at any tier, and the reason is one piece of state: the query. A list that narrows as you type cannot exist without it, and there is no native element that filters a list for you. Tabs and combobox are the precedents.
The decision that keeps it small: filtering is a plain function, a case-insensitive substring match over label plus keywords, exported as filterItems from the normal tier so the a11y tier and tests share it. The open state is yours. The lower tiers render open as the native attribute, so the palette is non-modal there; only the a11y tier calls showModal().
The result count is spoken
A sighted user watches the list shrink with every keystroke. A screen reader user hears nothing unless told, so a polite live region reads “3 results” as the query changes, and the input's aria-expanded drops to false when nothing matches and the emptyText stands in for the list.
An item's href is followed, not rendered
At the lower tiers an item with href is a real <a>. At the a11y tier it is a plain option whose link is followed on Enter or click, because a link inside role="option" is a nested interactive control, which the listbox model forbids. It navigates with window.location, so for client-side routing use onSelect with your router instead.
Props
| Prop | Type | Default | Tier | Description |
|---|---|---|---|---|
id | string | — (required) | all | Names the dialog and, at a11y, the ids inside it. Not generated, like popover and menu. |
items | Array<{ label, value?, group?, keywords?, onSelect?, href?, disabled? }> | — | all | keywords is a string or array of extra terms the filter matches. value is the key, falling back to label. |
open | boolean | false | all | Controlled. The native open attribute at the lower tiers; drives showModal() and close() at a11y. |
onClose | () => void | — | all | After a choice. At a11y also on Escape, backdrop click and close(). |
placeholder | string | — | all | On the search input. |
emptyText | string | 'No results' | all | Shown in place of the list when nothing matches. |
className | string | — | all | Merged with the base class. |
label | string | — | a11y | Accessible name for the palette and its input. Omitting it warns in dev. |
shortcut | string | — | a11y | A key, for example 'k'. Meta or Ctrl plus that key on the document calls onOpen. |
onOpen | () => void | — | a11y | Called when shortcut is pressed. |