@inklu/keys 2.1.0 → 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/README.md CHANGED
@@ -8,9 +8,9 @@ Three ideas, learned as you need them:
8
8
  - **Handler**: what the command does, attached from the component that owns the state.
9
9
  - **Keymap**: the keys, kept apart from the definition so users can rebind them.
10
10
 
11
- It has no dependencies, and listens with one `keydown` listener however many commands exist.
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
12
 
13
- 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`
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
14
14
 
15
15
  ```bash
16
16
  pnpm add @inklu/keys
@@ -38,6 +38,43 @@ export const commands = createCommands({
38
38
  });
39
39
  ```
40
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
+
41
78
  ## 2. React Setup
42
79
 
43
80
  Pass the registry explicitly to all hooks. There is no Context provider.
@@ -89,6 +126,7 @@ useCommandHandler(commands, "doc.save", save, dirty);
89
126
  - A command with no handler cannot run and leaves its key alone, so unmounting a feature switches its shortcuts off with it.
90
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.
91
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.
92
130
 
93
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.
94
132
 
@@ -165,6 +203,7 @@ import { createRebinder } from "@inklu/keys";
165
203
  const rebinder = createRebinder(commands, "palette.open", {
166
204
  mode: "chord", // or "sequence": Enter or `commit()` finishes it
167
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.
168
207
  onRecord: (binding, conflicts) => {},
169
208
  });
170
209
  rebinder.subscribe(() => render(rebinder.state)); // { recording, binding, steps, conflicts }
@@ -175,39 +214,51 @@ While recording, every command is silenced — including `"*"` ones — so the k
175
214
 
176
215
  ## 5. Conflicts
177
216
 
178
- `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`:
179
218
 
180
- - `"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).
181
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.
182
222
 
183
- In development, both kinds are also reported with `console.warn` when the registry is created. The check is dropped from production builds.
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.
184
234
 
185
235
  ## 6. Persistence and SSR
186
236
 
187
237
  ```ts
188
- createCommands(definitions, {
189
- persist: {
190
- storage: () => (typeof window === "undefined" ? null : localStorage),
191
- key: "my-app.keys",
192
- },
193
- });
238
+ import { createCommands, webStorage } from "@inklu/keys";
239
+
240
+ createCommands(definitions, { persist: webStorage("my-app.keys") });
194
241
  ```
195
242
 
196
- 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 ignores it instead of misreading it; the user's next change then replaces 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.
246
+
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.
197
248
 
198
- 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.
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.
199
250
 
200
251
  The registry is safe to create at module scope and render on the server:
201
252
 
202
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.
203
- - 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.
204
255
  - Nothing listens for keys until `mount()` (or `useMountCommands`).
205
256
 
206
257
  ## 7. Inputs and events
207
258
 
208
- `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.
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.
209
260
 
210
- 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.
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.
211
262
 
212
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.
213
264
 
@@ -261,20 +312,84 @@ const stop = observe(commands); // or observe(commands, { root: element })
261
312
 
262
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.
263
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
+
264
377
  ## API Summary
265
378
 
266
379
  - `createCommands(definitions, options)` - creates the registry
267
- - `options`: `{ bindings, platform, target, persist, display, singleKeyShortcuts, onRun, onError }`
380
+ - `options`: `{ bindings, platform, target, persist, display, singleKeyShortcuts, onRun, onError }`; `platform` is `"mac"` or `"other"`
268
381
  - `commands.run(id)`, `canRun(id)`, `whyNotRun(id)`, `handle(id, run)`
269
382
  - `commands.get(id)`, `list()`, `subscribe(listener)`: snapshots with `display`, `tokens`, `aria`, `spoken`, `reachable`, `overridden`
270
383
  - `commands.pushScope(name, { exclusive })`, `capture(onKey)`, `conflicts(binding, options)`, `mount()`, `destroy()`
271
384
  - `commands.serverList()`: the list as the server rendered it, for a framework's hydration snapshot
272
- - `commands.keymap`: `get`, `set`, `add`, `reset`, `export`, `import`
385
+ - `commands.keymap`: `get`, `set`, `add`, `reset`
273
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
274
389
  - `createRebinder(commands, id, options)` - framework-agnostic shortcut recording
275
390
  - `observe(commands, { root })` from `@inklu/keys/attributes` - declarative DOM wiring
276
391
  - `matchCommands(list, query)` - palette filtering over `title`, `description`, `group`, `keywords`, id and displayed shortcut
277
- - `formatBinding`, `bindingTokens`, `ariaKeyShortcuts`, `spokenBinding`, `validateBinding`, `isSingleKeyBinding`, `seq`
392
+ - `formatBinding`, `bindingTokens`, `ariaKeyShortcuts`, `spokenBinding`, `isSingleKeyBinding`, `seq`
278
393
  - React (`@inklu/keys/react`):
279
394
  - `useMountCommands(commands)` - starts listening
280
395
  - `useCommands(commands)`, `useCommand(commands, id)` - live command snapshots
@@ -283,13 +398,15 @@ Clicking an element with `data-command` runs that command (`source: "dom"`), unl
283
398
  - `useCommandHandler(commands, id, run, enabled)` - what a command does while a component is mounted
284
399
  - `useRebind(commands, id, options)` - records user shortcuts
285
400
  - `usePendingSequences(commands)` - half-typed sequences
286
- - `useHeldKeys()`, `useHeldKeyCodes()`, `useKeyHold(key)` - live key state
401
+ - `useHeldKeys()` - the keys held down right now, by name
287
402
 
288
403
  Inside a handler, the context's `commands` is typed with the registry's own ids, so `commands.run("typo")` is a compile error.
289
404
 
290
405
  ## Accessibility
291
406
 
292
- 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:
293
410
 
294
411
  ```tsx
295
412
  const { aria, spoken } = useShortcut(commands, "nav.inbox");
@@ -1,4 +1,4 @@
1
- import { l as Commands } from "./types-QEXldPnL.mjs";
1
+ import { l as Commands } from "./types-D-hUKGoN.mjs";
2
2
  //#region src/attributes.d.ts
3
3
  export interface ObserveOptions {
4
4
  /** Root document or element to delegate from. Defaults to `document`. */
@@ -52,28 +52,25 @@ function observe(registry, options = {}) {
52
52
  lastPainted = next;
53
53
  paintWithin(root);
54
54
  });
55
- let observer;
56
- if ("MutationObserver" in globalThis) {
57
- observer = new MutationObserver((mutations) => {
58
- for (const mutation of mutations) {
59
- if (mutation.type === "attributes" && mutation.target instanceof Element) {
60
- paintElement(mutation.target);
61
- continue;
62
- }
63
- 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;
64
60
  }
65
- });
66
- observer.observe(root instanceof Document ? root.documentElement : root, {
67
- childList: true,
68
- subtree: true,
69
- attributes: true,
70
- attributeFilter: [COMMAND_ATTRIBUTE, KEYS_ATTRIBUTE]
71
- });
72
- }
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
+ });
73
70
  return () => {
74
71
  root.removeEventListener("click", onClick);
75
72
  unsubscribe();
76
- observer?.disconnect();
73
+ observer.disconnect();
77
74
  };
78
75
  }
79
76
  //#endregion
@@ -1,4 +1,4 @@
1
- import { C as SequenceBinding, D as Chord, a as CommandDefinition, d as Conflict, k as Platform, l as Commands, m as DisplayOptions, r as Command, t as Binding, u as CommandsOptions } from "./types-QEXldPnL.mjs";
1
+ import { C as SequenceBinding, D as Chord, T as SerializedKeymap, a as CommandDefinition, d as Conflict, g as KeymapStore, k as Platform, l as Commands, m as Json, p as DisplayOptions, r as Command, t as Binding, u as CommandsOptions } from "./types-D-hUKGoN.mjs";
2
2
  //#region src/bindings.d.ts
3
3
  /**
4
4
  * Creates a sequence binding from ordered chords.
@@ -10,35 +10,54 @@ import { C as SequenceBinding, D as Chord, a as CommandDefinition, d as Conflict
10
10
  */
11
11
  declare function seq(...steps: Array<Chord>): SequenceBinding;
12
12
  /**
13
- * Whether every step of a binding is a single printable character with no
14
- * Control, Alt or Meta — the "character key shortcuts" WCAG 2.1.4 lets users
15
- * turn off, since speech input and stray keypresses trigger them by accident.
13
+ * Whether every step is a printable character with no Control, Alt or Meta:
14
+ * the "character key shortcuts" WCAG 2.1.4 lets users turn off.
16
15
  */
17
16
  declare function isSingleKeyBinding(binding: Binding): boolean;
18
- /** Validation result for a hotkey binding. */
19
- interface BindingValidation {
20
- readonly valid: boolean;
21
- readonly errors: ReadonlyArray<string>;
22
- readonly warnings: ReadonlyArray<string>;
23
- }
24
- /** Validates a binding's syntax without logging warnings or errors. */
25
- declare function validateBinding(binding: Binding): BindingValidation;
26
17
  //#endregion
27
18
  //#region src/display.d.ts
28
- /** Formats a binding for display on the target platform (e.g. `⌘ K` or `Ctrl+K`). */
19
+ /**
20
+ * Formats a binding the way its platform writes shortcuts: `⇧⌘S` on a Mac,
21
+ * `Ctrl+Shift+S` elsewhere. The steps of a sequence are set apart by a space.
22
+ */
29
23
  declare function formatBinding(binding: Binding, options?: DisplayOptions): string;
30
- /** Splits a binding into display tokens for rendering `<kbd>` elements. */
24
+ /**
25
+ * Splits a binding into display tokens for rendering `<kbd>` elements: one
26
+ * per key of a chord, one per step of a sequence.
27
+ */
31
28
  declare function bindingTokens(binding: Binding, options?: DisplayOptions): Array<string>;
32
29
  /**
33
- * Phrases a binding for a screen reader: `"Control+K"` for a chord,
34
- * `"G then I"` for a sequence. `aria-keyshortcuts` cannot express a sequence
35
- * (a space there means "or"), so this is how a sequence gets announced — put it
36
- * in visually hidden text or `aria-description`.
30
+ * Phrases a binding for a screen reader: `"Command K"` for a chord,
31
+ * `"G then I"` for a sequence, which `aria-keyshortcuts` cannot express.
37
32
  */
38
33
  declare function spokenBinding(binding: Binding, platform?: Platform): string;
39
- /** Generates an `aria-keyshortcuts` attribute string for a binding. */
34
+ /**
35
+ * The `aria-keyshortcuts` value for a chord, as in `"Meta+K"`. Empty for a
36
+ * sequence and for a chord that cannot be read.
37
+ */
40
38
  declare function ariaKeyShortcuts(binding: Binding, platform?: Platform): string;
41
39
  //#endregion
40
+ //#region src/persist.d.ts
41
+ type WebStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
42
+ /**
43
+ * Keeps user overrides in Web Storage under `key`, and follows edits made in
44
+ * other tabs. `storage` is only called in the browser; return
45
+ * `sessionStorage` or an object of your own to keep them somewhere else.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * createCommands(definitions, { persist: webStorage("my-app.keys") });
50
+ * ```
51
+ */
52
+ declare function webStorage(key?: string, storage?: () => WebStorage | null): KeymapStore;
53
+ /** A registry's user overrides as JSON-safe data, for your own storage or another device. */
54
+ declare function exportKeymap<Id extends string>(commands: Pick<Commands<Id>, "keymap">): SerializedKeymap;
55
+ /**
56
+ * Replaces a registry's user overrides with exported data, and saves them.
57
+ * Data in another format changes nothing.
58
+ */
59
+ declare function importKeymap<Id extends string>(commands: Pick<Commands<Id>, "keymap">, data: Json): void;
60
+ //#endregion
42
61
  //#region src/rebind.d.ts
43
62
  /** Hotkey capture mode: single `"chord"` or multi-step `"sequence"`. */
44
63
  type RebindMode = "chord" | "sequence";
@@ -50,7 +69,7 @@ interface RebindState<Id extends string = string> {
50
69
  readonly binding: Binding | null;
51
70
  /** Captured steps in the current sequence session. */
52
71
  readonly steps: ReadonlyArray<Chord>;
53
- /** Conflicting commands for the captured binding. */
72
+ /** What stands in the captured binding's way: other commands using it, or the platform reserving it. */
54
73
  readonly conflicts: ReadonlyArray<Conflict<Id>>;
55
74
  }
56
75
  /** Options for creating a rebinder. */
@@ -61,7 +80,10 @@ interface RebindOptions<Id extends string = string> {
61
80
  apply?: boolean;
62
81
  /** Whether to append rather than replace existing bindings. Defaults to `false`. */
63
82
  add?: boolean;
64
- /** Prevent applying bindings that conflict with other commands. Defaults to `false`. */
83
+ /**
84
+ * Prevent applying bindings that conflict with other commands. Defaults to
85
+ * `false`. A binding the browser or operating system reserves is never applied.
86
+ */
65
87
  rejectConflicts?: boolean;
66
88
  /** Ignore key events from inputs while recording. Defaults to `true`. */
67
89
  ignoreInputs?: boolean;
@@ -92,17 +114,14 @@ interface Rebinder<Id extends string = string> {
92
114
  destroy: () => void;
93
115
  }
94
116
  /**
95
- * Creates a rebinder instance for capturing and remapping a command's keys.
96
- * While it records it holds the whole keyboard, so no command runs on the
97
- * keys being pressed. Escape cancels, Backspace removes the shortcut (or the
98
- * last step of a sequence), and Enter finishes a sequence.
117
+ * Creates a rebinder for recording a command's keys. While it records, no
118
+ * command runs. Escape cancels, Backspace removes the shortcut (or the last
119
+ * step of a sequence), and Enter finishes a sequence.
99
120
  *
100
121
  * @example
101
122
  * ```ts
102
123
  * const rebinder = createRebinder(commands, "palette.open", {
103
- * onRecord: (binding, conflicts) => {
104
- * if (conflicts.length) console.warn("Conflict with", conflicts[0].id);
105
- * },
124
+ * onRecord: (binding, conflicts) => showConflicts(conflicts),
106
125
  * });
107
126
  * rebinder.start();
108
127
  * ```
@@ -125,7 +144,7 @@ declare function createRebinder<Id extends string>(registry: Commands<Id>, id: I
125
144
  * });
126
145
  * ```
127
146
  */
128
- declare function createCommands<Id extends string>(definitions: { [K in Id]: CommandDefinition<Id>; }, options?: CommandsOptions<NoInfer<Id>>): Commands<Id>;
147
+ declare function createCommands<Id extends string>(definitions: { [K in Id]: CommandDefinition<NoInfer<Id>>; }, options?: CommandsOptions<NoInfer<Id>>): Commands<Id>;
129
148
  /**
130
149
  * Extracts command ID union from a Commands registry type.
131
150
  *
@@ -139,12 +158,10 @@ type CommandId<C> = C extends Commands<infer Id> ? Id : never;
139
158
  //#endregion
140
159
  //#region src/search.d.ts
141
160
  /**
142
- * Filters commands for a search query, as a command palette does, leaving
143
- * out `hidden` ones. Every word of the query must appear somewhere in the
144
- * command — title, description, group, keywords, id or displayed shortcut —
145
- * in any order. Title matches come first: titles starting with the query,
146
- * then titles containing every word, then the rest; each tier keeps the
147
- * list's own order. An empty query returns every visible command.
161
+ * Filters commands for a search query, leaving out `hidden` ones. Every word
162
+ * must appear in the title, description, group, keywords, id or displayed
163
+ * shortcut. Titles starting with the query come first, then titles containing
164
+ * every word, then the rest. An empty query returns every visible command.
148
165
  *
149
166
  * @example
150
167
  * ```ts
@@ -153,4 +170,4 @@ type CommandId<C> = C extends Commands<infer Id> ? Id : never;
153
170
  */
154
171
  declare function matchCommands<Id extends string>(commands: ReadonlyArray<Command<Id>>, query: string): Array<Command<Id>>;
155
172
  //#endregion
156
- export { RebindOptions as a, createRebinder as c, formatBinding as d, spokenBinding as f, validateBinding as g, seq as h, RebindMode as i, ariaKeyShortcuts as l, isSingleKeyBinding as m, CommandId as n, RebindState as o, BindingValidation as p, createCommands as r, Rebinder as s, matchCommands as t, bindingTokens as u };
173
+ export { seq as _, RebindOptions as a, createRebinder as c, webStorage as d, ariaKeyShortcuts as f, isSingleKeyBinding as g, spokenBinding as h, RebindMode as i, exportKeymap as l, formatBinding as m, CommandId as n, RebindState as o, bindingTokens as p, createCommands as r, Rebinder as s, matchCommands as t, importKeymap as u };
package/dist/index.d.mts CHANGED
@@ -1,3 +1,3 @@
1
- import { C as SequenceBinding, D as Chord, E as SerializedOverride, O as Key, S as ScopeOptions, T as SerializedKeymap, _ as KeymapPersistence, a as CommandDefinition, b as RunBlockReason, c as CommandSource, d as Conflict, f as ConflictKind, g as KeyCapture, h as Json, i as CommandContext, k as Platform, l as Commands, m as DisplayOptions, n as BindingInput, o as CommandHandler, p as ConflictOptions, r as Command, s as CommandKeymap, t as Binding, u as CommandsOptions, v as KeymapStorage, w as SerializedBinding, x as RunOptions, y as PendingSequence } from "./types-QEXldPnL.mjs";
2
- import { a as RebindOptions, c as createRebinder, d as formatBinding, f as spokenBinding, g as validateBinding, h as seq, i as RebindMode, l as ariaKeyShortcuts, m as isSingleKeyBinding, n as CommandId, o as RebindState, p as BindingValidation, r as createCommands, s as Rebinder, t as matchCommands, u as bindingTokens } from "./index-3d9f54ln.mjs";
3
- export { type Binding, type BindingInput, type BindingValidation, type Chord, type Command, type CommandContext, type CommandDefinition, type CommandHandler, type CommandId, type CommandKeymap, type CommandSource, type Commands, type CommandsOptions, type Conflict, type ConflictKind, type ConflictOptions, type DisplayOptions, type Json, type Key, type KeyCapture, type KeymapPersistence, type KeymapStorage, type PendingSequence, type Platform, type RebindMode, type RebindOptions, type RebindState, type Rebinder, type RunBlockReason, type RunOptions, type ScopeOptions, type SequenceBinding, type SerializedBinding, type SerializedKeymap, type SerializedOverride, ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, formatBinding, isSingleKeyBinding, matchCommands, seq, spokenBinding, validateBinding };
1
+ import { C as SequenceBinding, D as Chord, E as SerializedOverride, O as Key, S as ScopeOptions, T as SerializedKeymap, _ as Override, a as CommandDefinition, b as RunBlockReason, c as CommandSource, d as Conflict, f as ConflictOptions, g as KeymapStore, h as KeyCapture, i as CommandContext, k as Platform, l as Commands, m as Json, n as BindingInput, o as CommandHandler, p as DisplayOptions, r as Command, s as CommandKeymap, t as Binding, u as CommandsOptions, v as PendingSequence, w as SerializedBinding, x as RunOptions, y as PlatformBindings } from "./types-D-hUKGoN.mjs";
2
+ import { _ as seq, a as RebindOptions, c as createRebinder, d as webStorage, f as ariaKeyShortcuts, g as isSingleKeyBinding, h as spokenBinding, i as RebindMode, l as exportKeymap, m as formatBinding, n as CommandId, o as RebindState, p as bindingTokens, r as createCommands, s as Rebinder, t as matchCommands, u as importKeymap } from "./index-DRXMJmbc.mjs";
3
+ export { type Binding, type BindingInput, type Chord, type Command, type CommandContext, type CommandDefinition, type CommandHandler, type CommandId, type CommandKeymap, type CommandSource, type Commands, type CommandsOptions, type Conflict, type ConflictOptions, type DisplayOptions, type Json, type Key, type KeyCapture, type KeymapStore, type Override, type PendingSequence, type Platform, type PlatformBindings, type RebindMode, type RebindOptions, type RebindState, type Rebinder, type RunBlockReason, type RunOptions, type ScopeOptions, type SequenceBinding, type SerializedBinding, type SerializedKeymap, type SerializedOverride, ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, exportKeymap, formatBinding, importKeymap, isSingleKeyBinding, matchCommands, seq, spokenBinding, webStorage };
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as bindingTokens, c as isSingleKeyBinding, i as ariaKeyShortcuts, l as seq, n as createCommands, o as formatBinding, r as createRebinder, s as spokenBinding, t as matchCommands, u as validateBinding } from "./src-B76YvObL.mjs";
2
- export { ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, formatBinding, isSingleKeyBinding, matchCommands, seq, spokenBinding, validateBinding };
1
+ import { a as importKeymap, c as bindingTokens, d as isSingleKeyBinding, f as seq, i as exportKeymap, l as formatBinding, n as createCommands, o as webStorage, r as createRebinder, s as ariaKeyShortcuts, t as matchCommands, u as spokenBinding } from "./src-TaZw4V61.mjs";
2
+ export { ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, exportKeymap, formatBinding, importKeymap, isSingleKeyBinding, matchCommands, seq, spokenBinding, webStorage };
package/dist/react.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { C as SequenceBinding, D as Chord, E as SerializedOverride, O as Key, S as ScopeOptions, T as SerializedKeymap, _ as KeymapPersistence, a as CommandDefinition, b as RunBlockReason, c as CommandSource, d as Conflict, f as ConflictKind, g as KeyCapture, h as Json, i as CommandContext, k as Platform, l as Commands, m as DisplayOptions, n as BindingInput, o as CommandHandler, p as ConflictOptions, r as Command, s as CommandKeymap, t as Binding, u as CommandsOptions, v as KeymapStorage, w as SerializedBinding, x as RunOptions, y as PendingSequence } from "./types-QEXldPnL.mjs";
2
- import { a as RebindOptions, c as createRebinder, d as formatBinding, f as spokenBinding, g as validateBinding, h as seq, i as RebindMode, l as ariaKeyShortcuts, m as isSingleKeyBinding, n as CommandId, o as RebindState, p as BindingValidation, r as createCommands, s as Rebinder, t as matchCommands, u as bindingTokens } from "./index-3d9f54ln.mjs";
1
+ import { C as SequenceBinding, D as Chord, E as SerializedOverride, O as Key, S as ScopeOptions, T as SerializedKeymap, _ as Override, a as CommandDefinition, b as RunBlockReason, c as CommandSource, d as Conflict, f as ConflictOptions, g as KeymapStore, h as KeyCapture, i as CommandContext, k as Platform, l as Commands, m as Json, n as BindingInput, o as CommandHandler, p as DisplayOptions, r as Command, s as CommandKeymap, t as Binding, u as CommandsOptions, v as PendingSequence, w as SerializedBinding, x as RunOptions, y as PlatformBindings } from "./types-D-hUKGoN.mjs";
2
+ import { _ as seq, a as RebindOptions, c as createRebinder, d as webStorage, f as ariaKeyShortcuts, g as isSingleKeyBinding, h as spokenBinding, i as RebindMode, l as exportKeymap, m as formatBinding, n as CommandId, o as RebindState, p as bindingTokens, r as createCommands, s as Rebinder, t as matchCommands, u as importKeymap } from "./index-DRXMJmbc.mjs";
3
3
  //#region src/react/hooks.d.ts
4
4
  /**
5
5
  * Mounts a commands registry for the lifecycle of the component.
@@ -13,9 +13,13 @@ import { a as RebindOptions, c as createRebinder, d as formatBinding, f as spoke
13
13
  * ```
14
14
  */
15
15
  export declare function useMountCommands<Id extends string>(registry: Commands<Id>): void;
16
- /** Returns all registered commands, re-rendering when the registry changes. */
16
+ /**
17
+ * Returns all registered commands, re-rendering when the registry changes.
18
+ * Hydrates against `serverList()`, so a subtree that hydrates late still
19
+ * matches the server's HTML.
20
+ */
17
21
  export declare function useCommands<Id extends string>(registry: Commands<Id>): ReadonlyArray<Command<Id>>;
18
- /** Returns a specific command snapshot by ID. */
22
+ /** Returns one command's snapshot, re-rendering only when that command changes. */
19
23
  export declare function useCommand<Id extends string>(registry: Commands<Id>, id: NoInfer<Id>): Command<Id> | undefined;
20
24
  /**
21
25
  * Returns display and accessibility shortcuts for a command's primary
@@ -30,9 +34,8 @@ export declare function useShortcut<Id extends string>(registry: Commands<Id>, i
30
34
  };
31
35
  /**
32
36
  * Activates a scope for the lifecycle of the component. Scopes rank in the
33
- * order they are pushed, which here is the order React runs effects in: a
34
- * surface opened later ranks higher, but of a parent and a child opening in
35
- * the same render the child is pushed first.
37
+ * order React runs effects in: of a parent and a child opening in the same
38
+ * render, the child is pushed first.
36
39
  *
37
40
  * @example
38
41
  * ```tsx
@@ -41,12 +44,10 @@ export declare function useShortcut<Id extends string>(registry: Commands<Id>, i
41
44
  */
42
45
  export declare function useScope<Id extends string>(registry: Commands<Id>, name: string, active?: boolean, options?: ScopeOptions): void;
43
46
  /**
44
- * Attaches what a command does for the lifecycle of the component, so the
45
- * behaviour lives where its state does. The latest `run` is always the one
46
- * called. While `enabled` is `false` the component steps back: the command
47
- * falls to the handler attached before it, or cannot run if there is none.
48
- * The handler attached last wins, and React attaches a parent's after its
49
- * children's when they mount together.
47
+ * Attaches what a command does for the lifecycle of the component. The latest
48
+ * `run` is always the one called, and the handler attached last wins. While
49
+ * `enabled` is `false` the command falls to the handler attached before it,
50
+ * or cannot run if there is none.
50
51
  *
51
52
  * @example
52
53
  * ```tsx
@@ -72,27 +73,9 @@ interface UseRebindResult<Id extends string = string> extends RebindState<Id> {
72
73
  }
73
74
  /** Hook for recording and configuring a command's keybindings. */
74
75
  export declare function useRebind<Id extends string>(registry: Commands<Id>, id: NoInfer<Id>, options?: RebindOptions<Id>): UseRebindResult<Id>;
75
- /** Returns an array of keys currently held down. */
76
+ /** Returns the keys held down right now, by the name `KeyboardEvent.key` gives each. */
76
77
  export declare function useHeldKeys(): ReadonlyArray<string>;
77
- /**
78
- * Checks whether a specific key is currently held down.
79
- *
80
- * @example
81
- * ```tsx
82
- * const shift = useKeyHold("Shift");
83
- * ```
84
- */
85
- export declare function useKeyHold(key: string): boolean;
86
- /** Returns a map of held key identifiers to physical event codes. */
87
- export declare function useHeldKeyCodes(): Readonly<Record<string, string>>;
88
- /**
89
- * Returns currently active partially-typed sequences.
90
- *
91
- * @example
92
- * ```tsx
93
- * const pending = usePendingSequences(commands);
94
- * ```
95
- */
78
+ /** Returns the half-typed sequences, for a hint such as "G …". */
96
79
  export declare function usePendingSequences<Id extends string>(registry: Commands<Id>): ReadonlyArray<PendingSequence<Id>>;
97
80
  //#endregion
98
- export { type Binding, type BindingInput, type BindingValidation, type Chord, type Command, type CommandContext, type CommandDefinition, type CommandHandler, type CommandId, type CommandKeymap, type CommandSource, type Commands, type CommandsOptions, type Conflict, type ConflictKind, type ConflictOptions, type DisplayOptions, type Json, type Key, type KeyCapture, type KeymapPersistence, type KeymapStorage, type PendingSequence, type Platform, type RebindMode, type RebindOptions, type RebindState, type Rebinder, type RunBlockReason, type RunOptions, type ScopeOptions, type SequenceBinding, type SerializedBinding, type SerializedKeymap, type SerializedOverride, type UseRebindResult, ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, formatBinding, isSingleKeyBinding, matchCommands, seq, spokenBinding, validateBinding };
81
+ export { type Binding, type BindingInput, type Chord, type Command, type CommandContext, type CommandDefinition, type CommandHandler, type CommandId, type CommandKeymap, type CommandSource, type Commands, type CommandsOptions, type Conflict, type ConflictOptions, type DisplayOptions, type Json, type Key, type KeyCapture, type KeymapStore, type Override, type PendingSequence, type Platform, type PlatformBindings, type RebindMode, type RebindOptions, type RebindState, type Rebinder, type RunBlockReason, type RunOptions, type ScopeOptions, type SequenceBinding, type SerializedBinding, type SerializedKeymap, type SerializedOverride, type UseRebindResult, ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, exportKeymap, formatBinding, importKeymap, isSingleKeyBinding, matchCommands, seq, spokenBinding, webStorage };