# @inklu/keys Keyboard shortcut management for the web. Built on TanStack Hotkeys, `@inklu/keys` provides a registry, scopes, conflict detection, and screen-reader-ready shortcuts. Docs: [inklu.saddy.me/keys](https://inklu.saddy.me/keys) · For agents: [llms.txt](https://inklu.saddy.me/keys/llms.txt) · Agent skill: `npx skills add nkurunziza-saddy/inklu --skill inklu-keys` ```bash pnpm add @inklu/keys ``` ## 1. Define Commands A command is an addressable action. It has an id, title, and a handler. ```ts // commands.ts import { createCommands, seq } from "@inklu/keys"; export const commands = createCommands({ "palette.open": { title: "Open command palette", keys: "Mod+K", run: () => setPaletteOpen(true), }, "nav.inbox": { title: "Go to inbox", keys: seq("G", "I"), // press G, then I run: () => navigate("/inbox"), }, }); ``` ## 2. React Setup Pass the registry explicitly to all hooks. There is no Context provider. ```tsx import { useCommands, useMountCommands, useShortcut } from "@inklu/keys/react"; import { commands } from "./commands"; function App() { // Starts listening in an effect useMountCommands(commands); return ; } function ShortcutButton() { // Live state for the primary binding: `tokens` is one string per key const shortcut = useShortcut(commands, "palette.open"); return ( ); } ``` ## 3. Scopes Use scopes to isolate commands when modals or dialogs are open. ```tsx import { useScope } from "@inklu/keys/react"; function Dialog({ isOpen }) { // When exclusive is true, commands outside this scope are silenced useScope(commands, "dialog", isOpen, { exclusive: true }); return isOpen ?
...
: null; } ``` `exclusive` is a barrier: it silences the whole app beneath it. To claim only a few keys, drop it and give those commands a `scope` instead — see below. ### When two commands share a key Exactly one command runs per keypress. The winner is: 1. The command in a named scope, over a global one. `"*"` counts as global here — it means "survives barriers", not "belongs to a region of the UI". 2. Failing that, the one registered later. Registration order tracks mount order, so the most recently opened surface claims the key. A winner whose `when()` currently returns `false` steps aside rather than swallowing the keypress. This is how `Escape` closes the dialog when one is open and the panel otherwise, without making the dialog `exclusive`. Two commands sharing a chord _in the same scope_ can never separate, so that case warns in development. ## 4. Rebinding Overrides are layered over defaults. ```ts commands.keymap.set("palette.open", "Mod+P"); // remap commands.keymap.add("palette.open", "Control+P"); // an additional key commands.keymap.set("palette.open", null); // unbind commands.keymap.reset("palette.open"); // reset to default ``` `set` replaces the command's keys outright; setting exactly the default forgets the override instead. `add` layers on top of the default rather than copying it, so if a later release of your app changes the default, the user gets the new one and keeps the keys they added. To rebind a command you did not define — one a package shipped — pass `bindings` when creating the registry. An unknown id is a compile error, and `reset` returns here rather than to the definition's `keys`: ```ts createCommands(commands, { bindings: { "palette.open": "Mod+P", // replace the shipped default "doc.publish": null, // ship it unbound }, }); ``` These are defaults, applied before persisted overrides load, so a user's own rebinding still wins. In React, use the `useRebind` hook for a capture flow: ```tsx const rebind = useRebind(commands, "palette.open"); ; ``` To build your own capture UI without React, use `createRebinder`: ```ts import { createRebinder } from "@inklu/keys"; const rebinder = createRebinder(commands, "palette.open", { mode: "chord", // or "sequence": Enter or `commit()` finishes it rejectConflicts: true, // don't apply a binding another command uses onRecord: (binding, conflicts) => {}, }); rebinder.subscribe(() => render(rebinder.state)); // { recording, binding, steps, conflicts } rebinder.start(); // Escape cancels, Backspace unbinds ``` While recording, every other command is silenced — including `"*"` ones — so the keys being recorded never run anything. ## 5. Conflicts `commands.conflicts(binding, { scope, ignore })` lists commands in overlapping scopes whose bindings collide with `binding`. Each result has a `kind`: - `"exact"` — the same keys. Only one command runs (see above). - `"prefix"` — one binding starts the other, like `G` against `seq("G", "I")`. These cannot be arbitrated: pressing `G` runs the chord, and continuing with `I` runs the sequence too. In development, both kinds are also reported with `console.warn` when commands are defined or added. ## 6. Persistence and SSR ```ts createCommands(definitions, { persist: { storage: () => (typeof window === "undefined" ? null : localStorage), key: "my-app.keys", }, }); ``` Only user overrides are stored — never defaults. `keymap.export()` / `keymap.import(json)` move them between devices. The stored data carries a `version`, and a release that doesn't recognise a version leaves it alone instead of misreading it. Data from releases before the version field still loads. While mounted, the registry follows edits other tabs make to the same storage key, so a shortcut rebound in one tab applies in every open tab, and a stale tab can't overwrite it on its next save. The registry is safe to create at module scope and render on the server: - Persisted overrides load on the first `mount()`, not at creation, so the server render and the client's first render match. Subscribers (and every hook) are notified when they arrive. Calling `keymap.set/add/reset` before mount loads them first, so an early edit never overwrites stored ones. - Before mount, shortcuts render in the non-Apple form (`Ctrl+K`); after mount they switch to the detected platform (`⌘K`). Pass `platform` to pin it. - Nothing listens for keys until `mount()` (or `useMountCommands`). ## 7. Inputs and events `ignoreInputs` on a command decides whether it fires while focus is in a text input, textarea, select or contenteditable. When omitted, chords holding `Control` or `Meta` (so any `Mod+…`) and bare `Escape` still fire there; bare keys, `Shift`/`Alt` chords and sequences starting with one are ignored, so typing is never hijacked. Keydowns that are part of an IME composition never trigger commands. A command calls `preventDefault()` only when it actually runs; one that is disabled, out of scope, guarded or outranked leaves the event alone. Opt out per command with `preventDefault: false`. Propagation is never stopped. `run` may be async: nothing awaits it, but a rejection reaches `onError`. `commands.whyNotRun(id)` explains a shortcut that did not fire: `"disabled"`, `"out-of-scope"`, `"guarded"`, `"unknown"`, or `null` when it would run. ## 8. Runtime commands ```tsx // Registered while the component is mounted; the latest `run` and `when` are always used. useRegisterCommand(commands, "editor.format", { title: "Format document", keys: "Shift+Alt+F", scope: "editor", run: () => format(), }); // Outside React: `add` returns a handle. Adding an existing id stacks over it until disposed. const handle = commands.add("editor.format", { title: "Format", run: format }); handle.dispose(); ``` ## 9. Sequence progress ```tsx const pending = usePendingSequences(commands); // [{ id, matched, total, remaining }] // e.g. show "G → …" while the user is halfway through `G then I` ``` `commands.pending()` / `commands.subscribePending()` are the non-React equivalents. ## 10. Command palette Definitions can carry what a palette needs to list and find them, and `matchCommands` does the filtering: ```ts createCommands({ "doc.find": { title: "Find in document", description: "Search the open document", group: "Document", // heading to list it under keywords: ["search", "lookup"], // extra search terms keys: "Mod+F", run: openFind, }, "palette.close": { title: "Close palette", hidden: true, keys: "Escape", run: close }, }); ``` ```tsx import { matchCommands } from "@inklu/keys"; const results = matchCommands(useCommands(commands), query); ``` Every word of the query must match the title, description, group, keywords, id or displayed shortcut. Titles starting with the query come first, then titles containing it, then everything else, each in list order. `hidden` commands never match; they still run and still appear in `list()`. ## 11. Without React: data attributes ```ts import { observe } from "@inklu/keys/attributes"; const stop = observe(commands); // or observe(commands, { root: element }) ``` ```html ``` Clicking an element with `data-command` runs that command (`source: "dom"`), unless the element or an ancestor has `aria-disabled="true"` or another handler already called `preventDefault()`. Elements with `data-command-keys` get the shortcut as text, and `data-command` elements get `aria-keyshortcuts`; both stay current across rebinds and DOM changes. ## API Summary - `createCommands(definitions, options)` - creates the registry - `options`: `{ bindings, platform, target, persist, display, singleKeyShortcuts, onRun, onError }` - `commands.run(id)`, `canRun(id)`, `whyNotRun(id)`, `setEnabled(id, enabled)`, `add(id, definition)`, `remove(id)` - `commands.pushScope(name, { exclusive, strict })`, `conflicts(binding, options)`, `display(id)`, `tokens(id)` - `commands.keymap`: `get`, `set`, `add`, `reset`, `export`, `import` - `commands.setSingleKeyShortcuts(enabled)`, `commands.singleKeyShortcuts` - `createRebinder(commands, id, options)` - framework-agnostic shortcut recording - `observe(commands, { root })` from `@inklu/keys/attributes` - declarative DOM wiring - `matchCommands(list, query)` - palette filtering over `title`, `description`, `group`, `keywords` - `formatBinding`, `bindingTokens`, `ariaKeyShortcuts`, `spokenBinding`, `validateBinding`, `isSingleKeyBinding`, `seq` - React (`@inklu/keys/react`): - `useMountCommands(commands)` - starts listening - `useCommands(commands)`, `useCommand(commands, id)` - live command snapshots - `useShortcut(commands, id)` - `{ display, tokens, aria, spoken }` for the primary binding - `useScope(commands, name, active, { exclusive, strict })` - isolates command execution - `useRegisterCommand(commands, id, definition)` - component-scoped commands - `useRebind(commands, id, options)` - records user shortcuts - `usePendingSequences(commands)` - half-typed sequences - `useHeldKeys()`, `useHeldKeyCodes()`, `useKeyHold(key)` - live key state Inside `run`, the context's `commands` is typed with the registry's own ids, so `commands.run("typo")` is a compile error. ## Accessibility Every command carries an `aria` string for the `aria-keyshortcuts` attribute. It lists chords only: the attribute separates alternatives with spaces, so it cannot express a sequence. Every command also carries `spoken` — `"Control+K"`, or `"G then I"` for a sequence — for visually hidden text or `aria-description`, which is how a sequence gets announced: ```tsx const { aria, spoken } = useShortcut(commands, "nav.inbox"); ; ``` WCAG 2.1.4 (Character Key Shortcuts) requires that users can turn off shortcuts made of printable characters without a modifier — `?`, `G then I` — because speech input and stray keypresses trigger them. Expose a setting wired to: ```ts commands.setSingleKeyShortcuts(false); // or createCommands(defs, { singleKeyShortcuts: false }) ``` While off, those bindings neither fire nor appear in `display`, `tokens`, `aria` or `spoken`; chords with `Control`, `Alt` or `Meta` and keys like `Escape` or `F1` keep working. Users can also remap any shortcut with `useRebind`. ## License MIT © nkurunziza-saddy