@tanstack/hotkeys 0.24.0 → 0.25.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 (53) hide show
  1. package/dist/_app-hotkeys-factory.d.ts +1 -3
  2. package/dist/_app-hotkeys-factory.js +2 -3
  3. package/dist/_event-target.js +25 -15
  4. package/dist/_keyboard-event.js +8 -5
  5. package/dist/_manager-ref.js +1 -2
  6. package/dist/_match.js +5 -3
  7. package/dist/_named-keys.d.ts +1 -3
  8. package/dist/_named-keys.js +1 -2
  9. package/dist/_recorder-chord.js +7 -13
  10. package/dist/_recording-guard.js +1 -2
  11. package/dist/_registration.d.ts +1 -3
  12. package/dist/_registration.js +11 -6
  13. package/dist/_server.js +2 -3
  14. package/dist/_store.d.ts +1 -3
  15. package/dist/_store.js +1 -2
  16. package/dist/_target-listeners.js +1 -2
  17. package/dist/adapter.d.ts +3 -5
  18. package/dist/adapter.js +2 -3
  19. package/dist/app-hotkeys.d.ts +4 -5
  20. package/dist/app-hotkeys.js +10 -21
  21. package/dist/conflicts.d.ts +2 -4
  22. package/dist/conflicts.js +5 -6
  23. package/dist/constants.d.ts +2 -4
  24. package/dist/constants.js +1 -2
  25. package/dist/display-labels.d.ts +1 -3
  26. package/dist/display-labels.js +1 -2
  27. package/dist/format.d.ts +2 -4
  28. package/dist/format.js +4 -5
  29. package/dist/hint.d.ts +1 -3
  30. package/dist/hint.js +1 -2
  31. package/dist/hotkey-manager.d.ts +1 -3
  32. package/dist/hotkey-manager.js +12 -13
  33. package/dist/hotkey-recorder.d.ts +19 -7
  34. package/dist/hotkey-recorder.js +8 -11
  35. package/dist/hotkey.types.d.ts +1 -3
  36. package/dist/index.d.ts +8 -8
  37. package/dist/index.js +3 -3
  38. package/dist/key-state-tracker.d.ts +1 -3
  39. package/dist/key-state-tracker.js +2 -3
  40. package/dist/key.types.d.ts +5 -6
  41. package/dist/match.d.ts +4 -5
  42. package/dist/match.js +7 -7
  43. package/dist/parse.d.ts +2 -3
  44. package/dist/parse.js +10 -19
  45. package/dist/platform.d.ts +1 -3
  46. package/dist/platform.js +1 -2
  47. package/dist/validate.d.ts +33 -13
  48. package/dist/validate.js +51 -34
  49. package/dist/vendor/@tanstack/store/dist/store.d.ts +0 -2
  50. package/dist/vendor/@tanstack/store/dist/types.d.ts +0 -2
  51. package/package.json +4 -4
  52. package/skills/app-hotkeys/SKILL.md +1 -1
  53. package/skills/hotkeys-core/SKILL.md +1 -1
@@ -1,14 +1,15 @@
1
1
  import { Hotkey } from "./hotkey.types.js";
2
- //#region src/validate.d.ts
3
2
  /** A hotkey to validate: a chord string, a sequence, or untyped input such as saved data. */
4
3
  export type ValidatableHotkey = Hotkey | (string & {}) | ReadonlyArray<string>;
5
4
  /**
6
5
  * Validates a hotkey and returns any warnings or errors.
7
- * Sequences are validated step by step, and messages name the failing step.
6
+ * Each issue has a `reason` code, so apps can write their own messages.
7
+ * Issues in a sequence carry the index of their `step`.
8
8
  *
9
9
  * Checks for:
10
10
  * - Valid syntax (modifier+...+key format)
11
11
  * - Known modifiers
12
+ * - A key that isn't a modifier
12
13
  * - Known keys
13
14
  *
14
15
  * @param hotkey - The hotkey string to validate
@@ -20,10 +21,10 @@ export type ValidatableHotkey = Hotkey | (string & {}) | ReadonlyArray<string>;
20
21
  * // { valid: true, warnings: [], errors: [] }
21
22
  *
22
23
  * validateHotkey('')
23
- * // { valid: false, warnings: [], errors: ['Hotkey cannot be empty'] }
24
+ * // { valid: false, warnings: [], errors: [{ reason: 'empty' }] }
24
25
  *
25
26
  * validateHotkey(['G', 'Foo+H'])
26
- * // { valid: false, warnings: [], errors: ["Step 2: Unknown modifier: 'Foo'"] }
27
+ * // { valid: false, warnings: [], errors: [{ reason: 'unknown-modifier', part: 'Foo', step: 1 }] }
27
28
  * ```
28
29
  */
29
30
  export declare function validateHotkey(hotkey: ValidatableHotkey): ValidationResult;
@@ -37,7 +38,7 @@ export declare function validateHotkey(hotkey: ValidatableHotkey): ValidationRes
37
38
  * @example
38
39
  * ```ts
39
40
  * assertValidHotkey('Mod+S') // OK
40
- * assertValidHotkey('') // Throws Error: Invalid hotkey: Hotkey cannot be empty
41
+ * assertValidHotkey('') // Throws Error: [hotkeys] Invalid hotkey '': empty
41
42
  * ```
42
43
  */
43
44
  export declare function assertValidHotkey(hotkey: ValidatableHotkey): void;
@@ -51,24 +52,43 @@ export declare function assertValidHotkey(hotkey: ValidatableHotkey): void;
51
52
  * @example
52
53
  * ```ts
53
54
  * checkHotkey('Control+escape')
54
- * // Console: Hotkey 'Control+escape' warnings: Unknown key: 'escape'. Key names are case-sensitive, such as 'Escape' and 'F1'.
55
+ * // Console: [hotkeys] Hotkey 'Control+escape' may never fire: unknown-key 'escape'
55
56
  * // Returns: true
56
57
  *
57
58
  * checkHotkey('Ctl+K')
58
- * // Console: Hotkey 'Ctl+K' errors: Unknown modifier: 'Ctl'
59
+ * // Console: [hotkeys] Invalid hotkey 'Ctl+K': unknown-modifier 'Ctl'
59
60
  * // Returns: false
60
61
  * ```
61
62
  */
62
63
  export declare function checkHotkey(hotkey: ValidatableHotkey): boolean;
64
+ /**
65
+ * Why a hotkey is invalid or may never fire:
66
+ *
67
+ * - `'empty'`: the hotkey or sequence is empty.
68
+ * - `'empty-part'`: a part between `+` signs is empty or whitespace.
69
+ * - `'unknown-modifier'`: a modifier isn't one the library knows, such as `Ctl`.
70
+ * - `'invalid-physical-key'`: a bracketed key isn't a `KeyboardEvent.code`.
71
+ * - `'modifier-only'`: the key is a modifier, such as `Shift` or `[ShiftLeft]`.
72
+ * - `'unknown-key'` (warning): the key isn't written like a known key name.
73
+ * Key names are case-sensitive, so `escape` never matches `Escape`.
74
+ */
75
+ export type HotkeyIssueReason = 'empty' | 'empty-part' | 'unknown-modifier' | 'invalid-physical-key' | 'modifier-only' | 'unknown-key';
76
+ /** One problem found by `validateHotkey`. */
77
+ export interface HotkeyIssue {
78
+ reason: HotkeyIssueReason;
79
+ /** The part of the chord with the problem, such as `'Ctl'` in `'Ctl+K'`. */
80
+ part?: string;
81
+ /** For a sequence, the index of the step with the problem. */
82
+ step?: number;
83
+ }
63
84
  /**
64
85
  * Result of validating a hotkey string.
65
86
  */
66
87
  export interface ValidationResult {
67
88
  /** Whether the hotkey is valid (can still have warnings) */
68
89
  valid: boolean;
69
- /** Warning messages about potential issues */
70
- warnings: Array<string>;
71
- /** Error messages about invalid syntax */
72
- errors: Array<string>;
73
- }
74
- //#endregion
90
+ /** Problems that may stop the hotkey from firing, such as an unknown key */
91
+ warnings: Array<HotkeyIssue>;
92
+ /** Problems that stop the hotkey from registering */
93
+ errors: Array<HotkeyIssue>;
94
+ }
package/dist/validate.js CHANGED
@@ -1,13 +1,15 @@
1
- import { isKeyNameShape, modifierAlias, physicalCodeOf, splitChordParts } from "./_keyboard-event.js";
1
+ import { isKeyNameShape, isModifierCode, modifierAlias, physicalCodeOf, splitChordParts } from "./_keyboard-event.js";
2
2
 
3
- //#region src/validate.ts
3
+ // src/validate.ts
4
4
  /**
5
5
  * Validates a hotkey and returns any warnings or errors.
6
- * Sequences are validated step by step, and messages name the failing step.
6
+ * Each issue has a `reason` code, so apps can write their own messages.
7
+ * Issues in a sequence carry the index of their `step`.
7
8
  *
8
9
  * Checks for:
9
10
  * - Valid syntax (modifier+...+key format)
10
11
  * - Known modifiers
12
+ * - A key that isn't a modifier
11
13
  * - Known keys
12
14
  *
13
15
  * @param hotkey - The hotkey string to validate
@@ -19,55 +21,71 @@ import { isKeyNameShape, modifierAlias, physicalCodeOf, splitChordParts } from "
19
21
  * // { valid: true, warnings: [], errors: [] }
20
22
  *
21
23
  * validateHotkey('')
22
- * // { valid: false, warnings: [], errors: ['Hotkey cannot be empty'] }
24
+ * // { valid: false, warnings: [], errors: [{ reason: 'empty' }] }
23
25
  *
24
26
  * validateHotkey(['G', 'Foo+H'])
25
- * // { valid: false, warnings: [], errors: ["Step 2: Unknown modifier: 'Foo'"] }
27
+ * // { valid: false, warnings: [], errors: [{ reason: 'unknown-modifier', part: 'Foo', step: 1 }] }
26
28
  * ```
27
29
  */
28
30
  function validateHotkey(hotkey) {
29
31
  if (typeof hotkey !== "string") {
30
- if (hotkey.length === 0) return {
31
- valid: false,
32
- warnings: [],
33
- errors: ["Sequence cannot be empty"]
34
- };
32
+ if (hotkey.length === 0) return invalid("empty");
35
33
  if (hotkey.length === 1) return validateChord(hotkey[0]);
36
34
  const results = hotkey.map(validateChord);
37
- const prefixed = (key) => results.flatMap((result, i) => result[key].map((message) => `Step ${i + 1}: ${message}`));
35
+ const withStep = (key) => results.flatMap((result, step) => result[key].map((issue) => ({
36
+ ...issue,
37
+ step
38
+ })));
38
39
  return {
39
40
  valid: results.every((result) => result.valid),
40
- warnings: prefixed("warnings"),
41
- errors: prefixed("errors")
41
+ warnings: withStep("warnings"),
42
+ errors: withStep("errors")
42
43
  };
43
44
  }
44
45
  return validateChord(hotkey);
45
46
  }
46
- function validateChord(hotkey) {
47
- const warnings = [];
48
- const errors = [];
49
- if (!hotkey || hotkey.trim() === "") return {
47
+ function invalid(reason) {
48
+ return {
50
49
  valid: false,
51
50
  warnings: [],
52
- errors: ["Hotkey cannot be empty"]
51
+ errors: [{ reason }]
53
52
  };
53
+ }
54
+ function validateChord(hotkey) {
55
+ const warnings = [];
56
+ const errors = [];
57
+ if (!hotkey || hotkey.trim() === "") return invalid("empty");
54
58
  const parts = splitChordParts(hotkey).map((p) => p.trim());
55
- if (parts.length === 0 || parts.some((p) => p === "")) return {
56
- valid: false,
57
- warnings: [],
58
- errors: ["Invalid hotkey format: empty parts detected"]
59
- };
59
+ if (parts.length === 0 || parts.some((p) => p === "")) return invalid("empty-part");
60
60
  const modifierParts = parts.slice(0, -1);
61
61
  const keyPart = parts[parts.length - 1];
62
- for (const modifier of modifierParts) if (!modifierAlias(modifier)) errors.push(`Unknown modifier: '${modifier}'`);
63
- 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]");
64
- if (physicalCodeOf(keyPart) === void 0 && !isKeyNameShape(keyPart)) warnings.push(`Unknown key: '${keyPart}'. Key names are case-sensitive, such as 'Escape' and 'F1'.`);
62
+ for (const modifier of modifierParts) if (!modifierAlias(modifier)) errors.push({
63
+ reason: "unknown-modifier",
64
+ part: modifier
65
+ });
66
+ const code = physicalCodeOf(keyPart);
67
+ if (keyPart.length > 1 && (keyPart.startsWith("[") || keyPart.endsWith("]")) && code === void 0) errors.push({
68
+ reason: "invalid-physical-key",
69
+ part: keyPart
70
+ });
71
+ if (code !== void 0 ? isModifierCode(code) : modifierAlias(keyPart)) errors.push({
72
+ reason: "modifier-only",
73
+ part: keyPart
74
+ });
75
+ if (code === void 0 && !isKeyNameShape(keyPart)) warnings.push({
76
+ reason: "unknown-key",
77
+ part: keyPart
78
+ });
65
79
  return {
66
80
  valid: errors.length === 0,
67
81
  warnings,
68
82
  errors
69
83
  };
70
84
  }
85
+ /** Lists issues for a console message, such as `unknown-modifier 'Ctl'`. */
86
+ function describeIssues(issues) {
87
+ return issues.map(({ reason, part, step }) => (step === void 0 ? "" : `step ${step + 1} `) + reason + (part === void 0 ? "" : ` '${part}'`)).join(", ");
88
+ }
71
89
  /**
72
90
  * Validates a hotkey and throws an error if invalid.
73
91
  * Useful for development-time validation.
@@ -78,12 +96,12 @@ function validateChord(hotkey) {
78
96
  * @example
79
97
  * ```ts
80
98
  * assertValidHotkey('Mod+S') // OK
81
- * assertValidHotkey('') // Throws Error: Invalid hotkey: Hotkey cannot be empty
99
+ * assertValidHotkey('') // Throws Error: [hotkeys] Invalid hotkey '': empty
82
100
  * ```
83
101
  */
84
102
  function assertValidHotkey(hotkey) {
85
103
  const result = validateHotkey(hotkey);
86
- if (!result.valid) throw new Error(`Invalid hotkey '${String(hotkey)}': ${result.errors.join(", ")}`);
104
+ if (!result.valid) throw new Error(`[hotkeys] Invalid hotkey '${String(hotkey)}': ${describeIssues(result.errors)}`);
87
105
  }
88
106
  /**
89
107
  * Validates a hotkey and logs errors and warnings to the console.
@@ -95,22 +113,21 @@ function assertValidHotkey(hotkey) {
95
113
  * @example
96
114
  * ```ts
97
115
  * checkHotkey('Control+escape')
98
- * // Console: Hotkey 'Control+escape' warnings: Unknown key: 'escape'. Key names are case-sensitive, such as 'Escape' and 'F1'.
116
+ * // Console: [hotkeys] Hotkey 'Control+escape' may never fire: unknown-key 'escape'
99
117
  * // Returns: true
100
118
  *
101
119
  * checkHotkey('Ctl+K')
102
- * // Console: Hotkey 'Ctl+K' errors: Unknown modifier: 'Ctl'
120
+ * // Console: [hotkeys] Invalid hotkey 'Ctl+K': unknown-modifier 'Ctl'
103
121
  * // Returns: false
104
122
  * ```
105
123
  */
106
124
  function checkHotkey(hotkey) {
107
125
  const result = validateHotkey(hotkey);
108
- if (process.env.NODE_ENV !== "production") {
109
- if (result.errors.length > 0) console.error(`Hotkey '${String(hotkey)}' errors:`, result.errors.join("; "));
110
- if (result.warnings.length > 0) console.warn(`Hotkey '${String(hotkey)}' warnings:`, result.warnings.join("; "));
126
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
127
+ if (result.errors.length > 0) console.error(`[hotkeys] Invalid hotkey '${String(hotkey)}': ${describeIssues(result.errors)}`);
128
+ if (result.warnings.length > 0) console.warn(`[hotkeys] Hotkey '${String(hotkey)}' may never fire: ${describeIssues(result.warnings)}`);
111
129
  }
112
130
  return result.valid;
113
131
  }
114
132
 
115
- //#endregion
116
133
  export { assertValidHotkey, checkHotkey, validateHotkey };
@@ -1,5 +1,4 @@
1
1
  import { Observer, Subscription } from "./types.js";
2
- //#region ../../node_modules/.pnpm/@tanstack+store@0.11.2/node_modules/@tanstack/store/dist/store.d.ts
3
2
  //#region src/store.d.ts
4
3
  type StoreAction = (...args: Array<any>) => any;
5
4
  type StoreActionMap = Record<string, StoreAction>;
@@ -19,5 +18,4 @@ declare class Store<T, TActions extends StoreActionMap = never> {
19
18
  get(): T;
20
19
  subscribe(observerOrFn: Observer<T> | ((value: T) => void)): Subscription;
21
20
  }
22
- //#endregion
23
21
  export { Store, StoreAction, StoreActionMap, StoreActionsFactory };
@@ -1,4 +1,3 @@
1
- //#region ../../node_modules/.pnpm/@tanstack+store@0.11.2/node_modules/@tanstack/store/dist/types.d.ts
2
1
  type Observer<T> = {
3
2
  next?: (value: T) => void;
4
3
  error?: (err: unknown) => void;
@@ -7,5 +6,4 @@ type Observer<T> = {
7
6
  interface Subscription {
8
7
  unsubscribe: () => void;
9
8
  }
10
- //#endregion
11
9
  export { Observer, Subscription };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/hotkeys",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Type-safe, framework-agnostic keyboard hotkey management for the browser",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -47,9 +47,9 @@
47
47
  },
48
48
  "scripts": {
49
49
  "clean": "premove ./build ./dist",
50
- "lint": "eslint ./src",
51
- "lint:fix": "eslint ./src --fix",
52
- "test:eslint": "eslint ./src",
50
+ "lint": "oxlint ./src",
51
+ "lint:fix": "oxlint ./src --fix",
52
+ "test:oxlint": "oxlint ./src",
53
53
  "test:lib": "vitest",
54
54
  "test:lib:dev": "pnpm test:lib --watch",
55
55
  "test:types": "tsc",
@@ -52,7 +52,7 @@ Augment `Register` in the adapter module you import from, such as `declare modul
52
52
 
53
53
  - **Register the app hotkey, not its value.** Pass `appHotkeys['doc.save']`. Passing `.hotkey` registers the declared default, so the user's rebinding never applies, and the list reports the shortcut as `'inactive'`.
54
54
  - **Save what `onCustomHotkeysChange` receives.** It holds only the shortcuts the user changed, so users still get new defaults. Saving every effective hotkey freezes today's defaults into their data.
55
- - **Ids are a storage contract.** Saved data is keyed by id, and unknown ids are dropped with a warning, so renaming an id discards users' customizations.
55
+ - **Ids are a storage contract.** Saved data is keyed by id, and unknown ids are silently dropped, so renaming an id discards users' customizations.
56
56
  - **`setCustomHotkey` returns conflicts and changes nothing when there are any.** Check the result before reporting success, or reject the key press earlier by calling `findConflicts` in the recorder's `validate`.
57
57
  - **Declare scopes on app hotkeys that share keys.** An app hotkey without a scope, or in `'global'`, conflicts with every other app hotkey on the same keys, so rebinding is rejected.
58
58
  - **Conflict checks treat physical and logical bindings as different.** A recorded `Mod+[KeyP]` doesn't conflict with a declared `Mod+P`, although Command+P runs both. Declaring rebindable defaults in the recorder's form, such as `Mod+[KeyP]`, makes them comparable.
@@ -59,7 +59,7 @@ When nesting scopes, stacking dialogs, or keeping some hotkeys working during an
59
59
  - **A disabled hotkey doesn't block the browser.** With `enabled: false`, `Mod+S` opens the Save Page dialog. Add `preventDefault: 'always'` to keep the action blocked while the hotkey can't fire, whether it's disabled or its scope is off. Skip it for hotkeys turned off to give the key back to the browser, such as `Mod+C`, `Mod+Z`, `Tab`, or single keys behind a user's off switch. Don't keep the hotkey enabled and return early from the callback; the hotkey would also stay `active` in app hotkey lists and show its hint.
60
60
  - **Modifiers match exactly.** `ArrowDown` never fires while Shift is held, so register `Shift+ArrowDown` instead of checking `event.shiftKey`. Shift that a symbol needs is the exception: `?` matches Shift+/.
61
61
  - **Chords repeat while held.** Set `requireReset: true` on toggles, or holding the keys flickers them.
62
- - **Key names are case-sensitive.** Write `Escape`, `ArrowUp`, `F1`, and `+` as the types spell them; `escape`, `Esc`, `f1`, and `Plus` never match. Only letters match in any case. Hotkeys from plain JavaScript or saved data skip the types, so check them with `validateHotkey`.
62
+ - **Key names are case-sensitive.** Write `Escape`, `ArrowUp`, `F1`, and `+` as the types spell them; `escape`, `Esc`, `f1`, and `Plus` never match. Only letters match in any case. Hotkeys from plain JavaScript or saved data skip the types, so check them with `validateHotkey`. Its errors and warnings are reason codes such as `{ reason: 'unknown-key', part: 'escape' }`, not text, so map them to your own messages. A modifier-only hotkey, or one with an unknown modifier such as `Ctl+K`, registers nothing.
63
63
  - **Letters follow the typed character.** For position-based controls such as WASD, use physical bindings such as `'[KeyW]'`. Keep letters for mnemonic commands such as `Mod+S`.
64
64
  - **Each open exclusive scope needs its own name.** A reusable dialog with a fixed scope name breaks when one dialog opens another: both share one scope and both handle `Escape`. Build the name per instance.
65
65
  - **A chord on a sequence's first key runs every time the sequence starts.** With `G` and `['G', 'G']`, the first `G` runs the chord. Later keys belong to the sequence. To show "waiting for the next key", read `matchedStepCount` from the adapter's registrations list.