@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 +67 -213
- package/dist/attributes.d.mts +1 -1
- package/dist/{index-DRXMJmbc.d.mts → index-BnB38jCF.d.mts} +20 -54
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +2 -2
- package/dist/react.d.mts +7 -12
- package/dist/react.mjs +9 -90
- package/dist/{src-TaZw4V61.mjs → src-CGKvYKjd.mjs} +331 -795
- package/dist/{types-D-hUKGoN.d.mts → types-BxMI3b4a.d.mts} +16 -117
- package/package.json +2 -3
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
|
|
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"`)
|
|
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
|
|
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
|
-
|
|
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 {
|
|
64
|
+
import { useMountCommands, useShortcut } from "@inklu/keys/react";
|
|
84
65
|
import { commands } from "./commands";
|
|
85
66
|
|
|
86
|
-
|
|
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`
|
|
101
|
-
const
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
- `"
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const results =
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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
|
|
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
|
|
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
|
-
|
|
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"
|
|
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,
|
|
381
|
-
-
|
|
382
|
-
- `commands.
|
|
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`, `
|
|
250
|
+
- `commands.keymap`: `get`, `set`, `reset`
|
|
386
251
|
- `commands.setSingleKeyShortcuts(enabled)`, `commands.singleKeyShortcuts`
|
|
387
|
-
- `webStorage(key, storage?)` - the `persist` option: Web Storage
|
|
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
|
-
- `
|
|
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
|
|
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.
|
|
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,
|
|
413
|
-
<button aria-keyshortcuts={aria || 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
|
|
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
|
|
package/dist/attributes.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as
|
|
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,
|
|
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,
|
|
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
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
/**
|
|
61
|
+
/** What the last recording produced: a binding, or `null` for Backspace, which unbinds. */
|
|
69
62
|
readonly binding: Binding | null;
|
|
70
|
-
/**
|
|
63
|
+
/** The steps typed so far, while recording a sequence. */
|
|
71
64
|
readonly steps: ReadonlyArray<Chord>;
|
|
72
|
-
/**
|
|
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
|
-
/**
|
|
78
|
-
|
|
79
|
-
/** Whether to write the
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
89
|
-
|
|
90
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
2
|
-
import {
|
|
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
|
|
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 };
|