@inklu/keys 1.0.0 → 1.1.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 +42 -189
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
# @inklu/keys
|
|
2
2
|
|
|
3
|
-
Keyboard shortcut management for the web.
|
|
4
|
-
|
|
5
|
-
Built on [TanStack Hotkeys](https://tanstack.com/hotkeys) for key matching, which means typed hotkey strings, portable `Mod` bindings, layout-aware parsing and a single document listener no matter how many commands you register. `@inklu/keys` adds what an application needs on top: a registry, scopes, conflict detection, persistence, rebinding, and shortcuts a screen reader can read.
|
|
3
|
+
Keyboard shortcut management for the web. Built on TanStack Hotkeys, `@inklu/keys` provides a registry, scopes, conflict detection, and screen-reader-ready shortcuts.
|
|
6
4
|
|
|
7
5
|
```bash
|
|
8
6
|
pnpm add @inklu/keys
|
|
9
7
|
```
|
|
10
8
|
|
|
11
|
-
##
|
|
9
|
+
## 1. Define Commands
|
|
12
10
|
|
|
13
|
-
A
|
|
11
|
+
A command is an addressable action. It has an id, title, and a handler.
|
|
14
12
|
|
|
15
13
|
```ts
|
|
14
|
+
// commands.ts
|
|
16
15
|
import { createCommands, seq } from "@inklu/keys";
|
|
17
16
|
|
|
18
17
|
export const commands = createCommands({
|
|
@@ -21,237 +20,91 @@ export const commands = createCommands({
|
|
|
21
20
|
keys: "Mod+K",
|
|
22
21
|
run: () => setPaletteOpen(true),
|
|
23
22
|
},
|
|
24
|
-
"doc.save": {
|
|
25
|
-
title: "Save document",
|
|
26
|
-
keys: "Mod+S",
|
|
27
|
-
when: () => isDirty(),
|
|
28
|
-
run: () => save(),
|
|
29
|
-
},
|
|
30
23
|
"nav.inbox": {
|
|
31
24
|
title: "Go to inbox",
|
|
32
25
|
keys: seq("G", "I"), // press G, then I
|
|
33
26
|
run: () => navigate("/inbox"),
|
|
34
27
|
},
|
|
35
28
|
});
|
|
36
|
-
|
|
37
|
-
commands.mount(); // start listening; returns a function to stop
|
|
38
29
|
```
|
|
39
30
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
`Mod` resolves to <kbd>⌘</kbd> on macOS and <kbd>Ctrl</kbd> everywhere else, so you write a shortcut once.
|
|
43
|
-
|
|
44
|
-
### What this is not
|
|
45
|
-
|
|
46
|
-
This is not a command-palette toolkit. There is no palette component, no fuzzy matcher, no ranking, and no `group` or `description` on a command — a command is an id, a title, a handler and its keys, and that is the whole of it. Filtering commands is your app's business and every app wants it slightly differently. This package gives you the list, current shortcuts and current state; sorting it is three lines you own:
|
|
47
|
-
|
|
48
|
-
```ts
|
|
49
|
-
const results = commands.filter((command) =>
|
|
50
|
-
command.title.toLowerCase().includes(query.toLowerCase()),
|
|
51
|
-
);
|
|
52
|
-
```
|
|
31
|
+
## 2. React Setup
|
|
53
32
|
|
|
54
|
-
|
|
33
|
+
Pass the registry explicitly to all hooks. There is no Context provider.
|
|
55
34
|
|
|
56
35
|
```tsx
|
|
57
|
-
import { useCommands, useMountCommands } from "@inklu/keys/react";
|
|
36
|
+
import { useCommands, useMountCommands, useShortcut } from "@inklu/keys/react";
|
|
58
37
|
import { commands } from "./commands";
|
|
59
38
|
|
|
60
39
|
function App() {
|
|
40
|
+
// Starts listening in an effect
|
|
61
41
|
useMountCommands(commands);
|
|
62
|
-
return <
|
|
42
|
+
return <ShortcutButton />;
|
|
63
43
|
}
|
|
64
44
|
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
onClick={() => commands.run(
|
|
72
|
-
aria-keyshortcuts={
|
|
45
|
+
function ShortcutButton() {
|
|
46
|
+
// Get live shortcut state
|
|
47
|
+
const shortcut = useShortcut(commands, "palette.open");
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<button
|
|
51
|
+
onClick={() => commands.run("palette.open")}
|
|
52
|
+
aria-keyshortcuts={shortcut.aria || undefined}
|
|
73
53
|
>
|
|
74
|
-
|
|
75
|
-
{
|
|
54
|
+
Open Palette
|
|
55
|
+
{shortcut.tokens[0]?.map((token, i) => <kbd key={i}>{token}</kbd>)}
|
|
76
56
|
</button>
|
|
77
|
-
)
|
|
57
|
+
);
|
|
78
58
|
}
|
|
79
59
|
```
|
|
80
60
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
**Every hook takes the registry as its first argument.** There is no provider and no context: you already have the registry by importing it, and passing it explicitly is what keeps the ids typed — through context they widen to `string` and `useShortcut("typo")` stops being an error.
|
|
84
|
-
|
|
85
|
-
| Hook | What it gives you |
|
|
86
|
-
| :--- | :--- |
|
|
87
|
-
| `useMountCommands(commands)` | Starts listening for as long as the component lives |
|
|
88
|
-
| `useCommands(commands)` | Every command, with live shortcuts and state |
|
|
89
|
-
| `useCommand(commands, id)` | One command's live state |
|
|
90
|
-
| `useShortcut(commands, id)` | `{ display, tokens, aria }`, ready to render |
|
|
91
|
-
| `useScope(commands, name, active, { exclusive })` | Activates a scope while a component is mounted |
|
|
92
|
-
| `useRegisterCommand(commands, id, definition)` | A command that exists only while a feature is on screen |
|
|
93
|
-
| `useRebind(commands, id)` | The "press the keys you want" flow, with conflicts |
|
|
94
|
-
| `useHeldKeys()` | The keys physically down right now |
|
|
95
|
-
|
|
96
|
-
## Server rendering
|
|
97
|
-
|
|
98
|
-
There is no `navigator` on the server, so a shortcut has to be written for *some* platform before the browser can be asked. The registry writes `Ctrl+K` on the server and in the first client render, then settles on the real platform at `mount()` and re-renders. A Mac user sees `⌘K` a frame later instead of a hydration mismatch.
|
|
99
|
-
|
|
100
|
-
Pin `platform` if you know better, and nothing ever changes:
|
|
101
|
-
|
|
102
|
-
```ts
|
|
103
|
-
createCommands(definitions, { platform: "mac" });
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
## Accessibility
|
|
61
|
+
## 3. Scopes
|
|
107
62
|
|
|
108
|
-
|
|
63
|
+
Use scopes to isolate commands when modals or dialogs are open.
|
|
109
64
|
|
|
110
65
|
```tsx
|
|
111
|
-
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
Sequences produce `""`. The attribute separates *alternatives* with spaces, so emitting `seq("G","I")` as `"G I"` would announce "press G or I" — the opposite of what it means. There is no way to express a sequence, so the package says nothing rather than something false.
|
|
115
|
-
|
|
116
|
-
If you render shortcuts as `<kbd>⌘</kbd>`, give the control an accessible name too — a screen reader reads that glyph by its Unicode name, not as "Command".
|
|
66
|
+
import { useScope } from "@inklu/keys/react";
|
|
117
67
|
|
|
118
|
-
|
|
68
|
+
function Dialog({ isOpen }) {
|
|
69
|
+
// When exclusive is true, commands outside this scope are silenced
|
|
70
|
+
useScope(commands, "dialog", isOpen, { exclusive: true });
|
|
119
71
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
```ts
|
|
123
|
-
const release = commands.pushScope("dialog", { exclusive: true });
|
|
124
|
-
// …everything below this barrier stops responding
|
|
125
|
-
release();
|
|
72
|
+
return isOpen ? <div className="dialog">...</div> : null;
|
|
73
|
+
}
|
|
126
74
|
```
|
|
127
75
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
`exclusive` is a **barrier, not a filter**: everything at or above the topmost barrier stays reachable, everything below it goes quiet. That is what lets a menu opened inside a dialog keep working while the page behind the dialog does not. Commands scoped `"*"` always fire.
|
|
76
|
+
## 4. Rebinding
|
|
131
77
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
## Rebinding
|
|
135
|
-
|
|
136
|
-
Bindings live in a keymap, layered as defaults plus user overrides, so "reset to default" is exact rather than approximate.
|
|
78
|
+
Overrides are layered over defaults.
|
|
137
79
|
|
|
138
80
|
```ts
|
|
139
81
|
commands.keymap.set("palette.open", "Mod+P"); // remap
|
|
140
82
|
commands.keymap.add("palette.open", "Control+P"); // an additional key
|
|
141
|
-
commands.keymap.set("palette.open", null); // unbind
|
|
142
|
-
commands.keymap.reset("palette.open"); //
|
|
143
|
-
commands.keymap.reset(); // …for every command
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
`set(id, null)` and `reset(id)` are different on purpose: the first records an explicit "no keys" that survives, the second forgets that the user chose anything at all.
|
|
147
|
-
|
|
148
|
-
Persist them by handing the registry a storage getter:
|
|
149
|
-
|
|
150
|
-
```ts
|
|
151
|
-
createCommands(definitions, {
|
|
152
|
-
persist: { storage: () => localStorage },
|
|
153
|
-
});
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
A getter is the only accepted form, so nothing touches `localStorage` at module scope and the package stays safe to import on the server. Overrides that no longer parse — a hand-edited value, a key that was renamed — are skipped rather than thrown on, so one bad row cannot lock a user out of their own shortcuts.
|
|
157
|
-
|
|
158
|
-
**Overrides are user data.** They are keyed by command id and outlive the command: unregistering a feature drops its defaults but keeps the key someone chose, so a panel that comes and goes does not quietly reset it. Only `keymap.reset(id)` forgets one.
|
|
159
|
-
|
|
160
|
-
### Conflicts
|
|
161
|
-
|
|
162
|
-
```ts
|
|
163
|
-
commands.conflicts("Mod+K", { ignore: "palette.open" });
|
|
164
|
-
// → [{ id: "search.open", display: "⌘K", scope: undefined }]
|
|
83
|
+
commands.keymap.set("palette.open", null); // unbind
|
|
84
|
+
commands.keymap.reset("palette.open"); // reset to default
|
|
165
85
|
```
|
|
166
86
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
In development the registry reports collisions itself: once at startup for the initial map, and again whenever a command is added at runtime, which in a React app is where most of them come from. It also warns about bindings the key parser cannot read — a mistyped modifier is otherwise silent, because the parser drops the part it does not recognize and `"Mdo+K"` quietly becomes a bare `K`.
|
|
170
|
-
|
|
171
|
-
### Recording
|
|
87
|
+
In React, use the `useRebind` hook for a capture flow:
|
|
172
88
|
|
|
173
89
|
```tsx
|
|
174
|
-
const rebind = useRebind("palette.open");
|
|
90
|
+
const rebind = useRebind(commands, "palette.open");
|
|
175
91
|
|
|
176
92
|
<button onClick={rebind.start}>
|
|
177
93
|
{rebind.recording ? "Press keys…" : rebind.display}
|
|
178
94
|
</button>;
|
|
179
|
-
{rebind.overridden && <button onClick={rebind.reset}>Reset</button>}
|
|
180
|
-
{rebind.conflicts.length > 0 && <p>Also used by {rebind.conflicts[0].id}</p>}
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
`display`, `tokens` and `overridden` come from the command itself rather than from the last capture, so a rebind made anywhere else in the app still shows up here.
|
|
184
|
-
|
|
185
|
-
Commands are suppressed while recording, so capturing `Mod+K` does not also fire whatever `Mod+K` is currently bound to.
|
|
186
|
-
|
|
187
|
-
## Markup
|
|
188
|
-
|
|
189
|
-
For pages that are not React, `@inklu/keys/attributes` wires the DOM to a registry with one delegated listener:
|
|
190
|
-
|
|
191
|
-
```html
|
|
192
|
-
<button data-command="palette.open">
|
|
193
|
-
Search <kbd data-command-keys="palette.open"></kbd>
|
|
194
|
-
</button>
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
```ts
|
|
198
|
-
import { observe } from "@inklu/keys/attributes";
|
|
199
|
-
const dispose = observe(commands);
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
Elements with `data-command-keys` are filled with the current shortcut and stay in sync when it is rebound. Elements with `data-command` also get `aria-keyshortcuts` set for free.
|
|
203
|
-
|
|
204
|
-
## Behaviour worth knowing
|
|
205
|
-
|
|
206
|
-
**Typing in inputs.** Bare keys and Shift/Alt combos are ignored while the user is in an `input`, `textarea` or contenteditable; Ctrl/Meta shortcuts and `Escape` still fire. So `Mod+K` works from inside your search box, and `/` does not. Override per command with `ignoreInputs`.
|
|
207
|
-
|
|
208
|
-
**Consuming the event.** A command calls `preventDefault()` only when it actually runs. A command that is disabled, out of scope, or blocked by `when` leaves the key alone for the browser and for other handlers. Opt out with `preventDefault: false`. Propagation is never stopped — the matcher already prevents other hotkeys from firing, and halting the DOM event too would silently break delegation in your app.
|
|
209
|
-
|
|
210
|
-
**`when` is a guard, not state.** It is checked immediately before running and is not observed, so it will not re-render anything. Use `setEnabled` or scopes for state that should be visible in the UI — `command.reachable` reflects both.
|
|
211
|
-
|
|
212
|
-
**Why didn't my shortcut fire?** `commands.whyNotRun(id)` returns `"disabled"`, `"out-of-scope"`, `"guarded"`, `"unknown"`, or `null` when it would have run.
|
|
213
|
-
|
|
214
|
-
**Knowing what ran.** `onRun` is called for every command, whatever triggered it — that is the hook for recent-command ordering, analytics or an activity log, so a command's own `run` does not have to report on itself.
|
|
215
|
-
|
|
216
|
-
```ts
|
|
217
|
-
createCommands(definitions, {
|
|
218
|
-
onRun: ({ id, source }) => recents.push(id),
|
|
219
|
-
});
|
|
220
95
|
```
|
|
221
96
|
|
|
222
|
-
|
|
97
|
+
## API Summary
|
|
223
98
|
|
|
224
|
-
|
|
99
|
+
- `createCommands(definitions, options)` - creates the registry
|
|
100
|
+
- `useMountCommands(commands)` - starts listening
|
|
101
|
+
- `useShortcut(commands, id)` - gets display tokens and aria for rendering
|
|
102
|
+
- `useScope(commands, name, active, { exclusive })` - isolates command execution
|
|
103
|
+
- `useRebind(commands, id)` - records user shortcuts
|
|
225
104
|
|
|
226
|
-
##
|
|
227
|
-
|
|
228
|
-
```ts
|
|
229
|
-
createCommands(definitions, options) // → Commands
|
|
230
|
-
commands.list() // → readonly Command[], stable identity
|
|
231
|
-
commands.get(id)
|
|
232
|
-
commands.run(id, options?) // → boolean
|
|
233
|
-
commands.canRun(id) / commands.whyNotRun(id)
|
|
234
|
-
commands.setEnabled(id, enabled)
|
|
235
|
-
commands.add(id, definition) // → { run, dispose }
|
|
236
|
-
commands.remove(id) // keeps the user's override
|
|
237
|
-
commands.pushScope(name, { exclusive }) // → release()
|
|
238
|
-
commands.conflicts(binding, { ignore, scope })
|
|
239
|
-
commands.display(id) / commands.tokens(id) // "" / [] when unbound
|
|
240
|
-
commands.mount() // → stop()
|
|
241
|
-
commands.subscribe(listener) // → unsubscribe()
|
|
242
|
-
commands.destroy()
|
|
243
|
-
commands.keymap // get / set / add / reset / export / import
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
`list()` returns the same array until something actually changes, so it can go straight into `useSyncExternalStore`. Each `Command` carries `title`, `scope`, `bindings`, `display`, `tokens`, `aria`, `enabled`, `reachable` and `overridden`.
|
|
247
|
-
|
|
248
|
-
`display(id)` and `tokens(id)` take a command id. To format a binding you are holding directly — one out of `conflicts()`, say — `formatBinding` and `bindingTokens` are exported for that, and every `Conflict` already carries its own `display`.
|
|
249
|
-
|
|
250
|
-
`CommandId<typeof commands>` is the id union, for typing values that travel alongside the registry:
|
|
105
|
+
## Accessibility
|
|
251
106
|
|
|
252
|
-
|
|
253
|
-
export type AppCommandId = CommandId<typeof commands>;
|
|
254
|
-
```
|
|
107
|
+
Every command carries an `aria` string. Apply it to `aria-keyshortcuts` so screen readers announce the exact key sequence.
|
|
255
108
|
|
|
256
109
|
## License
|
|
257
110
|
|
package/package.json
CHANGED