@tanstack/hotkeys 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +15 -1
  2. package/dist/{manager.utils.js → _event-target.js} +2 -41
  3. package/dist/_keyboard-event.js +75 -0
  4. package/dist/_match.js +48 -0
  5. package/dist/_named-keys.d.ts +6 -0
  6. package/dist/_named-keys.js +65 -0
  7. package/dist/_recorder-chord.js +42 -0
  8. package/dist/_recording-guard.js +55 -0
  9. package/dist/_registration.js +49 -0
  10. package/dist/conflicts.d.ts +36 -0
  11. package/dist/conflicts.js +54 -0
  12. package/dist/constants.d.ts +16 -144
  13. package/dist/constants.js +55 -172
  14. package/dist/display-labels.d.ts +90 -0
  15. package/dist/display-labels.js +112 -0
  16. package/dist/format.d.ts +48 -12
  17. package/dist/format.js +35 -39
  18. package/dist/hint.d.ts +20 -0
  19. package/dist/hint.js +23 -0
  20. package/dist/hotkey-manager.d.ts +14 -12
  21. package/dist/hotkey-manager.js +70 -21
  22. package/dist/hotkey-recorder.d.ts +8 -8
  23. package/dist/hotkey-recorder.js +74 -15
  24. package/dist/hotkey-sequence-recorder.d.ts +13 -9
  25. package/dist/hotkey-sequence-recorder.js +90 -28
  26. package/dist/{hotkey.d.ts → hotkey.types.d.ts} +58 -126
  27. package/dist/index.d.ts +14 -9
  28. package/dist/index.js +10 -6
  29. package/dist/key-state-tracker.d.ts +4 -7
  30. package/dist/key-state-tracker.js +30 -27
  31. package/dist/key.types.d.ts +79 -0
  32. package/dist/match.d.ts +24 -11
  33. package/dist/match.js +19 -30
  34. package/dist/parse.d.ts +18 -15
  35. package/dist/parse.js +39 -22
  36. package/dist/platform.d.ts +41 -0
  37. package/dist/platform.js +52 -0
  38. package/dist/recorder-options.d.ts +31 -0
  39. package/dist/sequence-manager.d.ts +27 -15
  40. package/dist/sequence-manager.js +61 -30
  41. package/dist/validate.d.ts +16 -8
  42. package/dist/validate.js +8 -13
  43. package/package.json +8 -13
  44. package/dist/constants.cjs +0 -535
  45. package/dist/constants.cjs.map +0 -1
  46. package/dist/constants.d.cts +0 -290
  47. package/dist/constants.js.map +0 -1
  48. package/dist/format.cjs +0 -91
  49. package/dist/format.cjs.map +0 -1
  50. package/dist/format.d.cts +0 -60
  51. package/dist/format.js.map +0 -1
  52. package/dist/hotkey-manager.cjs +0 -383
  53. package/dist/hotkey-manager.cjs.map +0 -1
  54. package/dist/hotkey-manager.d.cts +0 -230
  55. package/dist/hotkey-manager.js.map +0 -1
  56. package/dist/hotkey-recorder.cjs +0 -179
  57. package/dist/hotkey-recorder.cjs.map +0 -1
  58. package/dist/hotkey-recorder.d.cts +0 -116
  59. package/dist/hotkey-recorder.js.map +0 -1
  60. package/dist/hotkey-sequence-recorder.cjs +0 -192
  61. package/dist/hotkey-sequence-recorder.cjs.map +0 -1
  62. package/dist/hotkey-sequence-recorder.d.cts +0 -79
  63. package/dist/hotkey-sequence-recorder.js.map +0 -1
  64. package/dist/hotkey.d.cts +0 -301
  65. package/dist/index.cjs +0 -63
  66. package/dist/index.d.cts +0 -13
  67. package/dist/key-state-tracker.cjs +0 -197
  68. package/dist/key-state-tracker.cjs.map +0 -1
  69. package/dist/key-state-tracker.d.cts +0 -107
  70. package/dist/key-state-tracker.js.map +0 -1
  71. package/dist/manager.utils.cjs +0 -115
  72. package/dist/manager.utils.cjs.map +0 -1
  73. package/dist/manager.utils.d.cts +0 -13
  74. package/dist/manager.utils.d.ts +0 -13
  75. package/dist/manager.utils.js.map +0 -1
  76. package/dist/match.cjs +0 -153
  77. package/dist/match.cjs.map +0 -1
  78. package/dist/match.d.cts +0 -85
  79. package/dist/match.js.map +0 -1
  80. package/dist/parse.cjs +0 -228
  81. package/dist/parse.cjs.map +0 -1
  82. package/dist/parse.d.cts +0 -128
  83. package/dist/parse.js.map +0 -1
  84. package/dist/recorder-chord.cjs +0 -18
  85. package/dist/recorder-chord.cjs.map +0 -1
  86. package/dist/recorder-chord.js +0 -18
  87. package/dist/recorder-chord.js.map +0 -1
  88. package/dist/sequence-manager.cjs +0 -415
  89. package/dist/sequence-manager.cjs.map +0 -1
  90. package/dist/sequence-manager.d.cts +0 -157
  91. package/dist/sequence-manager.js.map +0 -1
  92. package/dist/validate.cjs +0 -116
  93. package/dist/validate.cjs.map +0 -1
  94. package/dist/validate.d.cts +0 -56
  95. package/dist/validate.js.map +0 -1
  96. package/src/constants.ts +0 -603
  97. package/src/format.ts +0 -142
  98. package/src/hotkey-manager.ts +0 -759
  99. package/src/hotkey-recorder.ts +0 -272
  100. package/src/hotkey-sequence-recorder.ts +0 -328
  101. package/src/hotkey.ts +0 -436
  102. package/src/index.ts +0 -11
  103. package/src/key-state-tracker.ts +0 -249
  104. package/src/manager.utils.ts +0 -231
  105. package/src/match.ts +0 -259
  106. package/src/parse.ts +0 -328
  107. package/src/recorder-chord.ts +0 -25
  108. package/src/sequence-manager.ts +0 -734
  109. package/src/validate.ts +0 -171
@@ -1,8 +1,13 @@
1
- import { detectPlatform, normalizeKeyName } from "./constants.js";
1
+ import { normalizeKeyName } from "./constants.js";
2
+ import { detectPlatform } from "./platform.js";
3
+ import { normalizeKeyboardEvent } from "./_keyboard-event.js";
2
4
  import { isModifierKey, parseHotkey } from "./parse.js";
3
5
  import { formatHotkeySequence } from "./format.js";
4
- import { matchesKeyboardEvent } from "./match.js";
5
- import { defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, isEventForTarget, shouldIgnoreInputEvent } from "./manager.utils.js";
6
+ import { matchKeyboardEvent } from "./_match.js";
7
+ import { areHotkeysEqual, matchesKeyboardEvent } from "./match.js";
8
+ import { isRecordingEvent } from "./_recording-guard.js";
9
+ import { defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
10
+ import { isEventForTarget, shouldIgnoreInputEvent } from "./_event-target.js";
6
11
  import { Store } from "@tanstack/store";
7
12
 
8
13
  //#region src/sequence-manager.ts
@@ -19,12 +24,6 @@ function generateSequenceId() {
19
24
  return `sequence_${++sequenceIdCounter}`;
20
25
  }
21
26
  /**
22
- * Returns a canonical string for sequence conflict comparison.
23
- */
24
- function sequenceKey(sequence) {
25
- return sequence.join("|");
26
- }
27
- /**
28
27
  * Manages keyboard sequence matching for Vim-style shortcuts.
29
28
  *
30
29
  * This class allows registering multi-key sequences like 'g g' or 'd d'
@@ -68,12 +67,16 @@ function toRegistrationView(reg) {
68
67
  }
69
68
  var SequenceManager = class SequenceManager {
70
69
  static #instance = null;
70
+ /**
71
+ * The TanStack Store containing sequence registration views for devtools.
72
+ * Subscribe to this to observe registration changes.
73
+ */
74
+ registrations = new Store(/* @__PURE__ */ new Map());
71
75
  #registrations = /* @__PURE__ */ new Map();
72
76
  #targetListeners = /* @__PURE__ */ new Map();
73
77
  #targetRegistrations = /* @__PURE__ */ new Map();
74
78
  #platform;
75
79
  constructor() {
76
- this.registrations = new Store(/* @__PURE__ */ new Map());
77
80
  this.#platform = detectPlatform();
78
81
  }
79
82
  /**
@@ -110,7 +113,7 @@ var SequenceManager = class SequenceManager {
110
113
  const parsedSequence = sequence.map((hotkey) => parseHotkey(hotkey, platform));
111
114
  const target = options.target ?? (typeof document !== "undefined" ? document : {});
112
115
  const conflictBehavior = options.conflictBehavior ?? "warn";
113
- const conflictingRegistration = this.#findConflictingSequence(sequence, target);
116
+ const conflictingRegistration = this.#findConflictingSequence(parsedSequence, target);
114
117
  if (conflictingRegistration) handleConflict(conflictingRegistration.id, formatHotkeySequence(sequence), conflictBehavior, (regId) => this.#unregister(regId));
115
118
  const firstStep = parsedSequence[0];
116
119
  const resolvedIgnoreInputs = options.ignoreInputs ?? getDefaultIgnoreInputs(firstStep);
@@ -154,7 +157,10 @@ var SequenceManager = class SequenceManager {
154
157
  },
155
158
  setOptions: (newOptions) => {
156
159
  const reg = manager.#registrations.get(id);
157
- if (reg) {
160
+ if (reg && !optionsEqual(reg.options, {
161
+ ...reg.options,
162
+ ...newOptions
163
+ })) {
158
164
  reg.options = {
159
165
  ...reg.options,
160
166
  ...newOptions
@@ -233,10 +239,23 @@ var SequenceManager = class SequenceManager {
233
239
  * Processes keyboard events for a specific target and event type.
234
240
  */
235
241
  #processTargetEvent(event, target, eventType) {
242
+ if (isRecordingEvent(event)) {
243
+ for (const id of this.#targetRegistrations.get(target) ?? []) {
244
+ const registration = this.#registrations.get(id);
245
+ if (registration && registration.currentIndex > 0) {
246
+ registration.currentIndex = 0;
247
+ this.#syncRegistrationView(registration);
248
+ }
249
+ }
250
+ return;
251
+ }
252
+ if (event.type === "keydown" && event.repeat || normalizeKeyboardEvent(event).isComposing) return;
236
253
  if (isModifierKey(normalizeKeyName(event.key))) return;
237
254
  const targetRegs = this.#targetRegistrations.get(target);
238
255
  if (!targetRegs) return;
239
256
  const now = Date.now();
257
+ const candidates = /* @__PURE__ */ new Map();
258
+ let bestScore = 0;
240
259
  registrationIds: for (const id of targetRegs) {
241
260
  const registration = this.#registrations.get(id);
242
261
  if (!registration) continue;
@@ -253,9 +272,24 @@ var SequenceManager = class SequenceManager {
253
272
  }
254
273
  const expectedHotkey = registration.parsedSequence[registration.currentIndex];
255
274
  if (!expectedHotkey) continue;
256
- if (matchesKeyboardEvent(event, expectedHotkey, registration.options.platform)) {
275
+ let match = matchKeyboardEvent(event, expectedHotkey, registration.options.platform);
276
+ let nextIndex = registration.currentIndex + 1;
277
+ if (!match.matched && registration.currentIndex > 0) {
278
+ match = matchKeyboardEvent(event, registration.parsedSequence[0], registration.options.platform);
279
+ nextIndex = 1;
280
+ }
281
+ candidates.set(id, {
282
+ score: match.score,
283
+ nextIndex
284
+ });
285
+ bestScore = Math.max(bestScore, match.score);
286
+ }
287
+ for (const [id, candidate] of candidates) {
288
+ const registration = this.#registrations.get(id);
289
+ if (!registration || !registration.options.enabled) continue;
290
+ if (candidate.score > 0 && candidate.score === bestScore) {
257
291
  registration.lastKeyTime = now;
258
- registration.currentIndex++;
292
+ registration.currentIndex = candidate.nextIndex;
259
293
  if (registration.currentIndex >= registration.parsedSequence.length) {
260
294
  if (registration.options.preventDefault) event.preventDefault();
261
295
  if (registration.options.stopPropagation) event.stopPropagation();
@@ -270,21 +304,16 @@ var SequenceManager = class SequenceManager {
270
304
  registration.callback(event, context);
271
305
  } else this.#syncRegistrationView(registration);
272
306
  } else if (registration.currentIndex > 0) {
273
- const firstHotkey = registration.parsedSequence[0];
274
- if (matchesKeyboardEvent(event, firstHotkey, registration.options.platform)) {
275
- registration.currentIndex = 1;
276
- registration.lastKeyTime = now;
277
- } else registration.currentIndex = 0;
307
+ registration.currentIndex = 0;
278
308
  this.#syncRegistrationView(registration);
279
309
  }
280
310
  }
281
311
  }
282
312
  /**
283
- * Finds an existing registration with the same sequence and target.
313
+ * Compares resolved step identities without changing the supplied sequence strings.
284
314
  */
285
- #findConflictingSequence(sequence, target) {
286
- const key = sequenceKey(sequence);
287
- for (const registration of this.#registrations.values()) if (sequenceKey(registration.sequence) === key && registration.target === target) return registration;
315
+ #findConflictingSequence(parsedSequence, target) {
316
+ for (const registration of this.#registrations.values()) if (registration.target === target && registration.parsedSequence.length === parsedSequence.length && parsedSequence.every((step, index) => areHotkeysEqual(step, registration.parsedSequence[index]))) return registration;
288
317
  return null;
289
318
  }
290
319
  /**
@@ -310,7 +339,7 @@ var SequenceManager = class SequenceManager {
310
339
  const lastParsed = registration.parsedSequence[registration.parsedSequence.length - 1];
311
340
  if (!lastParsed) return false;
312
341
  const syntheticEvent = new KeyboardEvent(registration.options.eventType ?? "keydown", {
313
- key: lastParsed.key,
342
+ ...lastParsed.code !== void 0 ? { code: lastParsed.code } : { key: lastParsed.key },
314
343
  ctrlKey: lastParsed.ctrl,
315
344
  shiftKey: lastParsed.shift,
316
345
  altKey: lastParsed.alt,
@@ -380,6 +409,7 @@ function createSequenceMatcher(sequence, options = {}) {
380
409
  let lastKeyTime = 0;
381
410
  return {
382
411
  match(event) {
412
+ if (event.type === "keydown" && event.repeat || normalizeKeyboardEvent(event, platform).isComposing || isModifierKey(normalizeKeyName(event.key))) return false;
383
413
  const now = Date.now();
384
414
  if (currentIndex > 0 && now - lastKeyTime > timeout) currentIndex = 0;
385
415
  const expected = parsedSequence[currentIndex];
@@ -391,10 +421,12 @@ function createSequenceMatcher(sequence, options = {}) {
391
421
  currentIndex = 0;
392
422
  return true;
393
423
  }
394
- } else if (currentIndex > 0) if (matchesKeyboardEvent(event, parsedSequence[0], platform)) {
395
- currentIndex = 1;
396
- lastKeyTime = now;
397
- } else currentIndex = 0;
424
+ } else if (currentIndex > 0) {
425
+ if (matchesKeyboardEvent(event, parsedSequence[0], platform)) {
426
+ currentIndex = 1;
427
+ lastKeyTime = now;
428
+ } else currentIndex = 0;
429
+ }
398
430
  return false;
399
431
  },
400
432
  reset() {
@@ -408,5 +440,4 @@ function createSequenceMatcher(sequence, options = {}) {
408
440
  }
409
441
 
410
442
  //#endregion
411
- export { DEFAULT_SEQUENCE_TIMEOUT, SequenceManager, createSequenceMatcher, getSequenceManager };
412
- //# sourceMappingURL=sequence-manager.js.map
443
+ export { DEFAULT_SEQUENCE_TIMEOUT, SequenceManager, createSequenceMatcher, getSequenceManager };
@@ -1,5 +1,4 @@
1
- import { Hotkey, ValidationResult } from "./hotkey.js";
2
-
1
+ import { Hotkey } from "./hotkey.types.js";
3
2
  //#region src/validate.d.ts
4
3
  /**
5
4
  * Validates a hotkey string and returns any warnings or errors.
@@ -21,7 +20,7 @@ import { Hotkey, ValidationResult } from "./hotkey.js";
21
20
  * // { valid: false, warnings: [], errors: ['Hotkey cannot be empty'] }
22
21
  * ```
23
22
  */
24
- declare function validateHotkey(hotkey: Hotkey | (string & {})): ValidationResult;
23
+ export declare function validateHotkey(hotkey: Hotkey | (string & {})): ValidationResult;
25
24
  /**
26
25
  * Validates a hotkey and throws an error if invalid.
27
26
  * Useful for development-time validation.
@@ -35,7 +34,7 @@ declare function validateHotkey(hotkey: Hotkey | (string & {})): ValidationResul
35
34
  * assertValidHotkey('') // Throws Error: Invalid hotkey: Hotkey cannot be empty
36
35
  * ```
37
36
  */
38
- declare function assertValidHotkey(hotkey: Hotkey | (string & {})): void;
37
+ export declare function assertValidHotkey(hotkey: Hotkey | (string & {})): void;
39
38
  /**
40
39
  * Validates a hotkey and logs warnings to the console.
41
40
  * Useful for development-time feedback.
@@ -50,7 +49,16 @@ declare function assertValidHotkey(hotkey: Hotkey | (string & {})): void;
50
49
  * // Returns: true
51
50
  * ```
52
51
  */
53
- declare function checkHotkey(hotkey: Hotkey | (string & {})): boolean;
54
- //#endregion
55
- export { assertValidHotkey, checkHotkey, validateHotkey };
56
- //# sourceMappingURL=validate.d.ts.map
52
+ export declare function checkHotkey(hotkey: Hotkey | (string & {})): boolean;
53
+ /**
54
+ * Result of validating a hotkey string.
55
+ */
56
+ export interface ValidationResult {
57
+ /** Whether the hotkey is valid (can still have warnings) */
58
+ valid: boolean;
59
+ /** Warning messages about potential issues */
60
+ warnings: Array<string>;
61
+ /** Error messages about invalid syntax */
62
+ errors: Array<string>;
63
+ }
64
+ //#endregion
package/dist/validate.js CHANGED
@@ -1,4 +1,5 @@
1
- import { ALL_KEYS, MODIFIER_ALIASES } from "./constants.js";
1
+ import { ALL_KEYS, MODIFIER_ALIASES, normalizeKeyName } from "./constants.js";
2
+ import { splitHotkeyParts } from "./_keyboard-event.js";
2
3
 
3
4
  //#region src/validate.ts
4
5
  /**
@@ -29,7 +30,7 @@ function validateHotkey(hotkey) {
29
30
  warnings: [],
30
31
  errors: ["Hotkey cannot be empty"]
31
32
  };
32
- const parts = hotkey.split("+").map((p) => p.trim());
33
+ const parts = splitHotkeyParts(hotkey).map((p) => p.trim());
33
34
  if (parts.length === 0 || parts.some((p) => p === "")) return {
34
35
  valid: false,
35
36
  warnings: [],
@@ -38,7 +39,8 @@ function validateHotkey(hotkey) {
38
39
  const modifierParts = parts.slice(0, -1);
39
40
  const keyPart = parts[parts.length - 1];
40
41
  for (const modifier of modifierParts) if (!(MODIFIER_ALIASES[modifier] ?? MODIFIER_ALIASES[modifier.toLowerCase()])) errors.push(`Unknown modifier: '${modifier}'`);
41
- if (!isKnownKey(normalizeKeyForValidation(keyPart)) && !isKnownKey(keyPart)) warnings.push(`Unknown key: '${keyPart}'. This may still work but won't have type-safe autocomplete.`);
42
+ 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]");
43
+ if (!isKnownKey(normalizeKeyName(keyPart)) && !isKnownKey(keyPart)) warnings.push(`Unknown key: '${keyPart}'. This may still work but won't have type-safe autocomplete.`);
42
44
  return {
43
45
  valid: errors.length === 0,
44
46
  warnings,
@@ -46,17 +48,11 @@ function validateHotkey(hotkey) {
46
48
  };
47
49
  }
48
50
  /**
49
- * Normalizes a key for validation checking.
50
- */
51
- function normalizeKeyForValidation(key) {
52
- if (key.length === 1 && /^[a-zA-Z]$/.test(key)) return key.toUpperCase();
53
- if (/^f([1-9]|1[0-2])$/i.test(key)) return key.toUpperCase();
54
- return key;
55
- }
56
- /**
57
51
  * Checks if a key is in the known keys set.
58
52
  */
59
53
  function isKnownKey(key) {
54
+ if (/^\[[A-Za-z][A-Za-z0-9]*\]$/.test(key)) return true;
55
+ if (Array.from(key).length === 1) return true;
60
56
  if (ALL_KEYS.has(key)) return true;
61
57
  if (key.length === 1 && ALL_KEYS.has(key.toUpperCase())) return true;
62
58
  return key in {
@@ -110,5 +106,4 @@ function checkHotkey(hotkey) {
110
106
  }
111
107
 
112
108
  //#endregion
113
- export { assertValidHotkey, checkHotkey, validateHotkey };
114
- //# sourceMappingURL=validate.js.map
109
+ export { assertValidHotkey, checkHotkey, validateHotkey };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/hotkeys",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Type-safe, framework-agnostic keyboard hotkey management for the browser",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -24,26 +24,20 @@
24
24
  "typescript"
25
25
  ],
26
26
  "type": "module",
27
- "main": "./dist/index.cjs",
28
- "module": "./dist/index.js",
29
- "types": "./dist/index.d.cts",
27
+ "types": "./dist/index.d.ts",
30
28
  "exports": {
31
- ".": {
32
- "import": "./dist/index.js",
33
- "require": "./dist/index.cjs"
34
- },
29
+ ".": "./dist/index.js",
35
30
  "./package.json": "./package.json"
36
31
  },
37
32
  "sideEffects": false,
38
33
  "engines": {
39
- "node": ">=18"
34
+ "node": ">=20"
40
35
  },
41
36
  "files": [
42
- "dist/",
43
- "src"
37
+ "dist"
44
38
  ],
45
39
  "dependencies": {
46
- "@tanstack/store": "^0.11.0"
40
+ "@tanstack/store": "^0.11.1"
47
41
  },
48
42
  "scripts": {
49
43
  "clean": "premove ./build ./dist",
@@ -53,6 +47,7 @@
53
47
  "test:lib": "vitest",
54
48
  "test:lib:dev": "pnpm test:lib --watch",
55
49
  "test:types": "tsc",
56
- "build": "tsdown"
50
+ "build": "tsdown",
51
+ "test:build": "publint --strict && node ../../scripts/verify-package.ts"
57
52
  }
58
53
  }