@inklu/keys 2.2.0 → 3.0.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,7 +8,7 @@ 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. 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.
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 and recording a shortcut are imports of their own, and the registry does not pull them in.
12
12
 
13
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
 
@@ -20,7 +20,7 @@ pnpm add @inklu/keys
20
20
 
21
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
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")`.
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"`). Chords are typed, so a misspelling is a compile error. An array of chords means alternatives; a sequence is `seq("G", "I")`, and its steps must come within a second of each other.
24
24
 
25
25
  ```ts
26
26
  // commands.ts
@@ -38,9 +38,11 @@ export const commands = createCommands({
38
38
  });
39
39
  ```
40
40
 
41
+ A chord names what the key types, so `Mod+S` matches the key that types `s` on the user's layout. Where a layout types no Latin letter on a letter key (Cyrillic, Greek), or on the digit row, the key at that position on a US layout matches too.
42
+
41
43
  ### Keys that differ by platform
42
44
 
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.
45
+ `Mod` covers most shortcuts. 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
46
 
45
47
  ```ts
46
48
  createCommands({
@@ -50,65 +52,26 @@ createCommands({
50
52
  });
51
53
  ```
52
54
 
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
- ```
55
+ Some keys are not an app's to bind, and the registry warns about them in development: keys the browser or operating system keeps (`Mod+N`, `Mod+T`, `Mod+W` and their `Shift` forms, `Control+Tab`, `Mod+Q` on a Mac, `Alt+F4` and anything holding `Meta` elsewhere), which the page mostly never receives, so `doc.new` above is not `Mod+N`; and `Control+Alt` with a character key, which is AltGr on many Windows and Linux layouts and the VoiceOver modifier on a Mac, so `Mod+Alt+N` is fine on a Mac and not elsewhere. Browser features a page _can_ take over (`Mod+F`, `Mod+P`, `Mod+L`, `Mod+R`) are left to your judgement.
75
56
 
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>({ … })`.
57
+ Commands generated from data are typed when the generated object's keys are: give an `Object.fromEntries` result its key type (`as Record<\`go.${Slug}\`, CommandDefinition>`) before spreading it in, or name every id yourself with `createCommands<AppCommandId>({ … })`.
77
58
 
78
59
  ## 2. React Setup
79
60
 
80
61
  Pass the registry explicitly to all hooks. There is no Context provider.
81
62
 
82
63
  ```tsx
83
- import { useCommandHandler, useMountCommands, useShortcut } from "@inklu/keys/react";
64
+ import { useMountCommands, useShortcut } from "@inklu/keys/react";
84
65
  import { commands } from "./commands";
85
66
 
86
- function App() {
87
- // Starts listening in an effect
88
- useMountCommands(commands);
89
- return <ShortcutButton />;
90
- }
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
- }
67
+ useMountCommands(commands); // once, near the root: starts listening in an effect
98
68
 
99
69
  function ShortcutButton() {
100
- // Live state for the primary binding: `tokens` is one string per key
101
- const shortcut = useShortcut(commands, "palette.open");
102
-
70
+ // Live state for the primary binding: `display` to read, `tokens` one string per key
71
+ const { display, aria } = useShortcut(commands, "palette.open");
103
72
  return (
104
- <button
105
- onClick={() => commands.run("palette.open")}
106
- aria-keyshortcuts={shortcut.aria || undefined}
107
- >
108
- Open Palette
109
- {shortcut.tokens.map((token, i) => (
110
- <kbd key={i}>{token}</kbd>
111
- ))}
73
+ <button onClick={() => commands.run("palette.open")} aria-keyshortcuts={aria || undefined}>
74
+ Open Palette <kbd>{display}</kbd>
112
75
  </button>
113
76
  );
114
77
  }
@@ -123,10 +86,9 @@ A command runs when its scope is open and it has a handler. A definition's own `
123
86
  useCommandHandler(commands, "doc.save", save, dirty);
124
87
  ```
125
88
 
126
- - A command with no handler cannot run and leaves its key alone, so unmounting a feature switches its shortcuts off with it.
89
+ - A command with no handler cannot run and leaves its key alone, so unmounting a feature switches its shortcuts off with it. Outside React, `commands.handle(id, run)` does the same and returns a function that detaches.
127
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.
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.
91
+ - Keep a handler with its state in code that is already loaded: inside a lazily loaded component it leaves the key to the browser until its chunk arrives.
130
92
 
131
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.
132
94
 
@@ -135,14 +97,8 @@ Switch a command off by its handler (`enabled`, or detaching) rather than with a
135
97
  Use scopes to isolate commands when modals or dialogs are open.
136
98
 
137
99
  ```tsx
138
- import { useScope } from "@inklu/keys/react";
139
-
140
- function Dialog({ isOpen }) {
141
- // When exclusive is true, the keys of commands outside this scope are silenced
142
- useScope(commands, "dialog", isOpen, { exclusive: true });
143
-
144
- return isOpen ? <div className="dialog">...</div> : null;
145
- }
100
+ // In the dialog: while open, the keys of commands outside this scope are silenced.
101
+ useScope(commands, "dialog", isOpen, { exclusive: true });
146
102
  ```
147
103
 
148
104
  `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.
@@ -154,11 +110,9 @@ A barrier claims the keyboard, not the command: `commands.run(id)` still works b
154
110
  Exactly one command runs per keypress. Among the commands that could take it, the winner is:
155
111
 
156
112
  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".
113
+ 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". With `useScope`, "most recently" is the order React runs effects in: of a parent and a child that open in the same render, the child is pushed first.
158
114
  3. Failing that, the one defined later.
159
115
 
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.
161
-
162
116
  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.
163
117
 
164
118
  ## 4. Rebinding
@@ -167,70 +121,41 @@ Overrides are layered over defaults.
167
121
 
168
122
  ```ts
169
123
  commands.keymap.set("palette.open", "Mod+P"); // remap
170
- commands.keymap.add("palette.open", "Control+P"); // an additional key
124
+ commands.keymap.set("palette.open", ["Mod+P", "Mod+K"]); // two keys for it
171
125
  commands.keymap.set("palette.open", null); // unbind
172
126
  commands.keymap.reset("palette.open"); // reset to default
173
127
  ```
174
128
 
175
- `set` replaces the command's keys outright; setting exactly the default forgets the override instead. `add` layers on top of the default rather than copying it, so if a later release of your app changes the default, the user gets the new one and keeps the keys they added.
129
+ `set` replaces the command's keys outright; setting exactly the default forgets the override instead.
176
130
 
177
- To rebind a command you did not define — one a package shipped — pass `bindings` when creating the registry. An unknown id is a compile error, and `reset` returns here rather than to the definition's `keys`:
131
+ To rebind a command you did not define — one a package shipped — pass `bindings: { "palette.open": "Mod+P", "doc.publish": null }` when creating the registry (`null` ships it unbound). An unknown id is a compile error, and `reset` returns here rather than to the definition's `keys`. These are defaults, applied before persisted overrides load, so a user's own rebinding still wins.
178
132
 
179
- ```ts
180
- createCommands(commands, {
181
- bindings: {
182
- "palette.open": "Mod+P", // replace the shipped default
183
- "doc.publish": null, // ship it unbound
184
- },
185
- });
186
- ```
187
-
188
- These are defaults, applied before persisted overrides load, so a user's own rebinding still wins.
189
-
190
- In React, use the `useRebind` hook for a capture flow:
133
+ In React, `useRebind` is a capture flow; `createRebinder` is the same without React:
191
134
 
192
135
  ```tsx
193
- const rebind = useRebind(commands, "palette.open");
194
-
195
- <button onClick={rebind.start}>{rebind.recording ? "Press keys…" : rebind.display}</button>;
196
- ```
197
-
198
- To build your own capture UI without React, use `createRebinder`:
199
-
200
- ```ts
201
- import { createRebinder } from "@inklu/keys";
136
+ const rebind = useRebind(commands, "palette.open"); // { sequence: true } records steps until Enter
137
+ <button onClick={rebind.recording ? rebind.cancel : rebind.start}>
138
+ {rebind.recording ? "Press keys…" : rebind.display}
139
+ </button>;
202
140
 
203
141
  const rebinder = createRebinder(commands, "palette.open", {
204
- mode: "chord", // or "sequence": Enter or `commit()` finishes it
205
142
  rejectConflicts: true, // don't apply a binding another command uses
206
- // A binding the browser or OS reserves is never applied, whatever this says.
207
- onRecord: (binding, conflicts) => {},
143
+ onRecord: (binding, conflicts) => {}, // `binding` is null for Backspace, which unbinds
208
144
  });
209
145
  rebinder.subscribe(() => render(rebinder.state)); // { recording, binding, steps, conflicts }
210
146
  rebinder.start(); // Escape cancels, Backspace unbinds
211
147
  ```
212
148
 
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.
149
+ While recording, every command is silenced — including `"*"` ones — so the keys being recorded never run anything. The rebinder applies whatever is recorded: keys the browser keeps are warned about in development, not refused. It 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.
214
150
 
215
151
  ## 5. Conflicts
216
152
 
217
- `commands.conflicts(binding, { scope, ignore })` lists what stands in the way of `binding`. Each result has a `kind`:
153
+ `commands.conflicts(binding, { scope, ignore })` lists the commands in overlapping scopes whose keys stand in the way of `binding`. Each result has the other command's `id`, its `binding` and `display`, and a `kind`:
218
154
 
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).
155
+ - `"exact"` — the same keys. Only one command runs (see above).
221
156
  - `"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.
222
157
 
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.
158
+ A settings row shows them as `Also used by ${commands.get(conflict.id)?.title}`. In development, both are also reported with `console.warn` when the registry is created, for the defaults on both kinds of platform, along with keys the browser or operating system keeps. The check is dropped from production builds.
234
159
 
235
160
  ## 6. Persistence and SSR
236
161
 
@@ -240,65 +165,41 @@ import { createCommands, webStorage } from "@inklu/keys";
240
165
  createCommands(definitions, { persist: webStorage("my-app.keys") });
241
166
  ```
242
167
 
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.
168
+ `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.
248
169
 
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.
170
+ Only user overrides are stored — never defaults. To move them between devices yourself, `exportKeymap(commands)` returns them as `{ version, overrides }` and `importKeymap(commands, data)` puts them back and saves them. Data in a version this release does not know is ignored instead of misread.
250
171
 
251
- The registry is safe to create at module scope and render on the server:
172
+ For a store that is not Web Storage, `persist` takes any object shaped like `KeymapStore`: `read()` returns a `Map` of bindings by command id (or `null` when it cannot) and `write(overrides)` takes one, both synchronous. For something asynchronous, such as a user's account, use `exportKeymap` and `importKeymap` instead.
252
173
 
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.
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.
255
- - Nothing listens for keys until `mount()` (or `useMountCommands`).
174
+ The registry is safe to create at module scope and render on the server. Persisted overrides load on the first `mount()`, not at creation, so the server render and the client's first render match; subscribers (and every hook) are notified when they arrive, and `keymap.set/reset` before mount loads them first. 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. Nothing listens for keys until `mount()` (or `useMountCommands`).
256
175
 
257
176
  ## 7. Inputs and events
258
177
 
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.
178
+ 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`), no command fires except one on `Escape`, so typing and the field's own editing keys are never hijacked. A command that should work there too, such as save on `Mod+S`, sets `ignoreInputs: false`. Keydowns that are part of an IME composition never trigger commands.
260
179
 
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.
180
+ A command that runs claims its key with `preventDefault()`, as does a key that is a step on the way through one of its sequences; one that is unhandled, out of scope or outranked leaves the event alone. Propagation is never stopped. A held key runs its command once: auto-repeat is ignored. A handler may be async; nothing awaits it.
262
181
 
263
182
  `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.
264
183
 
265
- ## 8. Sequence progress
266
-
267
- ```tsx
268
- const pending = usePendingSequences(commands); // [{ id, matched, total, remaining }]
269
- // e.g. show "G → …" while the user is halfway through `G then I`
270
- ```
271
-
272
- `commands.subscribePending()` / `commands.pending()` are the non-React equivalents. Progress is tracked only while something is subscribed, so `pending()` is empty otherwise.
273
-
274
- ## 9. Command palette
275
-
276
- Definitions can carry what a palette needs to list and find them, and `matchCommands` does the filtering:
184
+ ## 8. Command palette
277
185
 
278
- ```ts
279
- createCommands({
280
- "doc.find": {
281
- title: "Find in document",
282
- description: "Search the open document",
283
- group: "Document", // heading to list it under
284
- keywords: ["search", "lookup"], // extra search terms
285
- keys: "Mod+F",
286
- },
287
- "palette.close": { title: "Close palette", hidden: true, scope: "palette", keys: "Escape" },
288
- });
289
- ```
186
+ The registry lists every command with its `title`, its keys as `tokens` and whether it is `reachable`; the app filters and groups that list. What the registry does not hold, such as group headings or the ids to leave out, is a map of your own keyed by command id.
290
187
 
291
188
  ```tsx
292
- import { matchCommands } from "@inklu/keys";
293
-
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);
189
+ const GROUPS: Partial<Record<CommandId<typeof commands>, string>> = { "doc.find": "Document" };
190
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
191
+
192
+ // What can run right now (it has a handler and its scope is open), and matches every word.
193
+ const results = useCommands(commands).filter(
194
+ (command) =>
195
+ commands.whyNotRun(command.id) === null &&
196
+ words.every((word) => command.title.toLowerCase().includes(word)),
197
+ );
297
198
  ```
298
199
 
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()`.
200
+ Give `palette.open` `ignoreInputs: false`, so `Mod+K` works from a text field and from the palette's own search box.
300
201
 
301
- ## 10. Without React: data attributes
202
+ ## 9. Without React: data attributes
302
203
 
303
204
  ```ts
304
205
  import { observe } from "@inklu/keys/attributes";
@@ -312,45 +213,9 @@ const stop = observe(commands); // or observe(commands, { root: element })
312
213
 
313
214
  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.
314
215
 
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
216
+ ## 10. Recipes
320
217
 
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.
218
+ Patterns the pieces above already cover, so none of them needs an API of its own. Which item a shortcut acts on is your state: compute the target once and pass `targets.length > 0` as `enabled`, so with nothing targeted the key falls through, `reachable` is false for its button, and a palette that lists what can run leaves it out.
354
219
 
355
220
  ### A shortcut sheet on `?` and `Mod+/`
356
221
 
@@ -358,17 +223,16 @@ Bind a hold to a bare key. While Command is down, macOS sends no `keyup` for the
358
223
  "help.shortcuts": { title: "Keyboard shortcuts", keys: ["?", "Mod+/"], scope: "*" },
359
224
  ```
360
225
 
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.
226
+ Build the sheet from `useCommands(commands)`, grouped and searched the way the palette is. When a user turns single-key shortcuts off, `?` goes and `Mod+/` stays, so the sheet that explains the setting is still reachable.
362
227
 
363
228
  ### A way out of a text field
364
229
 
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:
230
+ Nothing fires while the user types, so give them a key that leaves the field. `Escape` is the one key that fires inside an input:
366
231
 
367
232
  ```tsx
368
- // "field.leave": { title: "Leave field", keys: "Escape", hidden: true }
233
+ // "field.leave": { title: "Leave field", keys: "Escape" }
369
234
  const [editing, setEditing] = useState(false);
370
235
  useCommandHandler(commands, "field.leave", () => rowRef.current?.focus(), editing);
371
-
372
236
  <input onFocus={() => setEditing(true)} onBlur={() => setEditing(false)} />;
373
237
  ```
374
238
 
@@ -377,28 +241,26 @@ Move focus to the item the field belongs to, not to nothing, so the next key has
377
241
  ## API Summary
378
242
 
379
243
  - `createCommands(definitions, options)` - creates the registry
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`
244
+ - `options`: `{ bindings, platform, persist, singleKeyShortcuts, onRun }`; `platform` is `"mac"` or `"other"`
245
+ - a definition: `{ title, keys, scope, ignoreInputs, run }`
246
+ - `commands.run(id)`, `whyNotRun(id)`, `handle(id, run)`
247
+ - `commands.get(id)`, `list()`, `subscribe(listener)`: snapshots with `display`, `tokens`, `aria`, `reachable`, `overridden`
383
248
  - `commands.pushScope(name, { exclusive })`, `capture(onKey)`, `conflicts(binding, options)`, `mount()`, `destroy()`
384
249
  - `commands.serverList()`: the list as the server rendered it, for a framework's hydration snapshot
385
- - `commands.keymap`: `get`, `set`, `add`, `reset`
250
+ - `commands.keymap`: `get`, `set`, `reset`
386
251
  - `commands.setSingleKeyShortcuts(enabled)`, `commands.singleKeyShortcuts`
387
- - `webStorage(key, storage?)` - the `persist` option: Web Storage, followed across tabs
252
+ - `webStorage(key, storage?)` - the `persist` option: Web Storage
388
253
  - `exportKeymap(commands)`, `importKeymap(commands, data)` - user overrides as JSON-safe data
389
254
  - `createRebinder(commands, id, options)` - framework-agnostic shortcut recording
390
255
  - `observe(commands, { root })` from `@inklu/keys/attributes` - declarative DOM wiring
391
- - `matchCommands(list, query)` - palette filtering over `title`, `description`, `group`, `keywords`, id and displayed shortcut
392
- - `formatBinding`, `bindingTokens`, `ariaKeyShortcuts`, `spokenBinding`, `isSingleKeyBinding`, `seq`
256
+ - `formatBinding(binding, platform?)`, `bindingTokens(binding, platform?)`, `ariaKeyShortcuts(binding, platform?)`, `isSingleKeyBinding`, `seq`
393
257
  - React (`@inklu/keys/react`):
394
258
  - `useMountCommands(commands)` - starts listening
395
259
  - `useCommands(commands)`, `useCommand(commands, id)` - live command snapshots
396
- - `useShortcut(commands, id)` - `{ display, tokens, aria, spoken }` for the primary binding
260
+ - `useShortcut(commands, id)` - `{ display, tokens, aria }` for the primary binding
397
261
  - `useScope(commands, name, active, { exclusive })` - opens a scope while a component needs it
398
262
  - `useCommandHandler(commands, id, run, enabled)` - what a command does while a component is mounted
399
263
  - `useRebind(commands, id, options)` - records user shortcuts
400
- - `usePendingSequences(commands)` - half-typed sequences
401
- - `useHeldKeys()` - the keys held down right now, by name
402
264
 
403
265
  Inside a handler, the context's `commands` is typed with the registry's own ids, so `commands.run("typo")` is a compile error.
404
266
 
@@ -406,22 +268,14 @@ Inside a handler, the context's `commands` is typed with the registry's own ids,
406
268
 
407
269
  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
270
 
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
+ Every command carries an `aria` string for the `aria-keyshortcuts` attribute. It holds key names (`Meta+K`), which is what screen readers expect, not glyphs, and lists chords only: the attribute separates alternatives with spaces, so it cannot express a sequence. For a sequence, `aria` is empty; write its hint in visible text next to the control ("G then I"), which is read as words:
410
272
 
411
273
  ```tsx
412
- const { aria, spoken } = useShortcut(commands, "nav.inbox");
413
- <button aria-keyshortcuts={aria || undefined} aria-description={spoken || undefined}>
414
- Inbox
415
- </button>;
416
- ```
417
-
418
- WCAG 2.1.4 (Character Key Shortcuts) requires that users can turn off shortcuts made of printable characters without a modifier — `?`, `G then I` — because speech input and stray keypresses trigger them. Expose a setting wired to:
419
-
420
- ```ts
421
- commands.setSingleKeyShortcuts(false); // or createCommands(defs, { singleKeyShortcuts: false })
274
+ const { aria, display } = useShortcut(commands, "nav.inbox");
275
+ <button aria-keyshortcuts={aria || undefined}>Inbox {display}</button>;
422
276
  ```
423
277
 
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`.
278
+ WCAG 2.1.4 (Character Key Shortcuts) requires that users can turn off shortcuts made of printable characters without a modifier — `?`, `G then I` — because speech input and stray keypresses trigger them. Expose a setting wired to `commands.setSingleKeyShortcuts(false)` (or `createCommands(defs, { singleKeyShortcuts: false })`). While off, those bindings neither fire nor appear in `display`, `tokens` or `aria`; chords with `Control`, `Alt` or `Meta` and keys like `Escape` or `F1` keep working. Users can also remap any shortcut with `useRebind`.
425
279
 
426
280
  ## Acknowledgements
427
281
 
@@ -1,4 +1,4 @@
1
- import { l as Commands } from "./types-D-hUKGoN.mjs";
1
+ import { l as Commands } from "./types-BxMI3b4a.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`. */
@@ -1,4 +1,4 @@
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";
1
+ import { C as Chord, S as SerializedKeymap, T as Platform, a as CommandDefinition, b as SequenceBinding, d as Conflict, h as KeymapStore, l as Commands, p as Json, t as Binding, u as CommandsOptions } from "./types-BxMI3b4a.mjs";
2
2
  //#region src/bindings.d.ts
3
3
  /**
4
4
  * Creates a sequence binding from ordered chords.
@@ -20,29 +20,24 @@ declare function isSingleKeyBinding(binding: Binding): boolean;
20
20
  * Formats a binding the way its platform writes shortcuts: `⇧⌘S` on a Mac,
21
21
  * `Ctrl+Shift+S` elsewhere. The steps of a sequence are set apart by a space.
22
22
  */
23
- declare function formatBinding(binding: Binding, options?: DisplayOptions): string;
23
+ declare function formatBinding(binding: Binding, platform?: Platform): string;
24
24
  /**
25
25
  * Splits a binding into display tokens for rendering `<kbd>` elements: one
26
26
  * per key of a chord, one per step of a sequence.
27
27
  */
28
- declare function bindingTokens(binding: Binding, options?: DisplayOptions): Array<string>;
29
- /**
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.
32
- */
33
- declare function spokenBinding(binding: Binding, platform?: Platform): string;
28
+ declare function bindingTokens(binding: Binding, platform?: Platform): Array<string>;
34
29
  /**
35
30
  * The `aria-keyshortcuts` value for a chord, as in `"Meta+K"`. Empty for a
36
- * sequence and for a chord that cannot be read.
31
+ * sequence, which the attribute cannot express, and for a chord that cannot be read.
37
32
  */
38
33
  declare function ariaKeyShortcuts(binding: Binding, platform?: Platform): string;
39
34
  //#endregion
40
35
  //#region src/persist.d.ts
41
36
  type WebStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
42
37
  /**
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.
38
+ * Keeps user overrides in Web Storage under `key`. `storage` is only called in
39
+ * the browser; return `sessionStorage` or an object of your own to keep them
40
+ * somewhere else.
46
41
  *
47
42
  * @example
48
43
  * ```ts
@@ -59,58 +54,43 @@ declare function exportKeymap<Id extends string>(commands: Pick<Commands<Id>, "k
59
54
  declare function importKeymap<Id extends string>(commands: Pick<Commands<Id>, "keymap">, data: Json): void;
60
55
  //#endregion
61
56
  //#region src/rebind.d.ts
62
- /** Hotkey capture mode: single `"chord"` or multi-step `"sequence"`. */
63
- type RebindMode = "chord" | "sequence";
64
57
  /** Current state of a rebinding session. */
65
58
  interface RebindState<Id extends string = string> {
66
59
  /** Whether keys are actively being recorded. */
67
60
  readonly recording: boolean;
68
- /** Most recently captured binding. */
61
+ /** What the last recording produced: a binding, or `null` for Backspace, which unbinds. */
69
62
  readonly binding: Binding | null;
70
- /** Captured steps in the current sequence session. */
63
+ /** The steps typed so far, while recording a sequence. */
71
64
  readonly steps: ReadonlyArray<Chord>;
72
- /** What stands in the captured binding's way: other commands using it, or the platform reserving it. */
65
+ /** Other commands using the recorded binding. */
73
66
  readonly conflicts: ReadonlyArray<Conflict<Id>>;
74
67
  }
75
68
  /** Options for creating a rebinder. */
76
69
  interface RebindOptions<Id extends string = string> {
77
- /** Capture mode. Defaults to `"chord"`. */
78
- mode?: RebindMode;
79
- /** Whether to write the recorded (or cleared) binding into the keymap. Defaults to `true`. */
70
+ /** Record a sequence of chords, finished with Enter, instead of one chord. Defaults to `false`. */
71
+ sequence?: boolean;
72
+ /** Whether to write the recording into the keymap. Defaults to `true`. */
80
73
  apply?: boolean;
81
- /** Whether to append rather than replace existing bindings. Defaults to `false`. */
82
- add?: boolean;
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
- */
74
+ /** Leave the keymap alone when the recording conflicts with another command. Defaults to `false`. */
87
75
  rejectConflicts?: boolean;
88
- /** Ignore key events from inputs while recording. Defaults to `true`. */
89
- ignoreInputs?: boolean;
90
- /** Inactivity timeout in milliseconds before a sequence commits. */
91
- idleTimeout?: number;
92
- /** Callback fired when a binding is recorded. */
93
- onRecord?: (binding: Binding, conflicts: ReadonlyArray<Conflict<Id>>) => void;
94
- /** Callback fired when recording is cancelled. */
76
+ /** Called with what was recorded: a binding, or `null` for Backspace, which unbinds. */
77
+ onRecord?: (binding: Binding | null, conflicts: ReadonlyArray<Conflict<Id>>) => void;
78
+ /** Called when a recording is cancelled with Escape or `cancel()`. */
95
79
  onCancel?: () => void;
96
- /** Callback fired when Backspace or Delete clears the binding while recording. */
97
- onClear?: () => void;
98
80
  }
99
81
  /** Controller instance for managing a rebinding session. */
100
82
  interface Rebinder<Id extends string = string> {
101
83
  /** Begins capturing keys. */
102
84
  start: () => void;
103
85
  /** Stops capturing keys without recording. */
104
- stop: () => void;
105
- /** Stops capturing keys and reports cancellation. */
106
86
  cancel: () => void;
107
- /** Commits the currently captured sequence steps. */
87
+ /** Records the sequence steps typed so far, as Enter does. */
108
88
  commit: () => void;
109
89
  /** Current rebinder state. */
110
90
  readonly state: RebindState<Id>;
111
91
  /** Subscribes to rebinder state changes. Returns an unsubscribe function. */
112
92
  subscribe: (listener: () => void) => () => void;
113
- /** Destroys the rebinder and cleans up listeners. */
93
+ /** Ends any recording, silently. The rebinder is not for use afterwards. */
114
94
  destroy: () => void;
115
95
  }
116
96
  /**
@@ -156,18 +136,4 @@ declare function createCommands<Id extends string>(definitions: { [K in Id]: Com
156
136
  */
157
137
  type CommandId<C> = C extends Commands<infer Id> ? Id : never;
158
138
  //#endregion
159
- //#region src/search.d.ts
160
- /**
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.
165
- *
166
- * @example
167
- * ```ts
168
- * const results = matchCommands(commands.list(), "go inbox");
169
- * ```
170
- */
171
- declare function matchCommands<Id extends string>(commands: ReadonlyArray<Command<Id>>, query: string): Array<Command<Id>>;
172
- //#endregion
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 };
139
+ export { Rebinder as a, importKeymap as c, bindingTokens as d, formatBinding as f, RebindState as i, webStorage as l, seq as m, createCommands as n, createRebinder as o, isSingleKeyBinding as p, RebindOptions as r, exportKeymap as s, CommandId as t, ariaKeyShortcuts 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 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 };
1
+ import { C as Chord, S as SerializedKeymap, T as Platform, _ as RunBlockReason, a as CommandDefinition, b as SequenceBinding, c as CommandSource, d as Conflict, f as ConflictOptions, g as PlatformBindings, h as KeymapStore, i as CommandContext, l as Commands, m as KeyCapture, n as BindingInput, o as CommandHandler, p as Json, r as Command, s as CommandKeymap, t as Binding, u as CommandsOptions, v as RunOptions, w as Key, x as SerializedBinding, y as ScopeOptions } from "./types-BxMI3b4a.mjs";
2
+ import { a as Rebinder, c as importKeymap, d as bindingTokens, f as formatBinding, i as RebindState, l as webStorage, m as seq, n as createCommands, o as createRebinder, p as isSingleKeyBinding, r as RebindOptions, s as exportKeymap, t as CommandId, u as ariaKeyShortcuts } from "./index-BnB38jCF.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 Json, type Key, type KeyCapture, type KeymapStore, type Platform, type PlatformBindings, type RebindOptions, type RebindState, type Rebinder, type RunBlockReason, type RunOptions, type ScopeOptions, type SequenceBinding, type SerializedBinding, type SerializedKeymap, ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, exportKeymap, formatBinding, importKeymap, isSingleKeyBinding, seq, webStorage };