@inklu/keys 2.0.1 → 2.1.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.
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`
6
14
 
7
15
  ```bash
8
16
  pnpm add @inklu/keys
@@ -10,22 +18,22 @@ 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
  ```
@@ -35,7 +43,7 @@ export const commands = createCommands({
35
43
  Pass the registry explicitly to all hooks. There is no Context provider.
36
44
 
37
45
  ```tsx
38
- import { useCommands, useMountCommands, useShortcut } from "@inklu/keys/react";
46
+ import { useCommandHandler, useMountCommands, useShortcut } from "@inklu/keys/react";
39
47
  import { commands } from "./commands";
40
48
 
41
49
  function App() {
@@ -44,6 +52,13 @@ function App() {
44
52
  return <ShortcutButton />;
45
53
  }
46
54
 
55
+ function Palette() {
56
+ const [open, setOpen] = useState(false);
57
+ // What the command does, attached where the state lives.
58
+ useCommandHandler(commands, "palette.open", () => setOpen(true));
59
+ return open ? <dialog open>…</dialog> : null;
60
+ }
61
+
47
62
  function ShortcutButton() {
48
63
  // Live state for the primary binding: `tokens` is one string per key
49
64
  const shortcut = useShortcut(commands, "palette.open");
@@ -62,6 +77,21 @@ function ShortcutButton() {
62
77
  }
63
78
  ```
64
79
 
80
+ ### Handlers
81
+
82
+ 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.
83
+
84
+ ```tsx
85
+ // The last argument switches the handler off: with nothing to save, Mod+S is not ours.
86
+ useCommandHandler(commands, "doc.save", save, dirty);
87
+ ```
88
+
89
+ - A command with no handler cannot run and leaves its key alone, so unmounting a feature switches its shortcuts off with it.
90
+ - 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
+ - Outside React, `commands.handle(id, run)` does the same and returns a function that detaches.
92
+
93
+ 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
+
65
95
  ## 3. Scopes
66
96
 
67
97
  Use scopes to isolate commands when modals or dialogs are open.
@@ -70,23 +100,28 @@ Use scopes to isolate commands when modals or dialogs are open.
70
100
  import { useScope } from "@inklu/keys/react";
71
101
 
72
102
  function Dialog({ isOpen }) {
73
- // When exclusive is true, commands outside this scope are silenced
103
+ // When exclusive is true, the keys of commands outside this scope are silenced
74
104
  useScope(commands, "dialog", isOpen, { exclusive: true });
75
105
 
76
106
  return isOpen ? <div className="dialog">...</div> : null;
77
107
  }
78
108
  ```
79
109
 
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.
110
+ `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.
111
+
112
+ 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
113
 
82
114
  ### When two commands share a key
83
115
 
84
- Exactly one command runs per keypress. The winner is:
116
+ Exactly one command runs per keypress. Among the commands that could take it, the winner is:
85
117
 
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.
118
+ 1. A sequence being finished, over a chord on the same key.
119
+ 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".
120
+ 3. Failing that, the one defined later.
88
121
 
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.
122
+ 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.
123
+
124
+ 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
125
 
91
126
  ## 4. Rebinding
92
127
 
@@ -136,7 +171,7 @@ rebinder.subscribe(() => render(rebinder.state)); // { recording, binding, steps
136
171
  rebinder.start(); // Escape cancels, Backspace unbinds
137
172
  ```
138
173
 
139
- While recording, every other command is silenced — including `"*"` ones — so the keys being recorded never run anything.
174
+ 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
175
 
141
176
  ## 5. Conflicts
142
177
 
@@ -145,7 +180,7 @@ While recording, every other command is silenced — including `"*"` ones — so
145
180
  - `"exact"` — the same keys. Only one command runs (see above).
146
181
  - `"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
182
 
148
- In development, both kinds are also reported with `console.warn` when commands are defined or added.
183
+ In development, both kinds are also reported with `console.warn` when the registry is created. The check is dropped from production builds.
149
184
 
150
185
  ## 6. Persistence and SSR
151
186
 
@@ -158,50 +193,34 @@ createCommands(definitions, {
158
193
  });
159
194
  ```
160
195
 
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.
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.
162
197
 
163
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.
164
199
 
165
200
  The registry is safe to create at module scope and render on the server:
166
201
 
167
202
  - 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.
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.
169
204
  - Nothing listens for keys until `mount()` (or `useMountCommands`).
170
205
 
171
206
  ## 7. Inputs and events
172
207
 
173
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.
174
209
 
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`.
176
-
177
- `commands.whyNotRun(id)` explains a shortcut that did not fire: `"disabled"`, `"out-of-scope"`, `"guarded"`, `"unknown"`, or `null` when it would run.
178
-
179
- ## 8. Runtime commands
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.
180
211
 
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
- ```
212
+ `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
213
 
195
- ## 9. Sequence progress
214
+ ## 8. Sequence progress
196
215
 
197
216
  ```tsx
198
217
  const pending = usePendingSequences(commands); // [{ id, matched, total, remaining }]
199
218
  // e.g. show "G → …" while the user is halfway through `G then I`
200
219
  ```
201
220
 
202
- `commands.pending()` / `commands.subscribePending()` are the non-React equivalents.
221
+ `commands.subscribePending()` / `commands.pending()` are the non-React equivalents. Progress is tracked only while something is subscribed, so `pending()` is empty otherwise.
203
222
 
204
- ## 10. Command palette
223
+ ## 9. Command palette
205
224
 
206
225
  Definitions can carry what a palette needs to list and find them, and `matchCommands` does the filtering:
207
226
 
@@ -213,21 +232,22 @@ createCommands({
213
232
  group: "Document", // heading to list it under
214
233
  keywords: ["search", "lookup"], // extra search terms
215
234
  keys: "Mod+F",
216
- run: openFind,
217
235
  },
218
- "palette.close": { title: "Close palette", hidden: true, keys: "Escape", run: close },
236
+ "palette.close": { title: "Close palette", hidden: true, scope: "palette", keys: "Escape" },
219
237
  });
220
238
  ```
221
239
 
222
240
  ```tsx
223
241
  import { matchCommands } from "@inklu/keys";
224
242
 
225
- const results = matchCommands(useCommands(commands), query);
243
+ // List what can run right now: it has a handler and its scope is open.
244
+ const runnable = useCommands(commands).filter((command) => commands.canRun(command.id));
245
+ const results = matchCommands(runnable, query);
226
246
  ```
227
247
 
228
248
  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
249
 
230
- ## 11. Without React: data attributes
250
+ ## 10. Without React: data attributes
231
251
 
232
252
  ```ts
233
253
  import { observe } from "@inklu/keys/attributes";
@@ -245,25 +265,27 @@ Clicking an element with `data-command` runs that command (`source: "dom"`), unl
245
265
 
246
266
  - `createCommands(definitions, options)` - creates the registry
247
267
  - `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)`
268
+ - `commands.run(id)`, `canRun(id)`, `whyNotRun(id)`, `handle(id, run)`
269
+ - `commands.get(id)`, `list()`, `subscribe(listener)`: snapshots with `display`, `tokens`, `aria`, `spoken`, `reachable`, `overridden`
270
+ - `commands.pushScope(name, { exclusive })`, `capture(onKey)`, `conflicts(binding, options)`, `mount()`, `destroy()`
271
+ - `commands.serverList()`: the list as the server rendered it, for a framework's hydration snapshot
250
272
  - `commands.keymap`: `get`, `set`, `add`, `reset`, `export`, `import`
251
273
  - `commands.setSingleKeyShortcuts(enabled)`, `commands.singleKeyShortcuts`
252
274
  - `createRebinder(commands, id, options)` - framework-agnostic shortcut recording
253
275
  - `observe(commands, { root })` from `@inklu/keys/attributes` - declarative DOM wiring
254
- - `matchCommands(list, query)` - palette filtering over `title`, `description`, `group`, `keywords`
276
+ - `matchCommands(list, query)` - palette filtering over `title`, `description`, `group`, `keywords`, id and displayed shortcut
255
277
  - `formatBinding`, `bindingTokens`, `ariaKeyShortcuts`, `spokenBinding`, `validateBinding`, `isSingleKeyBinding`, `seq`
256
278
  - React (`@inklu/keys/react`):
257
279
  - `useMountCommands(commands)` - starts listening
258
280
  - `useCommands(commands)`, `useCommand(commands, id)` - live command snapshots
259
281
  - `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
282
+ - `useScope(commands, name, active, { exclusive })` - opens a scope while a component needs it
283
+ - `useCommandHandler(commands, id, run, enabled)` - what a command does while a component is mounted
262
284
  - `useRebind(commands, id, options)` - records user shortcuts
263
285
  - `usePendingSequences(commands)` - half-typed sequences
264
286
  - `useHeldKeys()`, `useHeldKeyCodes()`, `useKeyHold(key)` - live key state
265
287
 
266
- Inside `run`, the context's `commands` is typed with the registry's own ids, so `commands.run("typo")` is a compile error.
288
+ Inside a handler, the context's `commands` is typed with the registry's own ids, so `commands.run("typo")` is a compile error.
267
289
 
268
290
  ## Accessibility
269
291
 
@@ -284,6 +306,10 @@ commands.setSingleKeyShortcuts(false); // or createCommands(defs, { singleKeySho
284
306
 
285
307
  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
308
 
309
+ ## Acknowledgements
310
+
311
+ 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.
312
+
287
313
  ## License
288
314
 
289
315
  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-QEXldPnL.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
  }
@@ -1,22 +1,20 @@
1
- import { C as Command, D as Conflict, F as SequenceBinding, N as Platform, S as Chord, a as Commands, b as Binding } from "./attributes-DotLr2MW.mjs";
2
- import { Hotkey } from "@tanstack/hotkeys";
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";
3
2
  //#region src/bindings.d.ts
4
3
  /**
5
- * Creates a sequence binding from ordered hotkey steps.
4
+ * Creates a sequence binding from ordered chords.
6
5
  *
7
6
  * @example
8
7
  * ```ts
9
8
  * keys: seq("G", "I")
10
9
  * ```
11
10
  */
12
- declare function seq(...steps: Array<Hotkey>): SequenceBinding;
11
+ declare function seq(...steps: Array<Chord>): SequenceBinding;
13
12
  /**
14
13
  * Whether every step of a binding is a single printable character with no
15
14
  * Control, Alt or Meta — the "character key shortcuts" WCAG 2.1.4 lets users
16
15
  * turn off, since speech input and stray keypresses trigger them by accident.
17
- * Shift does not count as a modifier here: `Shift+A` still types a character.
18
16
  */
19
- declare function isSingleKeyBinding(binding: Binding, platform?: Platform): boolean;
17
+ declare function isSingleKeyBinding(binding: Binding): boolean;
20
18
  /** Validation result for a hotkey binding. */
21
19
  interface BindingValidation {
22
20
  readonly valid: boolean;
@@ -26,6 +24,21 @@ interface BindingValidation {
26
24
  /** Validates a binding's syntax without logging warnings or errors. */
27
25
  declare function validateBinding(binding: Binding): BindingValidation;
28
26
  //#endregion
27
+ //#region src/display.d.ts
28
+ /** Formats a binding for display on the target platform (e.g. `⌘ K` or `Ctrl+K`). */
29
+ declare function formatBinding(binding: Binding, options?: DisplayOptions): string;
30
+ /** Splits a binding into display tokens for rendering `<kbd>` elements. */
31
+ declare function bindingTokens(binding: Binding, options?: DisplayOptions): Array<string>;
32
+ /**
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`.
37
+ */
38
+ declare function spokenBinding(binding: Binding, platform?: Platform): string;
39
+ /** Generates an `aria-keyshortcuts` attribute string for a binding. */
40
+ declare function ariaKeyShortcuts(binding: Binding, platform?: Platform): string;
41
+ //#endregion
29
42
  //#region src/rebind.d.ts
30
43
  /** Hotkey capture mode: single `"chord"` or multi-step `"sequence"`. */
31
44
  type RebindMode = "chord" | "sequence";
@@ -80,6 +93,9 @@ interface Rebinder<Id extends string = string> {
80
93
  }
81
94
  /**
82
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.
83
99
  *
84
100
  * @example
85
101
  * ```ts
@@ -93,9 +109,33 @@ interface Rebinder<Id extends string = string> {
93
109
  */
94
110
  declare function createRebinder<Id extends string>(registry: Commands<Id>, id: Id, options?: RebindOptions<Id>): Rebinder<Id>;
95
111
  //#endregion
96
- //#region src/scopes.d.ts
97
- /** Wildcard scope identifier for commands that remain active across exclusive scopes. */
98
- declare const ALWAYS_SCOPE = "*";
112
+ //#region src/registry.d.ts
113
+ /**
114
+ * Creates a command registry.
115
+ *
116
+ * @example
117
+ * ```ts
118
+ * export const commands = createCommands({
119
+ * "palette.open": { title: "Open command palette", keys: "Mod+K" },
120
+ * "nav.inbox": {
121
+ * title: "Go to inbox",
122
+ * keys: seq("G", "I"),
123
+ * run: () => router.navigate("/inbox"),
124
+ * },
125
+ * });
126
+ * ```
127
+ */
128
+ declare function createCommands<Id extends string>(definitions: { [K in Id]: CommandDefinition<Id>; }, options?: CommandsOptions<NoInfer<Id>>): Commands<Id>;
129
+ /**
130
+ * Extracts command ID union from a Commands registry type.
131
+ *
132
+ * @example
133
+ * ```ts
134
+ * export const commands = createCommands({ … });
135
+ * export type AppCommandId = CommandId<typeof commands>;
136
+ * ```
137
+ */
138
+ type CommandId<C> = C extends Commands<infer Id> ? Id : never;
99
139
  //#endregion
100
140
  //#region src/search.d.ts
101
141
  /**
@@ -113,4 +153,4 @@ declare const ALWAYS_SCOPE = "*";
113
153
  */
114
154
  declare function matchCommands<Id extends string>(commands: ReadonlyArray<Command<Id>>, query: string): Array<Command<Id>>;
115
155
  //#endregion
116
- export { RebindState as a, BindingValidation as c, validateBinding as d, RebindOptions as i, isSingleKeyBinding as l, ALWAYS_SCOPE as n, Rebinder as o, RebindMode as r, createRebinder as s, matchCommands as t, seq as u };
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 };
package/dist/index.d.mts CHANGED
@@ -1,3 +1,3 @@
1
- import { A as HotkeySequence, C as Command, D as Conflict, E as CommandSource, F as SequenceBinding, I as SerializedBinding, M as KeymapStorage, N as Platform, O as ConflictKind, P as ScopeOptions, S as Chord, T as CommandDefinition, _ as bindingTokens, a as Commands, b as Binding, c as PendingSequence, d as createCommands, f as CommandKeymap, g as ariaKeyShortcuts, h as DisplayOptions, i as CommandId, j as KeymapPersistence, k as Hotkey, l as RunBlockReason, m as SerializedOverride, n as observe, o as CommandsOptions, p as SerializedKeymap, r as CommandHandle, s as ConflictOptions, t as ObserveOptions, u as RunOptions, v as formatBinding, w as CommandContext, x as BindingInput, y as spokenBinding } from "./attributes-DotLr2MW.mjs";
2
- import { a as RebindState, c as BindingValidation, d as validateBinding, i as RebindOptions, l as isSingleKeyBinding, n as ALWAYS_SCOPE, o as Rebinder, r as RebindMode, s as createRebinder, t as matchCommands, u as seq } from "./index-BTgscYdI.mjs";
3
- export { ALWAYS_SCOPE, type Binding, type BindingInput, type BindingValidation, type Chord, type Command, type CommandContext, type CommandDefinition, type CommandHandle, type CommandId, type CommandKeymap, type CommandSource, type Commands, type CommandsOptions, type Conflict, type ConflictKind, type ConflictOptions, type DisplayOptions, type Hotkey, type HotkeySequence, type KeymapPersistence, type KeymapStorage, type ObserveOptions, 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, observe, seq, spokenBinding, validateBinding };
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 };
package/dist/index.mjs CHANGED
@@ -1,3 +1,2 @@
1
- import { observe } from "./attributes.mjs";
2
- import { a as ariaKeyShortcuts, c as spokenBinding, d as seq, i as createRebinder, n as createCommands, o as bindingTokens, p as validateBinding, r as ALWAYS_SCOPE, s as formatBinding, t as matchCommands, u as isSingleKeyBinding } from "./src-DgYjA1oS.mjs";
3
- export { ALWAYS_SCOPE, ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, formatBinding, isSingleKeyBinding, matchCommands, observe, seq, spokenBinding, validateBinding };
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 };
package/dist/react.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { A as HotkeySequence, C as Command, D as Conflict, E as CommandSource, F as SequenceBinding, I as SerializedBinding, M as KeymapStorage, N as Platform, O as ConflictKind, P as ScopeOptions, S as Chord, T as CommandDefinition, _ as bindingTokens, a as Commands, b as Binding, c as PendingSequence, d as createCommands, f as CommandKeymap, g as ariaKeyShortcuts, h as DisplayOptions, i as CommandId, j as KeymapPersistence, k as Hotkey, l as RunBlockReason, m as SerializedOverride, n as observe, o as CommandsOptions, p as SerializedKeymap, r as CommandHandle, s as ConflictOptions, t as ObserveOptions, u as RunOptions, v as formatBinding, w as CommandContext, x as BindingInput, y as spokenBinding } from "./attributes-DotLr2MW.mjs";
2
- import { a as RebindState, c as BindingValidation, d as validateBinding, i as RebindOptions, l as isSingleKeyBinding, n as ALWAYS_SCOPE, o as Rebinder, r as RebindMode, s as createRebinder, t as matchCommands, u as seq } from "./index-BTgscYdI.mjs";
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
3
  //#region src/react/hooks.d.ts
4
4
  /**
5
5
  * Mounts a commands registry for the lifecycle of the component.
@@ -29,7 +29,10 @@ export declare function useShortcut<Id extends string>(registry: Commands<Id>, i
29
29
  spoken: string;
30
30
  };
31
31
  /**
32
- * Activates a scope for the lifecycle of the component.
32
+ * 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.
33
36
  *
34
37
  * @example
35
38
  * ```tsx
@@ -37,8 +40,21 @@ export declare function useShortcut<Id extends string>(registry: Commands<Id>, i
37
40
  * ```
38
41
  */
39
42
  export declare function useScope<Id extends string>(registry: Commands<Id>, name: string, active?: boolean, options?: ScopeOptions): void;
40
- /** Registers a dynamic command for the lifecycle of the component. */
41
- export declare function useRegisterCommand<Id extends string>(registry: Commands<Id>, id: NoInfer<Id> | (string & {}), definition: CommandDefinition<NoInfer<Id>>): void;
43
+ /**
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.
50
+ *
51
+ * @example
52
+ * ```tsx
53
+ * useCommandHandler(commands, "palette.open", () => setOpen(true));
54
+ * useCommandHandler(commands, "doc.save", save, dirty);
55
+ * ```
56
+ */
57
+ export declare function useCommandHandler<Id extends string>(registry: Commands<Id>, id: NoInfer<Id>, run: CommandHandler<NoInfer<Id>>, enabled?: boolean): void;
42
58
  /** State and actions for rebinding a command. */
43
59
  interface UseRebindResult<Id extends string = string> extends RebindState<Id> {
44
60
  /** Active display string for the shortcut. */
@@ -79,4 +95,4 @@ export declare function useHeldKeyCodes(): Readonly<Record<string, string>>;
79
95
  */
80
96
  export declare function usePendingSequences<Id extends string>(registry: Commands<Id>): ReadonlyArray<PendingSequence<Id>>;
81
97
  //#endregion
82
- export { ALWAYS_SCOPE, type Binding, type BindingInput, type BindingValidation, type Chord, type Command, type CommandContext, type CommandDefinition, type CommandHandle, type CommandId, type CommandKeymap, type CommandSource, type Commands, type CommandsOptions, type Conflict, type ConflictKind, type ConflictOptions, type DisplayOptions, type Hotkey, type HotkeySequence, type KeymapPersistence, type KeymapStorage, type ObserveOptions, 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, observe, seq, spokenBinding, validateBinding };
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 };