Skip to content

Commands

Terminal window
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/commands.json
See it liveCommands declared by a shell and a screen: pressed, listed, rebound and in conflict.→

Not a component — a list and the hooks into it. It is the reason a key is written once.

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.

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.

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.

This is the half every product gets wrong once.

  • Not in a field someone is typing in. Mod+K in a text editor means “delete to end of line”; a palette opening on top of it looks like the application misheard. whileTyping: true lets 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 — g there 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.
  • within an element, only while the focus is inside it — and there, before the rest of the application. A list’s J and K, a panel’s Escape. This is also how a command answers plain keys inside a dialog: declared within it.

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.

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.

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([])

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.

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 })

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.

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).

The keys one command answers to now — for the hint beside a button that does the same, or its aria-keyshortcuts.

{ keys, lost, against, kept } for each conflict in the window now.

Runs a command by id — from a menu item that does the same. false when nothing by that id can be run.

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.

  1. v0.38.1
    • fix take a key before an editor's keymap when asked 53b2194
  2. v0.35.0breaking
    • feat one list of commands for keys, palette and sheet dd9a125