@inklu/keys 1.0.0 → 1.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/README.md +57 -179
- package/dist/{attributes-lCPeoWII.d.ts → attributes-DbxlLdrb.d.ts} +20 -2
- package/dist/attributes.d.ts +1 -1
- package/dist/{index-WDNLK0dX.d.ts → index-D4UoKAQU.d.ts} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/{src-B305icXC.js → src-DyFUgmho.js} +84 -7
- 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,116 @@ 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.
|
|
66
|
+
import { useScope } from "@inklu/keys/react";
|
|
115
67
|
|
|
116
|
-
|
|
68
|
+
function Dialog({ isOpen }) {
|
|
69
|
+
// When exclusive is true, commands outside this scope are silenced
|
|
70
|
+
useScope(commands, "dialog", isOpen, { exclusive: true });
|
|
117
71
|
|
|
118
|
-
|
|
72
|
+
return isOpen ? <div className="dialog">...</div> : null;
|
|
73
|
+
}
|
|
74
|
+
```
|
|
119
75
|
|
|
120
|
-
|
|
76
|
+
`exclusive` is a barrier: it silences the whole app beneath it. To claim only a few keys, drop it and give those commands a `scope` instead — see below.
|
|
121
77
|
|
|
122
|
-
|
|
123
|
-
const release = commands.pushScope("dialog", { exclusive: true });
|
|
124
|
-
// …everything below this barrier stops responding
|
|
125
|
-
release();
|
|
126
|
-
```
|
|
78
|
+
### When two commands share a key
|
|
127
79
|
|
|
128
|
-
|
|
80
|
+
Exactly one command runs per keypress. The winner is:
|
|
129
81
|
|
|
130
|
-
|
|
82
|
+
1. The command in a named scope, over a global one. `"*"` counts as global here — it means "survives barriers", not "belongs to a region of the UI".
|
|
83
|
+
2. Failing that, the one registered later. Registration order tracks mount order, so the most recently opened surface claims the key.
|
|
131
84
|
|
|
132
|
-
|
|
85
|
+
A winner whose `when()` currently returns `false` 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 never separate, so that case warns in development.
|
|
133
86
|
|
|
134
|
-
## Rebinding
|
|
87
|
+
## 4. Rebinding
|
|
135
88
|
|
|
136
|
-
|
|
89
|
+
Overrides are layered over defaults.
|
|
137
90
|
|
|
138
91
|
```ts
|
|
139
92
|
commands.keymap.set("palette.open", "Mod+P"); // remap
|
|
140
93
|
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
|
|
94
|
+
commands.keymap.set("palette.open", null); // unbind
|
|
95
|
+
commands.keymap.reset("palette.open"); // reset to default
|
|
144
96
|
```
|
|
145
97
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
Persist them by handing the registry a storage getter:
|
|
98
|
+
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`:
|
|
149
99
|
|
|
150
100
|
```ts
|
|
151
|
-
createCommands(
|
|
152
|
-
|
|
101
|
+
createCommands(commands, {
|
|
102
|
+
bindings: {
|
|
103
|
+
"palette.open": "Mod+P", // replace the shipped default
|
|
104
|
+
"doc.publish": null, // ship it unbound
|
|
105
|
+
},
|
|
153
106
|
});
|
|
154
107
|
```
|
|
155
108
|
|
|
156
|
-
|
|
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 }]
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
Two commands only conflict when their scopes can be active at the same time, so the same key may safely mean different things in different places. A global command conflicts with every scope, because it stays reachable inside them.
|
|
109
|
+
These are defaults, applied before persisted overrides load, so a user's own rebinding still wins.
|
|
168
110
|
|
|
169
|
-
In
|
|
170
|
-
|
|
171
|
-
### Recording
|
|
111
|
+
In React, use the `useRebind` hook for a capture flow:
|
|
172
112
|
|
|
173
113
|
```tsx
|
|
174
|
-
const rebind = useRebind("palette.open");
|
|
114
|
+
const rebind = useRebind(commands, "palette.open");
|
|
175
115
|
|
|
176
116
|
<button onClick={rebind.start}>
|
|
177
117
|
{rebind.recording ? "Press keys…" : rebind.display}
|
|
178
118
|
</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
|
-
```
|
|
221
|
-
|
|
222
|
-
**Async commands.** `run` may be async. Nothing awaits it — a keystroke is fire-and-forget — but a rejection still reaches `onError` rather than the unhandled-rejection channel. If you need to await, do it inside your own handler.
|
|
223
|
-
|
|
224
|
-
**One way to write a binding.** A chord is a string, `"Mod+K"`. A sequence is `seq("G", "I")`. An array means alternatives — `keys: ["Mod+K", "/"]` binds two different keys to one command. There is no object form of a chord and no inline `"G I"` string, so nothing has to normalize a binding before it can compare, format or store one.
|
|
225
|
-
|
|
226
|
-
## API
|
|
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
119
|
```
|
|
245
120
|
|
|
246
|
-
|
|
121
|
+
## API Summary
|
|
247
122
|
|
|
248
|
-
|
|
123
|
+
- `createCommands(definitions, options)` - creates the registry
|
|
124
|
+
- `options`: `{ bindings, platform, target, persist, display, onRun, onError }`
|
|
125
|
+
- `useMountCommands(commands)` - starts listening
|
|
126
|
+
- `useShortcut(commands, id)` - gets display tokens and aria for rendering
|
|
127
|
+
- `useScope(commands, name, active, { exclusive })` - isolates command execution
|
|
128
|
+
- `useRebind(commands, id)` - records user shortcuts
|
|
249
129
|
|
|
250
|
-
|
|
130
|
+
## Accessibility
|
|
251
131
|
|
|
252
|
-
|
|
253
|
-
export type AppCommandId = CommandId<typeof commands>;
|
|
254
|
-
```
|
|
132
|
+
Every command carries an `aria` string. Apply it to `aria-keyshortcuts` so screen readers announce the exact key sequence.
|
|
255
133
|
|
|
256
134
|
## License
|
|
257
135
|
|
|
@@ -143,7 +143,25 @@ interface CommandKeymap<Id extends string = string> {
|
|
|
143
143
|
//#endregion
|
|
144
144
|
//#region src/registry.d.ts
|
|
145
145
|
/** Configuration options for a commands registry. */
|
|
146
|
-
interface CommandsOptions {
|
|
146
|
+
interface CommandsOptions<Id extends string = string> {
|
|
147
|
+
/**
|
|
148
|
+
* Replaces the default `keys` a command was defined with, keyed by command
|
|
149
|
+
* id. Lets an app rebind commands it did not define — a package ships the
|
|
150
|
+
* behaviour, the app decides which key reaches it — and an unknown id is a
|
|
151
|
+
* compile error rather than a silent no-op. `null` ships the command with
|
|
152
|
+
* no default binding at all.
|
|
153
|
+
*
|
|
154
|
+
* These are defaults, applied before persisted overrides load, so a user's
|
|
155
|
+
* own rebinding still wins.
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```ts
|
|
159
|
+
* createCommands(commands, {
|
|
160
|
+
* bindings: { "palette.open": "Mod+P", "doc.publish": null },
|
|
161
|
+
* });
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
bindings?: Partial<Record<Id, BindingInput | null>>;
|
|
147
165
|
/** Platform for modifier resolution and display formatting. Defaults to auto-detection. */
|
|
148
166
|
platform?: Platform;
|
|
149
167
|
/** Target element for key events. Defaults to `document`. */
|
|
@@ -260,7 +278,7 @@ interface Commands<Id extends string = string> {
|
|
|
260
278
|
* });
|
|
261
279
|
* ```
|
|
262
280
|
*/
|
|
263
|
-
declare function createCommands<const T extends { [K in keyof T]: CommandDefinition & Record<Exclude<keyof T[K], keyof CommandDefinition>, never>; }>(definitions: T, options?: CommandsOptions): Commands<Extract<keyof T, string>>;
|
|
281
|
+
declare function createCommands<const T extends { [K in keyof T]: CommandDefinition & Record<Exclude<keyof T[K], keyof CommandDefinition>, never>; }>(definitions: T, options?: CommandsOptions<Extract<keyof T, string>>): Commands<Extract<keyof T, string>>;
|
|
264
282
|
/**
|
|
265
283
|
* Extracts command ID union from a Commands registry type.
|
|
266
284
|
*
|
package/dist/attributes.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as observe, t as ObserveOptions } from "./attributes-
|
|
1
|
+
import { n as observe, t as ObserveOptions } from "./attributes-DbxlLdrb.js";
|
|
2
2
|
export { ObserveOptions, observe };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { M as SequenceBinding, T as Conflict, a as Commands, b as Chord, v as Binding } from "./attributes-
|
|
1
|
+
import { M as SequenceBinding, T as Conflict, a as Commands, b as Chord, v as Binding } from "./attributes-DbxlLdrb.js";
|
|
2
2
|
import { Hotkey } from "@tanstack/hotkeys";
|
|
3
3
|
//#region src/bindings.d.ts
|
|
4
4
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { A as Platform, C as CommandDefinition, D as HotkeySequence, E as Hotkey, M as SequenceBinding, N as SerializedBinding, O as KeymapPersistence, S as CommandContext, T as Conflict, _ as formatBinding, a as Commands, b as Chord, c as PendingSequence, d as createCommands, f as CommandKeymap, g as bindingTokens, h as ariaKeyShortcuts, i as CommandId, j as ScopeOptions, k as KeymapStorage, l as RunBlockReason, m as DisplayOptions, n as observe, o as CommandsOptions, p as SerializedKeymap, r as CommandHandle, s as ConflictOptions, t as ObserveOptions, u as RunOptions, v as Binding, w as CommandSource, x as Command, y as BindingInput } from "./attributes-
|
|
2
|
-
import { a as Rebinder, c as seq, i as RebindState, l as validateBinding, n as RebindMode, o as createRebinder, r as RebindOptions, s as BindingValidation, t as ALWAYS_SCOPE } from "./index-
|
|
1
|
+
import { A as Platform, C as CommandDefinition, D as HotkeySequence, E as Hotkey, M as SequenceBinding, N as SerializedBinding, O as KeymapPersistence, S as CommandContext, T as Conflict, _ as formatBinding, a as Commands, b as Chord, c as PendingSequence, d as createCommands, f as CommandKeymap, g as bindingTokens, h as ariaKeyShortcuts, i as CommandId, j as ScopeOptions, k as KeymapStorage, l as RunBlockReason, m as DisplayOptions, n as observe, o as CommandsOptions, p as SerializedKeymap, r as CommandHandle, s as ConflictOptions, t as ObserveOptions, u as RunOptions, v as Binding, w as CommandSource, x as Command, y as BindingInput } from "./attributes-DbxlLdrb.js";
|
|
2
|
+
import { a as Rebinder, c as seq, i as RebindState, l as validateBinding, n as RebindMode, o as createRebinder, r as RebindOptions, s as BindingValidation, t as ALWAYS_SCOPE } from "./index-D4UoKAQU.js";
|
|
3
3
|
export { ALWAYS_SCOPE, type Binding, type BindingInput, type BindingValidation, type Chord, type Command, type CommandContext, type CommandDefinition, type CommandHandle, type CommandId, type CommandKeymap, type CommandSource, type Commands, type CommandsOptions, type Conflict, type ConflictOptions, type DisplayOptions, type Hotkey, type HotkeySequence, type KeymapPersistence, type KeymapStorage, type ObserveOptions, type PendingSequence, type Platform, type RebindMode, type RebindOptions, type RebindState, type Rebinder, type RunBlockReason, type RunOptions, type ScopeOptions, type SequenceBinding, type SerializedBinding, type SerializedKeymap, ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, formatBinding, observe, seq, validateBinding };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { observe } from "./attributes.js";
|
|
2
|
-
import { a as bindingTokens, c as seq, i as ariaKeyShortcuts, n as ALWAYS_SCOPE, o as formatBinding, r as createRebinder, t as createCommands, u as validateBinding } from "./src-
|
|
2
|
+
import { a as bindingTokens, c as seq, i as ariaKeyShortcuts, n as ALWAYS_SCOPE, o as formatBinding, r as createRebinder, t as createCommands, u as validateBinding } from "./src-DyFUgmho.js";
|
|
3
3
|
export { ALWAYS_SCOPE, ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, formatBinding, observe, seq, validateBinding };
|
package/dist/react.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { A as Platform, C as CommandDefinition, D as HotkeySequence, E as Hotkey, M as SequenceBinding, N as SerializedBinding, O as KeymapPersistence, S as CommandContext, T as Conflict, _ as formatBinding, a as Commands, b as Chord, c as PendingSequence, d as createCommands, f as CommandKeymap, g as bindingTokens, h as ariaKeyShortcuts, i as CommandId, j as ScopeOptions, k as KeymapStorage, l as RunBlockReason, m as DisplayOptions, n as observe, o as CommandsOptions, p as SerializedKeymap, r as CommandHandle, s as ConflictOptions, t as ObserveOptions, u as RunOptions, v as Binding, w as CommandSource, x as Command, y as BindingInput } from "./attributes-
|
|
2
|
-
import { a as Rebinder, c as seq, i as RebindState, l as validateBinding, n as RebindMode, o as createRebinder, r as RebindOptions, s as BindingValidation, t as ALWAYS_SCOPE } from "./index-
|
|
1
|
+
import { A as Platform, C as CommandDefinition, D as HotkeySequence, E as Hotkey, M as SequenceBinding, N as SerializedBinding, O as KeymapPersistence, S as CommandContext, T as Conflict, _ as formatBinding, a as Commands, b as Chord, c as PendingSequence, d as createCommands, f as CommandKeymap, g as bindingTokens, h as ariaKeyShortcuts, i as CommandId, j as ScopeOptions, k as KeymapStorage, l as RunBlockReason, m as DisplayOptions, n as observe, o as CommandsOptions, p as SerializedKeymap, r as CommandHandle, s as ConflictOptions, t as ObserveOptions, u as RunOptions, v as Binding, w as CommandSource, x as Command, y as BindingInput } from "./attributes-DbxlLdrb.js";
|
|
2
|
+
import { a as Rebinder, c as seq, i as RebindState, l as validateBinding, n as RebindMode, o as createRebinder, r as RebindOptions, s as BindingValidation, t as ALWAYS_SCOPE } from "./index-D4UoKAQU.js";
|
|
3
3
|
//#region src/react/hooks.d.ts
|
|
4
4
|
/**
|
|
5
5
|
* Mounts a commands registry for the lifecycle of the component.
|
package/dist/react.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { observe } from "./attributes.js";
|
|
3
|
-
import { a as bindingTokens, c as seq, i as ariaKeyShortcuts, l as toBindings, n as ALWAYS_SCOPE, o as formatBinding, r as createRebinder, s as bindingSignature, t as createCommands, u as validateBinding } from "./src-
|
|
3
|
+
import { a as bindingTokens, c as seq, i as ariaKeyShortcuts, l as toBindings, n as ALWAYS_SCOPE, o as formatBinding, r as createRebinder, s as bindingSignature, t as createCommands, u as validateBinding } from "./src-DyFUgmho.js";
|
|
4
4
|
import { getKeyStateTracker } from "@tanstack/hotkeys";
|
|
5
5
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
6
6
|
//#region src/react/hooks.ts
|
|
@@ -340,7 +340,7 @@ var Binder = class {
|
|
|
340
340
|
this.#bound.clear();
|
|
341
341
|
}
|
|
342
342
|
#register(id, binding, signature, definition, enabled) {
|
|
343
|
-
const callback = (event) => this.#handle(id, binding, event);
|
|
343
|
+
const callback = (event) => this.#handle(id, binding, signature, event);
|
|
344
344
|
const options = {
|
|
345
345
|
preventDefault: false,
|
|
346
346
|
stopPropagation: false,
|
|
@@ -370,8 +370,18 @@ var Binder = class {
|
|
|
370
370
|
return null;
|
|
371
371
|
}
|
|
372
372
|
}
|
|
373
|
-
|
|
373
|
+
/**
|
|
374
|
+
* One keypress can reach several registrations (see `conflictBehavior`
|
|
375
|
+
* above), so every candidate asks the registry whether it won before doing
|
|
376
|
+
* anything observable. Arbitration comes before `preventDefault` on
|
|
377
|
+
* purpose: a losing command must leave the event exactly as it found it,
|
|
378
|
+
* or a command declaring `preventDefault: false` would still have the
|
|
379
|
+
* browser's default action cancelled by whichever rival happened to be
|
|
380
|
+
* registered alongside it.
|
|
381
|
+
*/
|
|
382
|
+
#handle(id, binding, signature, event) {
|
|
374
383
|
if (!this.#host.canRun(id)) return;
|
|
384
|
+
if (!this.#host.claims(id, signature)) return;
|
|
375
385
|
const definition = this.#host.getDefinition(id);
|
|
376
386
|
if (!definition) return;
|
|
377
387
|
if (definition.preventDefault !== false) event.preventDefault();
|
|
@@ -394,7 +404,7 @@ var Keymap = class {
|
|
|
394
404
|
constructor(persistence) {
|
|
395
405
|
this.#persistence = persistence;
|
|
396
406
|
}
|
|
397
|
-
/** Sets a command's default bindings. */
|
|
407
|
+
/** Sets a command's default bindings. `null` leaves the command unbound. */
|
|
398
408
|
setDefault(loose, input) {
|
|
399
409
|
const id = loose;
|
|
400
410
|
const bindings = toBindings(input);
|
|
@@ -573,6 +583,7 @@ var CommandRegistry = class {
|
|
|
573
583
|
#pendingSubscription = null;
|
|
574
584
|
#snapshot = null;
|
|
575
585
|
#index = null;
|
|
586
|
+
#ownerIndex = null;
|
|
576
587
|
#mounts = 0;
|
|
577
588
|
#hydrated = false;
|
|
578
589
|
#barrier = false;
|
|
@@ -584,6 +595,7 @@ var CommandRegistry = class {
|
|
|
584
595
|
getBindings: (id) => this.#keymap.get(id),
|
|
585
596
|
canRun: (id) => this.canRun(id),
|
|
586
597
|
isReachable: (id) => this.#isReachable(id),
|
|
598
|
+
claims: (id, signature) => this.#claims(id, signature),
|
|
587
599
|
platform: () => this.#platform(),
|
|
588
600
|
execute: (id, context) => {
|
|
589
601
|
this.#execute(id, context);
|
|
@@ -808,7 +820,9 @@ var CommandRegistry = class {
|
|
|
808
820
|
const definition = this.#definition(id);
|
|
809
821
|
if (!definition) return;
|
|
810
822
|
this.#enabled.set(id, definition.enabled !== false);
|
|
811
|
-
this.#
|
|
823
|
+
const overrides = this.#options.bindings;
|
|
824
|
+
const keys = overrides && Object.hasOwn(overrides, id) ? overrides[id] : definition.keys;
|
|
825
|
+
this.#keymap.setDefault(id, keys);
|
|
812
826
|
}
|
|
813
827
|
#drop(id) {
|
|
814
828
|
this.#stacks.delete(id);
|
|
@@ -822,6 +836,57 @@ var CommandRegistry = class {
|
|
|
822
836
|
if (!(this.#enabled.get(id) ?? false)) return false;
|
|
823
837
|
return this.#scopes.isActive(this.#definition(id)?.scope);
|
|
824
838
|
}
|
|
839
|
+
/**
|
|
840
|
+
* Decides which of several commands sharing one binding gets to run.
|
|
841
|
+
*
|
|
842
|
+
* Without this every registration for the pressed chord fires, so two
|
|
843
|
+
* commands bound to `Mod+S` in overlapping scopes both ran and the only
|
|
844
|
+
* defence was a dev-time console warning. Exclusive scopes could not fix
|
|
845
|
+
* it either: a barrier shadows *every* global command, not just the one
|
|
846
|
+
* colliding, so "Esc closes the dialog, and the panel otherwise" forced
|
|
847
|
+
* the whole dialog to become exclusive.
|
|
848
|
+
*
|
|
849
|
+
* Ranking, highest first:
|
|
850
|
+
*
|
|
851
|
+
* 1. A command in a named scope beats a global one. `ALWAYS_SCOPE` is
|
|
852
|
+
* global for this purpose — it means "survives barriers", not
|
|
853
|
+
* "belongs to some region of the UI".
|
|
854
|
+
* 2. Later registration beats earlier. Registration order tracks mount
|
|
855
|
+
* order, so the most recently opened surface claims the key — the
|
|
856
|
+
* same intuition that makes `add()` stack definitions per id.
|
|
857
|
+
*
|
|
858
|
+
* Guards are evaluated live rather than baked into the ranking, so a
|
|
859
|
+
* winner whose `when()` currently returns false steps aside instead of
|
|
860
|
+
* swallowing the keypress.
|
|
861
|
+
*/
|
|
862
|
+
#claims(id, signature) {
|
|
863
|
+
const candidates = this.#owners().get(signature);
|
|
864
|
+
if (!candidates || candidates.length < 2) return true;
|
|
865
|
+
for (const candidate of candidates) {
|
|
866
|
+
if (candidate !== id && !this.canRun(candidate)) continue;
|
|
867
|
+
return candidate === id;
|
|
868
|
+
}
|
|
869
|
+
return false;
|
|
870
|
+
}
|
|
871
|
+
/** Binding signature → command ids that share it, ranked by `#claims`. */
|
|
872
|
+
#owners() {
|
|
873
|
+
if (this.#ownerIndex) return this.#ownerIndex;
|
|
874
|
+
const platform = this.#platform();
|
|
875
|
+
const index = /* @__PURE__ */ new Map();
|
|
876
|
+
for (const id of this.#order) for (const binding of this.#keymap.get(id)) {
|
|
877
|
+
const signature = bindingSignature(binding, platform);
|
|
878
|
+
const ids = index.get(signature);
|
|
879
|
+
if (ids) ids.unshift(id);
|
|
880
|
+
else index.set(signature, [id]);
|
|
881
|
+
}
|
|
882
|
+
for (const ids of index.values()) if (ids.length > 1) ids.sort((a, b) => this.#scopeRank(b) - this.#scopeRank(a));
|
|
883
|
+
this.#ownerIndex = index;
|
|
884
|
+
return index;
|
|
885
|
+
}
|
|
886
|
+
#scopeRank(id) {
|
|
887
|
+
const scope = this.#definition(id)?.scope;
|
|
888
|
+
return scope === void 0 || scope === "*" ? 0 : 1;
|
|
889
|
+
}
|
|
825
890
|
#execute(id, context) {
|
|
826
891
|
const definition = this.#definition(id);
|
|
827
892
|
if (!definition) return;
|
|
@@ -894,6 +959,7 @@ var CommandRegistry = class {
|
|
|
894
959
|
#invalidate() {
|
|
895
960
|
this.#snapshot = null;
|
|
896
961
|
this.#index = null;
|
|
962
|
+
this.#ownerIndex = null;
|
|
897
963
|
for (const listener of this.#listeners) listener();
|
|
898
964
|
}
|
|
899
965
|
#warnAboutConflicts() {
|
|
@@ -908,10 +974,10 @@ var CommandRegistry = class {
|
|
|
908
974
|
seen.set(key, id);
|
|
909
975
|
continue;
|
|
910
976
|
}
|
|
911
|
-
collisions.push(`${formatBinding(binding, this.#displayOptions())} — "${
|
|
977
|
+
collisions.push(`${formatBinding(binding, this.#displayOptions())} — "${id}" shadows "${owner}"`);
|
|
912
978
|
}
|
|
913
979
|
}
|
|
914
|
-
if (collisions.length > 0) console.warn(`[@inklu/keys] ${collisions.length}
|
|
980
|
+
if (collisions.length > 0) console.warn(`[@inklu/keys] ${collisions.length} unreachable binding(s) — same chord, same scope:\n ${collisions.join("\n ")}`);
|
|
915
981
|
}
|
|
916
982
|
#warnAboutBindings(id) {
|
|
917
983
|
for (const binding of this.#keymap.get(id)) {
|
|
@@ -921,9 +987,17 @@ var CommandRegistry = class {
|
|
|
921
987
|
console.warn(`[@inklu/keys] "${id}" has an unrecognized binding ${describeBinding(binding)}, which resolves to "${resolved}". ${errors.join("; ")}.`);
|
|
922
988
|
}
|
|
923
989
|
}
|
|
990
|
+
/** Catches ids in `options.bindings` that TypeScript could not — plain JS, or a runtime-only command. */
|
|
991
|
+
#warnAboutUnknownOverrides() {
|
|
992
|
+
const overrides = this.#options.bindings;
|
|
993
|
+
if (!overrides) return;
|
|
994
|
+
const unknown = Object.keys(overrides).filter((id) => !this.#stacks.has(id));
|
|
995
|
+
if (unknown.length > 0) console.warn(`[@inklu/keys] bindings override ${unknown.map((id) => `"${id}"`).join(", ")}, which ${unknown.length === 1 ? "is not a registered command" : "are not registered commands"}.`);
|
|
996
|
+
}
|
|
924
997
|
#audit() {
|
|
925
998
|
if (!isDevelopment()) return;
|
|
926
999
|
for (const id of this.#order) this.#warnAboutBindings(id);
|
|
1000
|
+
this.#warnAboutUnknownOverrides();
|
|
927
1001
|
this.#warnAboutConflicts();
|
|
928
1002
|
}
|
|
929
1003
|
#auditAdded(id) {
|
|
@@ -933,7 +1007,10 @@ var CommandRegistry = class {
|
|
|
933
1007
|
for (const conflict of this.conflicts(this.#keymap.get(id), {
|
|
934
1008
|
ignore: id,
|
|
935
1009
|
scope
|
|
936
|
-
}))
|
|
1010
|
+
})) {
|
|
1011
|
+
const winner = this.#scopeRank(conflict.id) > this.#scopeRank(id) ? conflict.id : id;
|
|
1012
|
+
console.warn(`[@inklu/keys] "${id}" binds ${conflict.display}, which "${conflict.id}" already uses. While both are runnable the key goes to "${winner}".`);
|
|
1013
|
+
}
|
|
937
1014
|
}
|
|
938
1015
|
};
|
|
939
1016
|
function describeBinding(binding) {
|
package/package.json
CHANGED