@tanstack/hotkeys 0.15.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 };
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
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.
@@ -136,17 +136,14 @@ var HotkeyManager = class HotkeyManager {
136
136
  * Gets the singleton instance of HotkeyManager.
137
137
  */
138
138
  static getInstance() {
139
- if (!HotkeyManager.#instance) HotkeyManager.#instance = new HotkeyManager();
140
- return HotkeyManager.#instance;
139
+ return managerRef.current ??= new HotkeyManager();
141
140
  }
142
141
  /**
143
142
  * Resets the singleton instance. Useful for testing.
144
143
  */
145
144
  static resetInstance() {
146
- if (HotkeyManager.#instance) {
147
- HotkeyManager.#instance.destroy();
148
- HotkeyManager.#instance = null;
149
- }
145
+ managerRef.current?.destroy();
146
+ managerRef.current = null;
150
147
  }
151
148
  /** Notifies subscribers after in-place updates such as counts and sequence progress. */
152
149
  #notify() {
@@ -175,44 +172,37 @@ var HotkeyManager = class HotkeyManager {
175
172
  const id = generateId();
176
173
  const platform = options.platform ?? this.#platform;
177
174
  const target = options.target ?? document;
178
- let registration;
179
- if (isHotkeySequence(hotkey)) registration = {
180
- kind: "sequence",
175
+ const sequence = isHotkeySequence(hotkey);
176
+ const common = {
181
177
  id,
182
- hotkey,
183
- parsedSequence: hotkey.map((step) => parseHotkey(step, platform)),
184
178
  callback,
185
179
  options: {
186
180
  ...defaultHotkeyOptions,
187
- timeout: DEFAULT_SEQUENCE_TIMEOUT,
188
- ...optionsForKind("sequence", options),
181
+ ...sequence ? { timeout: DEFAULT_SEQUENCE_TIMEOUT } : { requireReset: false },
182
+ ...optionsForKind(sequence ? "sequence" : "chord", options),
189
183
  platform,
190
184
  ignoreInputs: options.ignoreInputs ?? "auto"
191
185
  },
192
186
  target,
193
- currentIndex: 0,
194
- lastKeyTime: 0,
195
187
  triggerCount: 0,
196
188
  hasFired: false
197
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
+ };
198
199
  else {
199
200
  const parsedHotkey = typeof hotkey === "string" ? parseHotkey(hotkey, platform) : rawHotkeyToParsedHotkey(hotkey, platform);
200
201
  registration = {
202
+ ...common,
201
203
  kind: "chord",
202
- id,
203
204
  hotkey: typeof hotkey === "string" ? hotkey : formatHotkey(parsedHotkey),
204
- parsedHotkey,
205
- callback,
206
- options: {
207
- ...defaultHotkeyOptions,
208
- requireReset: false,
209
- ...optionsForKind("chord", options),
210
- platform,
211
- ignoreInputs: options.ignoreInputs ?? "auto"
212
- },
213
- target,
214
- hasFired: false,
215
- triggerCount: 0
205
+ parsedHotkey
216
206
  };
217
207
  }
218
208
  const conflicting = this.#findConflictingRegistration(registration);
@@ -320,18 +310,17 @@ var HotkeyManager = class HotkeyManager {
320
310
  if (!match?.matched || match.score !== bestScore) continue;
321
311
  const registration = this.#currentIn(snapshot, capture);
322
312
  if (!registration) continue;
313
+ this.#consume(registration, event);
323
314
  if (eventType === "keyup") {
324
- this.#executeCallback(registration, event);
315
+ this.#fire(registration, event);
325
316
  continue;
326
317
  }
327
- if (registration.options.preventDefault) event.preventDefault();
328
- if (registration.options.stopPropagation) event.stopPropagation();
329
318
  if (!registration.options.requireReset || !registration.hasFired) {
330
319
  if (registration.options.requireReset) {
331
320
  registration.hasFired = true;
332
321
  registration.activeMatch = match.identity;
333
322
  }
334
- this.#executeCallback(registration, event);
323
+ this.#fire(registration, event);
335
324
  }
336
325
  }
337
326
  }
@@ -410,8 +399,7 @@ var HotkeyManager = class HotkeyManager {
410
399
  registration.currentIndex = candidate.nextIndex;
411
400
  changed = true;
412
401
  if (registration.currentIndex >= registration.parsedSequence.length) {
413
- if (registration.options.preventDefault) event.preventDefault();
414
- if (registration.options.stopPropagation) event.stopPropagation();
402
+ this.#consume(registration, event);
415
403
  registration.currentIndex = 0;
416
404
  completed.push(registration);
417
405
  }
@@ -423,19 +411,20 @@ var HotkeyManager = class HotkeyManager {
423
411
  if (changed && completed.length === 0) this.#notify();
424
412
  for (const snapshot of completed) {
425
413
  const registration = this.#currentIn(snapshot, capture);
426
- if (registration) this.#executeCallback(registration, event);
414
+ if (registration) this.#fire(registration, event);
427
415
  }
428
416
  }
429
- /**
430
- * Executes a hotkey callback with proper event handling.
431
- */
432
- #executeCallback(registration, event) {
417
+ /** Applies a matched registration's preventDefault and stopPropagation options. */
418
+ #consume(registration, event) {
433
419
  if (registration.options.preventDefault) event.preventDefault();
434
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)) {
435
424
  registration.triggerCount++;
436
425
  if (registration.kind === "sequence") registration.hasFired = true;
437
426
  this.#notify();
438
- registration.callback(event, this.#callbackContext(registration));
427
+ registration.callback(event, context);
439
428
  }
440
429
  #callbackContext(registration) {
441
430
  return {
@@ -460,17 +449,7 @@ var HotkeyManager = class HotkeyManager {
460
449
  const parsed = registration.parsedHotkey;
461
450
  const releasedKey = normalizeKeyName(event.key);
462
451
  if (registration.activeMatch?.code && event.code === registration.activeMatch.code) return true;
463
- if (parsed.code !== void 0) {
464
- if (event.code === parsed.code) return true;
465
- } else {
466
- const parsedKeyNormalized = parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key;
467
- if ((releasedKey.length === 1 ? releasedKey.toUpperCase() : releasedKey) === parsedKeyNormalized) return true;
468
- }
469
- if (parsed.ctrl && releasedKey === "Control") return true;
470
- if (parsed.shift && releasedKey === "Shift") return true;
471
- if (parsed.alt && releasedKey === "Alt") return true;
472
- if (parsed.meta && releasedKey === "Meta") return true;
473
- return false;
452
+ return (parsed.code !== void 0 ? event.code === parsed.code : keysEqual(releasedKey, parsed.key)) || parsed.modifiers.includes(releasedKey);
474
453
  }
475
454
  /**
476
455
  * Resets the progress of every in-progress sequence.
@@ -504,10 +483,7 @@ var HotkeyManager = class HotkeyManager {
504
483
  bubbles: true,
505
484
  cancelable: true
506
485
  });
507
- registration.triggerCount++;
508
- if (registration.kind === "sequence") registration.hasFired = true;
509
- this.#notify();
510
- registration.callback(syntheticEvent, context);
486
+ this.#fire(registration, syntheticEvent, context);
511
487
  return true;
512
488
  }
513
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.15.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