@inklu/keys 2.0.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +209 -66
- package/dist/attributes.d.mts +11 -2
- package/dist/attributes.mjs +22 -24
- package/dist/index-DRXMJmbc.d.mts +173 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +2 -3
- package/dist/react.d.mts +27 -28
- package/dist/react.mjs +125 -130
- package/dist/src-TaZw4V61.mjs +1797 -0
- package/dist/types-D-hUKGoN.d.mts +357 -0
- package/package.json +5 -5
- package/dist/attributes-DotLr2MW.d.mts +0 -426
- package/dist/index-BTgscYdI.d.mts +0 -116
- package/dist/src-DgYjA1oS.mjs +0 -1697
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
# @inklu/keys
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@inklu/keys` organises an app's keys and respects its boundaries. Every shortcut is a named command declared in one place, and exactly one command runs per keypress: the right one for where the user is.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Three ideas, learned as you need them:
|
|
6
|
+
|
|
7
|
+
- **Command**: something the app can do, with a title, default keys and the scope it is live in.
|
|
8
|
+
- **Handler**: what the command does, attached from the component that owns the state.
|
|
9
|
+
- **Keymap**: the keys, kept apart from the definition so users can rebind them.
|
|
10
|
+
|
|
11
|
+
It has no dependencies, and listens with one `keydown` listener however many commands exist. What an app does not use, it does not ship: persistence, recording a shortcut and palette search are imports of their own, and the registry does not pull them in.
|
|
12
|
+
|
|
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
|
|
6
14
|
|
|
7
15
|
```bash
|
|
8
16
|
pnpm add @inklu/keys
|
|
@@ -10,32 +18,69 @@ pnpm add @inklu/keys
|
|
|
10
18
|
|
|
11
19
|
## 1. Define Commands
|
|
12
20
|
|
|
13
|
-
|
|
21
|
+
Declare every command in one module. Ids come from the object keys, so `commands.run("doc.save")` autocompletes and a typo is a type error.
|
|
22
|
+
|
|
23
|
+
A chord is written modifiers first, then one key: `"Mod+Shift+S"`. The modifiers are `Mod`, `Control`, `Alt`, `Shift` and `Meta`, in that order, and `Mod` is Command on macOS and Control elsewhere. The key is the character or the name `KeyboardEvent.key` gives it (`"K"`, `"?"`, `"Escape"`, `"ArrowUp"`), or a physical key in brackets (`"[KeyK]"`) that stays put whatever the layout. Chords are typed, so a misspelling is a compile error. An array of chords means alternatives; a sequence is `seq("G", "I")`.
|
|
14
24
|
|
|
15
25
|
```ts
|
|
16
26
|
// commands.ts
|
|
17
27
|
import { createCommands, seq } from "@inklu/keys";
|
|
18
28
|
|
|
19
29
|
export const commands = createCommands({
|
|
20
|
-
"palette.open": {
|
|
21
|
-
|
|
22
|
-
keys: "Mod+K",
|
|
23
|
-
run: () => setPaletteOpen(true),
|
|
24
|
-
},
|
|
30
|
+
"palette.open": { title: "Open command palette", keys: "Mod+K" },
|
|
31
|
+
"doc.save": { title: "Save document", keys: "Mod+S" },
|
|
25
32
|
"nav.inbox": {
|
|
26
33
|
title: "Go to inbox",
|
|
27
34
|
keys: seq("G", "I"), // press G, then I
|
|
28
|
-
|
|
35
|
+
// Needs nothing a component owns, so it can live here.
|
|
36
|
+
run: () => router.navigate("/inbox"),
|
|
29
37
|
},
|
|
30
38
|
});
|
|
31
39
|
```
|
|
32
40
|
|
|
41
|
+
### Keys that differ by platform
|
|
42
|
+
|
|
43
|
+
`Mod` covers most shortcuts, since it is Command on a Mac and Control everywhere else. Where the platforms' own conventions differ, give each its keys: `mac` is for Apple keyboards (macOS, iPadOS, iOS), `other` for the rest. A side left out is unbound there.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
createCommands({
|
|
47
|
+
"edit.redo": { title: "Redo", keys: { mac: "Mod+Shift+Z", other: ["Mod+Y", "Mod+Shift+Z"] } },
|
|
48
|
+
"nav.back": { title: "Back", keys: { mac: "Mod+[", other: "Alt+ArrowLeft" } },
|
|
49
|
+
"doc.new": { title: "New document", keys: { mac: "Mod+Alt+N", other: "Alt+N" } },
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Some keys are not an app's to bind, and the registry warns about them in development:
|
|
54
|
+
|
|
55
|
+
- **Reserved by the browser or the operating system.** `Mod+N`, `Mod+T`, `Mod+W` and their `Shift` forms, `Control+Tab`, `Mod+Q` on a Mac, `Alt+F4` and anything holding `Meta` (the Windows or Super key) elsewhere. The page never receives most of these, so `preventDefault()` cannot help. That is why `doc.new` above is not `Mod+N`.
|
|
56
|
+
- **`Control+Alt` with a character key, on Windows and Linux.** Many keyboard layouts report AltGr as `Control+Alt`, and there the keys type a character (`@`, `€`, `{`) instead. `Mod+Alt+N` is fine on a Mac and not elsewhere, which is the other reason for `{ mac, other }`.
|
|
57
|
+
- **`Control+Alt` on a Mac.** It is the VoiceOver modifier.
|
|
58
|
+
|
|
59
|
+
Browser features a page _can_ take over (`Mod+F`, `Mod+P`, `Mod+L`, `Mod+R`) are left to your judgement: do it only where your version does the same job better.
|
|
60
|
+
|
|
61
|
+
### Commands generated from data
|
|
62
|
+
|
|
63
|
+
Ids come from the object's keys, so generated commands are typed when their keys are. `Object.fromEntries` returns plain string keys; give the result its key type where you build it:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const pages = Object.fromEntries(
|
|
67
|
+
docs.map((doc) => [`go.${doc.slug}`, { title: doc.title, run: () => router.navigate(doc.path) }]),
|
|
68
|
+
) as Record<`go.${Slug}`, CommandDefinition>;
|
|
69
|
+
|
|
70
|
+
export const commands = createCommands({
|
|
71
|
+
"palette.open": { title: "Open command palette", keys: "Mod+K" },
|
|
72
|
+
...pages,
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Spread from a `Record<string, CommandDefinition>` instead, the commands still exist at runtime but the types only know the literal keys. To have the compiler check that every id is present, name the ids yourself: `createCommands<AppCommandId>({ … })`.
|
|
77
|
+
|
|
33
78
|
## 2. React Setup
|
|
34
79
|
|
|
35
80
|
Pass the registry explicitly to all hooks. There is no Context provider.
|
|
36
81
|
|
|
37
82
|
```tsx
|
|
38
|
-
import {
|
|
83
|
+
import { useCommandHandler, useMountCommands, useShortcut } from "@inklu/keys/react";
|
|
39
84
|
import { commands } from "./commands";
|
|
40
85
|
|
|
41
86
|
function App() {
|
|
@@ -44,6 +89,13 @@ function App() {
|
|
|
44
89
|
return <ShortcutButton />;
|
|
45
90
|
}
|
|
46
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
|
+
}
|
|
98
|
+
|
|
47
99
|
function ShortcutButton() {
|
|
48
100
|
// Live state for the primary binding: `tokens` is one string per key
|
|
49
101
|
const shortcut = useShortcut(commands, "palette.open");
|
|
@@ -62,6 +114,22 @@ function ShortcutButton() {
|
|
|
62
114
|
}
|
|
63
115
|
```
|
|
64
116
|
|
|
117
|
+
### Handlers
|
|
118
|
+
|
|
119
|
+
A command runs when its scope is open and it has a handler. A definition's own `run` is one; `useCommandHandler` attaches another for as long as a component is mounted, and the latest closure is always the one called.
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
// The last argument switches the handler off: with nothing to save, Mod+S is not ours.
|
|
123
|
+
useCommandHandler(commands, "doc.save", save, dirty);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- A command with no handler cannot run and leaves its key alone, so unmounting a feature switches its shortcuts off with it.
|
|
127
|
+
- The most recently mounted handler wins. Unmounting hands the command back to the one before it, or to the definition's `run`. "Most recently" is the order React runs effects in, so of a parent and a child mounting in the same render, the parent's comes last and wins.
|
|
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.
|
|
130
|
+
|
|
131
|
+
Switch a command off by its handler (`enabled`, or detaching) rather than with an early `return` inside it: only then does the key fall through to another command or the browser.
|
|
132
|
+
|
|
65
133
|
## 3. Scopes
|
|
66
134
|
|
|
67
135
|
Use scopes to isolate commands when modals or dialogs are open.
|
|
@@ -70,23 +138,28 @@ Use scopes to isolate commands when modals or dialogs are open.
|
|
|
70
138
|
import { useScope } from "@inklu/keys/react";
|
|
71
139
|
|
|
72
140
|
function Dialog({ isOpen }) {
|
|
73
|
-
// When exclusive is true, commands outside this scope are silenced
|
|
141
|
+
// When exclusive is true, the keys of commands outside this scope are silenced
|
|
74
142
|
useScope(commands, "dialog", isOpen, { exclusive: true });
|
|
75
143
|
|
|
76
144
|
return isOpen ? <div className="dialog">...</div> : null;
|
|
77
145
|
}
|
|
78
146
|
```
|
|
79
147
|
|
|
80
|
-
`exclusive` is a barrier: it silences
|
|
148
|
+
`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.
|
|
149
|
+
|
|
150
|
+
A barrier claims the keyboard, not the command: `commands.run(id)` still works beneath one, so a palette can run what it lists while its own scope is open. A direct run only needs the command's own scope to be open. To make a button obey the same gate as its key, read `reachable` from `useCommand(commands, id)`.
|
|
81
151
|
|
|
82
152
|
### When two commands share a key
|
|
83
153
|
|
|
84
|
-
Exactly one command runs per keypress.
|
|
154
|
+
Exactly one command runs per keypress. Among the commands that could take it, the winner is:
|
|
155
|
+
|
|
156
|
+
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".
|
|
158
|
+
3. Failing that, the one defined later.
|
|
85
159
|
|
|
86
|
-
|
|
87
|
-
2. Failing that, the one registered later. Registration order tracks mount order, so the most recently opened surface claims the key.
|
|
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.
|
|
88
161
|
|
|
89
|
-
A
|
|
162
|
+
A command with no handler is never a candidate, so it steps aside rather than swallowing the keypress. This is how `Escape` closes the dialog when one is open and the panel otherwise, without making the dialog `exclusive`. Two commands sharing a chord _in the same scope_ can only be told apart by which has a handler, so that case warns in development.
|
|
90
163
|
|
|
91
164
|
## 4. Rebinding
|
|
92
165
|
|
|
@@ -130,78 +203,75 @@ import { createRebinder } from "@inklu/keys";
|
|
|
130
203
|
const rebinder = createRebinder(commands, "palette.open", {
|
|
131
204
|
mode: "chord", // or "sequence": Enter or `commit()` finishes it
|
|
132
205
|
rejectConflicts: true, // don't apply a binding another command uses
|
|
206
|
+
// A binding the browser or OS reserves is never applied, whatever this says.
|
|
133
207
|
onRecord: (binding, conflicts) => {},
|
|
134
208
|
});
|
|
135
209
|
rebinder.subscribe(() => render(rebinder.state)); // { recording, binding, steps, conflicts }
|
|
136
210
|
rebinder.start(); // Escape cancels, Backspace unbinds
|
|
137
211
|
```
|
|
138
212
|
|
|
139
|
-
While recording, every
|
|
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.
|
|
140
214
|
|
|
141
215
|
## 5. Conflicts
|
|
142
216
|
|
|
143
|
-
`commands.conflicts(binding, { scope, ignore })` lists
|
|
217
|
+
`commands.conflicts(binding, { scope, ignore })` lists what stands in the way of `binding`. Each result has a `kind`:
|
|
144
218
|
|
|
145
|
-
- `"
|
|
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).
|
|
146
221
|
- `"prefix"` — one binding starts the other, like `G` against `seq("G", "I")`. These cannot be arbitrated: pressing `G` runs the chord, and continuing with `I` runs the sequence too.
|
|
147
222
|
|
|
148
|
-
|
|
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.
|
|
149
234
|
|
|
150
235
|
## 6. Persistence and SSR
|
|
151
236
|
|
|
152
237
|
```ts
|
|
153
|
-
createCommands
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
key: "my-app.keys",
|
|
157
|
-
},
|
|
158
|
-
});
|
|
238
|
+
import { createCommands, webStorage } from "@inklu/keys";
|
|
239
|
+
|
|
240
|
+
createCommands(definitions, { persist: webStorage("my-app.keys") });
|
|
159
241
|
```
|
|
160
242
|
|
|
161
|
-
|
|
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.
|
|
162
246
|
|
|
163
|
-
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.
|
|
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.
|
|
248
|
+
|
|
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.
|
|
164
250
|
|
|
165
251
|
The registry is safe to create at module scope and render on the server:
|
|
166
252
|
|
|
167
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.
|
|
168
|
-
- Before mount, shortcuts render in the non-Apple form (`Ctrl+K`); after mount they switch to the detected platform (`⌘K`). Pass `platform` to pin it.
|
|
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.
|
|
169
255
|
- Nothing listens for keys until `mount()` (or `useMountCommands`).
|
|
170
256
|
|
|
171
257
|
## 7. Inputs and events
|
|
172
258
|
|
|
173
|
-
`ignoreInputs` on a command decides whether it fires while focus is in a text input, textarea, select or contenteditable. When omitted, chords holding `Control` or `Meta` (so any `Mod+…`) and bare `Escape` still fire there; bare keys, `Shift`/`Alt` chords and sequences starting with one are ignored, so typing is never hijacked. Keydowns that are part of an IME composition never trigger commands.
|
|
174
|
-
|
|
175
|
-
A command calls `preventDefault()` only when it actually runs; one that is disabled, out of scope, guarded or outranked leaves the event alone. Opt out per command with `preventDefault: false`. Propagation is never stopped. `run` may be async: nothing awaits it, but a rejection reaches `onError`.
|
|
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.
|
|
176
260
|
|
|
177
|
-
`
|
|
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.
|
|
178
262
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
```tsx
|
|
182
|
-
// Registered while the component is mounted; the latest `run` and `when` are always used.
|
|
183
|
-
useRegisterCommand(commands, "editor.format", {
|
|
184
|
-
title: "Format document",
|
|
185
|
-
keys: "Shift+Alt+F",
|
|
186
|
-
scope: "editor",
|
|
187
|
-
run: () => format(),
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
// Outside React: `add` returns a handle. Adding an existing id stacks over it until disposed.
|
|
191
|
-
const handle = commands.add("editor.format", { title: "Format", run: format });
|
|
192
|
-
handle.dispose();
|
|
193
|
-
```
|
|
263
|
+
`commands.whyNotRun(id)` explains a command that would not run: `"unhandled"` (nothing has attached a handler), `"out-of-scope"` (its scope is not open), `"unknown"`, or `null` when it would run.
|
|
194
264
|
|
|
195
|
-
##
|
|
265
|
+
## 8. Sequence progress
|
|
196
266
|
|
|
197
267
|
```tsx
|
|
198
268
|
const pending = usePendingSequences(commands); // [{ id, matched, total, remaining }]
|
|
199
269
|
// e.g. show "G → …" while the user is halfway through `G then I`
|
|
200
270
|
```
|
|
201
271
|
|
|
202
|
-
`commands.
|
|
272
|
+
`commands.subscribePending()` / `commands.pending()` are the non-React equivalents. Progress is tracked only while something is subscribed, so `pending()` is empty otherwise.
|
|
203
273
|
|
|
204
|
-
##
|
|
274
|
+
## 9. Command palette
|
|
205
275
|
|
|
206
276
|
Definitions can carry what a palette needs to list and find them, and `matchCommands` does the filtering:
|
|
207
277
|
|
|
@@ -213,21 +283,22 @@ createCommands({
|
|
|
213
283
|
group: "Document", // heading to list it under
|
|
214
284
|
keywords: ["search", "lookup"], // extra search terms
|
|
215
285
|
keys: "Mod+F",
|
|
216
|
-
run: openFind,
|
|
217
286
|
},
|
|
218
|
-
"palette.close": { title: "Close palette", hidden: true,
|
|
287
|
+
"palette.close": { title: "Close palette", hidden: true, scope: "palette", keys: "Escape" },
|
|
219
288
|
});
|
|
220
289
|
```
|
|
221
290
|
|
|
222
291
|
```tsx
|
|
223
292
|
import { matchCommands } from "@inklu/keys";
|
|
224
293
|
|
|
225
|
-
|
|
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);
|
|
226
297
|
```
|
|
227
298
|
|
|
228
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()`.
|
|
229
300
|
|
|
230
|
-
##
|
|
301
|
+
## 10. Without React: data attributes
|
|
231
302
|
|
|
232
303
|
```ts
|
|
233
304
|
import { observe } from "@inklu/keys/attributes";
|
|
@@ -241,33 +312,101 @@ const stop = observe(commands); // or observe(commands, { root: element })
|
|
|
241
312
|
|
|
242
313
|
Clicking an element with `data-command` runs that command (`source: "dom"`), unless the element or an ancestor has `aria-disabled="true"` or another handler already called `preventDefault()`. Elements with `data-command-keys` get the shortcut as text, and `data-command` elements get `aria-keyshortcuts`; both stay current across rebinds and DOM changes.
|
|
243
314
|
|
|
315
|
+
## 11. Recipes
|
|
316
|
+
|
|
317
|
+
Patterns the pieces above already cover, so none of them needs an API of its own.
|
|
318
|
+
|
|
319
|
+
### Act on the selection, else on the highlighted item
|
|
320
|
+
|
|
321
|
+
Which item a shortcut applies to is your state. Compute the target once, and let it switch the handler on and off:
|
|
322
|
+
|
|
323
|
+
```tsx
|
|
324
|
+
const targets = selection.size > 0 ? [...selection] : highlighted ? [highlighted] : [];
|
|
325
|
+
|
|
326
|
+
useCommandHandler(commands, "issue.setStatus", () => openStatusPicker(targets), targets.length > 0);
|
|
327
|
+
useCommandHandler(commands, "issue.selectAll", () => setSelection(new Set(visibleIds)));
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
With nothing targeted the command has no handler, so its key falls through, `reachable` is false for its button, and a palette that lists what `canRun` leaves it out: the palette follows the selection without being told. `issue.selectAll` can be `Mod+A`: inside a text field that stays the field's own select-all.
|
|
331
|
+
|
|
332
|
+
### Hold a key to peek
|
|
333
|
+
|
|
334
|
+
`repeat: false` runs a command once per press. Letting go is a `keyup` away:
|
|
335
|
+
|
|
336
|
+
```tsx
|
|
337
|
+
// "issue.peek": { title: "Peek", keys: "Space", repeat: false }
|
|
338
|
+
const [peeking, setPeeking] = useState(false);
|
|
339
|
+
useCommandHandler(commands, "issue.peek", () => setPeeking(true), highlighted !== null);
|
|
340
|
+
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
if (!peeking) return;
|
|
343
|
+
const stop = () => setPeeking(false);
|
|
344
|
+
window.addEventListener("keyup", stop);
|
|
345
|
+
window.addEventListener("blur", stop);
|
|
346
|
+
return () => {
|
|
347
|
+
window.removeEventListener("keyup", stop);
|
|
348
|
+
window.removeEventListener("blur", stop);
|
|
349
|
+
};
|
|
350
|
+
}, [peeking]);
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Bind a hold to a bare key. While Command is down, macOS sends no `keyup` for the other keys, so a held `Mod+…` chord cannot tell when it was let go.
|
|
354
|
+
|
|
355
|
+
### A shortcut sheet on `?` and `Mod+/`
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
"help.shortcuts": { title: "Keyboard shortcuts", keys: ["?", "Mod+/"], scope: "*" },
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Build the sheet from `useCommands(commands)`, grouped by `group`, and search it with `matchCommands`, which also matches the displayed shortcut. When a user turns single-key shortcuts off, `?` goes and `Mod+/` stays, so the sheet that explains the setting is still reachable.
|
|
362
|
+
|
|
363
|
+
### A way out of a text field
|
|
364
|
+
|
|
365
|
+
Bare keys are ignored while the user types, so give them a key that leaves the field. `Escape` is the one bare key that fires inside an input:
|
|
366
|
+
|
|
367
|
+
```tsx
|
|
368
|
+
// "field.leave": { title: "Leave field", keys: "Escape", hidden: true }
|
|
369
|
+
const [editing, setEditing] = useState(false);
|
|
370
|
+
useCommandHandler(commands, "field.leave", () => rowRef.current?.focus(), editing);
|
|
371
|
+
|
|
372
|
+
<input onFocus={() => setEditing(true)} onBlur={() => setEditing(false)} />;
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
Move focus to the item the field belongs to, not to nothing, so the next key has a target.
|
|
376
|
+
|
|
244
377
|
## API Summary
|
|
245
378
|
|
|
246
379
|
- `createCommands(definitions, options)` - creates the registry
|
|
247
|
-
- `options`: `{ bindings, platform, target, persist, display, singleKeyShortcuts, onRun, onError }`
|
|
248
|
-
- `commands.run(id)`, `canRun(id)`, `whyNotRun(id)`, `
|
|
249
|
-
- `commands.
|
|
250
|
-
- `commands.
|
|
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`
|
|
383
|
+
- `commands.pushScope(name, { exclusive })`, `capture(onKey)`, `conflicts(binding, options)`, `mount()`, `destroy()`
|
|
384
|
+
- `commands.serverList()`: the list as the server rendered it, for a framework's hydration snapshot
|
|
385
|
+
- `commands.keymap`: `get`, `set`, `add`, `reset`
|
|
251
386
|
- `commands.setSingleKeyShortcuts(enabled)`, `commands.singleKeyShortcuts`
|
|
387
|
+
- `webStorage(key, storage?)` - the `persist` option: Web Storage, followed across tabs
|
|
388
|
+
- `exportKeymap(commands)`, `importKeymap(commands, data)` - user overrides as JSON-safe data
|
|
252
389
|
- `createRebinder(commands, id, options)` - framework-agnostic shortcut recording
|
|
253
390
|
- `observe(commands, { root })` from `@inklu/keys/attributes` - declarative DOM wiring
|
|
254
|
-
- `matchCommands(list, query)` - palette filtering over `title`, `description`, `group`, `keywords
|
|
255
|
-
- `formatBinding`, `bindingTokens`, `ariaKeyShortcuts`, `spokenBinding`, `
|
|
391
|
+
- `matchCommands(list, query)` - palette filtering over `title`, `description`, `group`, `keywords`, id and displayed shortcut
|
|
392
|
+
- `formatBinding`, `bindingTokens`, `ariaKeyShortcuts`, `spokenBinding`, `isSingleKeyBinding`, `seq`
|
|
256
393
|
- React (`@inklu/keys/react`):
|
|
257
394
|
- `useMountCommands(commands)` - starts listening
|
|
258
395
|
- `useCommands(commands)`, `useCommand(commands, id)` - live command snapshots
|
|
259
396
|
- `useShortcut(commands, id)` - `{ display, tokens, aria, spoken }` for the primary binding
|
|
260
|
-
- `useScope(commands, name, active, { exclusive
|
|
261
|
-
- `
|
|
397
|
+
- `useScope(commands, name, active, { exclusive })` - opens a scope while a component needs it
|
|
398
|
+
- `useCommandHandler(commands, id, run, enabled)` - what a command does while a component is mounted
|
|
262
399
|
- `useRebind(commands, id, options)` - records user shortcuts
|
|
263
400
|
- `usePendingSequences(commands)` - half-typed sequences
|
|
264
|
-
- `useHeldKeys()
|
|
401
|
+
- `useHeldKeys()` - the keys held down right now, by name
|
|
265
402
|
|
|
266
|
-
Inside
|
|
403
|
+
Inside a handler, the context's `commands` is typed with the registry's own ids, so `commands.run("typo")` is a compile error.
|
|
267
404
|
|
|
268
405
|
## Accessibility
|
|
269
406
|
|
|
270
|
-
|
|
407
|
+
Shortcuts are written the way each platform writes them: `⇧⌘S` on a Mac, symbols run together in Apple's order, and `Ctrl+Shift+S` elsewhere. `tokens` holds the same keys one at a time, for a `<kbd>` each.
|
|
408
|
+
|
|
409
|
+
Every command carries an `aria` string for the `aria-keyshortcuts` attribute. It lists chords only: the attribute separates alternatives with spaces, so it cannot express a sequence. Every command also carries `spoken` — `"Command K"` on a Mac and `"Control K"` elsewhere, or `"G then I"` for a sequence — for visually hidden text or `aria-description`, which is how a sequence gets announced:
|
|
271
410
|
|
|
272
411
|
```tsx
|
|
273
412
|
const { aria, spoken } = useShortcut(commands, "nav.inbox");
|
|
@@ -284,6 +423,10 @@ commands.setSingleKeyShortcuts(false); // or createCommands(defs, { singleKeySho
|
|
|
284
423
|
|
|
285
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`.
|
|
286
425
|
|
|
426
|
+
## Acknowledgements
|
|
427
|
+
|
|
428
|
+
The rules for matching a keypress across keyboard layouts follow the matcher in [TanStack Hotkeys](https://tanstack.com/hotkeys) (MIT), which earlier releases depended on.
|
|
429
|
+
|
|
287
430
|
## License
|
|
288
431
|
|
|
289
432
|
MIT © nkurunziza-saddy
|
package/dist/attributes.d.mts
CHANGED
|
@@ -1,2 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { l as Commands } from "./types-D-hUKGoN.mjs";
|
|
2
|
+
//#region src/attributes.d.ts
|
|
3
|
+
export interface ObserveOptions {
|
|
4
|
+
/** Root document or element to delegate from. Defaults to `document`. */
|
|
5
|
+
root?: Document | Element;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Wires a commands registry to DOM data attributes. Returns a cleanup function.
|
|
9
|
+
*/
|
|
10
|
+
export declare function observe<Id extends string>(registry: Commands<Id>, options?: ObserveOptions): () => void;
|
|
11
|
+
//#endregion
|
package/dist/attributes.mjs
CHANGED
|
@@ -7,15 +7,16 @@ const KEYS_ATTRIBUTE = "data-command-keys";
|
|
|
7
7
|
function observe(registry, options = {}) {
|
|
8
8
|
if (!("document" in globalThis)) return () => {};
|
|
9
9
|
const root = options.root ?? document;
|
|
10
|
+
const find = (id) => id ? registry.list().find((command) => command.id === id) : void 0;
|
|
10
11
|
const onClick = (event) => {
|
|
11
12
|
if (event.defaultPrevented) return;
|
|
12
13
|
const target = event.target;
|
|
13
14
|
if (!(target instanceof Element)) return;
|
|
14
15
|
const element = target.closest(`[${COMMAND_ATTRIBUTE}]`);
|
|
15
|
-
const
|
|
16
|
-
if (!
|
|
16
|
+
const command = find(element?.getAttribute(COMMAND_ATTRIBUTE) ?? null);
|
|
17
|
+
if (!command) return;
|
|
17
18
|
if (element?.closest("[aria-disabled=\"true\"]")) return;
|
|
18
|
-
registry.run(id, {
|
|
19
|
+
registry.run(command.id, {
|
|
19
20
|
source: "dom",
|
|
20
21
|
event
|
|
21
22
|
});
|
|
@@ -24,15 +25,15 @@ function observe(registry, options = {}) {
|
|
|
24
25
|
const paintElement = (element) => {
|
|
25
26
|
const keysId = element.getAttribute(KEYS_ATTRIBUTE);
|
|
26
27
|
if (keysId) {
|
|
27
|
-
const display =
|
|
28
|
+
const display = find(keysId)?.display[0] ?? "";
|
|
28
29
|
if (element.textContent !== display) element.textContent = display;
|
|
29
30
|
}
|
|
30
31
|
const commandId = element.getAttribute(COMMAND_ATTRIBUTE);
|
|
31
|
-
const aria =
|
|
32
|
+
const aria = find(commandId)?.aria ?? "";
|
|
32
33
|
if (aria) {
|
|
33
34
|
if (element.getAttribute("aria-keyshortcuts") !== aria) element.setAttribute("aria-keyshortcuts", aria);
|
|
34
35
|
labelled.add(element);
|
|
35
|
-
} else if (
|
|
36
|
+
} else if (labelled.has(element)) {
|
|
36
37
|
element.removeAttribute("aria-keyshortcuts");
|
|
37
38
|
labelled.delete(element);
|
|
38
39
|
}
|
|
@@ -51,28 +52,25 @@ function observe(registry, options = {}) {
|
|
|
51
52
|
lastPainted = next;
|
|
52
53
|
paintWithin(root);
|
|
53
54
|
});
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
paintElement(mutation.target);
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
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;
|
|
63
60
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
+
});
|
|
72
70
|
return () => {
|
|
73
71
|
root.removeEventListener("click", onClick);
|
|
74
72
|
unsubscribe();
|
|
75
|
-
observer
|
|
73
|
+
observer.disconnect();
|
|
76
74
|
};
|
|
77
75
|
}
|
|
78
76
|
//#endregion
|