@tanstack/hotkeys 0.12.1 → 0.13.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,4 +1,21 @@
1
+ import { normalizeKeyName } from "./constants.js";
2
+
1
3
  //#region src/_event-target.ts
4
+ function isNode(target) {
5
+ return typeof target?.nodeType === "number";
6
+ }
7
+ function isElement(target) {
8
+ return isNode(target) && target.nodeType === 1;
9
+ }
10
+ function isWindow(target) {
11
+ return "document" in target;
12
+ }
13
+ /** Returns the document a listener target belongs to, in any frame. */
14
+ function getTargetDocument(target) {
15
+ if (isWindow(target)) return target.document;
16
+ if (target.nodeType === 9) return target;
17
+ return target.ownerDocument;
18
+ }
2
19
  /**
3
20
  * Checks if an element is an input-like element that should be ignored for hotkeys.
4
21
  *
@@ -12,15 +29,13 @@
12
29
  * Mod+S and Escape fire when the user has tabbed to a form button.
13
30
  */
14
31
  function isInputElement(element) {
15
- if (!element) return false;
16
- if (element instanceof HTMLInputElement) {
32
+ if (!isElement(element)) return false;
33
+ if (element.localName === "input") {
17
34
  const type = element.type.toLowerCase();
18
- if (type === "button" || type === "submit" || type === "reset") return false;
19
- return true;
35
+ return type !== "button" && type !== "submit" && type !== "reset";
20
36
  }
21
- if (element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) return true;
22
- if (element instanceof HTMLElement && element.isContentEditable) return true;
23
- return false;
37
+ if (element.localName === "textarea" || element.localName === "select") return true;
38
+ return element.isContentEditable === true;
24
39
  }
25
40
  /**
26
41
  * Returns the focused element for the document associated with where hotkey
@@ -30,41 +45,150 @@ function isInputElement(element) {
30
45
  */
31
46
  function getActiveElementForListenerTarget(target) {
32
47
  if (typeof document === "undefined") return null;
33
- if (typeof Document !== "undefined" && target instanceof Document || typeof Node !== "undefined" && target.nodeType === Node.DOCUMENT_NODE) return target.activeElement;
34
- if (typeof HTMLElement !== "undefined" && target instanceof HTMLElement) return target.ownerDocument.activeElement ?? null;
35
- return target.document.activeElement ?? null;
48
+ return getTargetDocument(target).activeElement;
36
49
  }
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";
37
57
  /**
38
- * Returns whether an event should be ignored because it originated from an
39
- * input-like element other than the registration target.
40
- *
41
- * This checks:
42
- * - the currently focused element for the listener target
43
- * - the event's composed path (for shadow DOM)
44
- * - the event target as a final fallback
58
+ * Returns the keys a control handles itself: activation for buttons and
59
+ * links, and navigation for ARIA widgets, following the WAI-ARIA Authoring
60
+ * Practices keyboard patterns (https://www.w3.org/WAI/ARIA/apg/patterns/).
45
61
  */
46
- function shouldIgnoreInputEvent(event, listenerTarget, registrationTarget) {
62
+ function getOwnedKeys(element) {
63
+ const orientation = element.getAttribute("aria-orientation");
64
+ switch (element.getAttribute("role")?.trim().split(/\s+/)[0]) {
65
+ case "button":
66
+ case "tab":
67
+ case "option":
68
+ case "treeitem":
69
+ case "menuitem":
70
+ case "menuitemcheckbox":
71
+ case "menuitemradio": return ACTIVATION;
72
+ case "link": return ["Enter"];
73
+ case "checkbox":
74
+ case "switch":
75
+ case "radio": return ["Space"];
76
+ case "menu":
77
+ case "menubar":
78
+ case "tree": return [
79
+ ...ARROWS,
80
+ ...EDGES,
81
+ ...ACTIVATION,
82
+ TYPEAHEAD
83
+ ];
84
+ case "listbox": return [
85
+ ...orientation === "horizontal" ? HORIZONTAL : VERTICAL,
86
+ ...EDGES,
87
+ ...ACTIVATION,
88
+ TYPEAHEAD
89
+ ];
90
+ case "combobox": return [
91
+ ...VERTICAL,
92
+ ...EDGES,
93
+ ...ACTIVATION,
94
+ TYPEAHEAD
95
+ ];
96
+ case "tablist": return [
97
+ ...orientation === "vertical" ? VERTICAL : HORIZONTAL,
98
+ ...EDGES,
99
+ ...ACTIVATION
100
+ ];
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
119
+ ];
120
+ }
121
+ switch (element.localName) {
122
+ case "button":
123
+ case "summary": return ACTIVATION;
124
+ case "input": {
125
+ const type = element.type;
126
+ return type === "button" || type === "submit" || type === "reset" ? ACTIVATION : [];
127
+ }
128
+ case "a":
129
+ case "area": return element.hasAttribute("href") ? ["Enter"] : [];
130
+ }
131
+ return [];
132
+ }
133
+ /**
134
+ * Whether a control handles this key itself. Ctrl, Meta, and Alt combinations
135
+ * are never owned; Enter and Space are owned only without modifiers, while
136
+ * navigation and typeahead keys allow Shift (for example, to extend a selection).
137
+ */
138
+ function ownsKey(element, event, key) {
139
+ if (event.ctrlKey || event.metaKey || event.altKey) return false;
140
+ const owned = getOwnedKeys(element);
141
+ if (key === "Enter" || key === "Space") return !event.shiftKey && owned.includes(key);
142
+ return owned.includes(key) || owned.includes(TYPEAHEAD) && /^\S$/u.test(event.key);
143
+ }
144
+ /**
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.
149
+ */
150
+ function someElementInsideTarget(event, listenerTarget, registrationTarget, test) {
151
+ const scoped = !isWindow(registrationTarget) && registrationTarget.nodeType !== 9;
47
152
  const focused = getActiveElementForListenerTarget(listenerTarget);
48
- if (focused && isInputElement(focused) && focused !== registrationTarget) return true;
49
- if (event.composedPath().some((element) => isInputElement(element) && element !== registrationTarget)) return true;
50
- return isInputElement(event.target) && event.target !== registrationTarget;
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;
160
+ }
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));
171
+ }
172
+ /**
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.
176
+ */
177
+ function isTypingEvent(event, target) {
178
+ return someElementInsideTarget(event, target, target, isInputElement);
51
179
  }
52
180
  /**
53
181
  * Checks if an event is for the given target (originated from or bubbled to it).
54
182
  *
55
- * For document/window targets, also accepts document.documentElement as currentTarget
56
- * to handle Brave and other browsers where currentTarget may be documentElement
57
- * instead of document when listeners are attached to document.
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.
58
186
  */
59
187
  function isEventForTarget(event, target) {
60
- if (target === document || target === window) return event.currentTarget === target || event.currentTarget === document.documentElement;
61
- if (target === window) return event.currentTarget === window || event.currentTarget === document || event.currentTarget === document.documentElement;
62
- if (target instanceof HTMLElement) {
63
- if (event.currentTarget === target) return true;
64
- if (event.target instanceof Node && target.contains(event.target)) return true;
65
- }
66
- return false;
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);
67
191
  }
68
192
 
69
193
  //#endregion
70
- export { getActiveElementForListenerTarget, isEventForTarget, isInputElement, shouldIgnoreInputEvent };
194
+ export { getActiveElementForListenerTarget, getTargetDocument, isEventForTarget, isInputElement, isTypingEvent, shouldIgnoreInputEvent };
@@ -8,11 +8,11 @@ const defaultHotkeyOptions = {
8
8
  stopPropagation: true,
9
9
  eventType: "keydown",
10
10
  enabled: true,
11
- ignoreInputs: true,
11
+ ignoreInputs: "auto",
12
12
  conflictBehavior: "warn"
13
13
  };
14
14
  /**
15
- * Computes the default ignoreInputs value based on the hotkey.
15
+ * Resolves `ignoreInputs: 'auto'` from the hotkey (the first step of a sequence).
16
16
  * Ctrl/Meta shortcuts and Escape fire in inputs; single keys and Shift/Alt combos are ignored.
17
17
  */
18
18
  function getDefaultIgnoreInputs(parsedHotkey) {
package/dist/adapter.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { detectPlatform } from "./platform.js";
2
- import { isHotkeySequence, normalizeRegisterableHotkey, parseHotkey } from "./parse.js";
3
- import { defaultHotkeyOptions, getDefaultIgnoreInputs } from "./_registration.js";
2
+ import { isHotkeySequence, normalizeRegisterableHotkey } from "./parse.js";
3
+ import { defaultHotkeyOptions } from "./_registration.js";
4
4
  import { getHotkeyManager } from "./hotkey-manager.js";
5
5
  import { getAppHotkeyRegistrationOptions, isAppHotkey, resolveAppHotkey } from "./app-hotkeys.js";
6
6
 
@@ -23,14 +23,12 @@ function createHotkeyBindings() {
23
23
  const options = {
24
24
  ...defaultHotkeyOptions,
25
25
  ...merged,
26
- ignoreInputs: merged.ignoreInputs,
26
+ ignoreInputs: merged.ignoreInputs ?? "auto",
27
27
  target: "target" in merged ? merged.target ?? null : typeof document === "undefined" ? null : document,
28
28
  platform: merged.platform ?? detectPlatform()
29
29
  };
30
30
  if (!options.target || isHotkeySequence(definition.hotkey) && definition.hotkey.length === 0) return;
31
31
  const hotkey = normalizeRegisterableHotkey(definition.hotkey, options.platform);
32
- const first = isHotkeySequence(hotkey) ? hotkey[0] : hotkey;
33
- options.ignoreInputs ??= getDefaultIgnoreInputs(parseHotkey(first, options.platform));
34
32
  if (isHotkeySequence(hotkey)) delete options.requireReset;
35
33
  else delete options.timeout;
36
34
  prepared.push({
package/dist/conflicts.js CHANGED
@@ -2,18 +2,14 @@ import { detectPlatform } from "./platform.js";
2
2
  import { keysEqual } from "./_keyboard-event.js";
3
3
  import { isHotkeySequence, parseRegisterableHotkey } from "./parse.js";
4
4
  import { matchesKeyboardEvent } from "./match.js";
5
+ import { getTargetDocument } from "./_event-target.js";
5
6
  import { getHotkeyManager, toHotkeyRegistrationView } from "./hotkey-manager.js";
6
7
 
7
8
  //#region src/conflicts.ts
8
9
  /** Whether two targets share a document and one contains the other (or is its window). */
9
10
  function targetsOverlap(a, b) {
10
11
  if (a === b) return true;
11
- const documentOf = (target) => {
12
- if ("document" in target) return target.document;
13
- if (target.nodeType === 9) return target;
14
- return target.ownerDocument;
15
- };
16
- if (documentOf(a) !== documentOf(b)) return false;
12
+ if (getTargetDocument(a) !== getTargetDocument(b)) return false;
17
13
  if (!("contains" in a) || !("contains" in b)) return true;
18
14
  return a.contains(b) || b.contains(a);
19
15
  }
@@ -18,8 +18,17 @@ export interface HotkeyOptions {
18
18
  enabled?: boolean;
19
19
  /** The event type to listen for. Defaults to 'keydown' */
20
20
  eventType?: 'keydown' | 'keyup';
21
- /** Whether to ignore hotkeys when keyboard events originate from input-like elements (text inputs, textarea, select, contenteditable — button-type inputs like type=button/submit/reset are not ignored). Defaults based on the hotkey (the first step of a sequence): true for single keys and Shift/Alt combos; false for Ctrl/Meta shortcuts and Escape */
22
- ignoreInputs?: boolean;
21
+ /**
22
+ * Skip keys that a focused control inside the target uses itself: typing in
23
+ * text inputs, textarea, select, and contenteditable; Enter/Space on buttons
24
+ * and summary elements; Enter on links; and navigation keys in ARIA widgets
25
+ * such as menus, listboxes, tabs, and grids. A hotkey targeted at the control
26
+ * itself still receives them. Defaults to `'auto'`, which chooses from the
27
+ * hotkey (the first step of a sequence): true for single keys and Shift/Alt
28
+ * combos; false for Ctrl/Meta shortcuts and Escape. Set false to handle keys
29
+ * even in these controls.
30
+ */
31
+ ignoreInputs?: boolean | 'auto';
23
32
  /** The target platform for resolving 'Mod' */
24
33
  platform?: 'mac' | 'windows' | 'linux';
25
34
  /** Prevent the default browser action when the hotkey matches. Defaults to true */
@@ -5,9 +5,9 @@ import { isHotkeySequence, isModifierKey, parseHotkey, rawHotkeyToParsedHotkey,
5
5
  import { formatHotkey } from "./format.js";
6
6
  import { matchKeyboardEvent } from "./_match.js";
7
7
  import { DEFAULT_SEQUENCE_TIMEOUT, areHotkeysEqual } from "./match.js";
8
+ import { isEventForTarget, shouldIgnoreInputEvent } from "./_event-target.js";
8
9
  import { isRecordingEvent } from "./_recording-guard.js";
9
10
  import { defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
10
- import { isEventForTarget, shouldIgnoreInputEvent } from "./_event-target.js";
11
11
  import { Store } from "@tanstack/store";
12
12
 
13
13
  //#region src/hotkey-manager.ts
@@ -58,6 +58,11 @@ function optionsForKind(kind, options) {
58
58
  function resolvedSteps(registration) {
59
59
  return registration.kind === "chord" ? [registration.parsedHotkey] : registration.parsedSequence;
60
60
  }
61
+ /** Whether a registration skips keys that focused controls use, resolving `'auto'`. */
62
+ function ignoresInputs(registration) {
63
+ const { ignoreInputs = "auto" } = registration.options;
64
+ return ignoreInputs === "auto" ? getDefaultIgnoreInputs(resolvedSteps(registration)[0]) : ignoreInputs;
65
+ }
61
66
  function sameResolvedSteps(left, right) {
62
67
  return left.length === right.length && left.every((step, index) => areHotkeysEqual(step, right[index]));
63
68
  }
@@ -167,28 +172,26 @@ var HotkeyManager = class HotkeyManager {
167
172
  const platform = options.platform ?? this.#platform;
168
173
  const target = options.target ?? document;
169
174
  let registration;
170
- if (isHotkeySequence(hotkey)) {
171
- const parsedSequence = hotkey.map((step) => parseHotkey(step, platform));
172
- registration = {
173
- kind: "sequence",
174
- id,
175
- hotkey,
176
- parsedSequence,
177
- callback,
178
- options: {
179
- ...defaultHotkeyOptions,
180
- timeout: DEFAULT_SEQUENCE_TIMEOUT,
181
- ...optionsForKind("sequence", options),
182
- platform,
183
- ignoreInputs: options.ignoreInputs ?? getDefaultIgnoreInputs(parsedSequence[0])
184
- },
185
- target,
186
- currentIndex: 0,
187
- lastKeyTime: 0,
188
- triggerCount: 0,
189
- hasFired: false
190
- };
191
- } else {
175
+ if (isHotkeySequence(hotkey)) registration = {
176
+ kind: "sequence",
177
+ id,
178
+ hotkey,
179
+ parsedSequence: hotkey.map((step) => parseHotkey(step, platform)),
180
+ callback,
181
+ options: {
182
+ ...defaultHotkeyOptions,
183
+ timeout: DEFAULT_SEQUENCE_TIMEOUT,
184
+ ...optionsForKind("sequence", options),
185
+ platform,
186
+ ignoreInputs: options.ignoreInputs ?? "auto"
187
+ },
188
+ target,
189
+ currentIndex: 0,
190
+ lastKeyTime: 0,
191
+ triggerCount: 0,
192
+ hasFired: false
193
+ };
194
+ else {
192
195
  const parsedHotkey = typeof hotkey === "string" ? parseHotkey(hotkey, platform) : rawHotkeyToParsedHotkey(hotkey, platform);
193
196
  registration = {
194
197
  kind: "chord",
@@ -201,7 +204,7 @@ var HotkeyManager = class HotkeyManager {
201
204
  requireReset: false,
202
205
  ...optionsForKind("chord", options),
203
206
  platform,
204
- ignoreInputs: options.ignoreInputs ?? getDefaultIgnoreInputs(parsedHotkey)
207
+ ignoreInputs: options.ignoreInputs ?? "auto"
205
208
  },
206
209
  target,
207
210
  hasFired: false,
@@ -323,7 +326,7 @@ var HotkeyManager = class HotkeyManager {
323
326
  const matches = /* @__PURE__ */ new Map();
324
327
  let bestScore = 0;
325
328
  for (const registration of registrations) {
326
- if (!registration.options.enabled || registration.options.eventType !== eventType || !isEventForTarget(event, target) || registration.options.ignoreInputs !== false && shouldIgnoreInputEvent(event, target, registration.target)) continue;
329
+ if (!registration.options.enabled || registration.options.eventType !== eventType || !isEventForTarget(event, target) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target)) continue;
327
330
  const result = matchKeyboardEvent(event, registration.parsedHotkey, registration.options.platform);
328
331
  if (result.matched) {
329
332
  matches.set(registration.id, result);
@@ -367,7 +370,7 @@ var HotkeyManager = class HotkeyManager {
367
370
  const candidates = /* @__PURE__ */ new Map();
368
371
  let bestScore = 0;
369
372
  for (const registration of registrations) {
370
- if (!isEventForTarget(event, target) || !registration.options.enabled || registration.options.ignoreInputs !== false && shouldIgnoreInputEvent(event, target, registration.target) || registration.options.eventType !== eventType) continue;
373
+ if (!isEventForTarget(event, target) || !registration.options.enabled || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target) || registration.options.eventType !== eventType) continue;
371
374
  const timeout = registration.options.timeout ?? 1e3;
372
375
  if (registration.currentIndex > 0 && now - registration.lastKeyTime > timeout) {
373
376
  registration.currentIndex = 0;
@@ -2,8 +2,8 @@ import { detectPlatform } from "./platform.js";
2
2
  import { normalizeKeyboardEvent } from "./_keyboard-event.js";
3
3
  import { isModifierKey, parseHotkey, simplifyHotkey } from "./parse.js";
4
4
  import { validateHotkey } from "./validate.js";
5
+ import { isTypingEvent } from "./_event-target.js";
5
6
  import { beginRecording, captureRecordingEvent, endRecording } from "./_recording-guard.js";
6
- import { shouldIgnoreInputEvent } from "./_event-target.js";
7
7
  import { findHotkeyConflicts } from "./conflicts.js";
8
8
  import { chordRejection, hotkeyChordFromKeydown } from "./_recorder-chord.js";
9
9
  import { Store } from "@tanstack/store";
@@ -111,7 +111,7 @@ var HotkeyRecorder = class {
111
111
  }));
112
112
  const handler = (event) => {
113
113
  if (!this.#keydownHandler) return;
114
- if (this.#options.ignoreInputs !== false && event.key !== "Escape" && shouldIgnoreInputEvent(event, document, document)) return;
114
+ if (this.#options.ignoreInputs !== false && event.key !== "Escape" && isTypingEvent(event, document)) return;
115
115
  const platform = this.#options.platform ?? this.#platform;
116
116
  if (normalizeKeyboardEvent(event, platform).isComposing) return;
117
117
  captureRecordingEvent(event);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/hotkeys",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Type-safe, framework-agnostic keyboard hotkey management for the browser",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",