@inklu/keys 2.0.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 [Your Name]
3
+ Copyright (c) 2026 nkurunziza-saddy
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,8 +1,16 @@
1
1
  # @inklu/keys
2
2
 
3
- Keyboard shortcut management for the web. Built on TanStack Hotkeys, `@inklu/keys` provides a registry, scopes, conflict detection, and screen-reader-ready shortcuts.
3
+ `@inklu/keys` organises an app's keys and respects its boundaries. Every shortcut is a named command declared in one place, and exactly one command runs per keypress: the right one for where the user is.
4
4
 
5
- Docs: [inklu-keys.vercel.app](https://inklu-keys.vercel.app) · For agents: [llms.txt](https://inklu-keys.vercel.app/llms.txt) · Agent skill: `npx skills add nkurunziza-saddy/inklu --skill inklu-keys`
5
+ Three ideas, learned as you need them:
6
+
7
+ - **Command**: something the app can do, with a title, default keys and the scope it is live in.
8
+ - **Handler**: what the command does, attached from the component that owns the state.
9
+ - **Keymap**: the keys, kept apart from the definition so users can rebind them.
10
+
11
+ It has no dependencies, and listens with one `keydown` listener however many commands exist. What an app does not use, it does not ship: persistence, recording a shortcut and palette search are imports of their own, and the registry does not pull them in.
12
+
13
+ Docs: [inklu.saddy.me/keys](https://inklu.saddy.me/keys) · For agents: [llms.txt](https://inklu.saddy.me/keys/llms.txt) · Agent skills: `npx skills add nkurunziza-saddy/inklu --skill inklu-keys` for the API, and `--skill inklu-keys-practices` for choosing and showing shortcuts well
6
14
 
7
15
  ```bash
8
16
  pnpm add @inklu/keys
@@ -10,32 +18,69 @@ pnpm add @inklu/keys
10
18
 
11
19
  ## 1. Define Commands
12
20
 
13
- A command is an addressable action. It has an id, title, and a handler.
21
+ Declare every command in one module. Ids come from the object keys, so `commands.run("doc.save")` autocompletes and a typo is a type error.
22
+
23
+ A chord is written modifiers first, then one key: `"Mod+Shift+S"`. The modifiers are `Mod`, `Control`, `Alt`, `Shift` and `Meta`, in that order, and `Mod` is Command on macOS and Control elsewhere. The key is the character or the name `KeyboardEvent.key` gives it (`"K"`, `"?"`, `"Escape"`, `"ArrowUp"`), or a physical key in brackets (`"[KeyK]"`) that stays put whatever the layout. Chords are typed, so a misspelling is a compile error. An array of chords means alternatives; a sequence is `seq("G", "I")`.
14
24
 
15
25
  ```ts
16
26
  // commands.ts
17
27
  import { createCommands, seq } from "@inklu/keys";
18
28
 
19
29
  export const commands = createCommands({
20
- "palette.open": {
21
- title: "Open command palette",
22
- keys: "Mod+K",
23
- run: () => setPaletteOpen(true),
24
- },
30
+ "palette.open": { title: "Open command palette", keys: "Mod+K" },
31
+ "doc.save": { title: "Save document", keys: "Mod+S" },
25
32
  "nav.inbox": {
26
33
  title: "Go to inbox",
27
34
  keys: seq("G", "I"), // press G, then I
28
- run: () => navigate("/inbox"),
35
+ // Needs nothing a component owns, so it can live here.
36
+ run: () => router.navigate("/inbox"),
29
37
  },
30
38
  });
31
39
  ```
32
40
 
41
+ ### Keys that differ by platform
42
+
43
+ `Mod` covers most shortcuts, since it is Command on a Mac and Control everywhere else. Where the platforms' own conventions differ, give each its keys: `mac` is for Apple keyboards (macOS, iPadOS, iOS), `other` for the rest. A side left out is unbound there.
44
+
45
+ ```ts
46
+ createCommands({
47
+ "edit.redo": { title: "Redo", keys: { mac: "Mod+Shift+Z", other: ["Mod+Y", "Mod+Shift+Z"] } },
48
+ "nav.back": { title: "Back", keys: { mac: "Mod+[", other: "Alt+ArrowLeft" } },
49
+ "doc.new": { title: "New document", keys: { mac: "Mod+Alt+N", other: "Alt+N" } },
50
+ });
51
+ ```
52
+
53
+ Some keys are not an app's to bind, and the registry warns about them in development:
54
+
55
+ - **Reserved by the browser or the operating system.** `Mod+N`, `Mod+T`, `Mod+W` and their `Shift` forms, `Control+Tab`, `Mod+Q` on a Mac, `Alt+F4` and anything holding `Meta` (the Windows or Super key) elsewhere. The page never receives most of these, so `preventDefault()` cannot help. That is why `doc.new` above is not `Mod+N`.
56
+ - **`Control+Alt` with a character key, on Windows and Linux.** Many keyboard layouts report AltGr as `Control+Alt`, and there the keys type a character (`@`, `€`, `{`) instead. `Mod+Alt+N` is fine on a Mac and not elsewhere, which is the other reason for `{ mac, other }`.
57
+ - **`Control+Alt` on a Mac.** It is the VoiceOver modifier.
58
+
59
+ Browser features a page _can_ take over (`Mod+F`, `Mod+P`, `Mod+L`, `Mod+R`) are left to your judgement: do it only where your version does the same job better.
60
+
61
+ ### Commands generated from data
62
+
63
+ Ids come from the object's keys, so generated commands are typed when their keys are. `Object.fromEntries` returns plain string keys; give the result its key type where you build it:
64
+
65
+ ```ts
66
+ const pages = Object.fromEntries(
67
+ docs.map((doc) => [`go.${doc.slug}`, { title: doc.title, run: () => router.navigate(doc.path) }]),
68
+ ) as Record<`go.${Slug}`, CommandDefinition>;
69
+
70
+ export const commands = createCommands({
71
+ "palette.open": { title: "Open command palette", keys: "Mod+K" },
72
+ ...pages,
73
+ });
74
+ ```
75
+
76
+ Spread from a `Record<string, CommandDefinition>` instead, the commands still exist at runtime but the types only know the literal keys. To have the compiler check that every id is present, name the ids yourself: `createCommands<AppCommandId>({ … })`.
77
+
33
78
  ## 2. React Setup
34
79
 
35
80
  Pass the registry explicitly to all hooks. There is no Context provider.
36
81
 
37
82
  ```tsx
38
- import { useCommands, useMountCommands, useShortcut } from "@inklu/keys/react";
83
+ import { useCommandHandler, useMountCommands, useShortcut } from "@inklu/keys/react";
39
84
  import { commands } from "./commands";
40
85
 
41
86
  function App() {
@@ -44,6 +89,13 @@ function App() {
44
89
  return <ShortcutButton />;
45
90
  }
46
91
 
92
+ function Palette() {
93
+ const [open, setOpen] = useState(false);
94
+ // What the command does, attached where the state lives.
95
+ useCommandHandler(commands, "palette.open", () => setOpen(true));
96
+ return open ? <dialog open>…</dialog> : null;
97
+ }
98
+
47
99
  function ShortcutButton() {
48
100
  // Live state for the primary binding: `tokens` is one string per key
49
101
  const shortcut = useShortcut(commands, "palette.open");
@@ -62,6 +114,22 @@ function ShortcutButton() {
62
114
  }
63
115
  ```
64
116
 
117
+ ### Handlers
118
+
119
+ A command runs when its scope is open and it has a handler. A definition's own `run` is one; `useCommandHandler` attaches another for as long as a component is mounted, and the latest closure is always the one called.
120
+
121
+ ```tsx
122
+ // The last argument switches the handler off: with nothing to save, Mod+S is not ours.
123
+ useCommandHandler(commands, "doc.save", save, dirty);
124
+ ```
125
+
126
+ - A command with no handler cannot run and leaves its key alone, so unmounting a feature switches its shortcuts off with it.
127
+ - The most recently mounted handler wins. Unmounting hands the command back to the one before it, or to the definition's `run`. "Most recently" is the order React runs effects in, so of a parent and a child mounting in the same render, the parent's comes last and wins.
128
+ - Outside React, `commands.handle(id, run)` does the same and returns a function that detaches.
129
+ - A handler is attached when its component mounts. Keep it with its state in code that is already loaded, and lazy-load only what it shows: a handler inside a lazily loaded component leaves the key to the browser until its chunk arrives.
130
+
131
+ Switch a command off by its handler (`enabled`, or detaching) rather than with an early `return` inside it: only then does the key fall through to another command or the browser.
132
+
65
133
  ## 3. Scopes
66
134
 
67
135
  Use scopes to isolate commands when modals or dialogs are open.
@@ -70,23 +138,28 @@ Use scopes to isolate commands when modals or dialogs are open.
70
138
  import { useScope } from "@inklu/keys/react";
71
139
 
72
140
  function Dialog({ isOpen }) {
73
- // When exclusive is true, commands outside this scope are silenced
141
+ // When exclusive is true, the keys of commands outside this scope are silenced
74
142
  useScope(commands, "dialog", isOpen, { exclusive: true });
75
143
 
76
144
  return isOpen ? <div className="dialog">...</div> : null;
77
145
  }
78
146
  ```
79
147
 
80
- `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.
148
+ `exclusive` is a barrier: it silences every key beneath it. To claim only a few keys, drop it and give those commands a `scope` instead — see below.
149
+
150
+ A barrier claims the keyboard, not the command: `commands.run(id)` still works beneath one, so a palette can run what it lists while its own scope is open. A direct run only needs the command's own scope to be open. To make a button obey the same gate as its key, read `reachable` from `useCommand(commands, id)`.
81
151
 
82
152
  ### When two commands share a key
83
153
 
84
- Exactly one command runs per keypress. The winner is:
154
+ Exactly one command runs per keypress. Among the commands that could take it, the winner is:
155
+
156
+ 1. A sequence being finished, over a chord on the same key.
157
+ 2. The command whose scope was opened most recently. Scopes track the surfaces on screen, so the dialog on top claims `Escape` before the panel beneath it, and any scoped command beats a global one. `"*"` counts as global here — it means "survives barriers", not "belongs to a region of the UI".
158
+ 3. Failing that, the one defined later.
85
159
 
86
- 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".
87
- 2. Failing that, the one registered later. Registration order tracks mount order, so the most recently opened surface claims the key.
160
+ Scopes rank in the order they were pushed. With `useScope` that is the order React runs effects in: a surface opened later ranks higher, but of a parent and a child that open in the same render, the child is pushed first. Open the inner surface after the outer one when their order matters.
88
161
 
89
- 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.
162
+ A command with no handler is never a candidate, so it 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 only be told apart by which has a handler, so that case warns in development.
90
163
 
91
164
  ## 4. Rebinding
92
165
 
@@ -130,78 +203,75 @@ import { createRebinder } from "@inklu/keys";
130
203
  const rebinder = createRebinder(commands, "palette.open", {
131
204
  mode: "chord", // or "sequence": Enter or `commit()` finishes it
132
205
  rejectConflicts: true, // don't apply a binding another command uses
206
+ // A binding the browser or OS reserves is never applied, whatever this says.
133
207
  onRecord: (binding, conflicts) => {},
134
208
  });
135
209
  rebinder.subscribe(() => render(rebinder.state)); // { recording, binding, steps, conflicts }
136
210
  rebinder.start(); // Escape cancels, Backspace unbinds
137
211
  ```
138
212
 
139
- While recording, every other command is silenced — including `"*"` ones — so the keys being recorded never run anything.
213
+ While recording, every command is silenced — including `"*"` ones — so the keys being recorded never run anything. The rebinder is built on `commands.capture(onKey)`, which hands every keypress to `onKey(event, chord)` until the function it returns is called; use it directly for a recorder of your own.
140
214
 
141
215
  ## 5. Conflicts
142
216
 
143
- `commands.conflicts(binding, { scope, ignore })` lists commands in overlapping scopes whose bindings collide with `binding`. Each result has a `kind`:
217
+ `commands.conflicts(binding, { scope, ignore })` lists what stands in the way of `binding`. Each result has a `kind`:
144
218
 
145
- - `"exact"` — the same keys. Only one command runs (see above).
219
+ - `"reserved"` — the browser or operating system keeps these keys on this platform (see [Keys that differ by platform](#keys-that-differ-by-platform)). Listed first. A rebinder reports it and never applies the binding.
220
+ - `"exact"` — a command in an overlapping scope has the same keys. Only one command runs (see above).
146
221
  - `"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.
147
222
 
148
- In development, both kinds are also reported with `console.warn` when commands are defined or added.
223
+ Only a conflict with a command has an `id`, so narrow on `kind` before reading it:
224
+
225
+ ```tsx
226
+ rebind.conflicts.map((conflict) =>
227
+ conflict.kind === "reserved"
228
+ ? `${conflict.display} belongs to the browser`
229
+ : `Also used by ${commands.get(conflict.id)?.title}`,
230
+ );
231
+ ```
232
+
233
+ In development, all three are also reported with `console.warn` when the registry is created, for the defaults on both kinds of platform. The check is dropped from production builds.
149
234
 
150
235
  ## 6. Persistence and SSR
151
236
 
152
237
  ```ts
153
- createCommands(definitions, {
154
- persist: {
155
- storage: () => (typeof window === "undefined" ? null : localStorage),
156
- key: "my-app.keys",
157
- },
158
- });
238
+ import { createCommands, webStorage } from "@inklu/keys";
239
+
240
+ createCommands(definitions, { persist: webStorage("my-app.keys") });
159
241
  ```
160
242
 
161
- 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.
243
+ `webStorage(key, storage?)` keeps the user's overrides in `localStorage`. It is an import, so a registry created without it carries no storage code. Pass a second argument to keep them elsewhere: a function returning the storage, as in `webStorage("my-app.keys", () => sessionStorage)`. Any object with `getItem`, `setItem` and `removeItem` will do, and the function is only called in the browser.
244
+
245
+ Only user overrides are stored — never defaults. To move them between devices yourself, `exportKeymap(commands)` returns them as JSON-safe data and `importKeymap(commands, data)` puts them back and saves them. The data carries a `version`, and data in a version this release does not know is ignored instead of misread.
162
246
 
163
- 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.
247
+ 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. Each mount reads what is stored, so a registry that was unmounted for a while has caught up by the time it listens again.
248
+
249
+ For a store that is not Web Storage, `persist` takes any object shaped like `KeymapStore`: `read()`, `write(overrides)` and `watch(onChange)`, all synchronous, with `Override` the type of one command's entry. For something asynchronous, such as a user's account, use `exportKeymap` and `importKeymap` instead.
164
250
 
165
251
  The registry is safe to create at module scope and render on the server:
166
252
 
167
253
  - 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.
168
- - 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.
254
+ - Before mount, shortcuts render in the non-Apple form (`Ctrl+K`); after mount they switch to the detected platform (`⌘K`), and `{ mac, other }` keys to its side. Pass `platform` to pin it.
169
255
  - Nothing listens for keys until `mount()` (or `useMountCommands`).
170
256
 
171
257
  ## 7. Inputs and events
172
258
 
173
- `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.
174
-
175
- 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`.
259
+ `ignoreInputs` on a command decides whether it fires while focus is in a text input, textarea, select or contenteditable, or in a custom widget with a text-entry role (`textbox`, `searchbox`, `combobox`, `spinbutton`, `slider`). 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. Editing is not hijacked either. Select all, copy, cut, paste, undo and redo (`Mod+A`, `C`, `X`, `V`, `Z`, `Shift+Z`, `Shift+V`, and `Mod+Y` off the Mac) are ignored in a field too, and so is `Mod` with `Backspace`, `Delete`, an arrow, `Home` or `End`, which move and delete by word or line. An app's own undo, select-all or delete-item leaves the field's alone. Set `ignoreInputs: false` on a command that should take one of them anyway. Keydowns that are part of an IME composition never trigger commands.
176
260
 
177
- `commands.whyNotRun(id)` explains a shortcut that did not fire: `"disabled"`, `"out-of-scope"`, `"guarded"`, `"unknown"`, or `null` when it would run.
261
+ A command calls `preventDefault()` only when it actually runs, or when the key is a step on the way through one of its sequences; one that is unhandled, out of scope or outranked leaves the event alone. Opt out per command with `preventDefault: false`. Propagation is never stopped. A handler may be async: nothing awaits it, but a rejection reaches `onError`. Holding a chord down runs its command again on each auto-repeat, which suits stepping and undo; give a toggle, or anything that should happen once per press, `repeat: false`. It still claims its key for as long as the key is held.
178
262
 
179
- ## 8. Runtime commands
180
-
181
- ```tsx
182
- // Registered while the component is mounted; the latest `run` and `when` are always used.
183
- useRegisterCommand(commands, "editor.format", {
184
- title: "Format document",
185
- keys: "Shift+Alt+F",
186
- scope: "editor",
187
- run: () => format(),
188
- });
189
-
190
- // Outside React: `add` returns a handle. Adding an existing id stacks over it until disposed.
191
- const handle = commands.add("editor.format", { title: "Format", run: format });
192
- handle.dispose();
193
- ```
263
+ `commands.whyNotRun(id)` explains a command that would not run: `"unhandled"` (nothing has attached a handler), `"out-of-scope"` (its scope is not open), `"unknown"`, or `null` when it would run.
194
264
 
195
- ## 9. Sequence progress
265
+ ## 8. Sequence progress
196
266
 
197
267
  ```tsx
198
268
  const pending = usePendingSequences(commands); // [{ id, matched, total, remaining }]
199
269
  // e.g. show "G → …" while the user is halfway through `G then I`
200
270
  ```
201
271
 
202
- `commands.pending()` / `commands.subscribePending()` are the non-React equivalents.
272
+ `commands.subscribePending()` / `commands.pending()` are the non-React equivalents. Progress is tracked only while something is subscribed, so `pending()` is empty otherwise.
203
273
 
204
- ## 10. Command palette
274
+ ## 9. Command palette
205
275
 
206
276
  Definitions can carry what a palette needs to list and find them, and `matchCommands` does the filtering:
207
277
 
@@ -213,21 +283,22 @@ createCommands({
213
283
  group: "Document", // heading to list it under
214
284
  keywords: ["search", "lookup"], // extra search terms
215
285
  keys: "Mod+F",
216
- run: openFind,
217
286
  },
218
- "palette.close": { title: "Close palette", hidden: true, keys: "Escape", run: close },
287
+ "palette.close": { title: "Close palette", hidden: true, scope: "palette", keys: "Escape" },
219
288
  });
220
289
  ```
221
290
 
222
291
  ```tsx
223
292
  import { matchCommands } from "@inklu/keys";
224
293
 
225
- const results = matchCommands(useCommands(commands), query);
294
+ // List what can run right now: it has a handler and its scope is open.
295
+ const runnable = useCommands(commands).filter((command) => commands.canRun(command.id));
296
+ const results = matchCommands(runnable, query);
226
297
  ```
227
298
 
228
299
  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()`.
229
300
 
230
- ## 11. Without React: data attributes
301
+ ## 10. Without React: data attributes
231
302
 
232
303
  ```ts
233
304
  import { observe } from "@inklu/keys/attributes";
@@ -241,33 +312,101 @@ const stop = observe(commands); // or observe(commands, { root: element })
241
312
 
242
313
  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.
243
314
 
315
+ ## 11. Recipes
316
+
317
+ Patterns the pieces above already cover, so none of them needs an API of its own.
318
+
319
+ ### Act on the selection, else on the highlighted item
320
+
321
+ Which item a shortcut applies to is your state. Compute the target once, and let it switch the handler on and off:
322
+
323
+ ```tsx
324
+ const targets = selection.size > 0 ? [...selection] : highlighted ? [highlighted] : [];
325
+
326
+ useCommandHandler(commands, "issue.setStatus", () => openStatusPicker(targets), targets.length > 0);
327
+ useCommandHandler(commands, "issue.selectAll", () => setSelection(new Set(visibleIds)));
328
+ ```
329
+
330
+ With nothing targeted the command has no handler, so its key falls through, `reachable` is false for its button, and a palette that lists what `canRun` leaves it out: the palette follows the selection without being told. `issue.selectAll` can be `Mod+A`: inside a text field that stays the field's own select-all.
331
+
332
+ ### Hold a key to peek
333
+
334
+ `repeat: false` runs a command once per press. Letting go is a `keyup` away:
335
+
336
+ ```tsx
337
+ // "issue.peek": { title: "Peek", keys: "Space", repeat: false }
338
+ const [peeking, setPeeking] = useState(false);
339
+ useCommandHandler(commands, "issue.peek", () => setPeeking(true), highlighted !== null);
340
+
341
+ useEffect(() => {
342
+ if (!peeking) return;
343
+ const stop = () => setPeeking(false);
344
+ window.addEventListener("keyup", stop);
345
+ window.addEventListener("blur", stop);
346
+ return () => {
347
+ window.removeEventListener("keyup", stop);
348
+ window.removeEventListener("blur", stop);
349
+ };
350
+ }, [peeking]);
351
+ ```
352
+
353
+ Bind a hold to a bare key. While Command is down, macOS sends no `keyup` for the other keys, so a held `Mod+…` chord cannot tell when it was let go.
354
+
355
+ ### A shortcut sheet on `?` and `Mod+/`
356
+
357
+ ```ts
358
+ "help.shortcuts": { title: "Keyboard shortcuts", keys: ["?", "Mod+/"], scope: "*" },
359
+ ```
360
+
361
+ Build the sheet from `useCommands(commands)`, grouped by `group`, and search it with `matchCommands`, which also matches the displayed shortcut. When a user turns single-key shortcuts off, `?` goes and `Mod+/` stays, so the sheet that explains the setting is still reachable.
362
+
363
+ ### A way out of a text field
364
+
365
+ Bare keys are ignored while the user types, so give them a key that leaves the field. `Escape` is the one bare key that fires inside an input:
366
+
367
+ ```tsx
368
+ // "field.leave": { title: "Leave field", keys: "Escape", hidden: true }
369
+ const [editing, setEditing] = useState(false);
370
+ useCommandHandler(commands, "field.leave", () => rowRef.current?.focus(), editing);
371
+
372
+ <input onFocus={() => setEditing(true)} onBlur={() => setEditing(false)} />;
373
+ ```
374
+
375
+ Move focus to the item the field belongs to, not to nothing, so the next key has a target.
376
+
244
377
  ## API Summary
245
378
 
246
379
  - `createCommands(definitions, options)` - creates the registry
247
- - `options`: `{ bindings, platform, target, persist, display, singleKeyShortcuts, onRun, onError }`
248
- - `commands.run(id)`, `canRun(id)`, `whyNotRun(id)`, `setEnabled(id, enabled)`, `add(id, definition)`, `remove(id)`
249
- - `commands.pushScope(name, { exclusive, strict })`, `conflicts(binding, options)`, `display(id)`, `tokens(id)`
250
- - `commands.keymap`: `get`, `set`, `add`, `reset`, `export`, `import`
380
+ - `options`: `{ bindings, platform, target, persist, display, singleKeyShortcuts, onRun, onError }`; `platform` is `"mac"` or `"other"`
381
+ - `commands.run(id)`, `canRun(id)`, `whyNotRun(id)`, `handle(id, run)`
382
+ - `commands.get(id)`, `list()`, `subscribe(listener)`: snapshots with `display`, `tokens`, `aria`, `spoken`, `reachable`, `overridden`
383
+ - `commands.pushScope(name, { exclusive })`, `capture(onKey)`, `conflicts(binding, options)`, `mount()`, `destroy()`
384
+ - `commands.serverList()`: the list as the server rendered it, for a framework's hydration snapshot
385
+ - `commands.keymap`: `get`, `set`, `add`, `reset`
251
386
  - `commands.setSingleKeyShortcuts(enabled)`, `commands.singleKeyShortcuts`
387
+ - `webStorage(key, storage?)` - the `persist` option: Web Storage, followed across tabs
388
+ - `exportKeymap(commands)`, `importKeymap(commands, data)` - user overrides as JSON-safe data
252
389
  - `createRebinder(commands, id, options)` - framework-agnostic shortcut recording
253
390
  - `observe(commands, { root })` from `@inklu/keys/attributes` - declarative DOM wiring
254
- - `matchCommands(list, query)` - palette filtering over `title`, `description`, `group`, `keywords`
255
- - `formatBinding`, `bindingTokens`, `ariaKeyShortcuts`, `spokenBinding`, `validateBinding`, `isSingleKeyBinding`, `seq`
391
+ - `matchCommands(list, query)` - palette filtering over `title`, `description`, `group`, `keywords`, id and displayed shortcut
392
+ - `formatBinding`, `bindingTokens`, `ariaKeyShortcuts`, `spokenBinding`, `isSingleKeyBinding`, `seq`
256
393
  - React (`@inklu/keys/react`):
257
394
  - `useMountCommands(commands)` - starts listening
258
395
  - `useCommands(commands)`, `useCommand(commands, id)` - live command snapshots
259
396
  - `useShortcut(commands, id)` - `{ display, tokens, aria, spoken }` for the primary binding
260
- - `useScope(commands, name, active, { exclusive, strict })` - isolates command execution
261
- - `useRegisterCommand(commands, id, definition)` - component-scoped commands
397
+ - `useScope(commands, name, active, { exclusive })` - opens a scope while a component needs it
398
+ - `useCommandHandler(commands, id, run, enabled)` - what a command does while a component is mounted
262
399
  - `useRebind(commands, id, options)` - records user shortcuts
263
400
  - `usePendingSequences(commands)` - half-typed sequences
264
- - `useHeldKeys()`, `useHeldKeyCodes()`, `useKeyHold(key)` - live key state
401
+ - `useHeldKeys()` - the keys held down right now, by name
265
402
 
266
- Inside `run`, the context's `commands` is typed with the registry's own ids, so `commands.run("typo")` is a compile error.
403
+ Inside a handler, the context's `commands` is typed with the registry's own ids, so `commands.run("typo")` is a compile error.
267
404
 
268
405
  ## Accessibility
269
406
 
270
- 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:
407
+ Shortcuts are written the way each platform writes them: `⇧⌘S` on a Mac, symbols run together in Apple's order, and `Ctrl+Shift+S` elsewhere. `tokens` holds the same keys one at a time, for a `<kbd>` each.
408
+
409
+ 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` — `"Command K"` on a Mac and `"Control K"` elsewhere, or `"G then I"` for a sequence — for visually hidden text or `aria-description`, which is how a sequence gets announced:
271
410
 
272
411
  ```tsx
273
412
  const { aria, spoken } = useShortcut(commands, "nav.inbox");
@@ -284,6 +423,10 @@ commands.setSingleKeyShortcuts(false); // or createCommands(defs, { singleKeySho
284
423
 
285
424
  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`.
286
425
 
426
+ ## Acknowledgements
427
+
428
+ The rules for matching a keypress across keyboard layouts follow the matcher in [TanStack Hotkeys](https://tanstack.com/hotkeys) (MIT), which earlier releases depended on.
429
+
287
430
  ## License
288
431
 
289
432
  MIT © nkurunziza-saddy
@@ -1,2 +1,11 @@
1
- import { n as observe, t as ObserveOptions } from "./attributes-DotLr2MW.mjs";
2
- export { ObserveOptions, observe };
1
+ import { l as Commands } from "./types-D-hUKGoN.mjs";
2
+ //#region src/attributes.d.ts
3
+ export interface ObserveOptions {
4
+ /** Root document or element to delegate from. Defaults to `document`. */
5
+ root?: Document | Element;
6
+ }
7
+ /**
8
+ * Wires a commands registry to DOM data attributes. Returns a cleanup function.
9
+ */
10
+ export declare function observe<Id extends string>(registry: Commands<Id>, options?: ObserveOptions): () => void;
11
+ //#endregion
@@ -7,15 +7,16 @@ const KEYS_ATTRIBUTE = "data-command-keys";
7
7
  function observe(registry, options = {}) {
8
8
  if (!("document" in globalThis)) return () => {};
9
9
  const root = options.root ?? document;
10
+ const find = (id) => id ? registry.list().find((command) => command.id === id) : void 0;
10
11
  const onClick = (event) => {
11
12
  if (event.defaultPrevented) return;
12
13
  const target = event.target;
13
14
  if (!(target instanceof Element)) return;
14
15
  const element = target.closest(`[${COMMAND_ATTRIBUTE}]`);
15
- const id = element?.getAttribute(COMMAND_ATTRIBUTE);
16
- if (!id) return;
16
+ const command = find(element?.getAttribute(COMMAND_ATTRIBUTE) ?? null);
17
+ if (!command) return;
17
18
  if (element?.closest("[aria-disabled=\"true\"]")) return;
18
- registry.run(id, {
19
+ registry.run(command.id, {
19
20
  source: "dom",
20
21
  event
21
22
  });
@@ -24,15 +25,15 @@ function observe(registry, options = {}) {
24
25
  const paintElement = (element) => {
25
26
  const keysId = element.getAttribute(KEYS_ATTRIBUTE);
26
27
  if (keysId) {
27
- const display = registry.display(keysId);
28
+ const display = find(keysId)?.display[0] ?? "";
28
29
  if (element.textContent !== display) element.textContent = display;
29
30
  }
30
31
  const commandId = element.getAttribute(COMMAND_ATTRIBUTE);
31
- const aria = commandId ? registry.get(commandId)?.aria ?? "" : "";
32
+ const aria = find(commandId)?.aria ?? "";
32
33
  if (aria) {
33
34
  if (element.getAttribute("aria-keyshortcuts") !== aria) element.setAttribute("aria-keyshortcuts", aria);
34
35
  labelled.add(element);
35
- } else if (commandId || labelled.has(element)) {
36
+ } else if (labelled.has(element)) {
36
37
  element.removeAttribute("aria-keyshortcuts");
37
38
  labelled.delete(element);
38
39
  }
@@ -51,28 +52,25 @@ function observe(registry, options = {}) {
51
52
  lastPainted = next;
52
53
  paintWithin(root);
53
54
  });
54
- let observer;
55
- if ("MutationObserver" in globalThis) {
56
- observer = new MutationObserver((mutations) => {
57
- for (const mutation of mutations) {
58
- if (mutation.type === "attributes" && mutation.target instanceof Element) {
59
- paintElement(mutation.target);
60
- continue;
61
- }
62
- for (const node of mutation.addedNodes) if (node instanceof Element) paintWithin(node);
55
+ const observer = new MutationObserver((mutations) => {
56
+ for (const mutation of mutations) {
57
+ if (mutation.type === "attributes" && mutation.target instanceof Element) {
58
+ paintElement(mutation.target);
59
+ continue;
63
60
  }
64
- });
65
- observer.observe(root instanceof Document ? root.documentElement : root, {
66
- childList: true,
67
- subtree: true,
68
- attributes: true,
69
- attributeFilter: [COMMAND_ATTRIBUTE, KEYS_ATTRIBUTE]
70
- });
71
- }
61
+ for (const node of mutation.addedNodes) if (node instanceof Element) paintWithin(node);
62
+ }
63
+ });
64
+ observer.observe(root instanceof Document ? root.documentElement : root, {
65
+ childList: true,
66
+ subtree: true,
67
+ attributes: true,
68
+ attributeFilter: [COMMAND_ATTRIBUTE, KEYS_ATTRIBUTE]
69
+ });
72
70
  return () => {
73
71
  root.removeEventListener("click", onClick);
74
72
  unsubscribe();
75
- observer?.disconnect();
73
+ observer.disconnect();
76
74
  };
77
75
  }
78
76
  //#endregion