@tanstack/hotkeys 0.15.0 → 0.16.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 CHANGED
@@ -51,7 +51,7 @@
51
51
  # TanStack Hotkeys
52
52
 
53
53
  > [!NOTE]
54
- > TanStack Hotkeys is alpha. We are actively developing the library and are open to feedback and contributions.
54
+ > TanStack Hotkeys is in beta. We are actively developing the library and are open to feedback and contributions.
55
55
 
56
56
  Type-safe keyboard shortcuts for the web. Template-string bindings, parsed objects, a cross-platform `Mod` key, a singleton Hotkey Manager, and utilities for cheatsheet UIs—built to stay SSR-friendly.
57
57
 
@@ -1,4 +1,4 @@
1
- import { PUNCTUATION_CODE_MAP, normalizeKeyName } from "./constants.js";
1
+ import { MODIFIER_ALIASES, PUNCTUATION_CODE_MAP, normalizeKeyName } from "./constants.js";
2
2
 
3
3
  //#region src/_keyboard-event.ts
4
4
  /** Normalizes Unicode key names and modifier flags while retaining physical code and IME state. */
@@ -45,8 +45,24 @@ function codeToLogicalKey(code) {
45
45
  function isPrintableKey(key) {
46
46
  return key !== "" && Array.from(key).length === 1;
47
47
  }
48
+ /**
49
+ * Resolves a modifier token or alias case-insensitively: `ctrl` → `Control`,
50
+ * `cmd` → `Meta`, `mod` → `Mod`. Returns undefined for anything else.
51
+ */
52
+ function modifierAlias(token) {
53
+ const lower = token.toLowerCase();
54
+ return Object.hasOwn(MODIFIER_ALIASES, lower) ? MODIFIER_ALIASES[lower] : void 0;
55
+ }
56
+ /** Whether a string has the shape of a `KeyboardEvent.code`, such as `KeyS`. */
57
+ function isCodeName(code) {
58
+ return /^[A-Za-z][A-Za-z0-9]*$/.test(code);
59
+ }
60
+ /** The code inside a physical token: `[KeyS]` → `KeyS`. Undefined for anything else. */
61
+ function physicalCodeOf(token) {
62
+ return token.startsWith("[") && token.endsWith("]") && isCodeName(token.slice(1, -1)) ? token.slice(1, -1) : void 0;
63
+ }
48
64
  /** Splits modifier tokens while retaining a trailing literal plus: `Control++` → `['Control', '+']`. */
49
- function splitHotkeyParts(hotkey) {
65
+ function splitChordParts(hotkey) {
50
66
  if (!hotkey.endsWith("+")) return hotkey.split("+");
51
67
  const parts = hotkey.slice(0, -1).split("+");
52
68
  if (parts.at(-1) === "") parts.pop();
@@ -72,4 +88,4 @@ function assertLogicalKey(key) {
72
88
  }
73
89
 
74
90
  //#endregion
75
- export { assertLogicalKey, codeToLogicalKey, isAsciiLetter, isLatinLetter, isPrintableKey, keysEqual, normalizeKeyboardEvent, splitHotkeyParts };
91
+ export { assertLogicalKey, codeToLogicalKey, isAsciiLetter, isCodeName, isLatinLetter, isPrintableKey, keysEqual, modifierAlias, normalizeKeyboardEvent, physicalCodeOf, splitChordParts };
@@ -0,0 +1,10 @@
1
+ //#region src/_manager-ref.ts
2
+ /**
3
+ * The HotkeyManager singleton, once created. Code that only reads the live
4
+ * registry, such as conflict checks, reads this instead of importing the
5
+ * manager, so a recorder bundle does not include hotkey dispatch.
6
+ */
7
+ const managerRef = { current: null };
8
+
9
+ //#endregion
10
+ export { managerRef };
package/dist/_match.js CHANGED
@@ -45,6 +45,14 @@ function modifiersMatch(event, parsed, allowImplicitShift) {
45
45
  if (ctrl !== parsed.ctrl || alt !== parsed.alt || event.meta !== parsed.meta) return false;
46
46
  return event.shift === parsed.shift || allowImplicitShift && event.shift;
47
47
  }
48
+ /**
49
+ * Whether two parsed chords have the same identity: equal modifiers, and the
50
+ * same physical code or (case-insensitively) the same logical key. A code and
51
+ * a key never match, even when one keyboard event could produce both.
52
+ */
53
+ function parsedChordsEqual(a, b) {
54
+ return a.ctrl === b.ctrl && a.alt === b.alt && a.shift === b.shift && a.meta === b.meta && (a.code !== void 0 || b.code !== void 0 ? a.code === b.code : keysEqual(a.key, b.key));
55
+ }
48
56
 
49
57
  //#endregion
50
- export { matchKeyboardEvent };
58
+ export { matchKeyboardEvent, parsedChordsEqual };
@@ -1,6 +1,6 @@
1
1
  import { normalizeKeyName } from "./constants.js";
2
- import { normalizeKeyboardEvent } from "./_keyboard-event.js";
3
- import { isModifierKey, normalizeHotkeyFromParsed, parseKeyboardEvent } from "./parse.js";
2
+ import { isCodeName, normalizeKeyboardEvent } from "./_keyboard-event.js";
3
+ import { isModifierKey, normalizeHotkey, parseKeyboardEvent } from "./parse.js";
4
4
 
5
5
  //#region src/_recorder-chord.ts
6
6
  /**
@@ -11,16 +11,16 @@ import { isModifierKey, normalizeHotkeyFromParsed, parseKeyboardEvent } from "./
11
11
  function hotkeyChordFromKeydown(event, platform, recordBy = "code") {
12
12
  const normalized = normalizeKeyboardEvent(event, platform);
13
13
  if (normalized.isComposing || isModifierKey(normalizeKeyName(event.key)) || event.key === "AltGraph") return null;
14
- if (recordBy === "code" && (!/^[A-Za-z][A-Za-z0-9]*$/.test(event.code) || event.code === "Unidentified" || normalized.altGraph)) return null;
14
+ if (recordBy === "code" && (!hasUsableCode(event) || normalized.altGraph)) return null;
15
15
  const parsed = parseKeyboardEvent(event, platform);
16
16
  if (recordBy === "code") {
17
17
  const { key: _key, code: _code, ...modifiers } = parsed;
18
- return normalizeHotkeyFromParsed({
18
+ return normalizeHotkey({
19
19
  ...modifiers,
20
20
  code: event.code
21
21
  }, platform);
22
22
  }
23
- return parsed.key ? normalizeHotkeyFromParsed(parsed, platform) : null;
23
+ return parsed.key ? normalizeHotkey(parsed, platform) : null;
24
24
  }
25
25
  /**
26
26
  * Explains why a physical chord cannot be recorded before syntax/policy checks.
@@ -32,11 +32,15 @@ function chordRejection(event, options) {
32
32
  reason: "alt-graph",
33
33
  message: "AltGraph character entry cannot be recorded as a physical shortcut. Use key mode for characters."
34
34
  };
35
- if (!/^[A-Za-z][A-Za-z0-9]*$/.test(event.code) || event.code === "Unidentified") return {
35
+ if (!hasUsableCode(event)) return {
36
36
  reason: "missing-code",
37
37
  message: "This event has no usable physical key code."
38
38
  };
39
39
  }
40
+ /** Whether the event reports a real physical key code to record. */
41
+ function hasUsableCode(event) {
42
+ return isCodeName(event.code) && event.code !== "Unidentified";
43
+ }
40
44
 
41
45
  //#endregion
42
46
  export { chordRejection, hotkeyChordFromKeydown };
@@ -1,4 +1,6 @@
1
1
  //#region src/_registration.ts
2
+ /** The scope of hotkeys that do not name one. It is always active unless an exclusive scope is active. */
3
+ const GLOBAL_SCOPE = "global";
2
4
  /**
3
5
  * Default options for hotkey/sequence registration.
4
6
  * Omitted: platform, target (resolved at registration), requireReset (HotkeyManager only).
@@ -10,15 +12,16 @@ const defaultHotkeyOptions = {
10
12
  eventType: "keydown",
11
13
  enabled: true,
12
14
  ignoreInputs: "auto",
13
- conflictBehavior: "warn"
15
+ conflictBehavior: "warn",
16
+ scope: GLOBAL_SCOPE
14
17
  };
15
18
  /**
16
19
  * Resolves `ignoreInputs: 'auto'` from the hotkey (the first step of a sequence).
17
20
  * Ctrl/Meta shortcuts and Escape fire in inputs; single keys and Shift/Alt combos are ignored.
18
21
  */
19
- function getDefaultIgnoreInputs(parsedHotkey) {
20
- if (parsedHotkey.ctrl || parsedHotkey.meta) return false;
21
- if (parsedHotkey.key === "Escape" || parsedHotkey.code === "Escape") return false;
22
+ function getDefaultIgnoreInputs(parsedChord) {
23
+ if (parsedChord.ctrl || parsedChord.meta) return false;
24
+ if (parsedChord.key === "Escape" || parsedChord.code === "Escape") return false;
22
25
  return true;
23
26
  }
24
27
  /**
@@ -47,4 +50,4 @@ function optionsEqual(left, right) {
47
50
  }
48
51
 
49
52
  //#endregion
50
- export { defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual };
53
+ export { GLOBAL_SCOPE, defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual };
package/dist/adapter.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { HotkeyCallback, HotkeyChord, HotkeySequence, RegisterableHotkey, RegisterableHotkeyChord } from "./hotkey.types.js";
2
- import { HotkeyChordOptions, HotkeyOptions, HotkeySequenceOptions, Target } from "./hotkey-manager.js";
1
+ import { HotkeyCallback, HotkeyChord, HotkeySequence, RegisterableHotkey, RegisterableHotkeyChord, RegisterableHotkeySequence } from "./hotkey.types.js";
2
+ import { ActivateHotkeyScopeOptions, HotkeyChordOptions, HotkeyOptions, HotkeySequenceOptions, Target } from "./hotkey-manager.js";
3
3
  import { AppHotkey, AppHotkeysStore } from "./app-hotkeys.js";
4
4
  //#region src/adapter.d.ts
5
5
  /**
@@ -11,7 +11,7 @@ export type HotkeyDefinition = {
11
11
  callback: HotkeyCallback;
12
12
  options?: HotkeyChordOptions;
13
13
  } | {
14
- hotkey: HotkeySequence;
14
+ hotkey: RegisterableHotkeySequence;
15
15
  callback: HotkeyCallback;
16
16
  options?: HotkeySequenceOptions;
17
17
  };
@@ -55,15 +55,15 @@ export type AdapterAppHotkeyOptions<T extends AppHotkey, TTarget> = Omit<[T['def
55
55
  * accept only shared options.
56
56
  */
57
57
  export type AdapterHotkeyDefinition<TTarget> = {
58
- /** A chord string such as `'Mod+S'`, or a RawHotkey object */
58
+ /** A chord string such as `'Mod+S'`, or a chord object */
59
59
  hotkey: RegisterableHotkeyChord;
60
60
  /** The function to call when the hotkey is pressed */
61
61
  callback: HotkeyCallback;
62
62
  /** Per-hotkey options (merged on top of common options) */
63
63
  options?: AdapterChordOptions<TTarget>;
64
64
  } | {
65
- /** A sequence such as `['G', 'G']` */
66
- hotkey: HotkeySequence;
65
+ /** A sequence such as `['G', 'G']`, with each step a string or an object */
66
+ hotkey: RegisterableHotkeySequence;
67
67
  callback: HotkeyCallback;
68
68
  options?: AdapterSequenceOptions<TTarget>;
69
69
  } | {
@@ -113,4 +113,51 @@ export declare function prepareHotkeyDefinitions<TTarget>(definitions: ReadonlyA
113
113
  * unsubscribe function.
114
114
  */
115
115
  export declare function subscribePublishedAppHotkeys(onChange: () => void): () => void;
116
+ /** Scope props shared by framework providers. */
117
+ export interface HotkeysProviderScopeProps {
118
+ /**
119
+ * Scope for hotkeys registered inside this provider, active while it is
120
+ * mounted and enabled. Defaults to the parent provider's scope, or
121
+ * `'global'` at the root. App hotkeys that declare a scope keep it.
122
+ */
123
+ scope?: string;
124
+ /**
125
+ * While mounted and enabled, suspend hotkeys in every other scope,
126
+ * including `'global'`, except scopes of nested providers. Pass
127
+ * `{ allow: [...] }` to keep other scopes active, such as `'global'`. Use
128
+ * with `scope`, for dialogs and other overlays.
129
+ */
130
+ exclusive?: ActivateHotkeyScopeOptions['exclusive'];
131
+ /**
132
+ * Set false to deactivate this provider's scope, and the scopes of
133
+ * providers nested in it, without unmounting. Defaults to true.
134
+ */
135
+ enabled?: boolean;
136
+ }
137
+ /**
138
+ * Merges a provider's default options over its parent's. A provider with a
139
+ * `scope` sets `hotkey.scope`, so hotkeys registered inside it use that scope.
140
+ */
141
+ export declare function mergeHotkeysProviderDefaults<T extends {
142
+ hotkey?: object;
143
+ hotkeyRecorder?: object;
144
+ }>(parent: T | undefined, own: T | undefined, scope: string | undefined): T;
145
+ /**
146
+ * A key for a provider's scope activation, or `null` when the scope should not
147
+ * be active. Equal keys mean the same activation, so adapters reactivate only
148
+ * when the key changes, not when an inline `exclusive` object is recreated.
149
+ */
150
+ export declare function getHotkeysProviderScopeKey(options: {
151
+ scope: string | undefined;
152
+ exclusive: ActivateHotkeyScopeOptions['exclusive'];
153
+ /** Whether this provider and every ancestor are enabled. */
154
+ active: boolean;
155
+ /** The parent provider's scope. */
156
+ parent: string | undefined;
157
+ }): string | null;
158
+ /**
159
+ * Activates the scope described by a key from `getHotkeysProviderScopeKey`.
160
+ * Returns a function that releases the activation.
161
+ */
162
+ export declare function activateHotkeysProviderScope(key: string): () => void;
116
163
  //#endregion
package/dist/adapter.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { detectPlatform } from "./platform.js";
2
- import { isHotkeySequence, normalizeRegisterableHotkey } from "./parse.js";
2
+ import { isHotkeySequence, normalizeHotkey } from "./parse.js";
3
3
  import { defaultHotkeyOptions } from "./_registration.js";
4
4
  import { getHotkeyManager } from "./hotkey-manager.js";
5
5
  import { getAppHotkeyRegistrationOptions, isAppHotkey, resolveAppHotkey } from "./app-hotkeys.js";
@@ -28,7 +28,7 @@ function createHotkeyBindings() {
28
28
  platform: merged.platform ?? detectPlatform()
29
29
  };
30
30
  if (!options.target || isHotkeySequence(definition.hotkey) && definition.hotkey.length === 0) return;
31
- const hotkey = normalizeRegisterableHotkey(definition.hotkey, options.platform);
31
+ const hotkey = normalizeHotkey(definition.hotkey, options.platform);
32
32
  if (isHotkeySequence(hotkey)) delete options.requireReset;
33
33
  else delete options.timeout;
34
34
  prepared.push({
@@ -127,6 +127,48 @@ function subscribePublishedAppHotkeys(onChange) {
127
127
  inner?.unsubscribe();
128
128
  };
129
129
  }
130
+ /**
131
+ * Merges a provider's default options over its parent's. A provider with a
132
+ * `scope` sets `hotkey.scope`, so hotkeys registered inside it use that scope.
133
+ */
134
+ function mergeHotkeysProviderDefaults(parent, own, scope) {
135
+ return {
136
+ hotkey: {
137
+ ...parent?.hotkey,
138
+ ...own?.hotkey,
139
+ ...scope && { scope }
140
+ },
141
+ hotkeyRecorder: {
142
+ ...parent?.hotkeyRecorder,
143
+ ...own?.hotkeyRecorder
144
+ }
145
+ };
146
+ }
147
+ /**
148
+ * A key for a provider's scope activation, or `null` when the scope should not
149
+ * be active. Equal keys mean the same activation, so adapters reactivate only
150
+ * when the key changes, not when an inline `exclusive` object is recreated.
151
+ */
152
+ function getHotkeysProviderScopeKey(options) {
153
+ const { scope, exclusive, active, parent } = options;
154
+ if (!scope || !active) return null;
155
+ return JSON.stringify([
156
+ scope,
157
+ typeof exclusive === "object" ? exclusive.allow : !!exclusive,
158
+ parent ?? null
159
+ ]);
160
+ }
161
+ /**
162
+ * Activates the scope described by a key from `getHotkeysProviderScopeKey`.
163
+ * Returns a function that releases the activation.
164
+ */
165
+ function activateHotkeysProviderScope(key) {
166
+ const [scope, exclusive, parent] = JSON.parse(key);
167
+ return getHotkeyManager().activateScope(scope, {
168
+ exclusive: Array.isArray(exclusive) ? { allow: exclusive } : exclusive,
169
+ parent: parent ?? void 0
170
+ });
171
+ }
130
172
 
131
173
  //#endregion
132
- export { createHotkeyBindings, prepareHotkeyDefinitions, subscribePublishedAppHotkeys };
174
+ export { activateHotkeysProviderScope, createHotkeyBindings, getHotkeysProviderScopeKey, mergeHotkeysProviderDefaults, prepareHotkeyDefinitions, subscribePublishedAppHotkeys };
@@ -17,11 +17,12 @@ export interface AppHotkeyInput {
17
17
  /** Display metadata for cheat sheets and settings screens. */
18
18
  meta?: HotkeyMeta;
19
19
  /**
20
- * Label for where this hotkey applies, such as `'editor'`. App hotkeys with
21
- * different contexts may share a hotkey; one without a context conflicts with
22
- * any app hotkey that shares its hotkey. It does not affect matching.
20
+ * Scope this hotkey fires in, such as `'editor'`. Overrides the scope of the
21
+ * provider it registers under. App hotkeys in different scopes may share a
22
+ * hotkey; one in the `'global'` scope conflicts with any app hotkey that
23
+ * shares its hotkey.
23
24
  */
24
- context?: string;
25
+ scope?: string;
25
26
  /**
26
27
  * Options that describe the hotkey itself. Chord defaults accept
27
28
  * `requireReset`; sequence defaults accept `timeout`.
@@ -45,7 +46,7 @@ export interface AppHotkey<TId extends string = string, TDefault extends HotkeyC
45
46
  /** Hotkey declared in `defineAppHotkeys`. */
46
47
  readonly defaultHotkey: TDefault;
47
48
  readonly meta?: HotkeyMeta;
48
- readonly context?: string;
49
+ readonly scope?: string;
49
50
  readonly options?: AppHotkeyOptions;
50
51
  }
51
52
  /** A map of app hotkeys keyed by id. */
@@ -110,11 +111,12 @@ export declare function isCustomHotkey(appHotkey: AppHotkey, platform?: Platform
110
111
  /**
111
112
  * Status of an app hotkey in the running app.
112
113
  * - `'active'`: at least one enabled registration
114
+ * - `'suspended'`: has an enabled registration, but none in an active scope
113
115
  * - `'disabled'`: registered, but every registration has `enabled: false`
114
116
  * - `'inactive'`: has a hotkey, but nothing has registered it
115
117
  * - `'unbound'`: no hotkey assigned
116
118
  */
117
- export type AppHotkeyStatus = 'active' | 'disabled' | 'inactive' | 'unbound';
119
+ export type AppHotkeyStatus = 'active' | 'suspended' | 'disabled' | 'inactive' | 'unbound';
118
120
  /** One row of an app hotkey cheat sheet. */
119
121
  export interface AppHotkeyListItem<T extends AppHotkey = AppHotkey> {
120
122
  appHotkey: T;
@@ -174,7 +176,7 @@ export declare class AppHotkeysStore<T extends AppHotkeys = AppHotkeys> {
174
176
  get(id: string): AppHotkey | undefined;
175
177
  /**
176
178
  * Other app hotkeys that would collide if `id` used `hotkey`. App hotkeys
177
- * collide when their contexts match or either has no context, and their
179
+ * collide when their scopes match or either is `'global'`, and their
178
180
  * hotkeys are equal or one sequence starts with the other.
179
181
  */
180
182
  findConflicts(id: keyof T & string, hotkey: Hotkey): Array<AppHotkey>;
@@ -203,12 +205,13 @@ export declare class AppHotkeysStore<T extends AppHotkeys = AppHotkeys> {
203
205
  export declare function resolveAppHotkey(appHotkey: AppHotkey, store: AppHotkeysStore | null | undefined): AppHotkey;
204
206
  /**
205
207
  * Options that adapters pass to the manager when registering an app hotkey:
206
- * declared options, meta, and the app hotkey id. The manager drops options
208
+ * declared options, scope, meta, and the app hotkey id. The manager drops options
207
209
  * that do not apply to the effective hotkey's kind.
208
210
  */
209
211
  export declare function getAppHotkeyRegistrationOptions(appHotkey: AppHotkey): AppHotkeyOptions & {
210
212
  meta?: HotkeyMeta;
211
213
  appHotkeyId: string;
214
+ scope?: string;
212
215
  };
213
216
  /**
214
217
  * Publishes a provider's store on the HotkeyManager so devtools can list
@@ -2,6 +2,7 @@ import { detectPlatform } from "./platform.js";
2
2
  import { isHotkeySequence, simplifyHotkey } from "./parse.js";
3
3
  import { validateHotkey } from "./validate.js";
4
4
  import { areHotkeysEqual } from "./match.js";
5
+ import { GLOBAL_SCOPE } from "./_registration.js";
5
6
  import { getHotkeyManager } from "./hotkey-manager.js";
6
7
  import { Store } from "@tanstack/store";
7
8
 
@@ -30,7 +31,7 @@ function defineAppHotkeys(definitions) {
30
31
  hotkey,
31
32
  defaultHotkey: hotkey,
32
33
  meta: input.meta,
33
- context: input.context,
34
+ scope: input.scope,
34
35
  options: input.options
35
36
  };
36
37
  }
@@ -86,8 +87,10 @@ function sanitizeCustomHotkeys(appHotkeys, saved) {
86
87
  return result;
87
88
  }
88
89
  /** Whether two app hotkeys can apply at the same time. */
89
- function contextsOverlap(a, b) {
90
- return a.context === void 0 || b.context === void 0 || a.context === b.context;
90
+ function scopesOverlap(a, b) {
91
+ const left = a.scope ?? "global";
92
+ const right = b.scope ?? "global";
93
+ return left === "global" || right === "global" || left === right;
91
94
  }
92
95
  /** Whether two hotkeys collide: equal chords, or one sequence starts with the other. */
93
96
  function hotkeysOverlap(left, right, platform) {
@@ -162,14 +165,14 @@ var AppHotkeysStore = class {
162
165
  }
163
166
  /**
164
167
  * Other app hotkeys that would collide if `id` used `hotkey`. App hotkeys
165
- * collide when their contexts match or either has no context, and their
168
+ * collide when their scopes match or either is `'global'`, and their
166
169
  * hotkeys are equal or one sequence starts with the other.
167
170
  */
168
171
  findConflicts(id, hotkey) {
169
172
  const self = this.store.state.appHotkeys[id];
170
173
  if (!self) return [];
171
174
  const candidate = simplifyHotkey(hotkey);
172
- return Object.values(this.store.state.appHotkeys).filter((other) => other.id !== id && other.hotkey !== null && contextsOverlap(self, other) && hotkeysOverlap(candidate, other.hotkey, this.#platform));
175
+ return Object.values(this.store.state.appHotkeys).filter((other) => other.id !== id && other.hotkey !== null && scopesOverlap(self, other) && hotkeysOverlap(candidate, other.hotkey, this.#platform));
173
176
  }
174
177
  /**
175
178
  * Sets the user's hotkey for an app hotkey. `null` unbinds it. A hotkey
@@ -224,11 +227,13 @@ var AppHotkeysStore = class {
224
227
  if (list) list.push(registration);
225
228
  else byId.set(id, [registration]);
226
229
  }
230
+ const manager = getHotkeyManager();
227
231
  return Object.values(this.store.state.appHotkeys).map((appHotkey) => {
228
232
  const registered = byId.get(appHotkey.id) ?? [];
233
+ const enabled = registered.filter((r) => r.options.enabled !== false);
229
234
  return {
230
235
  appHotkey,
231
- status: appHotkey.hotkey === null ? "unbound" : registered.length === 0 ? "inactive" : registered.some((r) => r.options.enabled !== false) ? "active" : "disabled",
236
+ status: appHotkey.hotkey === null ? "unbound" : registered.length === 0 ? "inactive" : enabled.length === 0 ? "disabled" : enabled.some((r) => manager.isScopeActive(r.options.scope)) ? "active" : "suspended",
232
237
  isCustom: isCustomHotkey(appHotkey, this.#platform),
233
238
  registrations: registered
234
239
  };
@@ -245,12 +250,13 @@ function resolveAppHotkey(appHotkey, store) {
245
250
  }
246
251
  /**
247
252
  * Options that adapters pass to the manager when registering an app hotkey:
248
- * declared options, meta, and the app hotkey id. The manager drops options
253
+ * declared options, scope, meta, and the app hotkey id. The manager drops options
249
254
  * that do not apply to the effective hotkey's kind.
250
255
  */
251
256
  function getAppHotkeyRegistrationOptions(appHotkey) {
252
257
  return {
253
258
  ...appHotkey.options,
259
+ ...appHotkey.scope !== void 0 && { scope: appHotkey.scope },
254
260
  meta: appHotkey.meta,
255
261
  appHotkeyId: appHotkey.id
256
262
  };
package/dist/conflicts.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { detectPlatform } from "./platform.js";
2
- import { keysEqual } from "./_keyboard-event.js";
3
- import { isHotkeySequence, parseRegisterableHotkey } from "./parse.js";
2
+ import { isHotkeySequence, parseHotkey } from "./parse.js";
3
+ import { parsedChordsEqual } from "./_match.js";
4
4
  import { matchesKeyboardEvent } from "./match.js";
5
5
  import { getTargetDocument } from "./_event-target.js";
6
- import { getHotkeyManager, toHotkeyRegistrationView } from "./hotkey-manager.js";
6
+ import { managerRef } from "./_manager-ref.js";
7
+ import { toHotkeyRegistrationView } from "./hotkey-manager.js";
7
8
 
8
9
  //#region src/conflicts.ts
9
10
  /** Whether two targets share a document and one contains the other (or is its window). */
@@ -22,19 +23,19 @@ function findHotkeyConflicts(candidate, options = {}) {
22
23
  const steps = isHotkeySequence(candidate) ? candidate : [candidate];
23
24
  if (steps.length === 0) return [];
24
25
  const platform = options.platform ?? detectPlatform();
25
- const parsed = steps.map((step) => parseRegisterableHotkey(step, platform));
26
+ const parsed = steps.map((step) => parseHotkey(step, platform));
26
27
  const target = options.target ?? (typeof document === "undefined" ? void 0 : document);
27
28
  const conflicts = [];
28
29
  const matches = (registration, bindings) => {
29
30
  if (!options.includeDisabled && registration.options.enabled === false || (registration.options.eventType ?? "keydown") !== (options.eventType ?? "keydown") || options.excludeIds?.includes(registration.id) || options.exclude?.(registration) || options.scope !== "all" && target && !targetsOverlap(target, registration.target)) return false;
30
31
  return parsed.slice(0, Math.min(parsed.length, bindings.length)).every((a, index) => {
31
- const b = parseRegisterableHotkey(bindings[index], registration.options.platform);
32
- const same = a.ctrl === b.ctrl && a.alt === b.alt && a.shift === b.shift && a.meta === b.meta && (a.code !== void 0 || b.code !== void 0 ? a.code === b.code : keysEqual(a.key, b.key));
32
+ const b = parseHotkey(bindings[index], registration.options.platform);
33
33
  const event = options.events?.[index];
34
- return same || event !== void 0 && matchesKeyboardEvent(event, a, platform) && matchesKeyboardEvent(event, b, registration.options.platform);
34
+ return parsedChordsEqual(a, b) || event !== void 0 && matchesKeyboardEvent(event, a, platform) && matchesKeyboardEvent(event, b, registration.options.platform);
35
35
  });
36
36
  };
37
- for (const registration of getHotkeyManager().registrations.state.values()) {
37
+ const registrations = managerRef.current?.registrations.state.values() ?? [];
38
+ for (const registration of registrations) {
38
39
  const view = toHotkeyRegistrationView(registration);
39
40
  if (matches(view, view.kind === "sequence" ? view.hotkey : [view.hotkey])) conflicts.push({ registration: view });
40
41
  }
@@ -119,30 +119,7 @@ export declare const PUNCTUATION_CODE_MAP: Record<string, string>;
119
119
  * @see {@link PUNCTUATION_KEYS}
120
120
  */
121
121
  export declare const ALL_KEYS: Set<"CapsLock" | "ContextMenu" | "Convert" | "KanaMode" | "NonConvert" | "Help" | "Insert" | "NumLock" | "Fn" | "FnLock" | "PrintScreen" | "ScrollLock" | "Pause" | "BrowserBack" | "BrowserFavorites" | "BrowserForward" | "BrowserHome" | "BrowserRefresh" | "BrowserSearch" | "BrowserStop" | "Eject" | "LaunchMail" | "MediaPlayPause" | "MediaStop" | "MediaTrackNext" | "MediaTrackPrevious" | "Power" | "AudioVolumeDown" | "AudioVolumeMute" | "AudioVolumeUp" | "WakeUp" | "Hyper" | "Super" | "Again" | "Copy" | "Cut" | "Find" | "Open" | "Paste" | "Props" | "Select" | "Undo" | "Hiragana" | "Katakana" | "Clear" | "Cancel" | "Standby" | "LaunchApplication1" | "LaunchApplication2" | "LaunchMediaPlayer" | "HangulMode" | "HanjaMode" | "Zenkaku" | "Hankaku" | "ZenkakuHankaku" | LetterKey | NumberKey | FunctionKey | NavigationKey | EditingKey | PunctuationKey>;
122
- /**
123
- * Normalizes a key name to its canonical form.
124
- *
125
- * Converts various key name formats (aliases, case variations) into the standard
126
- * canonical names used throughout the library. This enables a more forgiving API
127
- * where users can write keys in different ways and still get correct behavior.
128
- *
129
- * Normalization rules:
130
- * 1. Check aliases first (e.g., 'Esc' → 'Escape', 'Del' → 'Delete')
131
- * 2. Single letters → uppercase (e.g., 'a' → 'A', 's' → 'S')
132
- * 3. Function keys → uppercase (e.g., 'f1' → 'F1', 'F12' → 'F12')
133
- * 4. Other keys → returned as-is (already canonical or unknown)
134
- *
135
- * @param key - The key name to normalize (can be an alias, lowercase, etc.)
136
- * @returns The canonical key name
137
- *
138
- * @example
139
- * ```ts
140
- * normalizeKeyName('esc') // 'Escape'
141
- * normalizeKeyName('a') // 'A'
142
- * normalizeKeyName('f1') // 'F1'
143
- * normalizeKeyName('ArrowUp') // 'ArrowUp' (already canonical)
144
- * ```
145
- */
122
+ /** Whether a key is a single Unicode letter, such as `'a'` or `'é'`. */
146
123
  export declare function isSingleLetterKey(key: string): boolean;
147
124
  /**
148
125
  * Normalizes a key name to its canonical form.
package/dist/constants.js CHANGED
@@ -28,7 +28,7 @@ const MODIFIER_ORDER = [
28
28
  * a modifier is released so we can clear non-modifier keys whose keyup events
29
29
  * may have been swallowed by the OS (e.g. macOS Cmd+key combos).
30
30
  */
31
- const MODIFIER_KEYS = new Set(MODIFIER_ORDER);
31
+ const MODIFIER_KEYS = /* @__PURE__ */ new Set(MODIFIER_ORDER);
32
32
  /**
33
33
  * Maps modifier key aliases to their canonical form or platform-adaptive 'Mod'.
34
34
  *
@@ -283,89 +283,28 @@ const ALL_KEYS = /* @__PURE__ */ new Set([
283
283
  ...SHARED_NAMED_KEYS,
284
284
  ...LOGICAL_ONLY_NAMED_KEYS
285
285
  ]);
286
- const CANONICAL_KEY_NAMES = new Map(Array.from(ALL_KEYS, (key) => [key.toLowerCase(), key]));
287
286
  /**
288
- * Maps key name aliases to their canonical form.
289
- *
290
- * Handles common variations and alternative names for keys to provide a more
291
- * forgiving API. For example, users can write 'Esc', 'esc', or 'escape' and
292
- * they'll all normalize to 'Escape'.
293
- *
294
- * This map is used internally by `normalizeKeyName()` to convert user input
295
- * into the canonical key names used throughout the library.
296
- *
297
- * @remarks Case-insensitive lookups are supported via lowercase variants
298
- *
299
- * @example
300
- * ```ts
301
- * KEY_ALIASES['Esc'] // 'Escape'
302
- * KEY_ALIASES['Del'] // 'Delete'
303
- * KEY_ALIASES['Up'] // 'ArrowUp'
304
- * ```
305
- */
306
- const KEY_ALIASES = {
307
- Plus: "+",
308
- plus: "+",
309
- Esc: "Escape",
310
- esc: "Escape",
311
- escape: "Escape",
312
- Return: "Enter",
313
- return: "Enter",
314
- enter: "Enter",
315
- " ": "Space",
316
- space: "Space",
317
- Spacebar: "Space",
318
- spacebar: "Space",
319
- tab: "Tab",
320
- backspace: "Backspace",
321
- Del: "Delete",
322
- del: "Delete",
323
- delete: "Delete",
324
- Up: "ArrowUp",
325
- up: "ArrowUp",
326
- arrowup: "ArrowUp",
327
- Down: "ArrowDown",
328
- down: "ArrowDown",
329
- arrowdown: "ArrowDown",
330
- Left: "ArrowLeft",
331
- left: "ArrowLeft",
332
- arrowleft: "ArrowLeft",
333
- Right: "ArrowRight",
334
- right: "ArrowRight",
335
- arrowright: "ArrowRight",
336
- home: "Home",
337
- end: "End",
338
- pageup: "PageUp",
339
- pagedown: "PageDown",
340
- PgUp: "PageUp",
341
- PgDn: "PageDown",
342
- pgup: "PageUp",
343
- pgdn: "PageDown"
344
- };
345
- /**
346
- * Normalizes a key name to its canonical form.
347
- *
348
- * Converts various key name formats (aliases, case variations) into the standard
349
- * canonical names used throughout the library. This enables a more forgiving API
350
- * where users can write keys in different ways and still get correct behavior.
351
- *
352
- * Normalization rules:
353
- * 1. Check aliases first (e.g., 'Esc' → 'Escape', 'Del' → 'Delete')
354
- * 2. Single letters → uppercase (e.g., 'a' → 'A', 's' → 'S')
355
- * 3. Function keys → uppercase (e.g., 'f1' → 'F1', 'F12' → 'F12')
356
- * 4. Other keys → returned as-is (already canonical or unknown)
357
- *
358
- * @param key - The key name to normalize (can be an alias, lowercase, etc.)
359
- * @returns The canonical key name
360
- *
361
- * @example
362
- * ```ts
363
- * normalizeKeyName('esc') // 'Escape'
364
- * normalizeKeyName('a') // 'A'
365
- * normalizeKeyName('f1') // 'F1'
366
- * normalizeKeyName('ArrowUp') // 'ArrowUp' (already canonical)
367
- * ```
287
+ * Maps lowercase key names and aliases to their canonical form, so lookups
288
+ * are case-insensitive: `'esc'`, `'Esc'`, and `'ESCAPE'` all become `'Escape'`.
289
+ * Canonical names come from {@link ALL_KEYS}, so validation and normalization
290
+ * share one spelling for every key.
368
291
  */
292
+ const CANONICAL_KEY_NAMES = /* @__PURE__ */ new Map([
293
+ ...Array.from(ALL_KEYS, (key) => [key.toLowerCase(), key]),
294
+ ["plus", "+"],
295
+ ["esc", "Escape"],
296
+ ["return", "Enter"],
297
+ [" ", "Space"],
298
+ ["spacebar", "Space"],
299
+ ["del", "Delete"],
300
+ ["up", "ArrowUp"],
301
+ ["down", "ArrowDown"],
302
+ ["left", "ArrowLeft"],
303
+ ["right", "ArrowRight"],
304
+ ["pgup", "PageUp"],
305
+ ["pgdn", "PageDown"]
306
+ ]);
307
+ /** Whether a key is a single Unicode letter, such as `'a'` or `'é'`. */
369
308
  function isSingleLetterKey(key) {
370
309
  return /^\p{Letter}$/u.test(key);
371
310
  }
@@ -385,7 +324,6 @@ function isSingleLetterKey(key) {
385
324
  */
386
325
  function normalizeKeyName(key) {
387
326
  if (!key) return "";
388
- if (key in KEY_ALIASES) return KEY_ALIASES[key];
389
327
  if (isSingleLetterKey(key)) {
390
328
  const upper = key.toUpperCase();
391
329
  return upper.length === 1 ? upper : key;