@tanstack/hotkeys 0.20.0 → 0.22.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.
@@ -22,7 +22,7 @@ function normalizeKeyboardEvent(event, platform) {
22
22
  meta: Boolean(event.metaKey),
23
23
  altGraph: altGraph && !controlAlt,
24
24
  location: event.location,
25
- isComposing: Boolean(event.isComposing) || key === "Process" || key === "Unidentified"
25
+ isComposing: Boolean(event.isComposing) || event.keyCode === 229 || key === "Process" || key === "Unidentified"
26
26
  };
27
27
  }
28
28
  /**
@@ -93,10 +93,15 @@ function isAsciiLetter(key) {
93
93
  function isLatinLetter(key) {
94
94
  return /^\p{Script_Extensions=Latin}$/u.test(key);
95
95
  }
96
+ let collator;
96
97
  /** Compares letters without case, preserving accents and avoiding expansions such as ß → SS. */
97
98
  function keysEqual(left, right) {
98
- if (/^\p{Letter}$/u.test(left) && /^\p{Letter}$/u.test(right)) return left.localeCompare(right, void 0, { sensitivity: "accent" }) === 0;
99
- return left === right;
99
+ if (left === right) return true;
100
+ if (/^\p{Letter}$/u.test(left) && /^\p{Letter}$/u.test(right)) {
101
+ collator ??= new Intl.Collator(void 0, { sensitivity: "accent" });
102
+ return collator.compare(left, right) === 0;
103
+ }
104
+ return false;
100
105
  }
101
106
  /** Rejects reserved physical-token syntax in logical object fields while allowing Unicode characters. */
102
107
  function assertLogicalKey(key) {
package/dist/_match.js CHANGED
@@ -11,7 +11,7 @@ function matchKeyboardEvent(event, hotkey, platform = detectPlatform()) {
11
11
  matched: false,
12
12
  score: 0
13
13
  };
14
- if (normalized.isComposing && (parsed.code !== void 0 || isPrintableKey(parsed.key))) return noMatch;
14
+ if (normalized.isComposing) return noMatch;
15
15
  if (normalized.altGraph && (parsed.ctrl || parsed.alt)) return noMatch;
16
16
  if (normalized.altGraph && !altGraphTypedCharacter(normalized)) return noMatch;
17
17
  if (parsed.code !== void 0) {
@@ -0,0 +1,14 @@
1
+ //#region src/_registration.d.ts
2
+ /**
3
+ * Merges option layers left to right. A key set to `undefined` keeps the
4
+ * earlier layer's value, so an unset option keeps its default. Getters are
5
+ * read once.
6
+ */
7
+ export declare function definedOptions<T extends object>(...layers: ReadonlyArray<object | undefined>): T;
8
+ /**
9
+ * Merges options over defaults for an API whose `setOptions` treats
10
+ * `undefined` as "clear". An unset option takes its default, or stays
11
+ * `undefined` to clear an earlier value when it has no default.
12
+ */
13
+ export declare function withDefaults<T extends object>(defaults: object | undefined, options: T | undefined): T;
14
+ //#endregion
@@ -16,6 +16,37 @@ const defaultHotkeyOptions = {
16
16
  scope: GLOBAL_SCOPE
17
17
  };
18
18
  /**
19
+ * Merges option layers left to right. A key set to `undefined` keeps the
20
+ * earlier layer's value, so an unset option keeps its default. Getters are
21
+ * read once.
22
+ */
23
+ function definedOptions(...layers) {
24
+ const result = {};
25
+ for (const layer of layers) {
26
+ if (!layer) continue;
27
+ for (const key of Object.keys(layer)) {
28
+ const value = layer[key];
29
+ if (value !== void 0) result[key] = value;
30
+ }
31
+ }
32
+ return result;
33
+ }
34
+ /**
35
+ * Merges options over defaults for an API whose `setOptions` treats
36
+ * `undefined` as "clear". An unset option takes its default, or stays
37
+ * `undefined` to clear an earlier value when it has no default.
38
+ */
39
+ function withDefaults(defaults, options) {
40
+ return {
41
+ ...options,
42
+ ...definedOptions(defaults, options)
43
+ };
44
+ }
45
+ /** Whether a parsed chord has a key. An empty one would match key-less events. */
46
+ function chordHasKey(chord) {
47
+ return !!(chord.code || chord.key);
48
+ }
49
+ /**
19
50
  * Resolves `ignoreInputs: 'auto'` from the hotkey (the first step of a sequence).
20
51
  * Ctrl/Meta shortcuts and Escape fire in inputs; single keys and Shift/Alt combos are ignored.
21
52
  */
@@ -50,4 +81,4 @@ function optionsEqual(left, right) {
50
81
  }
51
82
 
52
83
  //#endregion
53
- export { GLOBAL_SCOPE, defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual };
84
+ export { GLOBAL_SCOPE, chordHasKey, defaultHotkeyOptions, definedOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual, withDefaults };
@@ -9,11 +9,7 @@ var TargetListeners = class {
9
9
  }
10
10
  add(target, capture, id) {
11
11
  if (typeof document === "undefined") return;
12
- let phases = this.#targets.get(target);
13
- if (!phases) {
14
- phases = /* @__PURE__ */ new Map();
15
- this.#targets.set(target, phases);
16
- }
12
+ const phases = this.#targets.get(target) ?? /* @__PURE__ */ new Map();
17
13
  let group = phases.get(capture);
18
14
  if (!group) {
19
15
  const ids = /* @__PURE__ */ new Set();
@@ -41,9 +37,10 @@ var TargetListeners = class {
41
37
  keydown: listener("keydown"),
42
38
  keyup: listener("keyup")
43
39
  };
44
- phases.set(capture, group);
45
40
  target.addEventListener("keydown", group.keydown, capture);
46
41
  target.addEventListener("keyup", group.keyup, capture);
42
+ phases.set(capture, group);
43
+ this.#targets.set(target, phases);
47
44
  }
48
45
  group.ids.add(id);
49
46
  }
package/dist/adapter.d.ts CHANGED
@@ -3,6 +3,7 @@ import { HotkeyCallback, HotkeyChord, HotkeySequence, RegisterableHotkey, Regist
3
3
  import { ActivateHotkeyScopeOptions, HotkeyChordOptions, HotkeyOptions, HotkeySequenceOptions, Target } from "./hotkey-manager.js";
4
4
  import { AppHotkey, AppHotkeysStore } from "./app-hotkeys.js";
5
5
  import { createAppHotkeysStore } from "./_app-hotkeys-factory.js";
6
+ import { definedOptions, withDefaults } from "./_registration.js";
6
7
  //#region src/adapter.d.ts
7
8
  /**
8
9
  * A hotkey and its callback, with optional per-binding options. Chords accept
@@ -16,6 +17,11 @@ export type HotkeyDefinition = {
16
17
  hotkey: RegisterableHotkeySequence;
17
18
  callback: HotkeyCallback;
18
19
  options?: HotkeySequenceOptions;
20
+ } | {
21
+ /** No hotkey. Registers nothing. */
22
+ hotkey: null | undefined;
23
+ callback: HotkeyCallback;
24
+ options?: HotkeyChordOptions & HotkeySequenceOptions;
19
25
  };
20
26
  /** Options shared by every definition. Each option applies only to the kinds that support it. */
21
27
  export type HotkeyBindingsCommonOptions = HotkeyChordOptions & HotkeySequenceOptions;
@@ -23,7 +29,8 @@ export type HotkeyBindingsCommonOptions = HotkeyChordOptions & HotkeySequenceOpt
23
29
  export declare function createHotkeyBindings(): {
24
30
  /**
25
31
  * Registers new definitions, updates existing ones in place, and removes
26
- * stale ones. Definitions with an empty sequence or a null target are skipped.
32
+ * stale ones. Definitions with no hotkey (undefined, null, empty string, or
33
+ * empty sequence) or a null target are skipped.
27
34
  */
28
35
  update(definitions: ReadonlyArray<HotkeyDefinition>, commonOptions?: HotkeyBindingsCommonOptions): void;
29
36
  destroy(): void;
@@ -79,9 +86,10 @@ export type AdapterHotkeyDefinition<TTarget> = {
79
86
  } | {
80
87
  /**
81
88
  * A hotkey whose kind is not known statically, such as user data typed
82
- * `Hotkey`. Accepts only shared options.
89
+ * `Hotkey`. Accepts only shared options. `undefined`, `null`, and `''`
90
+ * register nothing, for an optional shortcut.
83
91
  */
84
- hotkey: RegisterableHotkey;
92
+ hotkey: RegisterableHotkey | null | undefined;
85
93
  callback: HotkeyCallback;
86
94
  options?: AdapterHotkeyOptions<TTarget>;
87
95
  };
@@ -96,7 +104,7 @@ export interface PrepareHotkeyDefinitionsOptions<TTarget> {
96
104
  /**
97
105
  * Resolves a framework target, such as a ref. Return `undefined` for the
98
106
  * document or `null` to skip registration until the target exists.
99
- * Without it, targets pass through and `null` means document.
107
+ * Without it, targets pass through, so `null` skips registration.
100
108
  */
101
109
  resolveTarget?: (target: TTarget) => Target | null | undefined;
102
110
  }
@@ -163,4 +171,4 @@ export declare function getHotkeysProviderScopeKey(options: {
163
171
  */
164
172
  export declare function activateHotkeysProviderScope(key: string): () => void;
165
173
  //#endregion
166
- export { type Store, createAppHotkeysStore, createStore };
174
+ export { type Store, createAppHotkeysStore, createStore, definedOptions, withDefaults };
package/dist/adapter.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { detectPlatform } from "./platform.js";
2
- import { isHotkeySequence, normalizeHotkey } from "./parse.js";
2
+ import { isHotkeySequence, normalizeHotkey, parseHotkey } from "./parse.js";
3
+ import { chordHasKey, defaultHotkeyOptions, definedOptions, withDefaults } from "./_registration.js";
3
4
  import { createStore } from "./_store.js";
4
- import { defaultHotkeyOptions } from "./_registration.js";
5
5
  import { getHotkeyManager } from "./hotkey-manager.js";
6
6
  import { createAppHotkeysStore } from "./_app-hotkeys-factory.js";
7
7
  import { getAppHotkeyRegistrationOptions, isAppHotkey, resolveAppHotkey } from "./app-hotkeys.js";
@@ -13,24 +13,23 @@ function createHotkeyBindings() {
13
13
  return {
14
14
  /**
15
15
  * Registers new definitions, updates existing ones in place, and removes
16
- * stale ones. Definitions with an empty sequence or a null target are skipped.
16
+ * stale ones. Definitions with no hotkey (undefined, null, empty string, or
17
+ * empty sequence) or a null target are skipped.
17
18
  */
18
19
  update(definitions, commonOptions = {}) {
19
20
  const prepared = [];
20
21
  definitions.forEach((definition, index) => {
21
- const merged = {
22
- ...commonOptions,
23
- ...definition.options
24
- };
22
+ const merged = definedOptions(commonOptions, definition.options);
25
23
  const options = {
26
24
  ...defaultHotkeyOptions,
27
25
  ...merged,
28
- ignoreInputs: merged.ignoreInputs ?? "auto",
29
26
  target: "target" in merged ? merged.target ?? null : typeof document === "undefined" ? null : document,
30
27
  platform: merged.platform ?? detectPlatform()
31
28
  };
32
- if (!options.target || isHotkeySequence(definition.hotkey) && definition.hotkey.length === 0) return;
33
- const hotkey = normalizeHotkey(definition.hotkey, options.platform);
29
+ if (!options.target || !definition.hotkey || isHotkeySequence(definition.hotkey) && definition.hotkey.length === 0) return;
30
+ const parsed = parseHotkey(definition.hotkey, options.platform);
31
+ if (!(Array.isArray(parsed) ? parsed : [parsed]).every(chordHasKey)) return;
32
+ const hotkey = normalizeHotkey(parsed, options.platform);
34
33
  if (isHotkeySequence(hotkey)) delete options.requireReset;
35
34
  else delete options.timeout;
36
35
  prepared.push({
@@ -89,13 +88,8 @@ function prepareHotkeyDefinitions(definitions, options = {}) {
89
88
  declared = getAppHotkeyRegistrationOptions(appHotkey);
90
89
  hotkey = appHotkey.hotkey ?? [];
91
90
  }
92
- const { target, ...merged } = {
93
- ...options.defaults,
94
- ...declared,
95
- ...options.commonOptions,
96
- ...definition.options
97
- };
98
- const resolved = target === void 0 ? void 0 : options.resolveTarget ? options.resolveTarget(target) : target ?? void 0;
91
+ const { target, ...merged } = definedOptions(options.defaults, declared, options.commonOptions, definition.options);
92
+ const resolved = target === void 0 ? void 0 : options.resolveTarget ? options.resolveTarget(target) : target;
99
93
  return {
100
94
  hotkey,
101
95
  callback: definition.callback,
@@ -136,14 +130,10 @@ function subscribePublishedAppHotkeys(onChange) {
136
130
  function mergeHotkeysProviderDefaults(parent, own, scope) {
137
131
  return {
138
132
  hotkey: {
139
- ...parent?.hotkey,
140
- ...own?.hotkey,
133
+ ...definedOptions(parent?.hotkey, own?.hotkey),
141
134
  ...scope && { scope }
142
135
  },
143
- hotkeyRecorder: {
144
- ...parent?.hotkeyRecorder,
145
- ...own?.hotkeyRecorder
146
- }
136
+ hotkeyRecorder: definedOptions(parent?.hotkeyRecorder, own?.hotkeyRecorder)
147
137
  };
148
138
  }
149
139
  /**
@@ -173,4 +163,4 @@ function activateHotkeysProviderScope(key) {
173
163
  }
174
164
 
175
165
  //#endregion
176
- export { activateHotkeysProviderScope, createAppHotkeysStore, createHotkeyBindings, createStore, getHotkeysProviderScopeKey, mergeHotkeysProviderDefaults, prepareHotkeyDefinitions, subscribePublishedAppHotkeys };
166
+ export { activateHotkeysProviderScope, createAppHotkeysStore, createHotkeyBindings, createStore, definedOptions, getHotkeysProviderScopeKey, mergeHotkeysProviderDefaults, prepareHotkeyDefinitions, subscribePublishedAppHotkeys, withDefaults };
@@ -135,7 +135,13 @@ export interface AppHotkeysState<T extends AppHotkeys = AppHotkeys> {
135
135
  }
136
136
  /** Options for `AppHotkeysStore`. */
137
137
  export interface AppHotkeysStoreOptions<T extends AppHotkeys = AppHotkeys> {
138
- /** Starting custom hotkeys, such as saved data. Invalid entries are ignored with a warning. */
138
+ /**
139
+ * The user's saved custom hotkeys. Invalid entries are ignored with a
140
+ * warning. Passing a different value to `setOptions` later, such as data
141
+ * that finished loading or another user's data, replaces the current
142
+ * custom hotkeys. `undefined` leaves them unchanged; a change to `null`
143
+ * clears them.
144
+ */
139
145
  customHotkeys?: CustomHotkeys<T> | null;
140
146
  /** Called with the custom hotkeys after every change, ready to save. */
141
147
  onCustomHotkeysChange?: (customHotkeys: CustomHotkeys<T>) => void;
@@ -165,8 +171,17 @@ export declare class AppHotkeysStore<T extends AppHotkeys = AppHotkeys> {
165
171
  get appHotkeys(): T;
166
172
  /** The user's hotkeys that differ from the defaults. */
167
173
  get customHotkeys(): CustomHotkeys<T>;
168
- /** Updates callbacks and other options without resetting custom hotkeys. */
169
- setOptions(options: Omit<AppHotkeysStoreOptions<T>, 'customHotkeys'>): void;
174
+ /**
175
+ * Updates options. Providers call it with their props on every update.
176
+ *
177
+ * When `customHotkeys` differs from the value last passed in, it replaces
178
+ * the current custom hotkeys. An equal value, such as the same saved data
179
+ * parsed again, keeps changes the user made since. `undefined` changes
180
+ * nothing, such as while saved data loads; `null` clears them, such as on
181
+ * sign-out. Replacing does not call `onCustomHotkeysChange`, because the
182
+ * data came from the app.
183
+ */
184
+ setOptions(options: AppHotkeysStoreOptions<T>): void;
170
185
  /**
171
186
  * Replaces the declarations, for example after hot module replacement.
172
187
  * Custom hotkeys for ids that still exist are kept.
@@ -1,9 +1,9 @@
1
1
  import { detectPlatform } from "./platform.js";
2
2
  import { isHotkeySequence, simplifyHotkey } from "./parse.js";
3
3
  import { validateHotkey } from "./validate.js";
4
+ import { GLOBAL_SCOPE } from "./_registration.js";
4
5
  import { areHotkeysEqual } from "./match.js";
5
6
  import { createStore } from "./_store.js";
6
- import { GLOBAL_SCOPE } from "./_registration.js";
7
7
  import { getHotkeyManager } from "./hotkey-manager.js";
8
8
  import { setAppHotkeysStoreFactory } from "./_app-hotkeys-factory.js";
9
9
 
@@ -88,6 +88,31 @@ function sanitizeCustomHotkeys(appHotkeys, saved) {
88
88
  }
89
89
  return result;
90
90
  }
91
+ /** Whether two saved values hold the same data. Nothing saved equals `{}`. */
92
+ function sameSavedValue(left, right) {
93
+ if (Object.is(left, right)) return true;
94
+ if (Array.isArray(left) || Array.isArray(right)) return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((step, i) => Object.is(step, right[i]));
95
+ if (typeof left !== "object" || typeof right !== "object") return false;
96
+ if (left === null || right === null) return false;
97
+ const keys = Object.keys(left);
98
+ return keys.length === Object.keys(right).length && keys.every((key) => Object.hasOwn(right, key) && sameSavedValue(left[key], right[key]));
99
+ }
100
+ /** Copies saved custom hotkeys, so later changes made in place are detected. */
101
+ function copySavedCustomHotkeys(saved) {
102
+ if (typeof saved !== "object" || saved === null) return saved;
103
+ const copy = {};
104
+ for (const [id, value] of Object.entries(saved)) copy[id] = Array.isArray(value) ? [...value] : value;
105
+ return copy;
106
+ }
107
+ /** Whether two saved custom hotkeys objects hold the same data. */
108
+ function sameSavedCustomHotkeys(left, right) {
109
+ return sameSavedValue(left === void 0 ? {} : left, right === void 0 ? {} : right);
110
+ }
111
+ /** Whether two sanitized custom hotkeys objects bind the same hotkeys. */
112
+ function sameCustomHotkeys(left, right, platform) {
113
+ const keys = Object.keys(left);
114
+ return keys.length === Object.keys(right).length && keys.every((key) => Object.hasOwn(right, key) && hotkeysEqual(left[key] ?? null, right[key] ?? null, platform));
115
+ }
91
116
  /** Whether two app hotkeys can apply at the same time. */
92
117
  function scopesOverlap(a, b) {
93
118
  const left = a.scope ?? "global";
@@ -121,9 +146,12 @@ var AppHotkeysStore = class {
121
146
  store;
122
147
  #declarations;
123
148
  #options;
149
+ /** The `customHotkeys` option last passed in, compared by value. */
150
+ #savedCustomHotkeys;
124
151
  constructor(appHotkeys, options = {}) {
125
152
  this.#declarations = appHotkeys;
126
153
  this.#options = options;
154
+ this.#savedCustomHotkeys = copySavedCustomHotkeys(options.customHotkeys);
127
155
  const customHotkeys = sanitizeCustomHotkeys(appHotkeys, options.customHotkeys);
128
156
  this.store = createStore({
129
157
  appHotkeys: applyCustomHotkeys(appHotkeys, customHotkeys),
@@ -141,12 +169,31 @@ var AppHotkeysStore = class {
141
169
  get customHotkeys() {
142
170
  return this.store.state.customHotkeys;
143
171
  }
144
- /** Updates callbacks and other options without resetting custom hotkeys. */
172
+ /**
173
+ * Updates options. Providers call it with their props on every update.
174
+ *
175
+ * When `customHotkeys` differs from the value last passed in, it replaces
176
+ * the current custom hotkeys. An equal value, such as the same saved data
177
+ * parsed again, keeps changes the user made since. `undefined` changes
178
+ * nothing, such as while saved data loads; `null` clears them, such as on
179
+ * sign-out. Replacing does not call `onCustomHotkeysChange`, because the
180
+ * data came from the app.
181
+ */
145
182
  setOptions(options) {
183
+ const { customHotkeys, ...rest } = options;
146
184
  this.#options = {
147
185
  ...this.#options,
148
- ...options
186
+ ...rest
149
187
  };
188
+ if (customHotkeys === void 0) return;
189
+ if (sameSavedCustomHotkeys(customHotkeys, this.#savedCustomHotkeys)) return;
190
+ this.#savedCustomHotkeys = copySavedCustomHotkeys(customHotkeys);
191
+ const next = sanitizeCustomHotkeys(this.#declarations, customHotkeys);
192
+ if (sameCustomHotkeys(next, this.store.state.customHotkeys, this.#platform)) return;
193
+ this.store.setState(() => ({
194
+ appHotkeys: applyCustomHotkeys(this.#declarations, next),
195
+ customHotkeys: next
196
+ }));
150
197
  }
151
198
  /**
152
199
  * Replaces the declarations, for example after hot module replacement.
package/dist/hint.d.ts CHANGED
@@ -11,11 +11,12 @@ export interface HeldModifierOptions {
11
11
  * By default, any nonempty subset of the binding's modifiers qualifies; an
12
12
  * unrelated modifier hides the hint. `exact` requires every binding modifier.
13
13
  * Nonmodifier keys are ignored. This does not check registration or focus.
14
- * A sequence reveals its hint from its first step.
14
+ * A sequence reveals its hint from its first step. Returns false for no
15
+ * hotkey (`undefined`, `null`, or `''`).
15
16
  *
16
17
  * @example
17
18
  * matchesHeldModifiers('Alt+Shift+[KeyK]', ['Alt']) // true
18
19
  * matchesHeldModifiers('Alt+Shift+[KeyK]', ['Alt', 'Control']) // false
19
20
  */
20
- export declare function matchesHeldModifiers(hotkey: RegisterableHotkey, heldKeys: ReadonlyArray<string>, options?: HeldModifierOptions): boolean;
21
+ export declare function matchesHeldModifiers(hotkey: RegisterableHotkey | null | undefined, heldKeys: ReadonlyArray<string>, options?: HeldModifierOptions): boolean;
21
22
  //#endregion
package/dist/hint.js CHANGED
@@ -7,13 +7,15 @@ import { isHotkeySequence, parseHotkey } from "./parse.js";
7
7
  * By default, any nonempty subset of the binding's modifiers qualifies; an
8
8
  * unrelated modifier hides the hint. `exact` requires every binding modifier.
9
9
  * Nonmodifier keys are ignored. This does not check registration or focus.
10
- * A sequence reveals its hint from its first step.
10
+ * A sequence reveals its hint from its first step. Returns false for no
11
+ * hotkey (`undefined`, `null`, or `''`).
11
12
  *
12
13
  * @example
13
14
  * matchesHeldModifiers('Alt+Shift+[KeyK]', ['Alt']) // true
14
15
  * matchesHeldModifiers('Alt+Shift+[KeyK]', ['Alt', 'Control']) // false
15
16
  */
16
17
  function matchesHeldModifiers(hotkey, heldKeys, options = {}) {
18
+ if (!hotkey) return false;
17
19
  if (heldKeys.includes("AltGraph")) return false;
18
20
  const held = [...new Set(heldKeys.map(normalizeKeyName).filter((key) => MODIFIER_KEYS.has(key)))];
19
21
  const first = isHotkeySequence(hotkey) ? hotkey[0] : hotkey;
@@ -385,10 +385,11 @@ export declare class HotkeyManager {
385
385
  * only while focus is inside that element. An app hotkey matches
386
386
  * registrations by id, so custom hotkeys apply.
387
387
  *
388
- * @param hotkey - A chord, sequence, or app hotkey
388
+ * @param hotkey - A chord, sequence, or app hotkey. No hotkey
389
+ * (`undefined`, `null`, or `''`) returns false.
389
390
  * @returns True if a registration that can fire exists
390
391
  */
391
- hasActiveRegistration(hotkey: RegisterableHotkey | AppHotkey): boolean;
392
+ hasActiveRegistration(hotkey: RegisterableHotkey | AppHotkey | null | undefined): boolean;
392
393
  /**
393
394
  * Destroys the manager and removes all listeners.
394
395
  */
@@ -4,13 +4,13 @@ import { isKeyNameShape, keysEqual, normalizeKeyboardEvent } from "./_keyboard-e
4
4
  import { isHotkeySequence, isModifierKey, parseHotkey, simplifyHotkey } from "./parse.js";
5
5
  import { formatHotkey } from "./format.js";
6
6
  import { matchKeyboardEvent, parsedChordsEqual } from "./_match.js";
7
+ import { GLOBAL_SCOPE, chordHasKey, defaultHotkeyOptions, definedOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
7
8
  import { DEFAULT_SEQUENCE_TIMEOUT, areHotkeysEqual } from "./match.js";
8
9
  import { getTargetDocument, isDocumentLevel, shouldIgnoreInputEvent, targetHasFocus } from "./_event-target.js";
9
10
  import { createStore } from "./_store.js";
10
11
  import { TargetListeners } from "./_target-listeners.js";
11
12
  import { managerRef } from "./_manager-ref.js";
12
13
  import { isRecordingEvent } from "./_recording-guard.js";
13
- import { GLOBAL_SCOPE, defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
14
14
 
15
15
  //#region src/hotkey-manager.ts
16
16
  /** Whether a registration can fire now. Without a manager, scopes don't apply. */
@@ -57,6 +57,31 @@ function chordString(chord, parsed) {
57
57
  function generateId() {
58
58
  return `hotkey_${++registrationIdCounter}`;
59
59
  }
60
+ /** A handle for a registration that listens for nothing. */
61
+ function inactiveHandle(callback) {
62
+ let currentCallback = callback;
63
+ return {
64
+ id: generateId(),
65
+ unregister: () => {},
66
+ get callback() {
67
+ return currentCallback;
68
+ },
69
+ set callback(newCallback) {
70
+ currentCallback = newCallback;
71
+ },
72
+ setOptions: () => {},
73
+ get isActive() {
74
+ return false;
75
+ }
76
+ };
77
+ }
78
+ /** Defaults for a registration of the given kind. */
79
+ function registrationDefaults(kind) {
80
+ return {
81
+ ...defaultHotkeyOptions,
82
+ ...kind === "sequence" ? { timeout: DEFAULT_SEQUENCE_TIMEOUT } : { requireReset: false }
83
+ };
84
+ }
60
85
  /** Drops options that do not apply to a registration's kind. */
61
86
  function optionsForKind(kind, options) {
62
87
  if (kind === "chord") {
@@ -297,37 +322,19 @@ var HotkeyManager = class HotkeyManager {
297
322
  }
298
323
  register(input, callback, options = {}) {
299
324
  const hotkey = simplifyHotkey(input);
300
- if (isHotkeySequence(hotkey) && hotkey.length === 0) throw new Error("Sequence must contain at least one hotkey");
301
- if (typeof document === "undefined" && !options.target) {
302
- let currentCallback = callback;
303
- return {
304
- id: generateId(),
305
- unregister: () => {},
306
- get callback() {
307
- return currentCallback;
308
- },
309
- set callback(newCallback) {
310
- currentCallback = newCallback;
311
- },
312
- setOptions: () => {},
313
- get isActive() {
314
- return false;
315
- }
316
- };
317
- }
325
+ if (hotkey == null || isHotkeySequence(hotkey) && hotkey.length === 0 || typeof document === "undefined" && !options.target) return inactiveHandle(callback);
318
326
  const id = generateId();
319
327
  const platform = options.platform ?? this.#platform;
320
328
  const target = options.target ?? document;
321
329
  const sequence = isHotkeySequence(hotkey);
330
+ const kind = sequence ? "sequence" : "chord";
322
331
  const common = {
323
332
  id,
324
333
  callback,
325
334
  options: {
326
- ...defaultHotkeyOptions,
327
- ...sequence ? { timeout: DEFAULT_SEQUENCE_TIMEOUT } : { requireReset: false },
328
- ...optionsForKind(sequence ? "sequence" : "chord", options),
329
- platform,
330
- ignoreInputs: options.ignoreInputs ?? "auto"
335
+ ...registrationDefaults(kind),
336
+ ...optionsForKind(kind, definedOptions(options)),
337
+ platform
331
338
  },
332
339
  target,
333
340
  triggerCount: 0,
@@ -353,14 +360,15 @@ var HotkeyManager = class HotkeyManager {
353
360
  parsedChord
354
361
  };
355
362
  }
363
+ const steps = registration.kind === "sequence" ? registration.parsedSequence : [registration.parsedChord];
364
+ if (!steps.every(chordHasKey)) return inactiveHandle(callback);
356
365
  if (process.env.NODE_ENV !== "production") {
357
- const steps = registration.kind === "sequence" ? registration.parsedSequence : [registration.parsedChord];
358
366
  for (const { key } of steps) if (key !== void 0 && !isKeyNameShape(key)) console.warn(`Unknown key '${key}' in '${formatForMessage(registration.hotkey)}'. Key names are case-sensitive, such as 'Escape' and 'F1', so this hotkey may never fire.`);
359
367
  }
360
368
  const conflicting = this.#findConflictingRegistration(registration);
361
369
  if (conflicting) handleConflict(conflicting.id, formatForMessage(registration.hotkey), options.conflictBehavior ?? "warn", (conflictId) => this.#unregister(conflictId));
362
- this.registrations.setState((prev) => new Map(prev).set(id, registration));
363
370
  this.#listeners.add(target, !!registration.options.capture, id);
371
+ this.registrations.setState((prev) => new Map(prev).set(id, registration));
364
372
  const manager = this;
365
373
  return {
366
374
  get id() {
@@ -380,10 +388,15 @@ var HotkeyManager = class HotkeyManager {
380
388
  manager.registrations.setState((prev) => {
381
389
  const reg = prev.get(id);
382
390
  if (!reg) return prev;
383
- const options = {
384
- ...reg.options,
385
- ...optionsForKind(reg.kind, newOptions)
391
+ const defaults = {
392
+ ...registrationDefaults(reg.kind),
393
+ platform: reg.options.platform
386
394
  };
395
+ const options = { ...reg.options };
396
+ const entries = Object.entries(optionsForKind(reg.kind, newOptions));
397
+ for (const [key, value] of entries) if (value !== void 0) options[key] = value;
398
+ else if (key in defaults) options[key] = defaults[key];
399
+ else delete options[key];
387
400
  if (optionsEqual(reg.options, options)) return prev;
388
401
  const capture = !!reg.options.capture;
389
402
  if (capture !== !!options.capture) {
@@ -504,7 +517,7 @@ var HotkeyManager = class HotkeyManager {
504
517
  this.#fire(registration, event);
505
518
  continue;
506
519
  }
507
- if (!registration.options.requireReset || !registration.hasFired) {
520
+ if (!registration.options.requireReset || !registration.hasFired || !event.repeat) {
508
521
  if (registration.options.requireReset) {
509
522
  registration.hasFired = true;
510
523
  registration.activeMatch = match.identity;
@@ -635,13 +648,13 @@ var HotkeyManager = class HotkeyManager {
635
648
  };
636
649
  }
637
650
  /**
638
- * Finds an existing registration on the same target and scope whose steps
651
+ * Finds an existing registration on the same target, scope, and event type whose steps
639
652
  * resolve to the same identity. Each side keeps its own platform, so `Mod+S` on macOS
640
653
  * and `Mod+S` on Windows are different bindings.
641
654
  */
642
655
  #findConflictingRegistration(candidate) {
643
656
  const steps = resolvedSteps(candidate);
644
- for (const registration of this.registrations.state.values()) if (registration.target === candidate.target && registration.options.scope === candidate.options.scope && sameResolvedSteps(resolvedSteps(registration), steps)) return registration;
657
+ for (const registration of this.registrations.state.values()) if (registration.target === candidate.target && registration.options.scope === candidate.options.scope && registration.options.eventType === candidate.options.eventType && sameResolvedSteps(resolvedSteps(registration), steps)) return registration;
645
658
  return null;
646
659
  }
647
660
  /**
@@ -689,10 +702,12 @@ var HotkeyManager = class HotkeyManager {
689
702
  * only while focus is inside that element. An app hotkey matches
690
703
  * registrations by id, so custom hotkeys apply.
691
704
  *
692
- * @param hotkey - A chord, sequence, or app hotkey
705
+ * @param hotkey - A chord, sequence, or app hotkey. No hotkey
706
+ * (`undefined`, `null`, or `''`) returns false.
693
707
  * @returns True if a registration that can fire exists
694
708
  */
695
709
  hasActiveRegistration(hotkey) {
710
+ if (!hotkey) return false;
696
711
  const id = typeof hotkey === "object" && "~appHotkey" in hotkey ? hotkey.id : void 0;
697
712
  for (const registration of this.registrations.state.values()) if (this.#isLive(registration) && targetHasFocus(registration.target) && (id === void 0 ? areHotkeysEqual(registration.hotkey, hotkey, registration.options.platform) : registration.options.appHotkeyId === id)) return true;
698
713
  return false;
@@ -100,11 +100,14 @@ var KeyStateTracker = class KeyStateTracker {
100
100
  for (const [entryIdentity, entry] of this.#heldEntries) if (entry.key === key) this.#heldEntries.delete(entryIdentity);
101
101
  }
102
102
  this.#reconcileModifiers(event);
103
- if (MODIFIER_KEYS.has(key)) {
104
- for (const [entryIdentity, entry] of this.#heldEntries) if (!MODIFIER_KEYS.has(entry.key)) this.#heldEntries.delete(entryIdentity);
105
- }
103
+ if (MODIFIER_KEYS.has(key)) this.#releaseNonModifiers();
106
104
  this.#syncState();
107
105
  };
106
+ const onContextMenu = (event) => {
107
+ if (event.defaultPrevented || !event.isTrusted) return;
108
+ const reconciled = this.#reconcileModifiers(event);
109
+ if (this.#releaseNonModifiers() || reconciled) this.#syncState();
110
+ };
108
111
  const onMouse = (event) => {
109
112
  if (this.#reconcileModifiers(event)) this.#syncState();
110
113
  };
@@ -139,6 +142,16 @@ var KeyStateTracker = class KeyStateTracker {
139
142
  "blur",
140
143
  onBlur,
141
144
  false
145
+ ],
146
+ [
147
+ "contextmenu",
148
+ onContextMenu,
149
+ false
150
+ ],
151
+ [
152
+ "visibilitychange",
153
+ () => document.hidden && onBlur(),
154
+ false
142
155
  ]
143
156
  ];
144
157
  for (const [type, listener, capture] of this.#listeners) window.addEventListener(type, listener, capture);
@@ -159,6 +172,15 @@ var KeyStateTracker = class KeyStateTracker {
159
172
  }
160
173
  return changed;
161
174
  }
175
+ /** Forgets every held key that isn't a modifier. Returns whether any was held. */
176
+ #releaseNonModifiers() {
177
+ let changed = false;
178
+ for (const [identity, entry] of this.#heldEntries) if (!MODIFIER_KEYS.has(entry.key)) {
179
+ this.#heldEntries.delete(identity);
180
+ changed = true;
181
+ }
182
+ return changed;
183
+ }
162
184
  /**
163
185
  * Syncs the internal Set to the Store state.
164
186
  */
package/dist/match.js CHANGED
@@ -4,6 +4,7 @@ import { normalizeKeyboardEvent } from "./_keyboard-event.js";
4
4
  import { isHotkeySequence, isModifierKey, parseHotkey } from "./parse.js";
5
5
  import { formatHotkey } from "./format.js";
6
6
  import { matchKeyboardEvent, parsedChordsEqual } from "./_match.js";
7
+ import { chordHasKey } from "./_registration.js";
7
8
 
8
9
  //#region src/match.ts
9
10
  /**
@@ -36,7 +37,9 @@ import { matchKeyboardEvent, parsedChordsEqual } from "./_match.js";
36
37
  * ```
37
38
  */
38
39
  function matchesKeyboardEvent(event, chord, platform = detectPlatform()) {
39
- return matchKeyboardEvent(event, chord, platform).matched;
40
+ const parsed = typeof chord === "string" ? parseHotkey(chord, platform) : chord;
41
+ if (!chordHasKey(parsed)) return false;
42
+ return matchKeyboardEvent(event, parsed, platform).matched;
40
43
  }
41
44
  /**
42
45
  * Creates a keyboard event handler that calls the callback when the chord
@@ -50,9 +50,13 @@ export declare function assertValidHotkey(hotkey: ValidatableHotkey): void;
50
50
  *
51
51
  * @example
52
52
  * ```ts
53
- * checkHotkey('Alt+C')
54
- * // Console: Warning: Alt+C may not work reliably on macOS...
53
+ * checkHotkey('Control+escape')
54
+ * // Console: Hotkey 'Control+escape' warnings: Unknown key: 'escape'. Key names are case-sensitive, such as 'Escape' and 'F1'.
55
55
  * // Returns: true
56
+ *
57
+ * checkHotkey('Ctl+K')
58
+ * // Console: Hotkey 'Ctl+K' errors: Unknown modifier: 'Ctl'
59
+ * // Returns: false
56
60
  * ```
57
61
  */
58
62
  export declare function checkHotkey(hotkey: ValidatableHotkey): boolean;
package/dist/validate.js CHANGED
@@ -94,9 +94,13 @@ function assertValidHotkey(hotkey) {
94
94
  *
95
95
  * @example
96
96
  * ```ts
97
- * checkHotkey('Alt+C')
98
- * // Console: Warning: Alt+C may not work reliably on macOS...
97
+ * checkHotkey('Control+escape')
98
+ * // Console: Hotkey 'Control+escape' warnings: Unknown key: 'escape'. Key names are case-sensitive, such as 'Escape' and 'F1'.
99
99
  * // Returns: true
100
+ *
101
+ * checkHotkey('Ctl+K')
102
+ * // Console: Hotkey 'Ctl+K' errors: Unknown modifier: 'Ctl'
103
+ * // Returns: false
100
104
  * ```
101
105
  */
102
106
  function checkHotkey(hotkey) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/hotkeys",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Type-safe, framework-agnostic keyboard hotkey management for the browser",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -46,12 +46,11 @@ export const appHotkeys = defineAppHotkeys({
46
46
  })
47
47
  ```
48
48
 
49
- Augment `Register` in the adapter module you import from, such as `declare module '@tanstack/react-hotkeys' { interface Register { appHotkeys: typeof appHotkeys } }`, to type ids. Lit, Alpine, and Ember have no provider: call `provideAppHotkeys(appHotkeys, options)` once at startup.
49
+ Augment `Register` in the adapter module you import from, such as `declare module '@tanstack/react-hotkeys' { interface Register { appHotkeys: typeof appHotkeys } }`, to type ids. Lit, Alpine, and Ember have no provider: call `provideAppHotkeys(appHotkeys, options)` once at startup. Pass saved data that loads later to `store.setOptions({ customHotkeys })` on the returned store; providers apply a changed `customHotkeys` prop themselves. `undefined` leaves custom hotkeys unchanged while data loads; `null` clears them, such as on sign-out.
50
50
 
51
51
  ## Pitfalls
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
- - **The provider reads `customHotkeys` once.** When saved shortcuts load asynchronously, render the provider after they arrive, or key it by user. Change shortcuts afterwards through the adapter's custom hotkeys API, not by updating the prop.
55
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.
56
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.
57
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`.
@@ -4,4 +4,4 @@
4
4
  - **Reset between tests.** Call `HotkeyManager.resetInstance()`, plus `KeyStateTracker.resetInstance()` for held-key tests, in `afterEach`. Registrations and scope activations otherwise outlive a test that fails before unmounting.
5
5
  - **Dispatch real events.** Use `new KeyboardEvent('keydown', { key: 's', code: 'KeyS', metaKey: true, bubbles: true, cancelable: true })`. Physical bindings such as `[KeyS]` match only `code`. Check `event.defaultPrevented` to assert the browser action was blocked.
6
6
  - **Dispatch where focus is.** For a hotkey with a `target`, or to test typing in a field, focus the element and dispatch on it with `bubbles: true`.
7
- - **Repeats and sequences.** `repeat: true` simulates holding a key; it fires chords again unless they set `requireReset`, and it never advances a sequence. Use fake timers to test a sequence's `timeout` (1000 ms by default).
7
+ - **Repeats and sequences.** `repeat: true` simulates holding a key; it fires chords again unless they set `requireReset`, and it never advances a sequence. A keydown without `repeat` is a new press, so it fires a `requireReset` chord even without a keyup in between. Use fake timers to test a sequence's `timeout` (1000 ms by default).