@inklu/keys 1.4.1 → 2.0.1

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 CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Keyboard shortcut management for the web. Built on TanStack Hotkeys, `@inklu/keys` provides a registry, scopes, conflict detection, and screen-reader-ready shortcuts.
4
4
 
5
+ Docs: [inklu-keys.vercel.app](https://inklu-keys.vercel.app) · For agents: [llms.txt](https://inklu-keys.vercel.app/llms.txt) · Agent skill: `npx skills add nkurunziza-saddy/inklu --skill inklu-keys`
6
+
5
7
  ```bash
6
8
  pnpm add @inklu/keys
7
9
  ```
@@ -43,7 +45,7 @@ function App() {
43
45
  }
44
46
 
45
47
  function ShortcutButton() {
46
- // Get live shortcut state
48
+ // Live state for the primary binding: `tokens` is one string per key
47
49
  const shortcut = useShortcut(commands, "palette.open");
48
50
 
49
51
  return (
@@ -52,7 +54,7 @@ function ShortcutButton() {
52
54
  aria-keyshortcuts={shortcut.aria || undefined}
53
55
  >
54
56
  Open Palette
55
- {shortcut.tokens[0]?.map((token, i) => (
57
+ {shortcut.tokens.map((token, i) => (
56
58
  <kbd key={i}>{token}</kbd>
57
59
  ))}
58
60
  </button>
@@ -97,6 +99,8 @@ commands.keymap.set("palette.open", null); // unbind
97
99
  commands.keymap.reset("palette.open"); // reset to default
98
100
  ```
99
101
 
102
+ `set` replaces the command's keys outright; setting exactly the default forgets the override instead. `add` layers on top of the default rather than copying it, so if a later release of your app changes the default, the user gets the new one and keeps the keys they added.
103
+
100
104
  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`:
101
105
 
102
106
  ```ts
@@ -118,18 +122,167 @@ const rebind = useRebind(commands, "palette.open");
118
122
  <button onClick={rebind.start}>{rebind.recording ? "Press keys…" : rebind.display}</button>;
119
123
  ```
120
124
 
125
+ To build your own capture UI without React, use `createRebinder`:
126
+
127
+ ```ts
128
+ import { createRebinder } from "@inklu/keys";
129
+
130
+ const rebinder = createRebinder(commands, "palette.open", {
131
+ mode: "chord", // or "sequence": Enter or `commit()` finishes it
132
+ rejectConflicts: true, // don't apply a binding another command uses
133
+ onRecord: (binding, conflicts) => {},
134
+ });
135
+ rebinder.subscribe(() => render(rebinder.state)); // { recording, binding, steps, conflicts }
136
+ rebinder.start(); // Escape cancels, Backspace unbinds
137
+ ```
138
+
139
+ While recording, every other command is silenced — including `"*"` ones — so the keys being recorded never run anything.
140
+
141
+ ## 5. Conflicts
142
+
143
+ `commands.conflicts(binding, { scope, ignore })` lists commands in overlapping scopes whose bindings collide with `binding`. Each result has a `kind`:
144
+
145
+ - `"exact"` — the same keys. Only one command runs (see above).
146
+ - `"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
+
148
+ In development, both kinds are also reported with `console.warn` when commands are defined or added.
149
+
150
+ ## 6. Persistence and SSR
151
+
152
+ ```ts
153
+ createCommands(definitions, {
154
+ persist: {
155
+ storage: () => (typeof window === "undefined" ? null : localStorage),
156
+ key: "my-app.keys",
157
+ },
158
+ });
159
+ ```
160
+
161
+ Only user overrides are stored — never defaults. `keymap.export()` / `keymap.import(json)` move them between devices. The stored data carries a `version`, and a release that doesn't recognise a version leaves it alone instead of misreading it. Data from releases before the version field still loads.
162
+
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.
164
+
165
+ The registry is safe to create at module scope and render on the server:
166
+
167
+ - 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.
169
+ - Nothing listens for keys until `mount()` (or `useMountCommands`).
170
+
171
+ ## 7. Inputs and events
172
+
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`.
176
+
177
+ `commands.whyNotRun(id)` explains a shortcut that did not fire: `"disabled"`, `"out-of-scope"`, `"guarded"`, `"unknown"`, or `null` when it would run.
178
+
179
+ ## 8. Runtime commands
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
+ ```
194
+
195
+ ## 9. Sequence progress
196
+
197
+ ```tsx
198
+ const pending = usePendingSequences(commands); // [{ id, matched, total, remaining }]
199
+ // e.g. show "G → …" while the user is halfway through `G then I`
200
+ ```
201
+
202
+ `commands.pending()` / `commands.subscribePending()` are the non-React equivalents.
203
+
204
+ ## 10. Command palette
205
+
206
+ Definitions can carry what a palette needs to list and find them, and `matchCommands` does the filtering:
207
+
208
+ ```ts
209
+ createCommands({
210
+ "doc.find": {
211
+ title: "Find in document",
212
+ description: "Search the open document",
213
+ group: "Document", // heading to list it under
214
+ keywords: ["search", "lookup"], // extra search terms
215
+ keys: "Mod+F",
216
+ run: openFind,
217
+ },
218
+ "palette.close": { title: "Close palette", hidden: true, keys: "Escape", run: close },
219
+ });
220
+ ```
221
+
222
+ ```tsx
223
+ import { matchCommands } from "@inklu/keys";
224
+
225
+ const results = matchCommands(useCommands(commands), query);
226
+ ```
227
+
228
+ 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
+
230
+ ## 11. Without React: data attributes
231
+
232
+ ```ts
233
+ import { observe } from "@inklu/keys/attributes";
234
+
235
+ const stop = observe(commands); // or observe(commands, { root: element })
236
+ ```
237
+
238
+ ```html
239
+ <button data-command="palette.open">Search <kbd data-command-keys="palette.open"></kbd></button>
240
+ ```
241
+
242
+ 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
+
121
244
  ## API Summary
122
245
 
123
246
  - `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
247
+ - `options`: `{ bindings, platform, target, persist, display, singleKeyShortcuts, onRun, onError }`
248
+ - `commands.run(id)`, `canRun(id)`, `whyNotRun(id)`, `setEnabled(id, enabled)`, `add(id, definition)`, `remove(id)`
249
+ - `commands.pushScope(name, { exclusive, strict })`, `conflicts(binding, options)`, `display(id)`, `tokens(id)`
250
+ - `commands.keymap`: `get`, `set`, `add`, `reset`, `export`, `import`
251
+ - `commands.setSingleKeyShortcuts(enabled)`, `commands.singleKeyShortcuts`
252
+ - `createRebinder(commands, id, options)` - framework-agnostic shortcut recording
253
+ - `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`, `validateBinding`, `isSingleKeyBinding`, `seq`
256
+ - React (`@inklu/keys/react`):
257
+ - `useMountCommands(commands)` - starts listening
258
+ - `useCommands(commands)`, `useCommand(commands, id)` - live command snapshots
259
+ - `useShortcut(commands, id)` - `{ display, tokens, aria, spoken }` for the primary binding
260
+ - `useScope(commands, name, active, { exclusive, strict })` - isolates command execution
261
+ - `useRegisterCommand(commands, id, definition)` - component-scoped commands
262
+ - `useRebind(commands, id, options)` - records user shortcuts
263
+ - `usePendingSequences(commands)` - half-typed sequences
264
+ - `useHeldKeys()`, `useHeldKeyCodes()`, `useKeyHold(key)` - live key state
265
+
266
+ Inside `run`, the context's `commands` is typed with the registry's own ids, so `commands.run("typo")` is a compile error.
129
267
 
130
268
  ## Accessibility
131
269
 
132
- Every command carries an `aria` string. Apply it to `aria-keyshortcuts` so screen readers announce the exact key sequence.
270
+ 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` — `"Control+K"`, or `"G then I"` for a sequence — for visually hidden text or `aria-description`, which is how a sequence gets announced:
271
+
272
+ ```tsx
273
+ const { aria, spoken } = useShortcut(commands, "nav.inbox");
274
+ <button aria-keyshortcuts={aria || undefined} aria-description={spoken || undefined}>
275
+ Inbox
276
+ </button>;
277
+ ```
278
+
279
+ 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:
280
+
281
+ ```ts
282
+ commands.setSingleKeyShortcuts(false); // or createCommands(defs, { singleKeyShortcuts: false })
283
+ ```
284
+
285
+ 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`.
133
286
 
134
287
  ## License
135
288
 
@@ -41,10 +41,28 @@ interface CommandContext<Id extends string = string> {
41
41
  */
42
42
  readonly commands: Commands<Id>;
43
43
  }
44
- /** Definition of a command and its default configuration. */
45
- interface CommandDefinition {
44
+ /**
45
+ * Definition of a command and its default configuration.
46
+ *
47
+ * `Id` is the registry's id union. `createCommands` fills it in, so
48
+ * `commands.run(...)` inside a handler is checked against the registry's own
49
+ * ids; a definition written on its own defaults to plain `string`.
50
+ */
51
+ interface CommandDefinition<Id extends string = string> {
46
52
  /** Human-readable title. */
47
53
  title: string;
54
+ /** Longer explanation, such as a palette's secondary line or a tooltip. */
55
+ description?: string;
56
+ /** Heading to list the command under in a palette or shortcut sheet, e.g. `"Navigate"`. */
57
+ group?: string;
58
+ /** Extra search terms `matchCommands` checks besides the title, e.g. `["find", "search"]`. */
59
+ keywords?: ReadonlyArray<string>;
60
+ /**
61
+ * Leave the command out of `matchCommands` — for commands that only make
62
+ * sense from their key, like a palette's own Close. It still runs and
63
+ * still appears in `list()`. Defaults to `false`.
64
+ */
65
+ hidden?: boolean;
48
66
  /** Default key binding(s). */
49
67
  keys?: BindingInput;
50
68
  /** Scope required for this command to fire. Omit for global, or `"*"` to bypass exclusive scopes. */
@@ -53,21 +71,38 @@ interface CommandDefinition {
53
71
  when?: () => boolean;
54
72
  /** Whether the command is enabled. Defaults to `true`. */
55
73
  enabled?: boolean;
56
- /** Prevent default event behavior when run. Defaults to `true`. */
74
+ /**
75
+ * Prevent default event behavior when run. Defaults to `true`. For a
76
+ * sequence this also covers each step as it matches, so a first step such
77
+ * as `Mod+K` does not trigger the browser's own shortcut on the way.
78
+ */
57
79
  preventDefault?: boolean;
58
- /** Whether to ignore events in form inputs/textareas/contenteditable. */
80
+ /**
81
+ * Whether to ignore key events from text inputs, textareas and
82
+ * contenteditable elements. When omitted, TanStack Hotkeys decides per
83
+ * binding: chords holding Control or Meta (so `Mod+…`) and bare `Escape`
84
+ * still fire inside inputs, while everything else — bare keys, Shift/Alt
85
+ * chords, and sequences starting with one — is ignored there, so typing is
86
+ * never hijacked.
87
+ */
59
88
  ignoreInputs?: boolean;
60
89
  /** Require key release before firing again (prevents auto-repeat). Defaults to `false`. */
61
90
  requireReset?: boolean;
62
91
  /** Timeout in milliseconds between sequence steps. Defaults to `1000`. */
63
92
  sequenceTimeout?: number;
64
93
  /** Handler executed when the command runs. */
65
- run: (context: CommandContext) => void | Promise<void>;
94
+ run: (context: CommandContext<Id>) => void | Promise<void>;
66
95
  }
67
96
  /** Resolved command snapshot including effective bindings, display tokens, and reachability. */
68
97
  interface Command<Id extends string = string> {
69
98
  readonly id: Id;
70
99
  readonly title: string;
100
+ readonly description?: string;
101
+ readonly group?: string;
102
+ /** Extra search terms; empty when the definition gave none. */
103
+ readonly keywords: ReadonlyArray<string>;
104
+ /** Whether `matchCommands` leaves the command out. */
105
+ readonly hidden: boolean;
71
106
  readonly scope?: string;
72
107
  /** Active bindings after user overrides. */
73
108
  readonly bindings: ReadonlyArray<Binding>;
@@ -75,8 +110,18 @@ interface Command<Id extends string = string> {
75
110
  readonly display: ReadonlyArray<string>;
76
111
  /** Formatted tokens per binding for rendering `<kbd>` elements. */
77
112
  readonly tokens: ReadonlyArray<ReadonlyArray<string>>;
78
- /** Value for `aria-keyshortcuts` attribute. */
113
+ /**
114
+ * Value for the `aria-keyshortcuts` attribute. Only chords appear here: the
115
+ * attribute separates alternatives with spaces, so it cannot express a
116
+ * sequence. Use `spoken` to announce those.
117
+ */
79
118
  readonly aria: string;
119
+ /**
120
+ * Every binding phrased for a screen reader, alternatives joined by
121
+ * `" or "` — e.g. `"Control+K"` or `"G then I"`. Suited to visually hidden
122
+ * text or `aria-description`, and the only accessible form of a sequence.
123
+ */
124
+ readonly spoken: string;
80
125
  /** Whether the command is enabled. */
81
126
  readonly enabled: boolean;
82
127
  /** Whether the command is enabled and in an active scope. */
@@ -99,12 +144,20 @@ interface KeymapPersistence {
99
144
  /** Getter returning the storage implementation lazily. */
100
145
  storage: () => KeymapStorage | null;
101
146
  }
102
- /** Details of a conflict between commands with identical bindings in overlapping scopes. */
147
+ /**
148
+ * How two bindings collide: `"exact"` when they are the same keys, `"prefix"`
149
+ * when one is the start of the other — chord `G` against `G then I` — so the
150
+ * shorter one fires on the way to the longer one.
151
+ */
152
+ type ConflictKind = "exact" | "prefix";
153
+ /** Details of a conflict between commands whose bindings collide in overlapping scopes. */
103
154
  interface Conflict<Id extends string = string> {
104
155
  /** ID of the conflicting command. */
105
156
  readonly id: Id;
106
- /** Shared binding. */
157
+ /** The conflicting command's binding. */
107
158
  readonly binding: Binding;
159
+ /** Whether the bindings are identical or one is a prefix of the other. */
160
+ readonly kind: ConflictKind;
108
161
  /** Formatted display representation of the binding. */
109
162
  readonly display: string;
110
163
  /** Overlapping scope where conflict occurs, if any. */
@@ -114,34 +167,66 @@ interface Conflict<Id extends string = string> {
114
167
  interface ScopeOptions {
115
168
  /** Shadow commands in lower scopes, including global commands. */
116
169
  exclusive?: boolean;
170
+ /**
171
+ * Also shadow `"*"` commands, which otherwise survive every barrier.
172
+ * Implies `exclusive`. Meant for surfaces that must own the whole keyboard,
173
+ * such as recording a new shortcut.
174
+ */
175
+ strict?: boolean;
117
176
  }
118
177
  //#endregion
119
178
  //#region src/display.d.ts
120
179
  /** Display options for formatting hotkeys. */
121
- type DisplayOptions = FormatDisplayOptions;
180
+ type DisplayOptions = Omit<FormatDisplayOptions, "parts">;
122
181
  /** Formats a binding for display on the target platform (e.g. `⌘K` or `Ctrl+K`). */
123
182
  declare function formatBinding(binding: Binding, options?: DisplayOptions): string;
124
183
  /** Splits a binding into display tokens for rendering `<kbd>` elements. */
125
184
  declare function bindingTokens(binding: Binding, options?: DisplayOptions): Array<string>;
185
+ /**
186
+ * Phrases a binding for a screen reader: `"Control+K"` for a chord,
187
+ * `"G then I"` for a sequence. `aria-keyshortcuts` cannot express a sequence
188
+ * (a space there means "or"), so this is how a sequence gets announced — put it
189
+ * in visually hidden text or `aria-description`.
190
+ */
191
+ declare function spokenBinding(binding: Binding, platform?: Platform): string;
126
192
  /** Generates an `aria-keyshortcuts` attribute string for a binding. */
127
193
  declare function ariaKeyShortcuts(binding: Binding, platform?: Platform): string;
128
194
  //#endregion
129
195
  //#region src/keymap.d.ts
130
- /** Persisted map of command IDs to serialized bindings. */
131
- type SerializedKeymap = Record<string, Array<SerializedBinding>>;
196
+ /** One command's stored override: an array replaces the default, `{ add }` extends it. */
197
+ type SerializedOverride = Array<SerializedBinding> | {
198
+ add: Array<SerializedBinding>;
199
+ };
200
+ /** Persisted user overrides, keyed by command id. */
201
+ interface SerializedKeymap {
202
+ /** Format version, so a release can skip a format it does not know instead of misreading it. */
203
+ version: 1;
204
+ overrides: Record<string, SerializedOverride>;
205
+ }
132
206
  /** Keymap interface for querying, modifying, and persisting keybindings. */
133
207
  interface CommandKeymap<Id extends string = string> {
134
- /** Active bindings in effect (user overrides if present, otherwise defaults). */
208
+ /** Active bindings in effect: the default, with any user override applied. */
135
209
  get(id: Id | (string & {})): ReadonlyArray<Binding>;
136
- /** Rebinds a command. Passing `null` unbinds it without restoring the default. */
210
+ /**
211
+ * Replaces a command's bindings. Passing `null` unbinds it without
212
+ * restoring the default; passing exactly the default forgets the override,
213
+ * so the command follows the default again if the app changes it.
214
+ */
137
215
  set(id: Id | (string & {}), input: BindingInput | null): void;
138
- /** Appends an additional binding to a command. */
216
+ /**
217
+ * Appends an additional binding. On a command the user has not replaced,
218
+ * it is stored on top of the default rather than as a copy of it, so a
219
+ * default the app changes later still applies alongside the added keys.
220
+ */
139
221
  add(id: Id | (string & {}), input: BindingInput): void;
140
222
  /** Resets user overrides back to defaults for a specific command or all commands. */
141
223
  reset(id?: Id | (string & {})): void;
142
224
  /** Exports user overrides as JSON-serializable data. */
143
225
  export(): SerializedKeymap;
144
- /** Imports user overrides, replacing current overrides and saving to storage. */
226
+ /**
227
+ * Imports user overrides, replacing current overrides and saving to
228
+ * storage. Accepts `export()` output from this and earlier releases.
229
+ */
145
230
  import(data: Json): void;
146
231
  }
147
232
  //#endregion
@@ -170,8 +255,21 @@ interface CommandsOptions<Id extends string = string> {
170
255
  platform?: Platform;
171
256
  /** Target element for key events. Defaults to `document`. */
172
257
  target?: HTMLElement | Document | Window | null;
173
- /** Persistence configuration for user keymap overrides. */
258
+ /**
259
+ * Persistence configuration for user keymap overrides. Overrides are read
260
+ * on the first `mount()` rather than at creation, so the server render and
261
+ * the client's first render agree and hydration never mismatches; the
262
+ * registry notifies subscribers once they load.
263
+ */
174
264
  persist?: KeymapPersistence;
265
+ /**
266
+ * Whether single-key shortcuts — bindings where every step is a printable
267
+ * character with no Control, Alt or Meta, such as `?` or `G then I` — are
268
+ * active. Defaults to `true`. WCAG 2.1.4 requires a way to turn these off
269
+ * for speech-input and motor-impaired users; wire a setting to
270
+ * `commands.setSingleKeyShortcuts()`.
271
+ */
272
+ singleKeyShortcuts?: boolean;
175
273
  /** Display options for rendering shortcuts. */
176
274
  display?: DisplayOptions;
177
275
  /**
@@ -184,9 +282,12 @@ interface CommandsOptions<Id extends string = string> {
184
282
  * });
185
283
  * ```
186
284
  */
187
- onRun?: (context: CommandContext) => void;
188
- /** Error handler called when a command execution fails. Defaults to `console.error`. */
189
- onError?: (error: Error, context: CommandContext) => void;
285
+ onRun?: (context: CommandContext<Id>) => void;
286
+ /**
287
+ * Error handler called when a command, its `when()` guard, or `onRun`
288
+ * throws. Defaults to `console.error`.
289
+ */
290
+ onError?: (error: Error, context: CommandContext<Id>) => void;
190
291
  }
191
292
  /** Options passed when manually running a command. */
192
293
  interface RunOptions {
@@ -232,6 +333,16 @@ interface Commands<Id extends string = string> {
232
333
  get(id: Id | (string & {})): Command<Id> | undefined;
233
334
  /** Returns all registered commands in definition order. */
234
335
  list(): ReadonlyArray<Command<Id>>;
336
+ /**
337
+ * The command list as the server renders it: the commands passed to
338
+ * `createCommands` with their default bindings (after `options.bindings`),
339
+ * formatted for the server platform — no runtime `add()`, scope, user
340
+ * override or `setEnabled` applied. Referentially stable. This is the
341
+ * `getServerSnapshot` the React hooks hand `useSyncExternalStore`, so a
342
+ * subtree that hydrates after the client state moved on still matches the
343
+ * server's HTML.
344
+ */
345
+ serverList(): ReadonlyArray<Command<Id>>;
235
346
  /** Runs a command by ID. Returns `true` if executed. */
236
347
  run(id: Id, options?: RunOptions): boolean;
237
348
  /** Checks whether a command can run currently. */
@@ -241,12 +352,16 @@ interface Commands<Id extends string = string> {
241
352
  /** Enables or disables a command. */
242
353
  setEnabled(id: Id | (string & {}), enabled: boolean): void;
243
354
  /** Registers a command at runtime. */
244
- add(id: Id | (string & {}), definition: CommandDefinition): CommandHandle;
355
+ add(id: Id | (string & {}), definition: CommandDefinition<Id>): CommandHandle;
245
356
  /** Removes a command registration. */
246
357
  remove(id: Id | (string & {})): void;
247
358
  /** Activates a scope until the returned disposal function is called. */
248
359
  pushScope(name: string, options?: ScopeOptions): () => void;
249
- /** Finds conflicting commands that share the given binding in overlapping scopes. */
360
+ /**
361
+ * Finds commands in overlapping scopes whose bindings collide with the given
362
+ * one — the same keys (`kind: "exact"`), or one a prefix of the other, like
363
+ * chord `G` against `G then I` (`kind: "prefix"`).
364
+ */
250
365
  conflicts(binding: BindingInput, options?: ConflictOptions<Id>): Array<Conflict<Id>>;
251
366
  /** Formats a command's primary binding for display. */
252
367
  display(id: Id | (string & {})): string;
@@ -260,6 +375,10 @@ interface Commands<Id extends string = string> {
260
375
  mount(): () => void;
261
376
  /** Subscribes to registry changes. Returns an unsubscribe function. */
262
377
  subscribe(listener: () => void): () => void;
378
+ /** Whether single-key shortcuts are active. See `CommandsOptions.singleKeyShortcuts`. */
379
+ readonly singleKeyShortcuts: boolean;
380
+ /** Turns single-key shortcuts on or off for every command (WCAG 2.1.4). */
381
+ setSingleKeyShortcuts(enabled: boolean): void;
263
382
  /** Tears down listeners and clears all command registrations. */
264
383
  destroy(): void;
265
384
  }
@@ -282,7 +401,7 @@ interface Commands<Id extends string = string> {
282
401
  * });
283
402
  * ```
284
403
  */
285
- 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>>;
404
+ declare function createCommands<Id extends string>(definitions: { [K in Id]: CommandDefinition<Id>; }, options?: CommandsOptions<NoInfer<Id>>): Commands<Id>;
286
405
  /**
287
406
  * Extracts command ID union from a Commands registry type.
288
407
  *
@@ -304,4 +423,4 @@ interface ObserveOptions {
304
423
  */
305
424
  declare function observe<Id extends string>(registry: Commands<Id>, options?: ObserveOptions): () => void;
306
425
  //#endregion
307
- export { Platform as A, CommandDefinition as C, HotkeySequence as D, Hotkey$1 as E, SequenceBinding as M, SerializedBinding as N, KeymapPersistence as O, CommandContext as S, Conflict as T, formatBinding as _, Commands as a, Chord as b, PendingSequence as c, createCommands as d, CommandKeymap as f, bindingTokens as g, ariaKeyShortcuts as h, CommandId as i, ScopeOptions as j, KeymapStorage as k, RunBlockReason as l, DisplayOptions as m, observe as n, CommandsOptions as o, SerializedKeymap as p, CommandHandle as r, ConflictOptions as s, ObserveOptions as t, RunOptions as u, Binding as v, CommandSource as w, Command as x, BindingInput as y };
426
+ export { HotkeySequence as A, Command as C, Conflict as D, CommandSource as E, SequenceBinding as F, SerializedBinding as I, KeymapStorage as M, Platform as N, ConflictKind as O, ScopeOptions as P, Chord as S, CommandDefinition as T, bindingTokens as _, Commands as a, Binding as b, PendingSequence as c, createCommands as d, CommandKeymap as f, ariaKeyShortcuts as g, DisplayOptions as h, CommandId as i, KeymapPersistence as j, Hotkey$1 as k, RunBlockReason as l, SerializedOverride as m, observe as n, CommandsOptions as o, SerializedKeymap as p, CommandHandle as r, ConflictOptions as s, ObserveOptions as t, RunOptions as u, formatBinding as v, CommandContext as w, BindingInput as x, spokenBinding as y };
@@ -1,2 +1,2 @@
1
- import { n as observe, t as ObserveOptions } from "./attributes-O6bXNKT9.js";
1
+ import { n as observe, t as ObserveOptions } from "./attributes-DotLr2MW.mjs";
2
2
  export { ObserveOptions, observe };
@@ -8,15 +8,19 @@ function observe(registry, options = {}) {
8
8
  if (!("document" in globalThis)) return () => {};
9
9
  const root = options.root ?? document;
10
10
  const onClick = (event) => {
11
+ if (event.defaultPrevented) return;
11
12
  const target = event.target;
12
13
  if (!(target instanceof Element)) return;
13
- const id = target.closest(`[${COMMAND_ATTRIBUTE}]`)?.getAttribute(COMMAND_ATTRIBUTE);
14
+ const element = target.closest(`[${COMMAND_ATTRIBUTE}]`);
15
+ const id = element?.getAttribute(COMMAND_ATTRIBUTE);
14
16
  if (!id) return;
17
+ if (element?.closest("[aria-disabled=\"true\"]")) return;
15
18
  registry.run(id, {
16
19
  source: "dom",
17
20
  event
18
21
  });
19
22
  };
23
+ const labelled = /* @__PURE__ */ new WeakSet();
20
24
  const paintElement = (element) => {
21
25
  const keysId = element.getAttribute(KEYS_ATTRIBUTE);
22
26
  if (keysId) {
@@ -24,10 +28,13 @@ function observe(registry, options = {}) {
24
28
  if (element.textContent !== display) element.textContent = display;
25
29
  }
26
30
  const commandId = element.getAttribute(COMMAND_ATTRIBUTE);
27
- if (commandId) {
28
- const aria = registry.get(commandId)?.aria ?? "";
29
- if (aria) element.setAttribute("aria-keyshortcuts", aria);
30
- else element.removeAttribute("aria-keyshortcuts");
31
+ const aria = commandId ? registry.get(commandId)?.aria ?? "" : "";
32
+ if (aria) {
33
+ if (element.getAttribute("aria-keyshortcuts") !== aria) element.setAttribute("aria-keyshortcuts", aria);
34
+ labelled.add(element);
35
+ } else if (commandId || labelled.has(element)) {
36
+ element.removeAttribute("aria-keyshortcuts");
37
+ labelled.delete(element);
31
38
  }
32
39
  };
33
40
  const paintWithin = (scope) => {
@@ -1,4 +1,4 @@
1
- import { M as SequenceBinding, T as Conflict, a as Commands, b as Chord, v as Binding } from "./attributes-O6bXNKT9.js";
1
+ import { C as Command, D as Conflict, F as SequenceBinding, N as Platform, S as Chord, a as Commands, b as Binding } from "./attributes-DotLr2MW.mjs";
2
2
  import { Hotkey } from "@tanstack/hotkeys";
3
3
  //#region src/bindings.d.ts
4
4
  /**
@@ -10,6 +10,13 @@ import { Hotkey } from "@tanstack/hotkeys";
10
10
  * ```
11
11
  */
12
12
  declare function seq(...steps: Array<Hotkey>): SequenceBinding;
13
+ /**
14
+ * Whether every step of a binding is a single printable character with no
15
+ * Control, Alt or Meta — the "character key shortcuts" WCAG 2.1.4 lets users
16
+ * turn off, since speech input and stray keypresses trigger them by accident.
17
+ * Shift does not count as a modifier here: `Shift+A` still types a character.
18
+ */
19
+ declare function isSingleKeyBinding(binding: Binding, platform?: Platform): boolean;
13
20
  /** Validation result for a hotkey binding. */
14
21
  interface BindingValidation {
15
22
  readonly valid: boolean;
@@ -90,4 +97,20 @@ declare function createRebinder<Id extends string>(registry: Commands<Id>, id: I
90
97
  /** Wildcard scope identifier for commands that remain active across exclusive scopes. */
91
98
  declare const ALWAYS_SCOPE = "*";
92
99
  //#endregion
93
- export { Rebinder as a, seq as c, RebindState as i, validateBinding as l, RebindMode as n, createRebinder as o, RebindOptions as r, BindingValidation as s, ALWAYS_SCOPE as t };
100
+ //#region src/search.d.ts
101
+ /**
102
+ * Filters commands for a search query, as a command palette does, leaving
103
+ * out `hidden` ones. Every word of the query must appear somewhere in the
104
+ * command — title, description, group, keywords, id or displayed shortcut —
105
+ * in any order. Title matches come first: titles starting with the query,
106
+ * then titles containing every word, then the rest; each tier keeps the
107
+ * list's own order. An empty query returns every visible command.
108
+ *
109
+ * @example
110
+ * ```ts
111
+ * const results = matchCommands(commands.list(), "go inbox");
112
+ * ```
113
+ */
114
+ declare function matchCommands<Id extends string>(commands: ReadonlyArray<Command<Id>>, query: string): Array<Command<Id>>;
115
+ //#endregion
116
+ export { RebindState as a, BindingValidation as c, validateBinding as d, RebindOptions as i, isSingleKeyBinding as l, ALWAYS_SCOPE as n, Rebinder as o, RebindMode as r, createRebinder as s, matchCommands as t, seq as u };
@@ -0,0 +1,3 @@
1
+ import { A as HotkeySequence, C as Command, D as Conflict, E as CommandSource, F as SequenceBinding, I as SerializedBinding, M as KeymapStorage, N as Platform, O as ConflictKind, P as ScopeOptions, S as Chord, T as CommandDefinition, _ as bindingTokens, a as Commands, b as Binding, c as PendingSequence, d as createCommands, f as CommandKeymap, g as ariaKeyShortcuts, h as DisplayOptions, i as CommandId, j as KeymapPersistence, k as Hotkey, l as RunBlockReason, m as SerializedOverride, n as observe, o as CommandsOptions, p as SerializedKeymap, r as CommandHandle, s as ConflictOptions, t as ObserveOptions, u as RunOptions, v as formatBinding, w as CommandContext, x as BindingInput, y as spokenBinding } from "./attributes-DotLr2MW.mjs";
2
+ import { a as RebindState, c as BindingValidation, d as validateBinding, i as RebindOptions, l as isSingleKeyBinding, n as ALWAYS_SCOPE, o as Rebinder, r as RebindMode, s as createRebinder, t as matchCommands, u as seq } from "./index-BTgscYdI.mjs";
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 ConflictKind, 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, type SerializedOverride, ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, formatBinding, isSingleKeyBinding, matchCommands, observe, seq, spokenBinding, validateBinding };
package/dist/index.mjs ADDED
@@ -0,0 +1,3 @@
1
+ import { observe } from "./attributes.mjs";
2
+ import { a as ariaKeyShortcuts, c as spokenBinding, d as seq, i as createRebinder, n as createCommands, o as bindingTokens, p as validateBinding, r as ALWAYS_SCOPE, s as formatBinding, t as matchCommands, u as isSingleKeyBinding } from "./src-DgYjA1oS.mjs";
3
+ export { ALWAYS_SCOPE, ariaKeyShortcuts, bindingTokens, createCommands, createRebinder, formatBinding, isSingleKeyBinding, matchCommands, observe, seq, spokenBinding, validateBinding };