Commands
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/commands.jsonNot a component — a list and the hooks into it. It is the reason a key is written once.
Why a list
Section titled “Why a list”Every product that grew a palette wrote each key three times: in the listener that answers it, in the palette row that hints at it, and in the help sheet that lists it. The three drift. The sheet promises a key the listener stopped answering, the palette hints a key that was rebound, and nothing says so — the failure mode of every hand-written sheet of shortcuts is being a version behind what the application actually answers to.
So a command is declared once, by the component that owns what it does, and for as long as that component is mounted:
import { useCommands } from '@/components/ui/commands'
useCommands([ { id: 'search', label: t('search'), group: t('everywhere'), keys: 'Mod+K', run: openPalette }, { id: 'go-dashboard', label: t('dashboard'), group: t('going'), keys: 'G D', run: () => navigate('/') }, { id: 'undo', label: t('undo'), group: t('everywhere'), keys: 'Mod+Z', enabled: canUndo, run: undo },])The keyboard answers it, CommandPalette
lists it, ShortcutsDialog lists its
keys — all from the same entry. A screen that declares its own commands adds
them while it is open, and they are gone from all three when it is not.
The handlers may be written inline. Each render hands over the newest ones, so a command never runs against stale state, and nothing is re-declared — and no list re-renders — unless something a list shows has changed.
One list per window
Section titled “One list per window”There is no provider. There is one keyboard per window, and two lists would be two listeners on the same keys — which is exactly the conflict this exists to catch. The list is the module, and a command can be declared from anywhere.
Sequences
Section titled “Sequences”G D is G, then D within a second and a half. A key that does not continue
the sequence ends it and does nothing else: a g pressed by mistake must
not turn the next keystroke into a jump, and the next keystroke must not act
on its own either. A key held down while a sequence waits is the same key, not
the next step.
Where a key is answered
Section titled “Where a key is answered”This is the half every product gets wrong once.
- Not in a field someone is typing in.
Mod+Kin a text editor means “delete to end of line”; a palette opening on top of it looks like the application misheard.whileTyping: truelets a command into fields, and it is refused when declared on a key a field types with — a?that fires while typing would be taken from every field in the application. What is left is a key with Mod, Ctrl or Alt, Escape, and the function keys: saving, the palette. - Not a plain key inside a dialog, a menu, a listbox or a tree. A plain key
is one without Mod, Ctrl or Alt. A dialog is a place of its own, and a menu,
a listbox or a tree types ahead —
gthere means the row starting with G. A key with a command modifier types nothing and is still answered. - Not Enter or Space on whatever has the focus, which they press. With nothing focused, they are the application’s.
withinan element, only while the focus is inside it — and there, before the rest of the application. A list’sJandK, a panel’s Escape. This is also how a command answers plain keys inside a dialog: declared within it.
Before an editor’s keymap
Section titled “Before an editor’s keymap”A key is answered after whatever has the focus has had its turn: a dialog’s
Escape, a list’s arrows and a field’s own keys stay theirs, because they call
preventDefault and a key something nearer handled is never answered here.
An editor with a keymap of its own — CodeMirror, a terminal — handles a long
list of keys that way, and that is a problem the moment someone can rebind a
command. Rebound onto Mod+D, a command is listed with Mod+D in the sheet
and in the palette’s hint, and inside the editor Mod+D selects the next
occurrence instead: the key is promised and not answered, the very drift the
list exists to end. capture: true takes the key on the way down, before the
editor sees it:
useCommands([ { id: 'save', label: t('save'), keys: 'Mod+S', whileTyping: true, capture: true, run: save },])It changes when a key is answered, never where: a capture command still
needs whileTyping to fire in a field and within to stay inside an element,
and the conflicts are the same. Declare it on the commands an editor must not
keep — in a product built around one, usually all of them.
Offered or not
Section titled “Offered or not”A palette offers what can be run from it: a command with a run, enabled, and
not declared within — a command tied to a place is usually about where the
focus is, and from inside the palette the focus is in the palette.
offered says otherwise where the key and the work part ways. An editor’s
Fold this section answers its key only inside the editor (within), but its
work folds the editor the person was last in, and the palette is where someone
looks for it:
useCommands([ { id: 'fold', label: t('fold'), keys: 'Mod+{', within: editor, whileTyping: true, offered: true, run: fold }, { id: 'palette', label: t('commands'), keys: 'Mod+Shift+P', offered: false, run: openPalette },])offered: false keeps a command out of the palette — the one that opens it,
which would do nothing from inside. The sheet lists both either way: it lists
keys, not what can be run.
Conflicts
Section titled “Conflicts”Two commands on the same keys in the same place cannot both work, and neither
can one on a prefix of another’s sequence — G fires the moment it is
pressed, so G D could never finish.
The one declared first keeps the keys. Mounted earlier, or earlier in the tree when they mount together, so a shell’s own commands outrank a screen’s. The other loses them everywhere at once — in the keyboard, in the palette’s hint and in the sheet — so nothing promises a key that runs something else. The console says which, once per conflict:
`link` cannot answer to `Mod+K`: `search` already answers to `Mod+K` in the same place, and was declared first.A command declared within an element and one declared for the whole window
on the same keys are not a conflict: the inner one is meant to win where it
is. Off an Apple platform Ctrl+K and Mod+K are one keystroke, and they
conflict there.
useCommandConflicts() returns the conflicts in the window now — for a
settings screen to warn about before saving a rebinding, or for a test that
says there are none:
let conflicts: Conflict[] = []function Watch() { conflicts = useCommandConflicts() return null}
render(<Shell><Watch /></Shell>)expect(conflicts).toEqual([])Keys something else answers
Section titled “Keys something else answers”A command without run declares keys that something else already answers —
Escape closing a dialog. It is listed in the sheet and takes part in conflicts,
but it is never run from here and never takes the keystroke from whoever
answers it.
Rebinding
Section titled “Rebinding”setKeymap({ search: 'Mod+P' }) moves keys by command id — which is why an id
has to be stable from one session to the next. It replaces what was set
before; an empty list unbinds a command. The keyboard, the palette’s hint and
the sheet all follow.
What it is given is the person’s data, not code, so a shortcut in it that does not parse is reported and skipped rather than thrown: a settings file written by an older version should not take the application down. A command declared to fire while typing, rebound to a plain letter, still does not fire into fields.
A settings screen records a new binding with
keysOf(event), in its own keydown handler.
It calls preventDefault, and a key something nearer already handled is never
answered here — so the key being recorded is not also run. With capture
commands declared, that handler listens on the window in the capture phase,
which comes before the list’s own:
window.addEventListener('keydown', record, { capture: true })In the palette
Section titled “In the palette”useCommandList() lists every command in the order declared; the ones with a
run are the ones a palette can offer — something to run, enabled, and not
tied to where the focus is. matchCommands(query, commands) ranks them the
way kilna’s palette always has: a label beginning with the query first, one
where every term begins a word next, one merely containing the letters last.
Every term is found in any order, in any alphabet.
const runnable = useCommandList().filter((command) => command.run)const found = matchCommands(query, runnable)const groups = groupCommands(found)useCommands(commands) and useCommand(command)
Section titled “useCommands(commands) and useCommand(command)”Declare for as long as the calling component is mounted.
| Field | Type | ||
|---|---|---|---|
id |
string |
required | Stable from one session to the next: a rebinding is stored against it |
label |
string |
required | What the palette shows and the sheet lists, in the product’s words |
group |
string |
The heading it is listed under | |
icon |
ReactNode |
Drawn at the start of its palette row | |
keys |
string | string[] |
As Mod+K or G D. Several are alternatives. See the notation |
|
run |
() => void |
Leave out for keys something else answers | |
enabled |
boolean |
true |
false stops it answering; the sheet still lists its keys |
whileTyping |
boolean |
false |
Fire into fields too. Refused on a key a field types with |
within |
RefObject<Element> |
Answer only while the focus is inside it | |
capture |
boolean |
false |
Answer before whatever has the focus — an editor’s keymap — sees the key |
offered |
boolean |
not within |
Whether a palette offers it to be run |
A shortcut that does not parse, and whileTyping on a key a field types with,
throw when declared: a typo in a shortcut is otherwise a key that silently
never fires.
useCommandList(): ListedCommand[]
Section titled “useCommandList(): ListedCommand[]”Every declared command, in the order declared, one line per id — two mounted
copies of one component declaring the same command in two places are one
line. Each has id, label, group, icon, keys (as it answers now,
after a rebinding and without any lost to a conflict) and run (present when
it can be run from a list: it has a run, is enabled and is offered).
useCommandKeys(id): string[]
Section titled “useCommandKeys(id): string[]”The keys one command answers to now — for the hint beside a button that does
the same, or its aria-keyshortcuts.
useCommandConflicts(): Conflict[]
Section titled “useCommandConflicts(): Conflict[]”{ keys, lost, against, kept } for each conflict in the window now.
runCommand(id): boolean
Section titled “runCommand(id): boolean”Runs a command by id — from a menu item that does the same. false when
nothing by that id can be run.
setKeymap(rebound)
Section titled “setKeymap(rebound)”Record<id, string | string[]>. Replaces the previous keymap.
matchCommands(query, commands) and groupCommands(commands)
Section titled “matchCommands(query, commands) and groupCommands(commands)”The palette’s ranking, and the headings in the order they first appear. Both
are pure and work over any { label } and { group }.
Accessibility
The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 21 rules applied and passed, and none failed. The accessibility report has every item.
History
Each release that changed ui/commands.tsx - the file a product copies - newest first.