@tanstack/hotkeys 0.14.0 → 0.15.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.
@@ -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,6 +45,22 @@ 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
65
  function splitHotkeyParts(hotkey) {
50
66
  if (!hotkey.endsWith("+")) return hotkey.split("+");
@@ -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, splitHotkeyParts };
@@ -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 parsedHotkeysEqual(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, parsedHotkeysEqual };
@@ -1,5 +1,5 @@
1
1
  import { normalizeKeyName } from "./constants.js";
2
- import { normalizeKeyboardEvent } from "./_keyboard-event.js";
2
+ import { isCodeName, normalizeKeyboardEvent } from "./_keyboard-event.js";
3
3
  import { isModifierKey, normalizeHotkeyFromParsed, parseKeyboardEvent } from "./parse.js";
4
4
 
5
5
  //#region src/_recorder-chord.ts
@@ -11,7 +11,7 @@ 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;
@@ -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 };
@@ -20,7 +20,8 @@ var TargetListeners = class {
20
20
  const listener = (eventType) => (rawEvent) => {
21
21
  const event = rawEvent;
22
22
  let processed = this.#processed.get(event);
23
- if (!processed || processed.groups.has(ids)) {
23
+ const first = !processed || processed.groups.has(ids);
24
+ if (!processed || first) {
24
25
  processed = {
25
26
  ids: /* @__PURE__ */ new Set(),
26
27
  groups: /* @__PURE__ */ new Set()
@@ -33,7 +34,7 @@ var TargetListeners = class {
33
34
  processed.ids.add(registrationId);
34
35
  pending.add(registrationId);
35
36
  }
36
- this.dispatch(event, target, eventType, capture, pending);
37
+ this.dispatch(event, target, eventType, capture, pending, first);
37
38
  };
38
39
  group = {
39
40
  ids,
package/dist/conflicts.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { detectPlatform } from "./platform.js";
2
- import { keysEqual } from "./_keyboard-event.js";
3
2
  import { isHotkeySequence, parseRegisterableHotkey } from "./parse.js";
3
+ import { parsedHotkeysEqual } 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). */
@@ -29,12 +30,12 @@ function findHotkeyConflicts(candidate, options = {}) {
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
32
  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));
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 parsedHotkeysEqual(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;
package/dist/format.js CHANGED
@@ -1,8 +1,8 @@
1
- import { MODIFIER_ALIASES, MODIFIER_ORDER, PUNCTUATION_CODE_MAP, normalizeKeyName } from "./constants.js";
1
+ import { MODIFIER_ORDER, normalizeKeyName } from "./constants.js";
2
2
  import { KEY_DISPLAY_SYMBOLS, LINUX_MODIFIER_LABELS, MAC_MODIFIER_LABELS, MAC_MODIFIER_SYMBOLS, PUNCTUATION_KEY_DISPLAY_LABELS, WINDOWS_MODIFIER_LABELS } from "./display-labels.js";
3
3
  import { detectPlatform } from "./platform.js";
4
- import { assertLogicalKey, splitHotkeyParts } from "./_keyboard-event.js";
5
- import { isModifierKey, normalizeHotkeyFromParsed, normalizeRegisterableHotkey } from "./parse.js";
4
+ import { assertLogicalKey, codeToLogicalKey, modifierAlias, physicalCodeOf, splitHotkeyParts } from "./_keyboard-event.js";
5
+ import { normalizeHotkeyFromParsed, normalizeRegisterableHotkey } from "./parse.js";
6
6
 
7
7
  //#region src/format.ts
8
8
  /**
@@ -55,23 +55,16 @@ function formatChordParts(hotkey, options) {
55
55
  segments.sort((a, b) => modifierIndex(a) - modifierIndex(b));
56
56
  }
57
57
  return segments.map((segment) => {
58
- if (isModifierKey(segment)) {
59
- const modifier = MODIFIER_ALIASES[segment] ?? MODIFIER_ALIASES[segment.toLowerCase()];
60
- return platform === "mac" ? modifierSymbols ? MAC_MODIFIER_SYMBOLS[modifier] : MAC_MODIFIER_LABELS[modifier] : platform === "windows" ? WINDOWS_MODIFIER_LABELS[modifier] : LINUX_MODIFIER_LABELS[modifier];
61
- }
62
- const code = /^\[([A-Za-z][A-Za-z0-9]*)\]$/.exec(segment)?.[1];
58
+ const modifier = modifierAlias(segment);
59
+ if (modifier) return platform === "mac" ? modifierSymbols ? MAC_MODIFIER_SYMBOLS[modifier] : MAC_MODIFIER_LABELS[modifier] : platform === "windows" ? WINDOWS_MODIFIER_LABELS[modifier] : LINUX_MODIFIER_LABELS[modifier];
60
+ const code = physicalCodeOf(segment);
63
61
  const label = options.keyLabels?.[code ?? segment];
64
62
  if (label !== void 0) return label;
65
63
  const numpad = code?.startsWith("Numpad") ?? false;
66
64
  let displayKey = code ?? segment;
67
65
  const mappedKey = code ? options.layoutMap?.get(code) : void 0;
68
66
  if (mappedKey) displayKey = normalizeKeyName(mappedKey);
69
- else if (code) {
70
- if (/^Key[A-Z]$/.test(code)) displayKey = code.slice(3);
71
- else if (/^Digit[0-9]$/.test(code)) displayKey = code.slice(5);
72
- else if (numpad) displayKey = code.slice(6);
73
- else displayKey = PUNCTUATION_CODE_MAP[code] ?? code;
74
- }
67
+ else if (code) displayKey = numpad ? code.slice(6) : codeToLogicalKey(code) ?? code;
75
68
  const display = (keySymbols ? KEY_DISPLAY_SYMBOLS : PUNCTUATION_KEY_DISPLAY_LABELS)[displayKey] ?? displayKey;
76
69
  return numpad ? `Numpad ${display.replace(/([a-z])([A-Z])/g, "$1 $2")}` : display;
77
70
  });
@@ -1,12 +1,13 @@
1
1
  import { normalizeKeyName } from "./constants.js";
2
2
  import { detectPlatform } from "./platform.js";
3
- import { normalizeKeyboardEvent } from "./_keyboard-event.js";
3
+ import { keysEqual, normalizeKeyboardEvent } from "./_keyboard-event.js";
4
4
  import { isHotkeySequence, isModifierKey, parseHotkey, rawHotkeyToParsedHotkey, simplifyHotkey } from "./parse.js";
5
5
  import { formatHotkey } from "./format.js";
6
- import { matchKeyboardEvent } from "./_match.js";
6
+ import { matchKeyboardEvent, parsedHotkeysEqual } from "./_match.js";
7
7
  import { DEFAULT_SEQUENCE_TIMEOUT, areHotkeysEqual } from "./match.js";
8
- import { isEventForTarget, shouldIgnoreInputEvent } from "./_event-target.js";
8
+ import { getTargetDocument, isEventForTarget, shouldIgnoreInputEvent } from "./_event-target.js";
9
9
  import { TargetListeners } from "./_target-listeners.js";
10
+ import { managerRef } from "./_manager-ref.js";
10
11
  import { isRecordingEvent } from "./_recording-guard.js";
11
12
  import { defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
12
13
  import { Store } from "@tanstack/store";
@@ -65,7 +66,7 @@ function ignoresInputs(registration) {
65
66
  return ignoreInputs === "auto" ? getDefaultIgnoreInputs(resolvedSteps(registration)[0]) : ignoreInputs;
66
67
  }
67
68
  function sameResolvedSteps(left, right) {
68
- return left.length === right.length && left.every((step, index) => areHotkeysEqual(step, right[index]));
69
+ return left.length === right.length && left.every((step, index) => parsedHotkeysEqual(step, right[index]));
69
70
  }
70
71
  function formatForMessage(hotkey) {
71
72
  return isHotkeySequence(hotkey) ? hotkey.join(" ") : hotkey;
@@ -97,7 +98,6 @@ function formatForMessage(hotkey) {
97
98
  * ```
98
99
  */
99
100
  var HotkeyManager = class HotkeyManager {
100
- static #instance = null;
101
101
  /**
102
102
  * The TanStack Store containing all hotkey registrations, chords and sequences.
103
103
  * Use this to subscribe to registration changes or access current registrations.
@@ -125,6 +125,10 @@ var HotkeyManager = class HotkeyManager {
125
125
  appHotkeys = new Store(null);
126
126
  #platform;
127
127
  #listeners = new TargetListeners((...args) => this.#processTargetEvent(...args));
128
+ /** Events that continue a sequence in progress, decided before any listener runs. */
129
+ #sequenceOwned = /* @__PURE__ */ new WeakMap();
130
+ /** Physical keys whose keydown a sequence owned, so their repeats and keyup are owned too. */
131
+ #sequenceOwnedKeys = /* @__PURE__ */ new Set();
128
132
  constructor() {
129
133
  this.#platform = detectPlatform();
130
134
  }
@@ -132,17 +136,14 @@ var HotkeyManager = class HotkeyManager {
132
136
  * Gets the singleton instance of HotkeyManager.
133
137
  */
134
138
  static getInstance() {
135
- if (!HotkeyManager.#instance) HotkeyManager.#instance = new HotkeyManager();
136
- return HotkeyManager.#instance;
139
+ return managerRef.current ??= new HotkeyManager();
137
140
  }
138
141
  /**
139
142
  * Resets the singleton instance. Useful for testing.
140
143
  */
141
144
  static resetInstance() {
142
- if (HotkeyManager.#instance) {
143
- HotkeyManager.#instance.destroy();
144
- HotkeyManager.#instance = null;
145
- }
145
+ managerRef.current?.destroy();
146
+ managerRef.current = null;
146
147
  }
147
148
  /** Notifies subscribers after in-place updates such as counts and sequence progress. */
148
149
  #notify() {
@@ -171,44 +172,37 @@ var HotkeyManager = class HotkeyManager {
171
172
  const id = generateId();
172
173
  const platform = options.platform ?? this.#platform;
173
174
  const target = options.target ?? document;
174
- let registration;
175
- if (isHotkeySequence(hotkey)) registration = {
176
- kind: "sequence",
175
+ const sequence = isHotkeySequence(hotkey);
176
+ const common = {
177
177
  id,
178
- hotkey,
179
- parsedSequence: hotkey.map((step) => parseHotkey(step, platform)),
180
178
  callback,
181
179
  options: {
182
180
  ...defaultHotkeyOptions,
183
- timeout: DEFAULT_SEQUENCE_TIMEOUT,
184
- ...optionsForKind("sequence", options),
181
+ ...sequence ? { timeout: DEFAULT_SEQUENCE_TIMEOUT } : { requireReset: false },
182
+ ...optionsForKind(sequence ? "sequence" : "chord", options),
185
183
  platform,
186
184
  ignoreInputs: options.ignoreInputs ?? "auto"
187
185
  },
188
186
  target,
189
- currentIndex: 0,
190
- lastKeyTime: 0,
191
187
  triggerCount: 0,
192
188
  hasFired: false
193
189
  };
190
+ let registration;
191
+ if (sequence) registration = {
192
+ ...common,
193
+ kind: "sequence",
194
+ hotkey,
195
+ parsedSequence: hotkey.map((step) => parseHotkey(step, platform)),
196
+ currentIndex: 0,
197
+ lastKeyTime: 0
198
+ };
194
199
  else {
195
200
  const parsedHotkey = typeof hotkey === "string" ? parseHotkey(hotkey, platform) : rawHotkeyToParsedHotkey(hotkey, platform);
196
201
  registration = {
202
+ ...common,
197
203
  kind: "chord",
198
- id,
199
204
  hotkey: typeof hotkey === "string" ? hotkey : formatHotkey(parsedHotkey),
200
- parsedHotkey,
201
- callback,
202
- options: {
203
- ...defaultHotkeyOptions,
204
- requireReset: false,
205
- ...optionsForKind("chord", options),
206
- platform,
207
- ignoreInputs: options.ignoreInputs ?? "auto"
208
- },
209
- target,
210
- hasFired: false,
211
- triggerCount: 0
205
+ parsedHotkey
212
206
  };
213
207
  }
214
208
  const conflicting = this.#findConflictingRegistration(registration);
@@ -285,9 +279,11 @@ var HotkeyManager = class HotkeyManager {
285
279
  }
286
280
  /**
287
281
  * Processes keyboard events for a specific target and event type.
288
- * Chords and sequences match independently; both may fire for one event.
282
+ * A key that continues a sequence in progress belongs to that sequence, so
283
+ * chords skip it. Chords and first steps of sequences may both fire.
289
284
  */
290
- #processTargetEvent(event, target, eventType, capture, ids) {
285
+ #processTargetEvent(event, target, eventType, capture, ids, first) {
286
+ if (first) this.#sequenceOwned.set(event, this.#isOwnedBySequence(event, eventType));
291
287
  this.#processChords(event, target, eventType, capture, this.#registrationsIn(ids, capture, "chord"));
292
288
  this.#processSequences(event, target, eventType, capture, this.#registrationsIn(ids, capture, "sequence"));
293
289
  }
@@ -298,7 +294,7 @@ var HotkeyManager = class HotkeyManager {
298
294
  registration.activeMatch = void 0;
299
295
  }
300
296
  }
301
- if (isRecordingEvent(event)) return;
297
+ if (isRecordingEvent(event) || this.#sequenceOwned.get(event)) return;
302
298
  const matches = /* @__PURE__ */ new Map();
303
299
  let bestScore = 0;
304
300
  for (const registration of registrations) {
@@ -314,21 +310,48 @@ var HotkeyManager = class HotkeyManager {
314
310
  if (!match?.matched || match.score !== bestScore) continue;
315
311
  const registration = this.#currentIn(snapshot, capture);
316
312
  if (!registration) continue;
313
+ this.#consume(registration, event);
317
314
  if (eventType === "keyup") {
318
- this.#executeCallback(registration, event);
315
+ this.#fire(registration, event);
319
316
  continue;
320
317
  }
321
- if (registration.options.preventDefault) event.preventDefault();
322
- if (registration.options.stopPropagation) event.stopPropagation();
323
318
  if (!registration.options.requireReset || !registration.hasFired) {
324
319
  if (registration.options.requireReset) {
325
320
  registration.hasFired = true;
326
321
  registration.activeMatch = match.identity;
327
322
  }
328
- this.#executeCallback(registration, event);
323
+ this.#fire(registration, event);
329
324
  }
330
325
  }
331
326
  }
327
+ /**
328
+ * Whether the event continues or completes a sequence in progress whose
329
+ * target the event reaches. The repeats and keyup of a keydown that a
330
+ * sequence owns belong to the sequence too.
331
+ */
332
+ #isOwnedBySequence(event, eventType) {
333
+ const key = event.code || normalizeKeyName(event.key);
334
+ if (eventType === "keyup") {
335
+ const ownedKeydown = this.#sequenceOwnedKeys.delete(key);
336
+ return this.#continuesSequence(event, eventType) || ownedKeydown;
337
+ }
338
+ if (event.repeat) return this.#sequenceOwnedKeys.has(key);
339
+ const owned = this.#continuesSequence(event, eventType);
340
+ if (owned) this.#sequenceOwnedKeys.add(key);
341
+ else this.#sequenceOwnedKeys.delete(key);
342
+ return owned;
343
+ }
344
+ #continuesSequence(event, eventType) {
345
+ if (isRecordingEvent(event) || normalizeKeyboardEvent(event).isComposing || isModifierKey(normalizeKeyName(event.key))) return false;
346
+ const path = event.composedPath();
347
+ const now = Date.now();
348
+ for (const registration of this.registrations.state.values()) {
349
+ if (registration.kind !== "sequence" || registration.currentIndex === 0 || !registration.options.enabled || registration.options.eventType !== eventType || now - registration.lastKeyTime > (registration.options.timeout ?? 1e3)) continue;
350
+ const target = registration.target;
351
+ if (path.includes(target.nodeType === 1 ? target : getTargetDocument(target)) && !(ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, target)) && matchKeyboardEvent(event, registration.parsedSequence[registration.currentIndex], registration.options.platform).matched) return true;
352
+ }
353
+ return false;
354
+ }
332
355
  #processSequences(event, target, eventType, capture, registrations) {
333
356
  if (registrations.length === 0) return;
334
357
  if (isRecordingEvent(event)) {
@@ -376,8 +399,7 @@ var HotkeyManager = class HotkeyManager {
376
399
  registration.currentIndex = candidate.nextIndex;
377
400
  changed = true;
378
401
  if (registration.currentIndex >= registration.parsedSequence.length) {
379
- if (registration.options.preventDefault) event.preventDefault();
380
- if (registration.options.stopPropagation) event.stopPropagation();
402
+ this.#consume(registration, event);
381
403
  registration.currentIndex = 0;
382
404
  completed.push(registration);
383
405
  }
@@ -389,19 +411,20 @@ var HotkeyManager = class HotkeyManager {
389
411
  if (changed && completed.length === 0) this.#notify();
390
412
  for (const snapshot of completed) {
391
413
  const registration = this.#currentIn(snapshot, capture);
392
- if (registration) this.#executeCallback(registration, event);
414
+ if (registration) this.#fire(registration, event);
393
415
  }
394
416
  }
395
- /**
396
- * Executes a hotkey callback with proper event handling.
397
- */
398
- #executeCallback(registration, event) {
417
+ /** Applies a matched registration's preventDefault and stopPropagation options. */
418
+ #consume(registration, event) {
399
419
  if (registration.options.preventDefault) event.preventDefault();
400
420
  if (registration.options.stopPropagation) event.stopPropagation();
421
+ }
422
+ /** Counts a trigger, notifies subscribers (such as devtools), and runs the callback. */
423
+ #fire(registration, event, context = this.#callbackContext(registration)) {
401
424
  registration.triggerCount++;
402
425
  if (registration.kind === "sequence") registration.hasFired = true;
403
426
  this.#notify();
404
- registration.callback(event, this.#callbackContext(registration));
427
+ registration.callback(event, context);
405
428
  }
406
429
  #callbackContext(registration) {
407
430
  return {
@@ -426,17 +449,7 @@ var HotkeyManager = class HotkeyManager {
426
449
  const parsed = registration.parsedHotkey;
427
450
  const releasedKey = normalizeKeyName(event.key);
428
451
  if (registration.activeMatch?.code && event.code === registration.activeMatch.code) return true;
429
- if (parsed.code !== void 0) {
430
- if (event.code === parsed.code) return true;
431
- } else {
432
- const parsedKeyNormalized = parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key;
433
- if ((releasedKey.length === 1 ? releasedKey.toUpperCase() : releasedKey) === parsedKeyNormalized) return true;
434
- }
435
- if (parsed.ctrl && releasedKey === "Control") return true;
436
- if (parsed.shift && releasedKey === "Shift") return true;
437
- if (parsed.alt && releasedKey === "Alt") return true;
438
- if (parsed.meta && releasedKey === "Meta") return true;
439
- return false;
452
+ return (parsed.code !== void 0 ? event.code === parsed.code : keysEqual(releasedKey, parsed.key)) || parsed.modifiers.includes(releasedKey);
440
453
  }
441
454
  /**
442
455
  * Resets the progress of every in-progress sequence.
@@ -470,10 +483,7 @@ var HotkeyManager = class HotkeyManager {
470
483
  bubbles: true,
471
484
  cancelable: true
472
485
  });
473
- registration.triggerCount++;
474
- if (registration.kind === "sequence") registration.hasFired = true;
475
- this.#notify();
476
- registration.callback(syntheticEvent, context);
486
+ this.#fire(registration, syntheticEvent, context);
477
487
  return true;
478
488
  }
479
489
  /**
@@ -1,7 +1,37 @@
1
- import { Hotkey, HotkeyChord, HotkeySequence } from "./hotkey.types.js";
2
- import { HotkeyRecorderValidationContext, RecorderOptions } from "./recorder-options.js";
1
+ import { Hotkey, HotkeyChord, HotkeySequence, ParsedHotkey } from "./hotkey.types.js";
2
+ import { HotkeyConflict, HotkeyConflictOptions } from "./conflicts.js";
3
3
  import { Store } from "@tanstack/store";
4
4
  //#region src/hotkey-recorder.d.ts
5
+ /** Event property used to record a binding. Physical codes are the default. */
6
+ export type RecorderKeyMode = 'code' | 'key';
7
+ export interface RecorderRejection {
8
+ reason: 'missing-code' | 'alt-graph' | 'invalid' | 'conflict' | 'validation';
9
+ message: string;
10
+ /** The rejected chord or sequence, when one was recorded. */
11
+ hotkey?: Hotkey;
12
+ conflicts?: Array<HotkeyConflict>;
13
+ }
14
+ export interface RecorderOptions {
15
+ /** Physical code recording is the default. Choose key to record logical characters. */
16
+ recordBy?: RecorderKeyMode;
17
+ /** Platform used for Mod and AltGraph handling. Defaults to platform detection. */
18
+ platform?: 'mac' | 'windows' | 'linux';
19
+ /** Opt in to live-registry conflicts, including sequence prefixes. Default: false. */
20
+ detectConflicts?: boolean | HotkeyConflictOptions;
21
+ /** Rejected candidates leave recording active. */
22
+ onReject?: (rejection: RecorderRejection) => void;
23
+ }
24
+ /** Validation context for a recorded chord or sequence. */
25
+ export interface HotkeyRecorderValidationContext {
26
+ /** The event that recorded the last chord. */
27
+ event: KeyboardEvent;
28
+ /** The last chord, parsed. */
29
+ parsedHotkey: ParsedHotkey;
30
+ /** The event that recorded each step. */
31
+ events: ReadonlyArray<KeyboardEvent>;
32
+ /** Every step, parsed. */
33
+ parsedSequence: ReadonlyArray<ParsedHotkey>;
34
+ }
5
35
  /**
6
36
  * State interface for the HotkeyRecorder.
7
37
  */
@@ -127,6 +127,19 @@ export type ParsedHotkey = ParsedModifiers & ({
127
127
  code: string;
128
128
  key?: never;
129
129
  });
130
+ /** Modifier flags shared by logical-key and physical-code registrations. */
131
+ export interface RawModifiers {
132
+ /** Platform-adaptive modifier: Command on macOS, Control on Windows/Linux. Defaults to false. */
133
+ mod?: boolean;
134
+ /** Whether the Control key is required. Defaults to false. */
135
+ ctrl?: boolean;
136
+ /** Whether the Shift key is required. Defaults to false. */
137
+ shift?: boolean;
138
+ /** Whether the Alt key is required. Defaults to false. */
139
+ alt?: boolean;
140
+ /** Whether the Meta (Command) key is required. Defaults to false. */
141
+ meta?: boolean;
142
+ }
130
143
  /**
131
144
  * A raw hotkey object for programmatic registration.
132
145
  *
@@ -149,20 +162,6 @@ export type ParsedHotkey = ParsedModifiers & ({
149
162
  * useHotkey({ code: 'NumpadAdd', mod: true }, handler) // Physical numpad plus
150
163
  * ```
151
164
  */
152
- /** Modifier flags shared by logical-key and physical-code registrations. */
153
- export interface RawModifiers {
154
- /** The non-modifier key (e.g., 'S', 'Escape', 'F1'). */
155
- /** Platform-adaptive modifier: Command on macOS, Control on Windows/Linux. Defaults to false. */
156
- mod?: boolean;
157
- /** Whether the Control key is required. Defaults to false. */
158
- ctrl?: boolean;
159
- /** Whether the Shift key is required. Defaults to false. */
160
- shift?: boolean;
161
- /** Whether the Alt key is required. Defaults to false. */
162
- alt?: boolean;
163
- /** Whether the Meta (Command) key is required. Defaults to false. */
164
- meta?: boolean;
165
- }
166
165
  export type RawHotkey = ({
167
166
  key: LogicalKey | (string & {});
168
167
  code?: never;
package/dist/index.d.ts CHANGED
@@ -12,6 +12,5 @@ import { CreateHotkeyHandlerOptions, DEFAULT_SEQUENCE_TIMEOUT, KeyboardEventMatc
12
12
  import { HeldModifierOptions, matchesHeldModifiers } from "./hint.js";
13
13
  import { HotkeyConflict, HotkeyConflictOptions, findHotkeyConflicts } from "./conflicts.js";
14
14
  import { KeyStateTracker, KeyStateTrackerState, getKeyStateTracker } from "./key-state-tracker.js";
15
- import { HotkeyRecorderValidationContext, RecorderKeyMode, RecorderOptions, RecorderRejection } from "./recorder-options.js";
16
- import { HotkeyChordRecorderOptions, HotkeyRecorder, HotkeyRecorderCommitKeys, HotkeyRecorderOptions, HotkeyRecorderState, HotkeySequenceRecorderOptions } from "./hotkey-recorder.js";
15
+ import { HotkeyChordRecorderOptions, HotkeyRecorder, HotkeyRecorderCommitKeys, HotkeyRecorderOptions, HotkeyRecorderState, HotkeyRecorderValidationContext, HotkeySequenceRecorderOptions, RecorderKeyMode, RecorderOptions, RecorderRejection } from "./hotkey-recorder.js";
17
16
  export { ALL_KEYS, AppHotkey, AppHotkeyChordOptions, AppHotkeyDefaultKind, AppHotkeyId, AppHotkeyInput, AppHotkeyListItem, AppHotkeyOptions, AppHotkeySequenceOptions, AppHotkeyStatus, AppHotkeys, AppHotkeysState, AppHotkeysStore, AppHotkeysStoreOptions, CanonicalModifier, ConflictBehavior, CreateHotkeyHandlerOptions, CustomHotkeys, DEFAULT_SEQUENCE_TIMEOUT, DefinedAppHotkeys, DisplayHotkey, DisplayHotkeyChord, EDITING_KEYS, EditingKey, FUNCTION_KEYS, FormatDisplayOptions, FourModifierHotkey, FunctionKey, HeldModifierOptions, Hotkey, HotkeyCallback, HotkeyCallbackContext, HotkeyChord, HotkeyChordOptions, HotkeyChordRecorderOptions, HotkeyChordRegistration, HotkeyChordRegistrationView, HotkeyConflict, HotkeyConflictOptions, HotkeyManager, HotkeyMeta, HotkeyOptions, HotkeyOptionsFor, HotkeyRecorder, HotkeyRecorderCommitKeys, HotkeyRecorderOptions, HotkeyRecorderState, HotkeyRecorderValidationContext, HotkeyRegistration, HotkeyRegistrationHandle, HotkeyRegistrationView, HotkeySequence, HotkeySequenceOptions, HotkeySequenceRecorderOptions, HotkeySequenceRegistration, HotkeySequenceRegistrationView, IndividualKey, KEY_DISPLAY_SYMBOLS, Key, KeyStateTracker, KeyStateTrackerState, KeyboardEventMatch, LETTER_KEYS, LINUX_MODIFIER_LABELS, LetterKey, LogicalKey, MAC_MODIFIER_LABELS, MAC_MODIFIER_SYMBOLS, MODIFIER_ALIASES, MODIFIER_KEYS, MODIFIER_ORDER, Modifier, MultiHotkeyHandler, NAVIGATION_KEYS, NUMBER_KEYS, NamedKey, NavigationKey, NonPunctuationKey, NormalizedKeyboardEvent, NumberKey, PUNCTUATION_CODE_MAP, PUNCTUATION_KEYS, PUNCTUATION_KEY_DISPLAY_LABELS, ParsedHotkey, ParsedModifiers, PhysicalKey, PhysicalKeyCode, PunctuationKey, RawHotkey, RawModifiers, RecorderKeyMode, RecorderOptions, RecorderRejection, Register, RegisterableHotkey, RegisterableHotkeyChord, RegisteredAppHotkeys, SingleModifierHotkey, Target, ThreeModifierHotkey, TwoModifierHotkey, ValidatableHotkey, ValidationResult, WINDOWS_MODIFIER_LABELS, areHotkeysEqual, assertValidHotkey, checkHotkey, createHotkeyHandler, createMultiHotkeyHandler, createSequenceMatcher, defineAppHotkeys, detectPlatform, findHotkeyConflicts, formatForDisplay, formatHotkey, getAppHotkeyRegistrationOptions, getHotkeyManager, getKeyStateTracker, hasNonModifierKey, isAppHotkey, isCustomHotkey, isHotkeySequence, isModifierKey, isSingleLetterKey, matchesHeldModifiers, matchesKeyboardEvent, normalizeHotkey, normalizeHotkeyFromEvent, normalizeHotkeyFromParsed, normalizeKeyName, normalizeRegisterableHotkey, parseHotkey, parseKeyboardEvent, parseRegisterableHotkey, provideAppHotkeys, publishAppHotkeysStore, rawHotkeyToParsedHotkey, resolveAppHotkey, resolveModifier, simplifyHotkey, toHotkeyRegistrationView, validateHotkey };
@@ -53,10 +53,8 @@ var KeyStateTracker = class KeyStateTracker {
53
53
  */
54
54
  store = new Store(getDefaultKeyStateTrackerState());
55
55
  #heldEntries = /* @__PURE__ */ new Map();
56
- #keydownListener = null;
57
- #keyupListener = null;
58
- #mouseListener = null;
59
- #blurListener = null;
56
+ /** Window listeners as `[type, listener, capture]`, removed together on destroy. */
57
+ #listeners = [];
60
58
  constructor() {
61
59
  this.#setupListeners();
62
60
  }
@@ -81,7 +79,7 @@ var KeyStateTracker = class KeyStateTracker {
81
79
  */
82
80
  #setupListeners() {
83
81
  if (typeof document === "undefined") return;
84
- this.#keydownListener = (event) => {
82
+ const onKeydown = (event) => {
85
83
  let changed = this.#reconcileModifiers(event);
86
84
  const key = normalizeKeyName(event.key);
87
85
  const identity = event.code || `key:${key}`;
@@ -94,7 +92,7 @@ var KeyStateTracker = class KeyStateTracker {
94
92
  }
95
93
  if (changed) this.#syncState();
96
94
  };
97
- this.#keyupListener = (event) => {
95
+ const onKeyup = (event) => {
98
96
  const key = normalizeKeyName(event.key);
99
97
  const identity = event.code || `key:${key}`;
100
98
  this.#heldEntries.delete(identity);
@@ -107,20 +105,43 @@ var KeyStateTracker = class KeyStateTracker {
107
105
  }
108
106
  this.#syncState();
109
107
  };
110
- this.#mouseListener = (event) => {
108
+ const onMouse = (event) => {
111
109
  if (this.#reconcileModifiers(event)) this.#syncState();
112
110
  };
113
- this.#blurListener = () => {
111
+ const onBlur = () => {
114
112
  if (this.#heldEntries.size > 0) {
115
113
  this.#heldEntries.clear();
116
114
  this.#syncState();
117
115
  }
118
116
  };
119
- window.addEventListener("keydown", this.#keydownListener, true);
120
- window.addEventListener("keyup", this.#keyupListener, true);
121
- window.addEventListener("mousemove", this.#mouseListener, true);
122
- window.addEventListener("mousedown", this.#mouseListener, true);
123
- window.addEventListener("blur", this.#blurListener);
117
+ this.#listeners = [
118
+ [
119
+ "keydown",
120
+ onKeydown,
121
+ true
122
+ ],
123
+ [
124
+ "keyup",
125
+ onKeyup,
126
+ true
127
+ ],
128
+ [
129
+ "mousemove",
130
+ onMouse,
131
+ true
132
+ ],
133
+ [
134
+ "mousedown",
135
+ onMouse,
136
+ true
137
+ ],
138
+ [
139
+ "blur",
140
+ onBlur,
141
+ false
142
+ ]
143
+ ];
144
+ for (const [type, listener, capture] of this.#listeners) window.addEventListener(type, listener, capture);
124
145
  }
125
146
  /**
126
147
  * Removes modifiers whose release was missed without a window blur.
@@ -153,24 +174,8 @@ var KeyStateTracker = class KeyStateTracker {
153
174
  * Removes the keyboard event listeners.
154
175
  */
155
176
  #removeListeners() {
156
- if (typeof document === "undefined") return;
157
- if (this.#keydownListener) {
158
- window.removeEventListener("keydown", this.#keydownListener, true);
159
- this.#keydownListener = null;
160
- }
161
- if (this.#keyupListener) {
162
- window.removeEventListener("keyup", this.#keyupListener, true);
163
- this.#keyupListener = null;
164
- }
165
- if (this.#mouseListener) {
166
- window.removeEventListener("mousemove", this.#mouseListener, true);
167
- window.removeEventListener("mousedown", this.#mouseListener, true);
168
- this.#mouseListener = null;
169
- }
170
- if (this.#blurListener) {
171
- window.removeEventListener("blur", this.#blurListener);
172
- this.#blurListener = null;
173
- }
177
+ for (const [type, listener, capture] of this.#listeners) window.removeEventListener(type, listener, capture);
178
+ this.#listeners = [];
174
179
  }
175
180
  /**
176
181
  * Gets an array of currently held key names.
package/dist/match.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { normalizeKeyName } from "./constants.js";
2
2
  import { detectPlatform } from "./platform.js";
3
- import { keysEqual, normalizeKeyboardEvent } from "./_keyboard-event.js";
3
+ import { normalizeKeyboardEvent } from "./_keyboard-event.js";
4
4
  import { isHotkeySequence, isModifierKey, parseHotkey, parseRegisterableHotkey } from "./parse.js";
5
- import { matchKeyboardEvent } from "./_match.js";
5
+ import { formatHotkey } from "./format.js";
6
+ import { matchKeyboardEvent, parsedHotkeysEqual } from "./_match.js";
6
7
 
7
8
  //#region src/match.ts
8
9
  /**
@@ -58,7 +59,7 @@ function matchesKeyboardEvent(event, hotkey, platform = detectPlatform()) {
58
59
  function createHotkeyHandler(hotkey, callback, options = {}) {
59
60
  const { preventDefault = true, stopPropagation = true, platform } = options;
60
61
  const resolvedPlatform = platform ?? detectPlatform();
61
- const hotkeyString = typeof hotkey === "string" ? hotkey : formatParsedHotkey(hotkey);
62
+ const hotkeyString = typeof hotkey === "string" ? hotkey : formatHotkey(hotkey);
62
63
  const parsed = typeof hotkey === "string" ? parseHotkey(hotkey, resolvedPlatform) : hotkey;
63
64
  const context = {
64
65
  hotkey: hotkeyString,
@@ -114,19 +115,6 @@ function createMultiHotkeyHandler(handlers, options = {}) {
114
115
  };
115
116
  }
116
117
  /**
117
- * Formats a ParsedHotkey back to a hotkey string.
118
- * Used internally to provide the hotkey string in callback context.
119
- */
120
- function formatParsedHotkey(parsed) {
121
- const parts = [];
122
- if (parsed.ctrl) parts.push("Control");
123
- if (parsed.alt) parts.push("Alt");
124
- if (parsed.shift) parts.push("Shift");
125
- if (parsed.meta) parts.push("Meta");
126
- parts.push(parsed.code !== void 0 ? `[${parsed.code}]` : parsed.key);
127
- return parts.join("+");
128
- }
129
- /**
130
118
  * Compares normalized hotkey identity, including all four modifiers.
131
119
  * Sequences are equal when every step is equal; a one-step sequence equals its chord.
132
120
  * Aliases resolve on the supplied platform. A physical code and a logical key
@@ -135,12 +123,7 @@ function formatParsedHotkey(parsed) {
135
123
  function areHotkeysEqual(left, right, platform = detectPlatform()) {
136
124
  const a = isHotkeySequence(left) ? left : [left];
137
125
  const b = isHotkeySequence(right) ? right : [right];
138
- return a.length === b.length && a.every((step, index) => areChordsEqual(step, b[index], platform));
139
- }
140
- function areChordsEqual(left, right, platform) {
141
- const a = parseRegisterableHotkey(left, platform);
142
- const b = parseRegisterableHotkey(right, platform);
143
- 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));
126
+ return a.length === b.length && a.every((step, index) => parsedHotkeysEqual(parseRegisterableHotkey(step, platform), parseRegisterableHotkey(b[index], platform)));
144
127
  }
145
128
  /** Default timeout between sequence steps (in milliseconds). */
146
129
  const DEFAULT_SEQUENCE_TIMEOUT = 1e3;
package/dist/parse.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { Key } from "./key.types.js";
2
2
  import { Hotkey, HotkeyChord, HotkeySequence, ParsedHotkey, RawHotkey, RegisterableHotkey, RegisterableHotkeyChord } from "./hotkey.types.js";
3
- import { MODIFIER_ALIASES } from "./constants.js";
4
3
  //#region src/parse.d.ts
5
4
  /**
6
5
  * Whether a hotkey is a sequence. Use this instead of `Array.isArray`, which
@@ -49,6 +48,12 @@ export declare function parseHotkey(hotkey: HotkeyChord | (string & {}), platfor
49
48
  * ```
50
49
  */
51
50
  export declare function rawHotkeyToParsedHotkey(raw: RawHotkey, platform?: 'mac' | 'windows' | 'linux'): ParsedHotkey;
51
+ /**
52
+ * Same canonical string as {@link normalizeHotkey}, but from an already-parsed
53
+ * hotkey: `Mod` first when the platform allows, then `Alt`, then `Shift`;
54
+ * otherwise full `Control+Alt+Shift+Meta` order.
55
+ */
56
+ export declare function normalizeHotkeyFromParsed(parsed: ParsedHotkey, platform?: 'mac' | 'windows' | 'linux'): HotkeyChord;
52
57
  /**
53
58
  * Normalizes a hotkey string to its canonical form.
54
59
  *
@@ -70,10 +75,6 @@ export declare function rawHotkeyToParsedHotkey(raw: RawHotkey, platform?: 'mac'
70
75
  * ```
71
76
  */
72
77
  export declare function normalizeHotkey(hotkey: Key | (string & {}), platform?: 'mac' | 'windows' | 'linux'): HotkeyChord;
73
- /**
74
- * Same canonical string as {@link normalizeHotkey}, but from an already-parsed hotkey.
75
- */
76
- export declare function normalizeHotkeyFromParsed(parsed: ParsedHotkey, platform?: 'mac' | 'windows' | 'linux'): HotkeyChord;
77
78
  /**
78
79
  * Normalizes a chord (string or {@link RawHotkey} object) or a sequence to canonical form.
79
80
  * Each sequence step is normalized, and a one-step sequence becomes its chord.
@@ -90,7 +91,7 @@ export declare function normalizeRegisterableHotkey(hotkey: RegisterableHotkey,
90
91
  * @param key - Key name or alias (e.g. from a hotkey string or `event.key`)
91
92
  * @returns True if the string is a recognized modifier
92
93
  */
93
- export declare function isModifierKey(key: Key | (string & {})): key is keyof typeof MODIFIER_ALIASES;
94
+ export declare function isModifierKey(key: Key | (string & {})): boolean;
94
95
  /**
95
96
  * Parses a KeyboardEvent into a ParsedHotkey object.
96
97
  *
package/dist/parse.js CHANGED
@@ -1,8 +1,25 @@
1
- import { MODIFIER_ALIASES, MODIFIER_ORDER, normalizeKeyName } from "./constants.js";
1
+ import { MODIFIER_ORDER, normalizeKeyName } from "./constants.js";
2
2
  import { detectPlatform, resolveModifier } from "./platform.js";
3
- import { assertLogicalKey, normalizeKeyboardEvent, splitHotkeyParts } from "./_keyboard-event.js";
3
+ import { assertLogicalKey, modifierAlias, normalizeKeyboardEvent, physicalCodeOf, splitHotkeyParts } from "./_keyboard-event.js";
4
4
 
5
5
  //#region src/parse.ts
6
+ /** Builds a ParsedHotkey, listing held modifiers in canonical order. */
7
+ function toParsedHotkey(identity, ctrl, shift, alt, meta) {
8
+ const held = {
9
+ Control: ctrl,
10
+ Alt: alt,
11
+ Shift: shift,
12
+ Meta: meta
13
+ };
14
+ return {
15
+ ...identity,
16
+ ctrl,
17
+ shift,
18
+ alt,
19
+ meta,
20
+ modifiers: MODIFIER_ORDER.filter((modifier) => held[modifier])
21
+ };
22
+ }
6
23
  /**
7
24
  * Whether a hotkey is a sequence. Use this instead of `Array.isArray`, which
8
25
  * does not narrow readonly arrays.
@@ -35,7 +52,7 @@ function parseHotkey(hotkey, platform = detectPlatform()) {
35
52
  const part = parts[i].trim();
36
53
  if (i === parts.length - 1) key = normalizeKeyName(part);
37
54
  else {
38
- const alias = MODIFIER_ALIASES[part] ?? MODIFIER_ALIASES[part.toLowerCase()];
55
+ const alias = modifierAlias(part);
39
56
  if (alias) {
40
57
  const resolved = resolveModifier(alias, platform);
41
58
  modifiers.add(resolved);
@@ -43,15 +60,8 @@ function parseHotkey(hotkey, platform = detectPlatform()) {
43
60
  }
44
61
  }
45
62
  if (!key && parts.length > 0) key = normalizeKeyName(parts[parts.length - 1].trim());
46
- const code = /^\[([A-Za-z][A-Za-z0-9]*)\]$/.exec(key)?.[1];
47
- return {
48
- ...code !== void 0 ? { code } : { key },
49
- ctrl: modifiers.has("Control"),
50
- shift: modifiers.has("Shift"),
51
- alt: modifiers.has("Alt"),
52
- meta: modifiers.has("Meta"),
53
- modifiers: MODIFIER_ORDER.filter((m) => modifiers.has(m))
54
- };
63
+ const code = physicalCodeOf(key);
64
+ return toParsedHotkey(code !== void 0 ? { code } : { key }, modifiers.has("Control"), modifiers.has("Shift"), modifiers.has("Alt"), modifiers.has("Meta"));
55
65
  }
56
66
  /**
57
67
  * Converts a RawHotkey object to a ParsedHotkey.
@@ -84,30 +94,14 @@ function rawHotkeyToParsedHotkey(raw, platform = detectPlatform()) {
84
94
  if (resolveModifier("Mod", platform) === "Control") ctrl = true;
85
95
  else meta = true;
86
96
  }
87
- const modifiers = MODIFIER_ORDER.filter((m) => {
88
- switch (m) {
89
- case "Control": return ctrl;
90
- case "Shift": return shift;
91
- case "Alt": return alt;
92
- case "Meta": return meta;
93
- default: return false;
94
- }
95
- });
96
- return {
97
- ...raw.code !== void 0 ? { code: raw.code } : { key: normalizeKeyName(raw.key) },
98
- ctrl,
99
- shift,
100
- alt,
101
- meta,
102
- modifiers
103
- };
97
+ return toParsedHotkey(raw.code !== void 0 ? { code: raw.code } : { key: normalizeKeyName(raw.key) }, ctrl, shift, alt, meta);
104
98
  }
105
99
  /**
106
- * Serializes a parsed hotkey to the canonical string form used for registration
107
- * and storage: `Mod` first when the platform allows, then `Alt`, then `Shift`;
100
+ * Same canonical string as {@link normalizeHotkey}, but from an already-parsed
101
+ * hotkey: `Mod` first when the platform allows, then `Alt`, then `Shift`;
108
102
  * otherwise full `Control+Alt+Shift+Meta` order.
109
103
  */
110
- function normalizedHotkeyStringFromParsed(parsed, platform) {
104
+ function normalizeHotkeyFromParsed(parsed, platform = detectPlatform()) {
111
105
  if (parsed.code === void 0) assertLogicalKey(parsed.key);
112
106
  const canUseMod = platform === "mac" ? parsed.meta && !parsed.ctrl : parsed.ctrl && !parsed.meta;
113
107
  const parts = [];
@@ -140,13 +134,7 @@ function normalizedHotkeyStringFromParsed(parsed, platform) {
140
134
  * ```
141
135
  */
142
136
  function normalizeHotkey(hotkey, platform = detectPlatform()) {
143
- return normalizedHotkeyStringFromParsed(parseHotkey(hotkey, platform), platform);
144
- }
145
- /**
146
- * Same canonical string as {@link normalizeHotkey}, but from an already-parsed hotkey.
147
- */
148
- function normalizeHotkeyFromParsed(parsed, platform = detectPlatform()) {
149
- return normalizedHotkeyStringFromParsed(parsed, platform);
137
+ return normalizeHotkeyFromParsed(parseHotkey(hotkey, platform), platform);
150
138
  }
151
139
  function normalizeRegisterableHotkey(hotkey, platform = detectPlatform()) {
152
140
  if (isHotkeySequence(hotkey)) {
@@ -165,7 +153,7 @@ function normalizeRegisterableHotkey(hotkey, platform = detectPlatform()) {
165
153
  * @returns True if the string is a recognized modifier
166
154
  */
167
155
  function isModifierKey(key) {
168
- return key in MODIFIER_ALIASES || key.toLowerCase() in MODIFIER_ALIASES;
156
+ return modifierAlias(key) !== void 0;
169
157
  }
170
158
  /**
171
159
  * Parses a KeyboardEvent into a ParsedHotkey object.
@@ -190,19 +178,7 @@ function parseKeyboardEvent(event, platform) {
190
178
  const ctrl = normalized.altGraph ? false : normalized.ctrl;
191
179
  const alt = normalized.altGraph ? false : normalized.alt;
192
180
  const shift = normalized.shift;
193
- const modifiers = [];
194
- if (ctrl) modifiers.push("Control");
195
- if (alt) modifiers.push("Alt");
196
- if (shift) modifiers.push("Shift");
197
- if (normalized.meta) modifiers.push("Meta");
198
- return {
199
- key: normalized.key,
200
- ctrl,
201
- shift,
202
- alt,
203
- meta: normalized.meta,
204
- modifiers
205
- };
181
+ return toParsedHotkey({ key: normalized.key }, ctrl, shift, alt, normalized.meta);
206
182
  }
207
183
  /**
208
184
  * Normalizes a keyboard event to the same canonical hotkey string as {@link normalizeHotkey}.
@@ -211,7 +187,7 @@ function parseKeyboardEvent(event, platform) {
211
187
  * @param platform - Target platform for `Mod` eligibility
212
188
  */
213
189
  function normalizeHotkeyFromEvent(event, platform = detectPlatform()) {
214
- return normalizedHotkeyStringFromParsed(parseKeyboardEvent(event, platform), platform);
190
+ return normalizeHotkeyFromParsed(parseKeyboardEvent(event, platform), platform);
215
191
  }
216
192
  /**
217
193
  * Checks if a hotkey or ParsedHotkey contains at least one non-modifier key.
package/dist/validate.js CHANGED
@@ -1,5 +1,5 @@
1
- import { ALL_KEYS, MODIFIER_ALIASES, normalizeKeyName } from "./constants.js";
2
- import { splitHotkeyParts } from "./_keyboard-event.js";
1
+ import { ALL_KEYS, normalizeKeyName } from "./constants.js";
2
+ import { modifierAlias, physicalCodeOf, splitHotkeyParts } from "./_keyboard-event.js";
3
3
 
4
4
  //#region src/validate.ts
5
5
  /**
@@ -60,34 +60,18 @@ function validateChord(hotkey) {
60
60
  };
61
61
  const modifierParts = parts.slice(0, -1);
62
62
  const keyPart = parts[parts.length - 1];
63
- for (const modifier of modifierParts) if (!(MODIFIER_ALIASES[modifier] ?? MODIFIER_ALIASES[modifier.toLowerCase()])) errors.push(`Unknown modifier: '${modifier}'`);
64
- if (keyPart.length > 1 && (keyPart.startsWith("[") || keyPart.endsWith("]")) && !/^\[[A-Za-z][A-Za-z0-9]*\]$/.test(keyPart)) errors.push("Invalid physical key: use a bracketed KeyboardEvent.code, such as [KeyS]");
65
- if (!isKnownKey(normalizeKeyName(keyPart)) && !isKnownKey(keyPart)) warnings.push(`Unknown key: '${keyPart}'. This may still work but won't have type-safe autocomplete.`);
63
+ for (const modifier of modifierParts) if (!modifierAlias(modifier)) errors.push(`Unknown modifier: '${modifier}'`);
64
+ if (keyPart.length > 1 && (keyPart.startsWith("[") || keyPart.endsWith("]")) && physicalCodeOf(keyPart) === void 0) errors.push("Invalid physical key: use a bracketed KeyboardEvent.code, such as [KeyS]");
65
+ if (!isKnownKey(normalizeKeyName(keyPart))) warnings.push(`Unknown key: '${keyPart}'. This may still work but won't have type-safe autocomplete.`);
66
66
  return {
67
67
  valid: errors.length === 0,
68
68
  warnings,
69
69
  errors
70
70
  };
71
71
  }
72
- /**
73
- * Checks if a key is in the known keys set.
74
- */
72
+ /** Physical codes, single characters, and named keys are known. */
75
73
  function isKnownKey(key) {
76
- if (/^\[[A-Za-z][A-Za-z0-9]*\]$/.test(key)) return true;
77
- if (Array.from(key).length === 1) return true;
78
- if (ALL_KEYS.has(key)) return true;
79
- if (key.length === 1 && ALL_KEYS.has(key.toUpperCase())) return true;
80
- return key in {
81
- Esc: true,
82
- Return: true,
83
- Space: true,
84
- " ": true,
85
- Del: true,
86
- Up: true,
87
- Down: true,
88
- Left: true,
89
- Right: true
90
- };
74
+ return physicalCodeOf(key) !== void 0 || Array.from(key).length === 1 || ALL_KEYS.has(key);
91
75
  }
92
76
  /**
93
77
  * Validates a hotkey and throws an error if invalid.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/hotkeys",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "description": "Type-safe, framework-agnostic keyboard hotkey management for the browser",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -1,34 +0,0 @@
1
- import { Hotkey, ParsedHotkey } from "./hotkey.types.js";
2
- import { HotkeyConflict, HotkeyConflictOptions } from "./conflicts.js";
3
- //#region src/recorder-options.d.ts
4
- /** Event property used to record a binding. Physical codes are the default. */
5
- export type RecorderKeyMode = 'code' | 'key';
6
- export interface RecorderRejection {
7
- reason: 'missing-code' | 'alt-graph' | 'invalid' | 'conflict' | 'validation';
8
- message: string;
9
- /** The rejected chord or sequence, when one was recorded. */
10
- hotkey?: Hotkey;
11
- conflicts?: Array<HotkeyConflict>;
12
- }
13
- export interface RecorderOptions {
14
- /** Physical code recording is the default. Choose key to record logical characters. */
15
- recordBy?: RecorderKeyMode;
16
- /** Platform used for Mod and AltGraph handling. Defaults to platform detection. */
17
- platform?: 'mac' | 'windows' | 'linux';
18
- /** Opt in to live-registry conflicts, including sequence prefixes. Default: false. */
19
- detectConflicts?: boolean | HotkeyConflictOptions;
20
- /** Rejected candidates leave recording active. */
21
- onReject?: (rejection: RecorderRejection) => void;
22
- }
23
- /** Validation context for a recorded chord or sequence. */
24
- export interface HotkeyRecorderValidationContext {
25
- /** The event that recorded the last chord. */
26
- event: KeyboardEvent;
27
- /** The last chord, parsed. */
28
- parsedHotkey: ParsedHotkey;
29
- /** The event that recorded each step. */
30
- events: ReadonlyArray<KeyboardEvent>;
31
- /** Every step, parsed. */
32
- parsedSequence: ReadonlyArray<ParsedHotkey>;
33
- }
34
- //#endregion