@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 +106 -135
- package/dist/attributes.d.mts +1 -1
- package/dist/attributes.mjs +15 -18
- package/dist/index-BnB38jCF.d.mts +139 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +2 -2
- package/dist/react.d.mts +19 -41
- package/dist/react.mjs +26 -145
- package/dist/src-CGKvYKjd.mjs +1333 -0
- package/dist/types-BxMI3b4a.d.mts +256 -0
- package/package.json +2 -3
- package/dist/index-3d9f54ln.d.mts +0 -156
- package/dist/src-B76YvObL.mjs +0 -1687
- package/dist/types-QEXldPnL.d.mts +0 -398
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
|
|
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"`)
|
|
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 {
|
|
64
|
+
import { useMountCommands, useShortcut } from "@inklu/keys/react";
|
|
47
65
|
import { commands } from "./commands";
|
|
48
66
|
|
|
49
|
-
|
|
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`
|
|
64
|
-
const
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
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
|
+
);
|
|
246
198
|
```
|
|
247
199
|
|
|
248
|
-
|
|
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
|
-
##
|
|
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,
|
|
268
|
-
-
|
|
269
|
-
- `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`
|
|
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`, `
|
|
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
|
-
- `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
304
|
-
|
|
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
|
|
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
|
|
package/dist/attributes.d.mts
CHANGED
package/dist/attributes.mjs
CHANGED
|
@@ -52,28 +52,25 @@ function observe(registry, options = {}) {
|
|
|
52
52
|
lastPainted = next;
|
|
53
53
|
paintWithin(root);
|
|
54
54
|
});
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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
|
|
2
|
-
import { a as
|
|
3
|
-
export { type Binding, type BindingInput, 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 };
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
export { ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, formatBinding,
|
|
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 };
|