@tanstack/hotkeys 0.17.0 → 0.19.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.
@@ -1,67 +1,169 @@
1
1
  import { normalizeKeyName } from "./constants.js";
2
2
 
3
3
  //#region src/_event-target.ts
4
- function isNode(target) {
5
- return typeof target?.nodeType === "number";
4
+ function isElement(node) {
5
+ return node?.nodeType === 1;
6
6
  }
7
- function isElement(target) {
8
- return isNode(target) && target.nodeType === 1;
9
- }
10
- function isWindow(target) {
11
- return "document" in target;
7
+ /** Whether a target is a document or window, which receive every key press in their document. */
8
+ function isDocumentLevel(target) {
9
+ return "document" in target || target.nodeType === 9;
12
10
  }
13
11
  /** Returns the document a listener target belongs to, in any frame. */
14
12
  function getTargetDocument(target) {
15
- if (isWindow(target)) return target.document;
16
- if (target.nodeType === 9) return target;
17
- return target.ownerDocument;
13
+ return "document" in target ? target.document : target.ownerDocument ?? target;
14
+ }
15
+ /** The focused element, looking inside open shadow roots. */
16
+ function getDeepActiveElement(doc) {
17
+ let active = doc.activeElement;
18
+ while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
19
+ return active;
20
+ }
21
+ /** The next node a key event bubbles to: a slot, a shadow host, or the parent. */
22
+ function composedParent(node) {
23
+ return node.assignedSlot ?? node.parentNode ?? (node.host || null);
18
24
  }
19
25
  /**
20
- * Checks if an element is an input-like element that should be ignored for hotkeys.
21
- *
22
- * This includes:
23
- * - HTMLInputElement (all input types except button, submit, reset)
24
- * - HTMLTextAreaElement
25
- * - HTMLSelectElement
26
- * - Elements with contentEditable enabled
26
+ * Whether key presses reach a listener on `target` right now. Document and
27
+ * window listeners receive every key press in their document. An element
28
+ * receives them only while focus is inside it, including inside a shadow
29
+ * root it contains or on content slotted into it.
30
+ */
31
+ function targetHasFocus(target) {
32
+ if (isDocumentLevel(target)) return true;
33
+ for (let node = getDeepActiveElement(target.ownerDocument); node; node = composedParent(node)) if (node === target) return true;
34
+ return false;
35
+ }
36
+ const VERTICAL = ["ArrowUp", "ArrowDown"];
37
+ const HORIZONTAL = ["ArrowLeft", "ArrowRight"];
38
+ const ENTER = ["Enter"];
39
+ const SPACE = ["Space"];
40
+ const ACTIVATION = ["Enter", "Space"];
41
+ const TYPEAHEAD = "typeahead";
42
+ const MENU = [
43
+ "ArrowUp",
44
+ "ArrowDown",
45
+ "ArrowLeft",
46
+ "ArrowRight",
47
+ "Home",
48
+ "End",
49
+ "Enter",
50
+ "Space",
51
+ TYPEAHEAD
52
+ ];
53
+ const COMBOBOX = [
54
+ "ArrowUp",
55
+ "ArrowDown",
56
+ "Home",
57
+ "End",
58
+ "Enter",
59
+ "Space",
60
+ TYPEAHEAD
61
+ ];
62
+ const GRID = [
63
+ "ArrowUp",
64
+ "ArrowDown",
65
+ "ArrowLeft",
66
+ "ArrowRight",
67
+ "Home",
68
+ "End",
69
+ "PageUp",
70
+ "PageDown",
71
+ "Enter",
72
+ "Space"
73
+ ];
74
+ const RADIOS = [
75
+ "ArrowUp",
76
+ "ArrowDown",
77
+ "ArrowLeft",
78
+ "ArrowRight",
79
+ "Space"
80
+ ];
81
+ const NAVIGATION = [
82
+ "ArrowUp",
83
+ "ArrowDown",
84
+ "ArrowLeft",
85
+ "ArrowRight",
86
+ "Home",
87
+ "End",
88
+ "PageUp",
89
+ "PageDown"
90
+ ];
91
+ const SPIN = [
92
+ "ArrowUp",
93
+ "ArrowDown",
94
+ "Home",
95
+ "End",
96
+ "PageUp",
97
+ "PageDown"
98
+ ];
99
+ const CARET = [
100
+ "ArrowLeft",
101
+ "ArrowRight",
102
+ "Home",
103
+ "End"
104
+ ];
105
+ /**
106
+ * Input types that aren't text fields, with the keys each one keeps. Browsers
107
+ * report unknown types as `'text'`.
108
+ */
109
+ const INPUT_KEYS = {
110
+ button: ACTIVATION,
111
+ submit: ACTIVATION,
112
+ reset: ACTIVATION,
113
+ image: ACTIVATION,
114
+ color: ACTIVATION,
115
+ file: ACTIVATION,
116
+ checkbox: SPACE,
117
+ radio: RADIOS,
118
+ range: NAVIGATION
119
+ };
120
+ function getRole(element) {
121
+ return element.getAttribute("role")?.trim().split(/\s+/)[0];
122
+ }
123
+ /**
124
+ * Checks if an element is a text field that hotkeys should not type over:
125
+ * - text-entry `<input>` types (text, number, date, and similar) that aren't read-only
126
+ * - `<textarea>` elements that aren't read-only, and `<select>` elements
127
+ * - elements with `role="textbox"` or `role="searchbox"`
128
+ * - contenteditable elements
27
129
  *
28
- * Button-type inputs (button, submit, reset) are excluded so hotkeys like
29
- * Mod+S and Escape fire when the user has tabbed to a form button.
130
+ * Checkbox, radio, range, color, file, and button-type inputs are not text
131
+ * fields. Hotkeys skip only the keys those controls use themselves.
30
132
  */
31
133
  function isInputElement(element) {
32
134
  if (!isElement(element)) return false;
33
- if (element.localName === "input") {
34
- const type = element.type.toLowerCase();
35
- return type !== "button" && type !== "submit" && type !== "reset";
135
+ switch (element.localName) {
136
+ case "input": {
137
+ const input = element;
138
+ return !INPUT_KEYS[input.type] && !input.readOnly;
139
+ }
140
+ case "textarea": return !element.readOnly;
141
+ case "select": return true;
36
142
  }
37
- if (element.localName === "textarea" || element.localName === "select") return true;
38
- return element.isContentEditable === true;
143
+ const role = getRole(element);
144
+ return role === "textbox" || role === "searchbox" || element.isContentEditable;
39
145
  }
40
146
  /**
41
- * Returns the focused element for the document associated with where hotkey
42
- * listeners are attached (listener root). Use this instead of the global
43
- * `document.activeElement` so registrations scoped to an iframe (or another
44
- * document) read focus from the correct tree.
147
+ * Whether the focused custom element may be a text field whose contents can't
148
+ * be seen from outside:
149
+ * - a host with focus inside a closed shadow root. Without a `tabindex` it
150
+ * can't hold focus itself, and an open root would have shown the focused
151
+ * element inside it.
152
+ * - a form-associated element without a `role` attribute naming another control
45
153
  */
46
- function getActiveElementForListenerTarget(target) {
47
- if (typeof document === "undefined") return null;
48
- return getTargetDocument(target).activeElement;
154
+ function isHiddenTextField(element) {
155
+ if (!element.localName.includes("-")) return false;
156
+ return element.constructor.formAssociated === true ? !element.hasAttribute("role") : !element.hasAttribute("tabindex");
49
157
  }
50
- const VERTICAL = ["ArrowUp", "ArrowDown"];
51
- const HORIZONTAL = ["ArrowLeft", "ArrowRight"];
52
- const ARROWS = [...VERTICAL, ...HORIZONTAL];
53
- const EDGES = ["Home", "End"];
54
- const PAGES = ["PageUp", "PageDown"];
55
- const ACTIVATION = ["Enter", "Space"];
56
- const TYPEAHEAD = "typeahead";
57
158
  /**
58
159
  * Returns the keys a control handles itself: activation for buttons and
59
160
  * links, and navigation for ARIA widgets, following the WAI-ARIA Authoring
60
161
  * Practices keyboard patterns (https://www.w3.org/WAI/ARIA/apg/patterns/).
162
+ * Editable text fields never get here: they block every key that types text.
61
163
  */
62
164
  function getOwnedKeys(element) {
63
165
  const orientation = element.getAttribute("aria-orientation");
64
- switch (element.getAttribute("role")?.trim().split(/\s+/)[0]) {
166
+ switch (getRole(element)) {
65
167
  case "button":
66
168
  case "tab":
67
169
  case "option":
@@ -69,64 +171,47 @@ function getOwnedKeys(element) {
69
171
  case "menuitem":
70
172
  case "menuitemcheckbox":
71
173
  case "menuitemradio": return ACTIVATION;
72
- case "link": return ["Enter"];
174
+ case "link": return ENTER;
73
175
  case "checkbox":
74
176
  case "switch":
75
- case "radio": return ["Space"];
177
+ case "radio": return SPACE;
76
178
  case "menu":
77
179
  case "menubar":
78
- case "tree": return [
79
- ...ARROWS,
80
- ...EDGES,
81
- ...ACTIVATION,
82
- TYPEAHEAD
83
- ];
180
+ case "tree": return MENU;
84
181
  case "listbox": return [
85
182
  ...orientation === "horizontal" ? HORIZONTAL : VERTICAL,
86
- ...EDGES,
87
- ...ACTIVATION,
88
- TYPEAHEAD
89
- ];
90
- case "combobox": return [
91
- ...VERTICAL,
92
- ...EDGES,
93
- ...ACTIVATION,
183
+ "Home",
184
+ "End",
185
+ "Enter",
186
+ "Space",
94
187
  TYPEAHEAD
95
188
  ];
189
+ case "combobox": return COMBOBOX;
96
190
  case "tablist": return [
97
191
  ...orientation === "vertical" ? VERTICAL : HORIZONTAL,
98
- ...EDGES,
99
- ...ACTIVATION
192
+ "Home",
193
+ "End",
194
+ "Enter",
195
+ "Space"
100
196
  ];
101
- case "toolbar": return [...orientation === "vertical" ? VERTICAL : HORIZONTAL, ...EDGES];
102
- case "grid":
103
- case "treegrid": return [
104
- ...ARROWS,
105
- ...EDGES,
106
- ...PAGES,
107
- ...ACTIVATION
108
- ];
109
- case "radiogroup": return [...ARROWS, "Space"];
110
- case "slider": return [
111
- ...ARROWS,
112
- ...EDGES,
113
- ...PAGES
114
- ];
115
- case "spinbutton": return [
116
- ...VERTICAL,
117
- ...EDGES,
118
- ...PAGES
197
+ case "toolbar": return [
198
+ ...orientation === "vertical" ? VERTICAL : HORIZONTAL,
199
+ "Home",
200
+ "End"
119
201
  ];
202
+ case "grid":
203
+ case "treegrid": return GRID;
204
+ case "radiogroup": return RADIOS;
205
+ case "slider": return NAVIGATION;
206
+ case "spinbutton": return SPIN;
120
207
  }
121
208
  switch (element.localName) {
122
209
  case "button":
123
210
  case "summary": return ACTIVATION;
124
- case "input": {
125
- const type = element.type;
126
- return type === "button" || type === "submit" || type === "reset" ? ACTIVATION : [];
127
- }
211
+ case "input": return INPUT_KEYS[element.type] ?? CARET;
212
+ case "textarea": return NAVIGATION;
128
213
  case "a":
129
- case "area": return element.hasAttribute("href") ? ["Enter"] : [];
214
+ case "area": return element.hasAttribute("href") ? ENTER : [];
130
215
  }
131
216
  return [];
132
217
  }
@@ -139,56 +224,60 @@ function ownsKey(element, event, key) {
139
224
  if (event.ctrlKey || event.metaKey || event.altKey) return false;
140
225
  const owned = getOwnedKeys(element);
141
226
  if (key === "Enter" || key === "Space") return !event.shiftKey && owned.includes(key);
142
- return owned.includes(key) || owned.includes(TYPEAHEAD) && /^\S$/u.test(event.key);
227
+ return owned.includes(key) || owned.includes(TYPEAHEAD) && /^.$/u.test(event.key);
143
228
  }
144
229
  /**
145
- * Whether the focused element or an element on the event's path passes `test`.
146
- * Only elements inside the registration target count: walking outward from
147
- * the event's origin stops at the registration target, so a hotkey targeted
148
- * at a control, or at an element inside it, is not blocked by that control.
230
+ * Finds the first element on the event's way out that blocks it, walking out
231
+ * from the focused element. A key event starts there in a browser. One sent
232
+ * by a test may start at an ancestor of the focused element or at the
233
+ * document, and the walk from focus passes through those too. Only an event
234
+ * that starts where focus can't be seen, such as inside a closed shadow root,
235
+ * uses its own path.
149
236
  */
150
- function someElementInsideTarget(event, listenerTarget, registrationTarget, test) {
151
- const scoped = !isWindow(registrationTarget) && registrationTarget.nodeType !== 9;
152
- const focused = getActiveElementForListenerTarget(listenerTarget);
153
- if (focused && focused !== registrationTarget && (!scoped || registrationTarget.contains(focused)) && test(focused)) return true;
154
- const path = event.composedPath();
155
- for (const element of path.length > 0 ? path : [event.target]) {
156
- if (element === registrationTarget) break;
157
- if (isElement(element) && test(element)) return true;
158
- }
159
- return false;
237
+ function findBlocker(event, doc, test) {
238
+ const focused = getDeepActiveElement(doc);
239
+ let elements = [];
240
+ for (let node = focused; node; node = composedParent(node)) if (isElement(node)) elements.push(node);
241
+ const origin = event.composedPath()[0];
242
+ if (isElement(origin) && !elements.includes(origin)) elements = event.composedPath().filter(isElement);
243
+ const index = elements.findIndex((element) => test(element) || element === focused && isHiddenTextField(element));
244
+ return {
245
+ elements,
246
+ index
247
+ };
160
248
  }
161
- /**
162
- * Returns whether a hotkey should skip an event because a focused control
163
- * inside the registration target handles the key itself:
164
- * - text inputs, textareas, selects, and contenteditable elements (typing)
165
- * - buttons, links, and `<summary>` elements (Enter and Space activation)
166
- * - ARIA widgets such as menus, listboxes, tabs, and grids (navigation keys)
167
- */
168
- function shouldIgnoreInputEvent(event, listenerTarget, registrationTarget) {
169
- const key = normalizeKeyName(event.key);
170
- return someElementInsideTarget(event, listenerTarget, registrationTarget, (element) => isInputElement(element) || ownsKey(element, event, key));
249
+ /** Whether the blocking element is inside `target`. A target never blocks itself. */
250
+ function blocks({ elements, index }, target) {
251
+ return index >= 0 && (isDocumentLevel(target) || index < elements.indexOf(target));
171
252
  }
253
+ const ignoreCache = /* @__PURE__ */ new WeakMap();
172
254
  /**
173
- * Returns whether an event comes from a text input, textarea, select, or
174
- * contenteditable element in the target's document. Unlike
175
- * `shouldIgnoreInputEvent`, buttons, links, and ARIA widgets don't count.
255
+ * Returns whether a hotkey on `target` should skip an event because a control
256
+ * inside the target handles the key itself:
257
+ * - text fields, including ARIA text roles and custom elements that hide one (typing)
258
+ * - buttons, links, `<summary>`, and button-like inputs (Enter and Space activation)
259
+ * - checkbox, radio, and range inputs, and ARIA widgets such as menus,
260
+ * listboxes, tabs, and grids (the keys they use)
261
+ *
262
+ * A hotkey targeted at a control receives that control's keys.
176
263
  */
177
- function isTypingEvent(event, target) {
178
- return someElementInsideTarget(event, target, target, isInputElement);
264
+ function shouldIgnoreInputEvent(event, target) {
265
+ let blocker = ignoreCache.get(event);
266
+ if (!blocker) {
267
+ const key = normalizeKeyName(event.key);
268
+ blocker = findBlocker(event, getTargetDocument(target), (element) => isInputElement(element) || ownsKey(element, event, key));
269
+ ignoreCache.set(event, blocker);
270
+ }
271
+ return blocks(blocker, target);
179
272
  }
180
273
  /**
181
- * Checks if an event is for the given target (originated from or bubbled to it).
182
- *
183
- * For document/window targets, also accepts the document's root element as
184
- * currentTarget to handle Brave and other browsers where currentTarget may be
185
- * documentElement instead of document when listeners are attached to document.
274
+ * Returns whether an event comes from a text field inside `target`, as
275
+ * `isInputElement` and hidden custom text fields define them. Buttons, links,
276
+ * and other controls don't count.
186
277
  */
187
- function isEventForTarget(event, target) {
188
- if (event.currentTarget === target) return true;
189
- if (isWindow(target) || target.nodeType === 9) return event.currentTarget === getTargetDocument(target).documentElement;
190
- return isNode(event.target) && target.contains(event.target);
278
+ function isTypingEvent(event, target) {
279
+ return blocks(findBlocker(event, getTargetDocument(target), isInputElement), target);
191
280
  }
192
281
 
193
282
  //#endregion
194
- export { getActiveElementForListenerTarget, getTargetDocument, isEventForTarget, isInputElement, isTypingEvent, shouldIgnoreInputEvent };
283
+ export { getTargetDocument, isDocumentLevel, isInputElement, isTypingEvent, shouldIgnoreInputEvent, targetHasFocus };
package/dist/_match.js CHANGED
@@ -22,7 +22,7 @@ function matchKeyboardEvent(event, hotkey, platform = detectPlatform()) {
22
22
  const direct = keysEqual(normalized.key, parsed.key);
23
23
  const implicitShift = direct && isPrintableKey(normalized.key) && !/^\p{Letter}$/u.test(normalized.key) && !parsed.shift;
24
24
  if (direct && modifiersMatch(normalized, parsed, implicitShift)) return matched("key", implicitShift ? 2 : 3);
25
- if (isAsciiLetter(normalized.key) || isLatinLetter(normalized.key) && normalized.code.startsWith("Key") && !normalized.alt) return noMatch;
25
+ if (!positionStandsIn(normalized)) return noMatch;
26
26
  if (normalized.altGraph) return noMatch;
27
27
  if (!modifiersMatch(normalized, parsed, false)) return noMatch;
28
28
  const fallbackKey = codeToLogicalKey(normalized.code);
@@ -39,6 +39,28 @@ function matchKeyboardEvent(event, hotkey, platform = detectPlatform()) {
39
39
  };
40
40
  }
41
41
  }
42
+ /**
43
+ * Whether a key's conventional position may stand in for what it typed. Only
44
+ * output that can't name a shortcut falls back:
45
+ * - non-Latin letters and symbols, such as Russian `ы` or macOS Option's `–`
46
+ * - dead keys
47
+ * - anything on the digit row, where AZERTY types `&` and `é` for 1 and 2
48
+ * - ASCII symbols typed with Shift, such as `!` for Shift+1
49
+ * - Latin letters on punctuation keys with Control or Command, so German
50
+ * Cmd+ü still fires `Mod+[` (German types `[` with Option)
51
+ *
52
+ * A named key (`End` from a NumLock-off numpad), any other ASCII character,
53
+ * or a Latin letter typed on its own is the key the user meant, wherever it
54
+ * sits. AZERTY's `+` on the US `/` position is `+`, not `/`.
55
+ */
56
+ function positionStandsIn({ key, code, ctrl, meta, shift, alt }) {
57
+ if (key !== "Dead" && !isPrintableKey(key)) return false;
58
+ if (code.startsWith("Digit")) return true;
59
+ if (isAsciiLetter(key)) return false;
60
+ if (/^[!-~]$/.test(key)) return shift;
61
+ if (!isLatinLetter(key) || alt) return true;
62
+ return (ctrl || meta) && !code.startsWith("Key");
63
+ }
42
64
  /** Compares modifiers, excluding synthetic AltGr flags and optionally allowing Shift used to type a glyph. */
43
65
  function modifiersMatch(event, parsed, allowImplicitShift) {
44
66
  const ctrl = event.altGraph ? false : event.ctrl;
@@ -227,13 +227,12 @@ var AppHotkeysStore = class {
227
227
  if (list) list.push(registration);
228
228
  else byId.set(id, [registration]);
229
229
  }
230
- const manager = getHotkeyManager();
231
230
  return Object.values(this.store.state.appHotkeys).map((appHotkey) => {
232
231
  const registered = byId.get(appHotkey.id) ?? [];
233
- const enabled = registered.filter((r) => r.options.enabled !== false);
232
+ const has = (status) => registered.some((r) => r.status === status);
234
233
  return {
235
234
  appHotkey,
236
- status: appHotkey.hotkey === null ? "unbound" : registered.length === 0 ? "inactive" : enabled.length === 0 ? "disabled" : enabled.some((r) => manager.isScopeActive(r.options.scope)) ? "active" : "suspended",
235
+ status: appHotkey.hotkey === null ? "unbound" : registered.length === 0 ? "inactive" : has("active") ? "active" : has("suspended") ? "suspended" : "disabled",
237
236
  isCustom: isCustomHotkey(appHotkey, this.#platform),
238
237
  registrations: registered
239
238
  };
@@ -1,5 +1,5 @@
1
1
  import { ConflictBehavior, HotkeyCallback, HotkeyCallbackContextFor, HotkeyChord, HotkeyMeta, HotkeySequence, ParsedHotkeyChord, RegisterableHotkey, RegisterableHotkeyChord, RegisterableHotkeySequence } from "./hotkey.types.js";
2
- import { AppHotkeysStore } from "./app-hotkeys.js";
2
+ import { AppHotkey, AppHotkeysStore } from "./app-hotkeys.js";
3
3
  import { Store } from "@tanstack/store";
4
4
  //#region src/hotkey-manager.d.ts
5
5
  /** An element, document, or window that hotkeys can listen on. */
@@ -41,9 +41,14 @@ export interface HotkeyOptions {
41
41
  platform?: 'mac' | 'windows' | 'linux';
42
42
  /**
43
43
  * Prevent the default browser action when the hotkey matches. For a
44
- * sequence, every step that advances it does. Defaults to true
44
+ * sequence, every step that advances it does. Defaults to true.
45
+ *
46
+ * `'always'` also prevents it while a chord can't fire because it is
47
+ * disabled or its scope is inactive or suspended, unless another hotkey
48
+ * handles the key. It never stops propagation for such a chord. Sequences
49
+ * treat `'always'` as `true`.
45
50
  */
46
- preventDefault?: boolean;
51
+ preventDefault?: boolean | 'always';
47
52
  /**
48
53
  * Stop event propagation when the hotkey matches. For a sequence, only the
49
54
  * last step does, so earlier steps reach other listeners. Defaults to true
@@ -118,8 +123,18 @@ export interface HotkeySequenceRegistration extends RegistrationBase<HotkeySeque
118
123
  }
119
124
  /** A registered hotkey in the HotkeyManager. */
120
125
  export type HotkeyRegistration = HotkeyChordRegistration | HotkeySequenceRegistration;
126
+ /**
127
+ * Whether a registration is on. Focus isn't checked: a registration on an
128
+ * element also needs focus inside that element to fire.
129
+ * - `'active'`: enabled, and its scope is active
130
+ * - `'suspended'`: enabled, but its scope is inactive or suspended by an exclusive scope
131
+ * - `'disabled'`: registered with `enabled: false`
132
+ */
133
+ export type HotkeyRegistrationStatus = 'active' | 'suspended' | 'disabled';
121
134
  interface RegistrationViewBase {
122
135
  id: string;
136
+ /** Whether the registration is enabled and in an active scope. */
137
+ status: HotkeyRegistrationStatus;
123
138
  target: Target;
124
139
  triggerCount: number;
125
140
  hasFired: boolean;
@@ -372,6 +387,17 @@ export declare class HotkeyManager {
372
387
  * @returns True if a matching registration exists
373
388
  */
374
389
  isRegistered(hotkey: RegisterableHotkey, target?: Target): boolean;
390
+ /**
391
+ * Checks whether a hotkey can fire now: at least one registration is
392
+ * enabled, in an active scope, and listening where key presses go. A
393
+ * registration on `document` or `window` always is; one on an element is
394
+ * only while focus is inside that element. An app hotkey matches
395
+ * registrations by id, so custom hotkeys apply.
396
+ *
397
+ * @param hotkey - A chord, sequence, or app hotkey
398
+ * @returns True if a registration that can fire exists
399
+ */
400
+ hasActiveRegistration(hotkey: RegisterableHotkey | AppHotkey): boolean;
375
401
  /**
376
402
  * Destroys the manager and removes all listeners.
377
403
  */
@@ -5,7 +5,7 @@ import { isHotkeySequence, isModifierKey, parseHotkey, simplifyHotkey } from "./
5
5
  import { formatHotkey } from "./format.js";
6
6
  import { matchKeyboardEvent, parsedChordsEqual } from "./_match.js";
7
7
  import { DEFAULT_SEQUENCE_TIMEOUT, areHotkeysEqual } from "./match.js";
8
- import { getTargetDocument, isEventForTarget, shouldIgnoreInputEvent } from "./_event-target.js";
8
+ import { getTargetDocument, isDocumentLevel, shouldIgnoreInputEvent, targetHasFocus } from "./_event-target.js";
9
9
  import { TargetListeners } from "./_target-listeners.js";
10
10
  import { managerRef } from "./_manager-ref.js";
11
11
  import { isRecordingEvent } from "./_recording-guard.js";
@@ -13,10 +13,16 @@ import { GLOBAL_SCOPE, defaultHotkeyOptions, getDefaultIgnoreInputs, handleConfl
13
13
  import { Store } from "@tanstack/store";
14
14
 
15
15
  //#region src/hotkey-manager.ts
16
+ /** Whether a registration can fire now. Without a manager, scopes don't apply. */
17
+ function getRegistrationStatus(options, manager) {
18
+ if (options.enabled === false) return "disabled";
19
+ return manager?.isScopeActive(options.scope) === false ? "suspended" : "active";
20
+ }
16
21
  /** Creates a public view from an internal registration, stripping the callback. */
17
22
  function toHotkeyRegistrationView(reg) {
18
23
  const base = {
19
24
  id: reg.id,
25
+ status: getRegistrationStatus(reg.options, managerRef.current),
20
26
  target: reg.target,
21
27
  triggerCount: reg.triggerCount,
22
28
  hasFired: reg.hasFired
@@ -270,7 +276,20 @@ var HotkeyManager = class HotkeyManager {
270
276
  }
271
277
  /** Whether a registration can fire now: enabled, in an active scope. */
272
278
  #isLive(registration) {
273
- return registration.options.enabled !== false && this.isScopeActive(registration.options.scope);
279
+ return getRegistrationStatus(registration.options, this) === "active";
280
+ }
281
+ /**
282
+ * Whether a hotkey that can fire would handle this key on any listener the
283
+ * event reaches, including other targets and phases. A sequence counts when
284
+ * the key matches its next step or its first step.
285
+ */
286
+ #handledByLiveHotkey(event, eventType) {
287
+ const path = event.composedPath();
288
+ for (const registration of this.registrations.state.values()) {
289
+ if (!this.#isLive(registration) || registration.options.eventType !== eventType || !path.includes(registration.target) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, registration.target)) continue;
290
+ if ((registration.kind === "chord" ? [registration.parsedChord] : [registration.parsedSequence[registration.currentIndex], registration.parsedSequence[0]]).some((step) => matchKeyboardEvent(event, step, registration.options.platform).matched)) return true;
291
+ }
292
+ return false;
274
293
  }
275
294
  /** Notifies subscribers after in-place updates such as counts and sequence progress. */
276
295
  #notify() {
@@ -455,14 +474,20 @@ var HotkeyManager = class HotkeyManager {
455
474
  if (isRecordingEvent(event) || this.#sequenceOwned.get(event)) return;
456
475
  const matches = /* @__PURE__ */ new Map();
457
476
  let bestScore = 0;
477
+ let reserved = false;
458
478
  for (const registration of registrations) {
459
- if (!this.#isLive(registration) || registration.options.eventType !== eventType || !isEventForTarget(event, target) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target)) continue;
479
+ const live = this.#isLive(registration);
480
+ if (!live && registration.options.preventDefault !== "always" || registration.options.eventType !== eventType || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target)) continue;
460
481
  const result = matchKeyboardEvent(event, registration.parsedChord, registration.options.platform);
461
- if (result.matched) {
462
- matches.set(registration.id, result);
463
- bestScore = Math.max(bestScore, result.score);
482
+ if (!result.matched) continue;
483
+ if (!live) {
484
+ reserved = true;
485
+ continue;
464
486
  }
487
+ matches.set(registration.id, result);
488
+ bestScore = Math.max(bestScore, result.score);
465
489
  }
490
+ if (reserved && matches.size === 0 && !this.#handledByLiveHotkey(event, eventType)) event.preventDefault();
466
491
  let bestRank = -1;
467
492
  for (const registration of registrations) if (matches.get(registration.id)?.score === bestScore) bestRank = Math.max(bestRank, this.#scopeRank(registration.options.scope));
468
493
  for (const snapshot of registrations) {
@@ -508,7 +533,7 @@ var HotkeyManager = class HotkeyManager {
508
533
  for (const registration of this.registrations.state.values()) {
509
534
  if (registration.kind !== "sequence" || registration.currentIndex === 0 || !this.#isLive(registration) || registration.options.eventType !== eventType || now - registration.lastKeyTime > (registration.options.timeout ?? 1e3)) continue;
510
535
  const target = registration.target;
511
- if (path.includes(target.nodeType === 1 ? target : getTargetDocument(target)) && !(ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, target)) && matchKeyboardEvent(event, registration.parsedSequence[registration.currentIndex], registration.options.platform).matched) return true;
536
+ if (path.includes(isDocumentLevel(target) ? getTargetDocument(target) : target) && !(ignoresInputs(registration) && shouldIgnoreInputEvent(event, target)) && matchKeyboardEvent(event, registration.parsedSequence[registration.currentIndex], registration.options.platform).matched) return true;
512
537
  }
513
538
  return false;
514
539
  }
@@ -531,7 +556,7 @@ var HotkeyManager = class HotkeyManager {
531
556
  const candidates = /* @__PURE__ */ new Map();
532
557
  let bestScore = 0;
533
558
  for (const registration of registrations) {
534
- if (!isEventForTarget(event, target) || !this.#isLive(registration) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target) || registration.options.eventType !== eventType) continue;
559
+ if (!this.#isLive(registration) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target) || registration.options.eventType !== eventType) continue;
535
560
  const timeout = registration.options.timeout ?? 1e3;
536
561
  if (registration.currentIndex > 0 && now - registration.lastKeyTime > timeout) {
537
562
  registration.currentIndex = 0;
@@ -679,6 +704,21 @@ var HotkeyManager = class HotkeyManager {
679
704
  return false;
680
705
  }
681
706
  /**
707
+ * Checks whether a hotkey can fire now: at least one registration is
708
+ * enabled, in an active scope, and listening where key presses go. A
709
+ * registration on `document` or `window` always is; one on an element is
710
+ * only while focus is inside that element. An app hotkey matches
711
+ * registrations by id, so custom hotkeys apply.
712
+ *
713
+ * @param hotkey - A chord, sequence, or app hotkey
714
+ * @returns True if a registration that can fire exists
715
+ */
716
+ hasActiveRegistration(hotkey) {
717
+ const id = typeof hotkey === "object" && "~appHotkey" in hotkey ? hotkey.id : void 0;
718
+ 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;
719
+ return false;
720
+ }
721
+ /**
682
722
  * Destroys the manager and removes all listeners.
683
723
  */
684
724
  destroy() {
@@ -101,7 +101,7 @@ var HotkeyRecorder = class {
101
101
  * and records chords according to `maxSteps`.
102
102
  */
103
103
  start() {
104
- if (this.#keydownHandler) return;
104
+ if (this.#keydownHandler || typeof document === "undefined") return;
105
105
  this.#clearIdleTimer();
106
106
  this.#events = [];
107
107
  beginRecording(this);
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { CanonicalModifier, EditingKey, FunctionKey, IndividualKey, Key, LetterKey, LogicalKey, Modifier, NamedKey, NavigationKey, NonPunctuationKey, NormalizedKeyboardEvent, NumberKey, PhysicalKey, PhysicalKeyCode, PunctuationKey } from "./key.types.js";
2
2
  import { ConflictBehavior, FourModifierHotkeyChord, Hotkey, HotkeyCallback, HotkeyCallbackContext, HotkeyCallbackContextFor, HotkeyChord, HotkeyChordCallbackContext, HotkeyMeta, HotkeySequence, HotkeySequenceCallbackContext, ParsedHotkeyChord, ParsedModifiers, RawHotkeyChord, RawModifiers, RegisterableHotkey, RegisterableHotkeyChord, RegisterableHotkeySequence, SingleModifierHotkeyChord, ThreeModifierHotkeyChord, TwoModifierHotkeyChord } from "./hotkey.types.js";
3
- import { ActivateHotkeyScopeOptions, HotkeyChordOptions, HotkeyChordRegistration, HotkeyChordRegistrationView, HotkeyManager, HotkeyOptions, HotkeyOptionsFor, HotkeyRegistration, HotkeyRegistrationHandle, HotkeyRegistrationView, HotkeyScopesState, HotkeySequenceOptions, HotkeySequenceRegistration, HotkeySequenceRegistrationView, Target, getHotkeyManager, toHotkeyRegistrationView } from "./hotkey-manager.js";
3
+ import { ActivateHotkeyScopeOptions, HotkeyChordOptions, HotkeyChordRegistration, HotkeyChordRegistrationView, HotkeyManager, HotkeyOptions, HotkeyOptionsFor, HotkeyRegistration, HotkeyRegistrationHandle, HotkeyRegistrationStatus, HotkeyRegistrationView, HotkeyScopesState, HotkeySequenceOptions, HotkeySequenceRegistration, HotkeySequenceRegistrationView, Target, getHotkeyManager, toHotkeyRegistrationView } from "./hotkey-manager.js";
4
4
  import { AppHotkey, AppHotkeyChordOptions, AppHotkeyDefaultKind, AppHotkeyId, AppHotkeyInput, AppHotkeyListItem, AppHotkeyOptions, AppHotkeySequenceOptions, AppHotkeyStatus, AppHotkeys, AppHotkeysState, AppHotkeysStore, AppHotkeysStoreOptions, CustomHotkeys, DefinedAppHotkeys, Register, RegisteredAppHotkeys, defineAppHotkeys, getAppHotkeyRegistrationOptions, isAppHotkey, isCustomHotkey, provideAppHotkeys, publishAppHotkeysStore, resolveAppHotkey } from "./app-hotkeys.js";
5
5
  import { ALL_KEYS, EDITING_KEYS, FUNCTION_KEYS, LETTER_KEYS, MODIFIER_ALIASES, MODIFIER_KEYS, MODIFIER_ORDER, NAVIGATION_KEYS, NUMBER_KEYS, PUNCTUATION_CODE_MAP, PUNCTUATION_KEYS, isSingleLetterKey, normalizeKeyName } from "./constants.js";
6
6
  import { KEY_DISPLAY_SYMBOLS, LINUX_MODIFIER_LABELS, MAC_MODIFIER_LABELS, MAC_MODIFIER_SYMBOLS, PUNCTUATION_KEY_DISPLAY_LABELS, WINDOWS_MODIFIER_LABELS } from "./display-labels.js";
@@ -13,4 +13,4 @@ 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
15
  import { HotkeyChordRecorderOptions, HotkeyChordRecorderValidationContext, HotkeyRecorder, HotkeyRecorderCommitKeys, HotkeyRecorderOptions, HotkeyRecorderState, HotkeyRecorderValidationContext, HotkeySequenceRecorderOptions, HotkeySequenceRecorderValidationContext, RecorderKeyMode, RecorderOptions, RecorderRejection } from "./hotkey-recorder.js";
16
- export { ALL_KEYS, ActivateHotkeyScopeOptions, AppHotkey, AppHotkeyChordOptions, AppHotkeyDefaultKind, AppHotkeyId, AppHotkeyInput, AppHotkeyListItem, AppHotkeyOptions, AppHotkeySequenceOptions, AppHotkeyStatus, AppHotkeys, AppHotkeysState, AppHotkeysStore, AppHotkeysStoreOptions, CanonicalModifier, ConflictBehavior, CreateHotkeyChordHandlerOptions, CustomHotkeys, DEFAULT_SEQUENCE_TIMEOUT, DefinedAppHotkeys, DisplayHotkey, DisplayHotkeyChord, EDITING_KEYS, EditingKey, FUNCTION_KEYS, FormatDisplayOptions, FourModifierHotkeyChord, FunctionKey, HeldModifierOptions, Hotkey, HotkeyCallback, HotkeyCallbackContext, HotkeyCallbackContextFor, HotkeyChord, HotkeyChordCallbackContext, HotkeyChordOptions, HotkeyChordRecorderOptions, HotkeyChordRecorderValidationContext, HotkeyChordRegistration, HotkeyChordRegistrationView, HotkeyConflict, HotkeyConflictOptions, HotkeyManager, HotkeyMeta, HotkeyOptions, HotkeyOptionsFor, HotkeyRecorder, HotkeyRecorderCommitKeys, HotkeyRecorderOptions, HotkeyRecorderState, HotkeyRecorderValidationContext, HotkeyRegistration, HotkeyRegistrationHandle, HotkeyRegistrationView, HotkeyScopesState, HotkeySequence, HotkeySequenceCallbackContext, HotkeySequenceOptions, HotkeySequenceRecorderOptions, HotkeySequenceRecorderValidationContext, 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, MultiHotkeyChordHandler, NAVIGATION_KEYS, NUMBER_KEYS, NamedKey, NavigationKey, NonPunctuationKey, NormalizedKeyboardEvent, NumberKey, PUNCTUATION_CODE_MAP, PUNCTUATION_KEYS, PUNCTUATION_KEY_DISPLAY_LABELS, ParsedHotkeyChord, ParsedModifiers, PhysicalKey, PhysicalKeyCode, PunctuationKey, RawHotkeyChord, RawModifiers, RecorderKeyMode, RecorderOptions, RecorderRejection, Register, RegisterableHotkey, RegisterableHotkeyChord, RegisterableHotkeySequence, RegisteredAppHotkeys, SingleModifierHotkeyChord, Target, ThreeModifierHotkeyChord, TwoModifierHotkeyChord, ValidatableHotkey, ValidationResult, WINDOWS_MODIFIER_LABELS, areHotkeysEqual, assertValidHotkey, checkHotkey, createHotkeyChordHandler, createMultiHotkeyChordHandler, createSequenceMatcher, defineAppHotkeys, detectPlatform, findHotkeyConflicts, formatForDisplay, formatHotkey, getAppHotkeyRegistrationOptions, getHotkeyManager, getKeyStateTracker, hasNonModifierKey, isAppHotkey, isCustomHotkey, isHotkeySequence, isModifierKey, isSingleLetterKey, matchesHeldModifiers, matchesKeyboardEvent, normalizeHotkey, normalizeKeyName, parseHotkey, parseKeyboardEvent, provideAppHotkeys, publishAppHotkeysStore, resolveAppHotkey, resolveModifier, simplifyHotkey, toHotkeyRegistrationView, validateHotkey };
16
+ export { ALL_KEYS, ActivateHotkeyScopeOptions, AppHotkey, AppHotkeyChordOptions, AppHotkeyDefaultKind, AppHotkeyId, AppHotkeyInput, AppHotkeyListItem, AppHotkeyOptions, AppHotkeySequenceOptions, AppHotkeyStatus, AppHotkeys, AppHotkeysState, AppHotkeysStore, AppHotkeysStoreOptions, CanonicalModifier, ConflictBehavior, CreateHotkeyChordHandlerOptions, CustomHotkeys, DEFAULT_SEQUENCE_TIMEOUT, DefinedAppHotkeys, DisplayHotkey, DisplayHotkeyChord, EDITING_KEYS, EditingKey, FUNCTION_KEYS, FormatDisplayOptions, FourModifierHotkeyChord, FunctionKey, HeldModifierOptions, Hotkey, HotkeyCallback, HotkeyCallbackContext, HotkeyCallbackContextFor, HotkeyChord, HotkeyChordCallbackContext, HotkeyChordOptions, HotkeyChordRecorderOptions, HotkeyChordRecorderValidationContext, HotkeyChordRegistration, HotkeyChordRegistrationView, HotkeyConflict, HotkeyConflictOptions, HotkeyManager, HotkeyMeta, HotkeyOptions, HotkeyOptionsFor, HotkeyRecorder, HotkeyRecorderCommitKeys, HotkeyRecorderOptions, HotkeyRecorderState, HotkeyRecorderValidationContext, HotkeyRegistration, HotkeyRegistrationHandle, HotkeyRegistrationStatus, HotkeyRegistrationView, HotkeyScopesState, HotkeySequence, HotkeySequenceCallbackContext, HotkeySequenceOptions, HotkeySequenceRecorderOptions, HotkeySequenceRecorderValidationContext, 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, MultiHotkeyChordHandler, NAVIGATION_KEYS, NUMBER_KEYS, NamedKey, NavigationKey, NonPunctuationKey, NormalizedKeyboardEvent, NumberKey, PUNCTUATION_CODE_MAP, PUNCTUATION_KEYS, PUNCTUATION_KEY_DISPLAY_LABELS, ParsedHotkeyChord, ParsedModifiers, PhysicalKey, PhysicalKeyCode, PunctuationKey, RawHotkeyChord, RawModifiers, RecorderKeyMode, RecorderOptions, RecorderRejection, Register, RegisterableHotkey, RegisterableHotkeyChord, RegisterableHotkeySequence, RegisteredAppHotkeys, SingleModifierHotkeyChord, Target, ThreeModifierHotkeyChord, TwoModifierHotkeyChord, ValidatableHotkey, ValidationResult, WINDOWS_MODIFIER_LABELS, areHotkeysEqual, assertValidHotkey, checkHotkey, createHotkeyChordHandler, createMultiHotkeyChordHandler, createSequenceMatcher, defineAppHotkeys, detectPlatform, findHotkeyConflicts, formatForDisplay, formatHotkey, getAppHotkeyRegistrationOptions, getHotkeyManager, getKeyStateTracker, hasNonModifierKey, isAppHotkey, isCustomHotkey, isHotkeySequence, isModifierKey, isSingleLetterKey, matchesHeldModifiers, matchesKeyboardEvent, normalizeHotkey, normalizeKeyName, parseHotkey, parseKeyboardEvent, provideAppHotkeys, publishAppHotkeysStore, resolveAppHotkey, resolveModifier, simplifyHotkey, toHotkeyRegistrationView, validateHotkey };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/hotkeys",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Type-safe, framework-agnostic keyboard hotkey management for the browser",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -56,7 +56,7 @@ When nesting scopes, stacking dialogs, or keeping some hotkeys working during an
56
56
 
57
57
  ## Pitfalls
58
58
 
59
- - **A disabled hotkey doesn't block the browser.** With `enabled: false`, `Mod+S` opens the Save Page dialog. To skip the action, keep the hotkey enabled and return early from the callback.
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
62
  - **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`.
@@ -9,3 +9,5 @@
9
9
  - A hotkey's `scope` option, or an app hotkey's declared `scope`, overrides the provider's.
10
10
  - Scopes follow the component tree, not the DOM, so a portaled dialog keeps its scope.
11
11
  - The hotkey that opens an exclusive dialog is suspended with the rest of the page. To close the dialog with the same keys, register that hotkey inside the dialog too.
12
+ - To show which scope owns the keyboard, use the adapter's scope state API, such as `useHotkeyScopesState()` in React. Don't subscribe to `manager.scopes` by hand.
13
+ - To list only hotkeys that are on, filter the adapter's registrations by `status === 'active'`. `'suspended'` means the hotkey's scope is inactive or suspended, and `'disabled'` means `enabled: false`.