@inklu/keys 2.1.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,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 and recording a shortcut 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
@@ -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,40 +38,40 @@ 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
+
43
+ ### Keys that differ by platform
44
+
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.
46
+
47
+ ```ts
48
+ createCommands({
49
+ "edit.redo": { title: "Redo", keys: { mac: "Mod+Shift+Z", other: ["Mod+Y", "Mod+Shift+Z"] } },
50
+ "nav.back": { title: "Back", keys: { mac: "Mod+[", other: "Alt+ArrowLeft" } },
51
+ "doc.new": { title: "New document", keys: { mac: "Mod+Alt+N", other: "Alt+N" } },
52
+ });
53
+ ```
54
+
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.
56
+
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>({ … })`.
58
+
41
59
  ## 2. React Setup
42
60
 
43
61
  Pass the registry explicitly to all hooks. There is no Context provider.
44
62
 
45
63
  ```tsx
46
- import { useCommandHandler, useMountCommands, useShortcut } from "@inklu/keys/react";
64
+ import { useMountCommands, useShortcut } from "@inklu/keys/react";
47
65
  import { commands } from "./commands";
48
66
 
49
- function App() {
50
- // Starts listening in an effect
51
- useMountCommands(commands);
52
- return <ShortcutButton />;
53
- }
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
- }
67
+ useMountCommands(commands); // once, near the root: starts listening in an effect
61
68
 
62
69
  function ShortcutButton() {
63
- // Live state for the primary binding: `tokens` is one string per key
64
- const shortcut = useShortcut(commands, "palette.open");
65
-
70
+ // Live state for the primary binding: `display` to read, `tokens` one string per key
71
+ const { display, aria } = useShortcut(commands, "palette.open");
66
72
  return (
67
- <button
68
- onClick={() => commands.run("palette.open")}
69
- aria-keyshortcuts={shortcut.aria || undefined}
70
- >
71
- Open Palette
72
- {shortcut.tokens.map((token, i) => (
73
- <kbd key={i}>{token}</kbd>
74
- ))}
73
+ <button onClick={() => commands.run("palette.open")} aria-keyshortcuts={aria || undefined}>
74
+ Open Palette <kbd>{display}</kbd>
75
75
  </button>
76
76
  );
77
77
  }
@@ -86,9 +86,9 @@ A command runs when its scope is open and it has a handler. A definition's own `
86
86
  useCommandHandler(commands, "doc.save", save, dirty);
87
87
  ```
88
88
 
89
- - 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.
90
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.
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.
92
92
 
93
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
94
 
@@ -97,14 +97,8 @@ Switch a command off by its handler (`enabled`, or detaching) rather than with a
97
97
  Use scopes to isolate commands when modals or dialogs are open.
98
98
 
99
99
  ```tsx
100
- import { useScope } from "@inklu/keys/react";
101
-
102
- function Dialog({ isOpen }) {
103
- // When exclusive is true, the keys of commands outside this scope are silenced
104
- useScope(commands, "dialog", isOpen, { exclusive: true });
105
-
106
- return isOpen ? <div className="dialog">...</div> : null;
107
- }
100
+ // In the dialog: while open, the keys of commands outside this scope are silenced.
101
+ useScope(commands, "dialog", isOpen, { exclusive: true });
108
102
  ```
109
103
 
110
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.
@@ -116,11 +110,9 @@ A barrier claims the keyboard, not the command: `commands.run(id)` still works b
116
110
  Exactly one command runs per keypress. Among the commands that could take it, the winner is:
117
111
 
118
112
  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".
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.
120
114
  3. Failing that, the one defined later.
121
115
 
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
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.
125
117
 
126
118
  ## 4. Rebinding
@@ -129,125 +121,85 @@ Overrides are layered over defaults.
129
121
 
130
122
  ```ts
131
123
  commands.keymap.set("palette.open", "Mod+P"); // remap
132
- commands.keymap.add("palette.open", "Control+P"); // an additional key
124
+ commands.keymap.set("palette.open", ["Mod+P", "Mod+K"]); // two keys for it
133
125
  commands.keymap.set("palette.open", null); // unbind
134
126
  commands.keymap.reset("palette.open"); // reset to default
135
127
  ```
136
128
 
137
- `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.
138
-
139
- 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`:
140
-
141
- ```ts
142
- createCommands(commands, {
143
- bindings: {
144
- "palette.open": "Mod+P", // replace the shipped default
145
- "doc.publish": null, // ship it unbound
146
- },
147
- });
148
- ```
129
+ `set` replaces the command's keys outright; setting exactly the default forgets the override instead.
149
130
 
150
- These are defaults, applied before persisted overrides load, so a user's own rebinding still wins.
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.
151
132
 
152
- In React, use the `useRebind` hook for a capture flow:
133
+ In React, `useRebind` is a capture flow; `createRebinder` is the same without React:
153
134
 
154
135
  ```tsx
155
- const rebind = useRebind(commands, "palette.open");
156
-
157
- <button onClick={rebind.start}>{rebind.recording ? "Press keys…" : rebind.display}</button>;
158
- ```
159
-
160
- To build your own capture UI without React, use `createRebinder`:
161
-
162
- ```ts
163
- 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>;
164
140
 
165
141
  const rebinder = createRebinder(commands, "palette.open", {
166
- mode: "chord", // or "sequence": Enter or `commit()` finishes it
167
142
  rejectConflicts: true, // don't apply a binding another command uses
168
- onRecord: (binding, conflicts) => {},
143
+ onRecord: (binding, conflicts) => {}, // `binding` is null for Backspace, which unbinds
169
144
  });
170
145
  rebinder.subscribe(() => render(rebinder.state)); // { recording, binding, steps, conflicts }
171
146
  rebinder.start(); // Escape cancels, Backspace unbinds
172
147
  ```
173
148
 
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.
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.
175
150
 
176
151
  ## 5. Conflicts
177
152
 
178
- `commands.conflicts(binding, { scope, ignore })` lists commands in overlapping scopes whose bindings collide with `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`:
179
154
 
180
155
  - `"exact"` — the same keys. Only one command runs (see above).
181
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.
182
157
 
183
- In development, both kinds are also reported with `console.warn` when the registry is created. 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.
184
159
 
185
160
  ## 6. Persistence and SSR
186
161
 
187
162
  ```ts
188
- createCommands(definitions, {
189
- persist: {
190
- storage: () => (typeof window === "undefined" ? null : localStorage),
191
- key: "my-app.keys",
192
- },
193
- });
163
+ import { createCommands, webStorage } from "@inklu/keys";
164
+
165
+ createCommands(definitions, { persist: webStorage("my-app.keys") });
194
166
  ```
195
167
 
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.
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.
197
169
 
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.
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.
199
171
 
200
- 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.
201
173
 
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.
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.
204
- - 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`).
205
175
 
206
176
  ## 7. Inputs and events
207
177
 
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.
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.
209
179
 
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
+ 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.
211
181
 
212
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.
213
183
 
214
- ## 8. Sequence progress
215
-
216
- ```tsx
217
- const pending = usePendingSequences(commands); // [{ id, matched, total, remaining }]
218
- // e.g. show "G → …" while the user is halfway through `G then I`
219
- ```
220
-
221
- `commands.subscribePending()` / `commands.pending()` are the non-React equivalents. Progress is tracked only while something is subscribed, so `pending()` is empty otherwise.
222
-
223
- ## 9. Command palette
184
+ ## 8. Command palette
224
185
 
225
- Definitions can carry what a palette needs to list and find them, and `matchCommands` does the filtering:
226
-
227
- ```ts
228
- createCommands({
229
- "doc.find": {
230
- title: "Find in document",
231
- description: "Search the open document",
232
- group: "Document", // heading to list it under
233
- keywords: ["search", "lookup"], // extra search terms
234
- keys: "Mod+F",
235
- },
236
- "palette.close": { title: "Close palette", hidden: true, scope: "palette", keys: "Escape" },
237
- });
238
- ```
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.
239
187
 
240
188
  ```tsx
241
- import { matchCommands } from "@inklu/keys";
242
-
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);
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
+ );
246
198
  ```
247
199
 
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()`.
200
+ Give `palette.open` `ignoreInputs: false`, so `Mod+K` works from a text field and from the palette's own search box.
249
201
 
250
- ## 10. Without React: data attributes
202
+ ## 9. Without React: data attributes
251
203
 
252
204
  ```ts
253
205
  import { observe } from "@inklu/keys/attributes";
@@ -261,50 +213,69 @@ const stop = observe(commands); // or observe(commands, { root: element })
261
213
 
262
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.
263
215
 
216
+ ## 10. Recipes
217
+
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.
219
+
220
+ ### A shortcut sheet on `?` and `Mod+/`
221
+
222
+ ```ts
223
+ "help.shortcuts": { title: "Keyboard shortcuts", keys: ["?", "Mod+/"], scope: "*" },
224
+ ```
225
+
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.
227
+
228
+ ### A way out of a text field
229
+
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:
231
+
232
+ ```tsx
233
+ // "field.leave": { title: "Leave field", keys: "Escape" }
234
+ const [editing, setEditing] = useState(false);
235
+ useCommandHandler(commands, "field.leave", () => rowRef.current?.focus(), editing);
236
+ <input onFocus={() => setEditing(true)} onBlur={() => setEditing(false)} />;
237
+ ```
238
+
239
+ Move focus to the item the field belongs to, not to nothing, so the next key has a target.
240
+
264
241
  ## API Summary
265
242
 
266
243
  - `createCommands(definitions, options)` - creates the registry
267
- - `options`: `{ bindings, platform, target, persist, display, singleKeyShortcuts, onRun, onError }`
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`
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`
270
248
  - `commands.pushScope(name, { exclusive })`, `capture(onKey)`, `conflicts(binding, options)`, `mount()`, `destroy()`
271
249
  - `commands.serverList()`: the list as the server rendered it, for a framework's hydration snapshot
272
- - `commands.keymap`: `get`, `set`, `add`, `reset`, `export`, `import`
250
+ - `commands.keymap`: `get`, `set`, `reset`
273
251
  - `commands.setSingleKeyShortcuts(enabled)`, `commands.singleKeyShortcuts`
252
+ - `webStorage(key, storage?)` - the `persist` option: Web Storage
253
+ - `exportKeymap(commands)`, `importKeymap(commands, data)` - user overrides as JSON-safe data
274
254
  - `createRebinder(commands, id, options)` - framework-agnostic shortcut recording
275
255
  - `observe(commands, { root })` from `@inklu/keys/attributes` - declarative DOM wiring
276
- - `matchCommands(list, query)` - palette filtering over `title`, `description`, `group`, `keywords`, id and displayed shortcut
277
- - `formatBinding`, `bindingTokens`, `ariaKeyShortcuts`, `spokenBinding`, `validateBinding`, `isSingleKeyBinding`, `seq`
256
+ - `formatBinding(binding, platform?)`, `bindingTokens(binding, platform?)`, `ariaKeyShortcuts(binding, platform?)`, `isSingleKeyBinding`, `seq`
278
257
  - React (`@inklu/keys/react`):
279
258
  - `useMountCommands(commands)` - starts listening
280
259
  - `useCommands(commands)`, `useCommand(commands, id)` - live command snapshots
281
- - `useShortcut(commands, id)` - `{ display, tokens, aria, spoken }` for the primary binding
260
+ - `useShortcut(commands, id)` - `{ display, tokens, aria }` for the primary binding
282
261
  - `useScope(commands, name, active, { exclusive })` - opens a scope while a component needs it
283
262
  - `useCommandHandler(commands, id, run, enabled)` - what a command does while a component is mounted
284
263
  - `useRebind(commands, id, options)` - records user shortcuts
285
- - `usePendingSequences(commands)` - half-typed sequences
286
- - `useHeldKeys()`, `useHeldKeyCodes()`, `useKeyHold(key)` - live key state
287
264
 
288
265
  Inside a handler, the context's `commands` is typed with the registry's own ids, so `commands.run("typo")` is a compile error.
289
266
 
290
267
  ## Accessibility
291
268
 
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:
293
-
294
- ```tsx
295
- const { aria, spoken } = useShortcut(commands, "nav.inbox");
296
- <button aria-keyshortcuts={aria || undefined} aria-description={spoken || undefined}>
297
- Inbox
298
- </button>;
299
- ```
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.
300
270
 
301
- 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:
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:
302
272
 
303
- ```ts
304
- commands.setSingleKeyShortcuts(false); // or createCommands(defs, { singleKeyShortcuts: false })
273
+ ```tsx
274
+ const { aria, display } = useShortcut(commands, "nav.inbox");
275
+ <button aria-keyshortcuts={aria || undefined}>Inbox {display}</button>;
305
276
  ```
306
277
 
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`.
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`.
308
279
 
309
280
  ## Acknowledgements
310
281
 
@@ -1,4 +1,4 @@
1
- import { l as Commands } from "./types-QEXldPnL.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`. */
@@ -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
@@ -0,0 +1,139 @@
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
+ //#region src/bindings.d.ts
3
+ /**
4
+ * Creates a sequence binding from ordered chords.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * keys: seq("G", "I")
9
+ * ```
10
+ */
11
+ declare function seq(...steps: Array<Chord>): SequenceBinding;
12
+ /**
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.
15
+ */
16
+ declare function isSingleKeyBinding(binding: Binding): boolean;
17
+ //#endregion
18
+ //#region src/display.d.ts
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
+ */
23
+ declare function formatBinding(binding: Binding, platform?: Platform): string;
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
+ */
28
+ declare function bindingTokens(binding: Binding, platform?: Platform): Array<string>;
29
+ /**
30
+ * The `aria-keyshortcuts` value for a chord, as in `"Meta+K"`. Empty for a
31
+ * sequence, which the attribute cannot express, and for a chord that cannot be read.
32
+ */
33
+ declare function ariaKeyShortcuts(binding: Binding, platform?: Platform): string;
34
+ //#endregion
35
+ //#region src/persist.d.ts
36
+ type WebStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
37
+ /**
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.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * createCommands(definitions, { persist: webStorage("my-app.keys") });
45
+ * ```
46
+ */
47
+ declare function webStorage(key?: string, storage?: () => WebStorage | null): KeymapStore;
48
+ /** A registry's user overrides as JSON-safe data, for your own storage or another device. */
49
+ declare function exportKeymap<Id extends string>(commands: Pick<Commands<Id>, "keymap">): SerializedKeymap;
50
+ /**
51
+ * Replaces a registry's user overrides with exported data, and saves them.
52
+ * Data in another format changes nothing.
53
+ */
54
+ declare function importKeymap<Id extends string>(commands: Pick<Commands<Id>, "keymap">, data: Json): void;
55
+ //#endregion
56
+ //#region src/rebind.d.ts
57
+ /** Current state of a rebinding session. */
58
+ interface RebindState<Id extends string = string> {
59
+ /** Whether keys are actively being recorded. */
60
+ readonly recording: boolean;
61
+ /** What the last recording produced: a binding, or `null` for Backspace, which unbinds. */
62
+ readonly binding: Binding | null;
63
+ /** The steps typed so far, while recording a sequence. */
64
+ readonly steps: ReadonlyArray<Chord>;
65
+ /** Other commands using the recorded binding. */
66
+ readonly conflicts: ReadonlyArray<Conflict<Id>>;
67
+ }
68
+ /** Options for creating a rebinder. */
69
+ interface RebindOptions<Id extends string = string> {
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`. */
73
+ apply?: boolean;
74
+ /** Leave the keymap alone when the recording conflicts with another command. Defaults to `false`. */
75
+ rejectConflicts?: boolean;
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()`. */
79
+ onCancel?: () => void;
80
+ }
81
+ /** Controller instance for managing a rebinding session. */
82
+ interface Rebinder<Id extends string = string> {
83
+ /** Begins capturing keys. */
84
+ start: () => void;
85
+ /** Stops capturing keys without recording. */
86
+ cancel: () => void;
87
+ /** Records the sequence steps typed so far, as Enter does. */
88
+ commit: () => void;
89
+ /** Current rebinder state. */
90
+ readonly state: RebindState<Id>;
91
+ /** Subscribes to rebinder state changes. Returns an unsubscribe function. */
92
+ subscribe: (listener: () => void) => () => void;
93
+ /** Ends any recording, silently. The rebinder is not for use afterwards. */
94
+ destroy: () => void;
95
+ }
96
+ /**
97
+ * Creates a rebinder for recording a command's keys. While it records, no
98
+ * command runs. Escape cancels, Backspace removes the shortcut (or the last
99
+ * step of a sequence), and Enter finishes a sequence.
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * const rebinder = createRebinder(commands, "palette.open", {
104
+ * onRecord: (binding, conflicts) => showConflicts(conflicts),
105
+ * });
106
+ * rebinder.start();
107
+ * ```
108
+ */
109
+ declare function createRebinder<Id extends string>(registry: Commands<Id>, id: Id, options?: RebindOptions<Id>): Rebinder<Id>;
110
+ //#endregion
111
+ //#region src/registry.d.ts
112
+ /**
113
+ * Creates a command registry.
114
+ *
115
+ * @example
116
+ * ```ts
117
+ * export const commands = createCommands({
118
+ * "palette.open": { title: "Open command palette", keys: "Mod+K" },
119
+ * "nav.inbox": {
120
+ * title: "Go to inbox",
121
+ * keys: seq("G", "I"),
122
+ * run: () => router.navigate("/inbox"),
123
+ * },
124
+ * });
125
+ * ```
126
+ */
127
+ declare function createCommands<Id extends string>(definitions: { [K in Id]: CommandDefinition<NoInfer<Id>>; }, options?: CommandsOptions<NoInfer<Id>>): Commands<Id>;
128
+ /**
129
+ * Extracts command ID union from a Commands registry type.
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * export const commands = createCommands({ … });
134
+ * export type AppCommandId = CommandId<typeof commands>;
135
+ * ```
136
+ */
137
+ type CommandId<C> = C extends Commands<infer Id> ? Id : never;
138
+ //#endregion
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 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 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 };
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 webStorage, c as formatBinding, i as importKeymap, l as isSingleKeyBinding, n as createRebinder, o as ariaKeyShortcuts, r as exportKeymap, s as bindingTokens, t as createCommands, u as seq } from "./src-CGKvYKjd.mjs";
2
+ export { ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, exportKeymap, formatBinding, importKeymap, isSingleKeyBinding, seq, webStorage };