@inklu/keys 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 [Your Name]
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,258 @@
1
+ # @inklu/keys
2
+
3
+ Keyboard shortcut management for the web. Define commands once, bind keys to them separately, and let users remap anything.
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.
6
+
7
+ ```bash
8
+ pnpm add @inklu/keys
9
+ ```
10
+
11
+ ## The idea
12
+
13
+ A **command** is an addressable thing your app can do — it has an id, a title and a handler. A **binding** is a key that triggers it. Keeping them apart is what makes the rest possible: the same command runs from the keyboard, a palette, a menu item or code, and a user can rebind it without you touching its definition.
14
+
15
+ ```ts
16
+ import { createCommands, seq } from "@inklu/keys";
17
+
18
+ export const commands = createCommands({
19
+ "palette.open": {
20
+ title: "Open command palette",
21
+ keys: "Mod+K",
22
+ run: () => setPaletteOpen(true),
23
+ },
24
+ "doc.save": {
25
+ title: "Save document",
26
+ keys: "Mod+S",
27
+ when: () => isDirty(),
28
+ run: () => save(),
29
+ },
30
+ "nav.inbox": {
31
+ title: "Go to inbox",
32
+ keys: seq("G", "I"), // press G, then I
33
+ run: () => navigate("/inbox"),
34
+ },
35
+ });
36
+
37
+ commands.mount(); // start listening; returns a function to stop
38
+ ```
39
+
40
+ Ids are inferred from the object, so `commands.run("doc.save")` autocompletes and a typo is a type error. So is a typo in a definition — `key:` instead of `keys:` would otherwise bind nothing at all, silently.
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
+ ```
53
+
54
+ ## React
55
+
56
+ ```tsx
57
+ import { useCommands, useMountCommands } from "@inklu/keys/react";
58
+ import { commands } from "./commands";
59
+
60
+ function App() {
61
+ useMountCommands(commands);
62
+ return <Shortcuts />;
63
+ }
64
+
65
+ function Shortcuts() {
66
+ const list = useCommands(commands);
67
+
68
+ return list.map((command) => (
69
+ <button
70
+ key={command.id}
71
+ onClick={() => commands.run(command.id)}
72
+ aria-keyshortcuts={command.aria || undefined}
73
+ >
74
+ {command.title}
75
+ {command.tokens[0]?.map((token, i) => <kbd key={i}>{token}</kbd>)}
76
+ </button>
77
+ ));
78
+ }
79
+ ```
80
+
81
+ `useMountCommands` starts listening in an effect, so nothing touches the DOM during render and the registry is safe to create at module scope in a server-rendered app.
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
107
+
108
+ Every command carries an `aria` string — the value for [`aria-keyshortcuts`](https://developer.mozilla.org/docs/Web/Accessibility/ARIA/Attributes/aria-keyshortcuts), which is what a screen reader announces. It is deliberately not the display string: the attribute wants resolved key *names*, so `Mod+K` becomes `Meta+K` on macOS and `Control+K` elsewhere, never `⌘K`.
109
+
110
+ ```tsx
111
+ <button aria-keyshortcuts={command.aria || undefined}>Save</button>
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".
117
+
118
+ ## Scopes
119
+
120
+ A scope decides whether a command is reachable. Push one while a dialog is open and it shadows the rest of the app, so you do not end up writing `if (!modalOpen)` in twenty different handlers.
121
+
122
+ ```ts
123
+ const release = commands.pushScope("dialog", { exclusive: true });
124
+ // …everything below this barrier stops responding
125
+ release();
126
+ ```
127
+
128
+ In React that is `useScope("dialog", isOpen, { exclusive: true })`.
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.
131
+
132
+ Scoping is resolved when a scope is pushed or released, never per keystroke — an open dialog costs one pass over the registrations rather than a check on every key the user presses.
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.
137
+
138
+ ```ts
139
+ commands.keymap.set("palette.open", "Mod+P"); // remap
140
+ commands.keymap.add("palette.open", "Control+P"); // an additional key
141
+ commands.keymap.set("palette.open", null); // unbind, but keep the command
142
+ commands.keymap.reset("palette.open"); // forget the override, back to the default
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 }]
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.
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
172
+
173
+ ```tsx
174
+ const rebind = useRebind("palette.open");
175
+
176
+ <button onClick={rebind.start}>
177
+ {rebind.recording ? "Press keys…" : rebind.display}
178
+ </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
+ ```
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:
251
+
252
+ ```ts
253
+ export type AppCommandId = CommandId<typeof commands>;
254
+ ```
255
+
256
+ ## License
257
+
258
+ MIT © nkurunziza-saddy
@@ -0,0 +1,285 @@
1
+ import { FormatDisplayOptions, Hotkey as Hotkey$1, HotkeySequence } from "@tanstack/hotkeys";
2
+ //#region src/types.d.ts
3
+ /** A single hotkey chord, e.g. `"Mod+K"`. */
4
+ type Chord = Hotkey$1;
5
+ /** A sequence of chords pressed in order, e.g. `G` then `I`. */
6
+ interface SequenceBinding {
7
+ readonly sequence: HotkeySequence;
8
+ }
9
+ /** A hotkey chord or sequence that triggers a command. */
10
+ type Binding = Chord | SequenceBinding;
11
+ /** One binding or an array of alternative bindings. */
12
+ type BindingInput = Binding | ReadonlyArray<Binding>;
13
+ /** JSON-serializable form of a binding. */
14
+ type SerializedBinding = string | Array<string>;
15
+ /** Trigger source for a command. */
16
+ type CommandSource = "keyboard" | "api" | "dom";
17
+ /** Context passed to a command's `run` handler. */
18
+ interface CommandContext<Id extends string = string> {
19
+ /** ID of the command being run. */
20
+ readonly id: Id;
21
+ /** How the command was triggered. */
22
+ readonly source: CommandSource;
23
+ /** Originating DOM event, if triggered by user interaction. */
24
+ readonly event?: Event;
25
+ /** Specific binding that triggered the command. */
26
+ readonly binding?: Binding;
27
+ /**
28
+ * The commands registry instance.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * run: ({ commands }) => {
33
+ * commands.setEnabled("doc.publish", false);
34
+ * commands.run("doc.save");
35
+ * }
36
+ * ```
37
+ */
38
+ readonly commands: Commands<Id>;
39
+ }
40
+ /** Definition of a command and its default configuration. */
41
+ interface CommandDefinition {
42
+ /** Human-readable title. */
43
+ title: string;
44
+ /** Default key binding(s). */
45
+ keys?: BindingInput;
46
+ /** Scope required for this command to fire. Omit for global, or `"*"` to bypass exclusive scopes. */
47
+ scope?: string;
48
+ /** Guard function evaluated before running. */
49
+ when?: () => boolean;
50
+ /** Whether the command is enabled. Defaults to `true`. */
51
+ enabled?: boolean;
52
+ /** Prevent default event behavior when run. Defaults to `true`. */
53
+ preventDefault?: boolean;
54
+ /** Whether to ignore events in form inputs/textareas/contenteditable. */
55
+ ignoreInputs?: boolean;
56
+ /** Require key release before firing again (prevents auto-repeat). Defaults to `false`. */
57
+ requireReset?: boolean;
58
+ /** Timeout in milliseconds between sequence steps. Defaults to `1000`. */
59
+ sequenceTimeout?: number;
60
+ /** Handler executed when the command runs. */
61
+ run: (context: CommandContext) => void | Promise<void>;
62
+ }
63
+ /** Resolved command snapshot including effective bindings, display tokens, and reachability. */
64
+ interface Command<Id extends string = string> {
65
+ readonly id: Id;
66
+ readonly title: string;
67
+ readonly scope?: string;
68
+ /** Active bindings after user overrides. */
69
+ readonly bindings: ReadonlyArray<Binding>;
70
+ /** Formatted display strings for active bindings. */
71
+ readonly display: ReadonlyArray<string>;
72
+ /** Formatted tokens per binding for rendering `<kbd>` elements. */
73
+ readonly tokens: ReadonlyArray<ReadonlyArray<string>>;
74
+ /** Value for `aria-keyshortcuts` attribute. */
75
+ readonly aria: string;
76
+ /** Whether the command is enabled. */
77
+ readonly enabled: boolean;
78
+ /** Whether the command is enabled and in an active scope. */
79
+ readonly reachable: boolean;
80
+ /** Whether the command has a user keymap override. */
81
+ readonly overridden: boolean;
82
+ }
83
+ /** Supported platforms for hotkey resolution and display formatting. */
84
+ type Platform = "mac" | "windows" | "linux";
85
+ /** Storage interface for persisting keymap overrides. */
86
+ interface KeymapStorage {
87
+ getItem: (key: string) => string | null;
88
+ setItem: (key: string, value: string) => void;
89
+ removeItem: (key: string) => void;
90
+ }
91
+ /** Configuration for persisting keymap overrides. */
92
+ interface KeymapPersistence {
93
+ /** Storage key name. Defaults to `"inklu.keys"`. */
94
+ key?: string;
95
+ /** Getter returning the storage implementation lazily. */
96
+ storage: () => KeymapStorage | null;
97
+ }
98
+ /** Details of a conflict between commands with identical bindings in overlapping scopes. */
99
+ interface Conflict<Id extends string = string> {
100
+ /** ID of the conflicting command. */
101
+ readonly id: Id;
102
+ /** Shared binding. */
103
+ readonly binding: Binding;
104
+ /** Formatted display representation of the binding. */
105
+ readonly display: string;
106
+ /** Overlapping scope where conflict occurs, if any. */
107
+ readonly scope?: string;
108
+ }
109
+ /** Options for activating a scope. */
110
+ interface ScopeOptions {
111
+ /** Shadow commands in lower scopes, including global commands. */
112
+ exclusive?: boolean;
113
+ }
114
+ //#endregion
115
+ //#region src/display.d.ts
116
+ /** Display options for formatting hotkeys. */
117
+ type DisplayOptions = FormatDisplayOptions;
118
+ /** Formats a binding for display on the target platform (e.g. `⌘K` or `Ctrl+K`). */
119
+ declare function formatBinding(binding: Binding, options?: DisplayOptions): string;
120
+ /** Splits a binding into display tokens for rendering `<kbd>` elements. */
121
+ declare function bindingTokens(binding: Binding, options?: DisplayOptions): Array<string>;
122
+ /** Generates an `aria-keyshortcuts` attribute string for a binding. */
123
+ declare function ariaKeyShortcuts(binding: Binding, platform?: Platform): string;
124
+ //#endregion
125
+ //#region src/keymap.d.ts
126
+ /** Persisted map of command IDs to serialized bindings. */
127
+ type SerializedKeymap = Record<string, Array<SerializedBinding>>;
128
+ /** Keymap interface for querying, modifying, and persisting keybindings. */
129
+ interface CommandKeymap<Id extends string = string> {
130
+ /** Active bindings in effect (user overrides if present, otherwise defaults). */
131
+ get(id: Id | (string & {})): ReadonlyArray<Binding>;
132
+ /** Rebinds a command. Passing `null` unbinds it without restoring the default. */
133
+ set(id: Id | (string & {}), input: BindingInput | null): void;
134
+ /** Appends an additional binding to a command. */
135
+ add(id: Id | (string & {}), input: BindingInput): void;
136
+ /** Resets user overrides back to defaults for a specific command or all commands. */
137
+ reset(id?: Id | (string & {})): void;
138
+ /** Exports user overrides as JSON-serializable data. */
139
+ export(): SerializedKeymap;
140
+ /** Imports user overrides, replacing current overrides and saving to storage. */
141
+ import(data: unknown): void;
142
+ }
143
+ //#endregion
144
+ //#region src/registry.d.ts
145
+ /** Configuration options for a commands registry. */
146
+ interface CommandsOptions {
147
+ /** Platform for modifier resolution and display formatting. Defaults to auto-detection. */
148
+ platform?: Platform;
149
+ /** Target element for key events. Defaults to `document`. */
150
+ target?: HTMLElement | Document | Window | null;
151
+ /** Persistence configuration for user keymap overrides. */
152
+ persist?: KeymapPersistence;
153
+ /** Display options for rendering shortcuts. */
154
+ display?: DisplayOptions;
155
+ /**
156
+ * Callback fired whenever a command runs.
157
+ *
158
+ * @example
159
+ * ```ts
160
+ * createCommands(definitions, {
161
+ * onRun: ({ id }) => logRun(id),
162
+ * });
163
+ * ```
164
+ */
165
+ onRun?: (context: CommandContext) => void;
166
+ /** Error handler called when a command execution fails. Defaults to `console.error`. */
167
+ onError?: (error: unknown, context: CommandContext) => void;
168
+ }
169
+ /** Options passed when manually running a command. */
170
+ interface RunOptions {
171
+ /** Trigger source. Defaults to `"api"`. */
172
+ source?: CommandSource;
173
+ /** Originating DOM event. */
174
+ event?: Event;
175
+ }
176
+ /** Options for conflict detection. */
177
+ interface ConflictOptions<Id extends string = string> {
178
+ /** Command ID to ignore during conflict checking. */
179
+ ignore?: Id;
180
+ /** Scope to evaluate conflicts in. Defaults to global. */
181
+ scope?: string;
182
+ }
183
+ /** Reason why a command cannot run. */
184
+ type RunBlockReason = "unknown" | "disabled" | "out-of-scope" | "guarded";
185
+ /** Represents a partially completed key sequence. */
186
+ interface PendingSequence<Id extends string = string> {
187
+ /** Command ID. */
188
+ readonly id: Id;
189
+ /** Sequence binding being typed. */
190
+ readonly binding: SequenceBinding;
191
+ /** Number of steps matched so far. */
192
+ readonly matched: number;
193
+ /** Total steps in the sequence. */
194
+ readonly total: number;
195
+ /** Formatted remaining steps. */
196
+ readonly remaining: ReadonlyArray<string>;
197
+ }
198
+ /** Handle for a dynamically registered runtime command. */
199
+ interface CommandHandle {
200
+ /** Runs the command. Returns `true` if executed. */
201
+ run: (options?: RunOptions) => boolean;
202
+ /** Disposes and unregisters the command. */
203
+ dispose: () => void;
204
+ }
205
+ /** Public interface for the commands registry. */
206
+ interface Commands<Id extends string = string> {
207
+ /** Keymap holding default and user bindings. */
208
+ readonly keymap: CommandKeymap<Id>;
209
+ /** Retrieves a command's snapshot, or `undefined` if unregistered. */
210
+ get(id: Id | (string & {})): Command<Id> | undefined;
211
+ /** Returns all registered commands in definition order. */
212
+ list(): ReadonlyArray<Command<Id>>;
213
+ /** Runs a command by ID. Returns `true` if executed. */
214
+ run(id: Id, options?: RunOptions): boolean;
215
+ /** Checks whether a command can run currently. */
216
+ canRun(id: Id | (string & {})): boolean;
217
+ /** Returns why a command cannot run, or `null` if executable. */
218
+ whyNotRun(id: Id | (string & {})): RunBlockReason | null;
219
+ /** Enables or disables a command. */
220
+ setEnabled(id: Id | (string & {}), enabled: boolean): void;
221
+ /** Registers a command at runtime. */
222
+ add(id: Id | (string & {}), definition: CommandDefinition): CommandHandle;
223
+ /** Removes a command registration. */
224
+ remove(id: Id | (string & {})): void;
225
+ /** Activates a scope until the returned disposal function is called. */
226
+ pushScope(name: string, options?: ScopeOptions): () => void;
227
+ /** Finds conflicting commands that share the given binding in overlapping scopes. */
228
+ conflicts(binding: BindingInput, options?: ConflictOptions<Id>): Array<Conflict<Id>>;
229
+ /** Formats a command's primary binding for display. */
230
+ display(id: Id | (string & {})): string;
231
+ /** Splits a command's primary binding into `<kbd>` tokens. */
232
+ tokens(id: Id | (string & {})): Array<string>;
233
+ /** Returns currently active partially-typed sequences. */
234
+ pending(): ReadonlyArray<PendingSequence<Id>>;
235
+ /** Subscribes to sequence progress changes. */
236
+ subscribePending(listener: () => void): () => void;
237
+ /** Starts listening for key events. Returns an unmount function. */
238
+ mount(): () => void;
239
+ /** Subscribes to registry changes. Returns an unsubscribe function. */
240
+ subscribe(listener: () => void): () => void;
241
+ /** Tears down listeners and clears all command registrations. */
242
+ destroy(): void;
243
+ }
244
+ /**
245
+ * Creates a command registry.
246
+ *
247
+ * @example
248
+ * ```ts
249
+ * export const commands = createCommands({
250
+ * "palette.open": {
251
+ * title: "Open command palette",
252
+ * keys: "Mod+K",
253
+ * run: () => setPaletteOpen(true),
254
+ * },
255
+ * "nav.inbox": {
256
+ * title: "Go to inbox",
257
+ * keys: seq("G", "I"),
258
+ * run: () => navigate("/inbox"),
259
+ * },
260
+ * });
261
+ * ```
262
+ */
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>>;
264
+ /**
265
+ * Extracts command ID union from a Commands registry type.
266
+ *
267
+ * @example
268
+ * ```ts
269
+ * export const commands = createCommands({ … });
270
+ * export type AppCommandId = CommandId<typeof commands>;
271
+ * ```
272
+ */
273
+ type CommandId<C> = C extends Commands<infer Id> ? Id : never;
274
+ //#endregion
275
+ //#region src/attributes.d.ts
276
+ interface ObserveOptions {
277
+ /** Root document or element to delegate from. Defaults to `document`. */
278
+ root?: Document | Element;
279
+ }
280
+ /**
281
+ * Wires a commands registry to DOM data attributes. Returns a cleanup function.
282
+ */
283
+ declare function observe<Id extends string>(registry: Commands<Id>, options?: ObserveOptions): () => void;
284
+ //#endregion
285
+ 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 };
@@ -0,0 +1,2 @@
1
+ import { n as observe, t as ObserveOptions } from "./attributes-lCPeoWII.js";
2
+ export { ObserveOptions, observe };
@@ -0,0 +1,72 @@
1
+ //#region src/attributes.ts
2
+ const COMMAND_ATTRIBUTE = "data-command";
3
+ const KEYS_ATTRIBUTE = "data-command-keys";
4
+ /**
5
+ * Wires a commands registry to DOM data attributes. Returns a cleanup function.
6
+ */
7
+ function observe(registry, options = {}) {
8
+ if (typeof document === "undefined") return () => {};
9
+ const root = options.root ?? document;
10
+ const onClick = (event) => {
11
+ const target = event.target;
12
+ if (!(target instanceof Element)) return;
13
+ const id = target.closest(`[${COMMAND_ATTRIBUTE}]`)?.getAttribute(COMMAND_ATTRIBUTE);
14
+ if (!id) return;
15
+ registry.run(id, {
16
+ source: "dom",
17
+ event
18
+ });
19
+ };
20
+ const paintElement = (element) => {
21
+ const keysId = element.getAttribute(KEYS_ATTRIBUTE);
22
+ if (keysId) {
23
+ const display = registry.display(keysId);
24
+ if (element.textContent !== display) element.textContent = display;
25
+ }
26
+ 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
+ }
32
+ };
33
+ const paintWithin = (scope) => {
34
+ if (scope instanceof Element) paintElement(scope);
35
+ for (const element of scope.querySelectorAll(`[${KEYS_ATTRIBUTE}],[${COMMAND_ATTRIBUTE}]`)) paintElement(element);
36
+ };
37
+ const painted = () => registry.list().map((command) => `${command.id} ${command.display[0] ?? ""} ${command.aria}`).join(" ");
38
+ root.addEventListener("click", onClick);
39
+ paintWithin(root);
40
+ let lastPainted = painted();
41
+ const unsubscribe = registry.subscribe(() => {
42
+ const next = painted();
43
+ if (next === lastPainted) return;
44
+ lastPainted = next;
45
+ paintWithin(root);
46
+ });
47
+ let observer;
48
+ if (typeof MutationObserver !== "undefined") {
49
+ observer = new MutationObserver((mutations) => {
50
+ for (const mutation of mutations) {
51
+ if (mutation.type === "attributes" && mutation.target instanceof Element) {
52
+ paintElement(mutation.target);
53
+ continue;
54
+ }
55
+ for (const node of mutation.addedNodes) if (node instanceof Element) paintWithin(node);
56
+ }
57
+ });
58
+ observer.observe(root instanceof Document ? root.documentElement : root, {
59
+ childList: true,
60
+ subtree: true,
61
+ attributes: true,
62
+ attributeFilter: [COMMAND_ATTRIBUTE, KEYS_ATTRIBUTE]
63
+ });
64
+ }
65
+ return () => {
66
+ root.removeEventListener("click", onClick);
67
+ unsubscribe();
68
+ observer?.disconnect();
69
+ };
70
+ }
71
+ //#endregion
72
+ export { observe };