@tanstack/hotkeys 0.12.1 → 0.14.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 };
@@ -4,15 +4,16 @@
4
4
  * Omitted: platform, target (resolved at registration), requireReset (HotkeyManager only).
5
5
  */
6
6
  const defaultHotkeyOptions = {
7
+ capture: false,
7
8
  preventDefault: true,
8
9
  stopPropagation: true,
9
10
  eventType: "keydown",
10
11
  enabled: true,
11
- ignoreInputs: true,
12
+ ignoreInputs: "auto",
12
13
  conflictBehavior: "warn"
13
14
  };
14
15
  /**
15
- * Computes the default ignoreInputs value based on the hotkey.
16
+ * Resolves `ignoreInputs: 'auto'` from the hotkey (the first step of a sequence).
16
17
  * Ctrl/Meta shortcuts and Escape fire in inputs; single keys and Shift/Alt combos are ignored.
17
18
  */
18
19
  function getDefaultIgnoreInputs(parsedHotkey) {
@@ -31,7 +32,7 @@ function getDefaultIgnoreInputs(parsedHotkey) {
31
32
  function handleConflict(conflictingId, keyDisplay, conflictBehavior, unregister) {
32
33
  if (conflictBehavior === "allow") return;
33
34
  if (conflictBehavior === "warn") {
34
- console.warn(`'${keyDisplay}' is already registered. Multiple handlers will be triggered. Use conflictBehavior: 'replace' to replace the existing handler, or conflictBehavior: 'allow' to suppress this warning.`);
35
+ console.warn(`'${keyDisplay}' is already registered. Multiple handlers may be triggered. Use conflictBehavior: 'replace' to replace the existing handler, or conflictBehavior: 'allow' to suppress this warning.`);
35
36
  return;
36
37
  }
37
38
  if (conflictBehavior === "error") throw new Error(`'${keyDisplay}' is already registered. Use conflictBehavior: 'replace' to replace the existing handler, or conflictBehavior: 'allow' to allow multiple registrations.`);
@@ -0,0 +1,72 @@
1
+ //#region src/_target-listeners.ts
2
+ /** Shares keyboard listeners within each target and capture phase. */
3
+ var TargetListeners = class {
4
+ dispatch;
5
+ #targets = /* @__PURE__ */ new Map();
6
+ #processed = /* @__PURE__ */ new WeakMap();
7
+ constructor(dispatch) {
8
+ this.dispatch = dispatch;
9
+ }
10
+ add(target, capture, id) {
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
+ }
17
+ let group = phases.get(capture);
18
+ if (!group) {
19
+ const ids = /* @__PURE__ */ new Set();
20
+ const listener = (eventType) => (rawEvent) => {
21
+ const event = rawEvent;
22
+ let processed = this.#processed.get(event);
23
+ if (!processed || processed.groups.has(ids)) {
24
+ processed = {
25
+ ids: /* @__PURE__ */ new Set(),
26
+ groups: /* @__PURE__ */ new Set()
27
+ };
28
+ this.#processed.set(event, processed);
29
+ }
30
+ processed.groups.add(ids);
31
+ const pending = /* @__PURE__ */ new Set();
32
+ for (const registrationId of ids) if (!processed.ids.has(registrationId)) {
33
+ processed.ids.add(registrationId);
34
+ pending.add(registrationId);
35
+ }
36
+ this.dispatch(event, target, eventType, capture, pending);
37
+ };
38
+ group = {
39
+ ids,
40
+ keydown: listener("keydown"),
41
+ keyup: listener("keyup")
42
+ };
43
+ phases.set(capture, group);
44
+ target.addEventListener("keydown", group.keydown, capture);
45
+ target.addEventListener("keyup", group.keyup, capture);
46
+ }
47
+ group.ids.add(id);
48
+ }
49
+ remove(target, capture, id) {
50
+ const phases = this.#targets.get(target);
51
+ const group = phases?.get(capture);
52
+ if (!group) return;
53
+ group.ids.delete(id);
54
+ if (group.ids.size === 0) {
55
+ target.removeEventListener("keydown", group.keydown, capture);
56
+ target.removeEventListener("keyup", group.keyup, capture);
57
+ phases.delete(capture);
58
+ if (phases.size === 0) this.#targets.delete(target);
59
+ }
60
+ }
61
+ destroy() {
62
+ for (const [target, phases] of this.#targets) for (const [capture, group] of phases) {
63
+ target.removeEventListener("keydown", group.keydown, capture);
64
+ target.removeEventListener("keyup", group.keyup, capture);
65
+ }
66
+ this.#targets.clear();
67
+ this.#processed = /* @__PURE__ */ new WeakMap();
68
+ }
69
+ };
70
+
71
+ //#endregion
72
+ export { TargetListeners };
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({
@@ -3,7 +3,7 @@ import { HotkeyChordOptions, HotkeyRegistrationView, HotkeySequenceOptions } fro
3
3
  import { Store } from "@tanstack/store";
4
4
  //#region src/app-hotkeys.d.ts
5
5
  type Platform = 'mac' | 'windows' | 'linux';
6
- type DeclaredOptionKeys = 'conflictBehavior' | 'eventType' | 'ignoreInputs' | 'preventDefault' | 'stopPropagation';
6
+ type DeclaredOptionKeys = 'capture' | 'conflictBehavior' | 'eventType' | 'ignoreInputs' | 'preventDefault' | 'stopPropagation';
7
7
  /** Declared options for an app hotkey whose default is a chord. */
8
8
  export type AppHotkeyChordOptions = Pick<HotkeyChordOptions, DeclaredOptionKeys | 'requireReset'>;
9
9
  /** Declared options for an app hotkey whose default is a sequence. */
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
  }
@@ -8,6 +8,14 @@ export type Target = HTMLElement | Document | Window;
8
8
  * Options shared by chords and sequences.
9
9
  */
10
10
  export interface HotkeyOptions {
11
+ /**
12
+ * Listen during capture, before descendant listeners. Defaults to `false` (bubble).
13
+ * With `stopPropagation: true`, a match on an ancestor prevents the event from
14
+ * reaching descendant widgets. Other listeners on the same target still run.
15
+ * Applies to both `keydown` and `keyup`, for chords and sequences.
16
+ * Changing this option preserves held-key reset state and sequence progress.
17
+ */
18
+ capture?: boolean;
11
19
  /** Behavior when this hotkey conflicts with an existing registration on the same target. Defaults to 'warn' */
12
20
  conflictBehavior?: ConflictBehavior;
13
21
  /**
@@ -18,8 +26,17 @@ export interface HotkeyOptions {
18
26
  enabled?: boolean;
19
27
  /** The event type to listen for. Defaults to 'keydown' */
20
28
  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;
29
+ /**
30
+ * Skip keys that a focused control inside the target uses itself: typing in
31
+ * text inputs, textarea, select, and contenteditable; Enter/Space on buttons
32
+ * and summary elements; Enter on links; and navigation keys in ARIA widgets
33
+ * such as menus, listboxes, tabs, and grids. A hotkey targeted at the control
34
+ * itself still receives them. Defaults to `'auto'`, which chooses from the
35
+ * hotkey (the first step of a sequence): true for single keys and Shift/Alt
36
+ * combos; false for Ctrl/Meta shortcuts and Escape. Set false to handle keys
37
+ * even in these controls.
38
+ */
39
+ ignoreInputs?: boolean | 'auto';
23
40
  /** The target platform for resolving 'Mod' */
24
41
  platform?: 'mac' | 'windows' | 'linux';
25
42
  /** Prevent the default browser action when the hotkey matches. Defaults to true */
@@ -147,6 +164,9 @@ export interface HotkeyRegistrationHandle<TOptions extends HotkeyOptions = Hotke
147
164
  /**
148
165
  * Update options (merged with existing options).
149
166
  * Useful for updating `enabled`, `preventDefault`, etc. without re-registering.
167
+ * Changing `capture` moves the listener phase without clearing `requireReset`
168
+ * state or sequence progress. A registration already processed for an event
169
+ * cannot process it again in another phase.
150
170
  */
151
171
  setOptions: (options: Partial<TOptions>) => void;
152
172
  /** Unregister this hotkey */
@@ -156,8 +176,8 @@ export interface HotkeyRegistrationHandle<TOptions extends HotkeyOptions = Hotke
156
176
  * Singleton manager for hotkey registrations, including sequences.
157
177
  *
158
178
  * This class provides a centralized way to register and manage keyboard
159
- * hotkeys. It attaches one keydown and one keyup listener per target,
160
- * regardless of how many hotkeys are registered.
179
+ * hotkeys. It shares one keydown and one keyup listener per target and
180
+ * capture phase across registrations.
161
181
  *
162
182
  * Sequences (Vim-style hotkeys such as `['G', 'G']`) match when every step is
163
183
  * pressed within the timeout. Modifier-only events (Shift, Control, Alt, or
@@ -5,9 +5,10 @@ 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";
9
+ import { TargetListeners } from "./_target-listeners.js";
8
10
  import { isRecordingEvent } from "./_recording-guard.js";
9
11
  import { defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
10
- import { isEventForTarget, shouldIgnoreInputEvent } from "./_event-target.js";
11
12
  import { Store } from "@tanstack/store";
12
13
 
13
14
  //#region src/hotkey-manager.ts
@@ -58,6 +59,11 @@ function optionsForKind(kind, options) {
58
59
  function resolvedSteps(registration) {
59
60
  return registration.kind === "chord" ? [registration.parsedHotkey] : registration.parsedSequence;
60
61
  }
62
+ /** Whether a registration skips keys that focused controls use, resolving `'auto'`. */
63
+ function ignoresInputs(registration) {
64
+ const { ignoreInputs = "auto" } = registration.options;
65
+ return ignoreInputs === "auto" ? getDefaultIgnoreInputs(resolvedSteps(registration)[0]) : ignoreInputs;
66
+ }
61
67
  function sameResolvedSteps(left, right) {
62
68
  return left.length === right.length && left.every((step, index) => areHotkeysEqual(step, right[index]));
63
69
  }
@@ -68,8 +74,8 @@ function formatForMessage(hotkey) {
68
74
  * Singleton manager for hotkey registrations, including sequences.
69
75
  *
70
76
  * This class provides a centralized way to register and manage keyboard
71
- * hotkeys. It attaches one keydown and one keyup listener per target,
72
- * regardless of how many hotkeys are registered.
77
+ * hotkeys. It shares one keydown and one keyup listener per target and
78
+ * capture phase across registrations.
73
79
  *
74
80
  * Sequences (Vim-style hotkeys such as `['G', 'G']`) match when every step is
75
81
  * pressed within the timeout. Modifier-only events (Shift, Control, Alt, or
@@ -118,8 +124,7 @@ var HotkeyManager = class HotkeyManager {
118
124
  */
119
125
  appHotkeys = new Store(null);
120
126
  #platform;
121
- #targetListeners = /* @__PURE__ */ new Map();
122
- #targetRegistrations = /* @__PURE__ */ new Map();
127
+ #listeners = new TargetListeners((...args) => this.#processTargetEvent(...args));
123
128
  constructor() {
124
129
  this.#platform = detectPlatform();
125
130
  }
@@ -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,
@@ -211,9 +214,7 @@ var HotkeyManager = class HotkeyManager {
211
214
  const conflicting = this.#findConflictingRegistration(registration);
212
215
  if (conflicting) handleConflict(conflicting.id, formatForMessage(registration.hotkey), options.conflictBehavior ?? "warn", (conflictId) => this.#unregister(conflictId));
213
216
  this.registrations.setState((prev) => new Map(prev).set(id, registration));
214
- if (!this.#targetRegistrations.has(target)) this.#targetRegistrations.set(target, /* @__PURE__ */ new Set());
215
- this.#targetRegistrations.get(target).add(id);
216
- this.#ensureListenersForTarget(target);
217
+ this.#listeners.add(target, !!registration.options.capture, id);
217
218
  const manager = this;
218
219
  return {
219
220
  get id() {
@@ -238,6 +239,11 @@ var HotkeyManager = class HotkeyManager {
238
239
  ...optionsForKind(reg.kind, newOptions)
239
240
  };
240
241
  if (optionsEqual(reg.options, options)) return prev;
242
+ const capture = !!reg.options.capture;
243
+ if (capture !== !!options.capture) {
244
+ manager.#listeners.remove(reg.target, capture, id);
245
+ manager.#listeners.add(reg.target, !!options.capture, id);
246
+ }
241
247
  return new Map(prev).set(id, {
242
248
  ...reg,
243
249
  options
@@ -261,58 +267,31 @@ var HotkeyManager = class HotkeyManager {
261
267
  next.delete(id);
262
268
  return next;
263
269
  });
264
- const targetRegs = this.#targetRegistrations.get(target);
265
- if (targetRegs) {
266
- targetRegs.delete(id);
267
- if (targetRegs.size === 0) this.#removeListenersForTarget(target);
268
- }
269
- }
270
- /**
271
- * Ensures event listeners are attached for a specific target.
272
- */
273
- #ensureListenersForTarget(target) {
274
- if (typeof document === "undefined") return;
275
- if (this.#targetListeners.has(target)) return;
276
- const keydown = (event) => this.#processTargetEvent(event, target, "keydown");
277
- const keyup = (event) => this.#processTargetEvent(event, target, "keyup");
278
- target.addEventListener("keydown", keydown);
279
- target.addEventListener("keyup", keyup);
280
- this.#targetListeners.set(target, {
281
- keydown,
282
- keyup
283
- });
284
- }
285
- /**
286
- * Removes event listeners for a specific target.
287
- */
288
- #removeListenersForTarget(target) {
289
- if (typeof document === "undefined") return;
290
- const listeners = this.#targetListeners.get(target);
291
- if (!listeners) return;
292
- target.removeEventListener("keydown", listeners.keydown);
293
- target.removeEventListener("keyup", listeners.keyup);
294
- this.#targetListeners.delete(target);
295
- this.#targetRegistrations.delete(target);
270
+ this.#listeners.remove(target, !!registration.options.capture, id);
296
271
  }
297
- /** Registrations of one kind on a target, in registration order. */
298
- #registrationsOn(target, kind) {
272
+ /** Registrations of one kind in the listener phase, in registration order. */
273
+ #registrationsIn(ids, capture, kind) {
299
274
  const result = [];
300
- for (const id of this.#targetRegistrations.get(target) ?? []) {
275
+ for (const id of ids) {
301
276
  const registration = this.registrations.state.get(id);
302
- if (registration?.kind === kind) result.push(registration);
277
+ if (registration?.kind === kind && !!registration.options.capture === capture) result.push(registration);
303
278
  }
304
279
  return result;
305
280
  }
281
+ /** The live registration, unless it was removed or moved to another phase. */
282
+ #currentIn(registration, capture) {
283
+ const current = this.registrations.state.get(registration.id);
284
+ return current && !!current.options.capture === capture ? current : void 0;
285
+ }
306
286
  /**
307
287
  * Processes keyboard events for a specific target and event type.
308
288
  * Chords and sequences match independently; both may fire for one event.
309
289
  */
310
- #processTargetEvent(event, target, eventType) {
311
- this.#processChords(event, target, eventType);
312
- this.#processSequences(event, target, eventType);
290
+ #processTargetEvent(event, target, eventType, capture, ids) {
291
+ this.#processChords(event, target, eventType, capture, this.#registrationsIn(ids, capture, "chord"));
292
+ this.#processSequences(event, target, eventType, capture, this.#registrationsIn(ids, capture, "sequence"));
313
293
  }
314
- #processChords(event, target, eventType) {
315
- const registrations = this.#registrationsOn(target, "chord");
294
+ #processChords(event, target, eventType, capture, registrations) {
316
295
  if (eventType === "keyup") {
317
296
  for (const registration of registrations) if (registration.hasFired && this.#shouldResetRegistration(registration, event)) {
318
297
  registration.hasFired = false;
@@ -323,16 +302,18 @@ var HotkeyManager = class HotkeyManager {
323
302
  const matches = /* @__PURE__ */ new Map();
324
303
  let bestScore = 0;
325
304
  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;
305
+ if (!registration.options.enabled || registration.options.eventType !== eventType || !isEventForTarget(event, target) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target)) continue;
327
306
  const result = matchKeyboardEvent(event, registration.parsedHotkey, registration.options.platform);
328
307
  if (result.matched) {
329
308
  matches.set(registration.id, result);
330
309
  bestScore = Math.max(bestScore, result.score);
331
310
  }
332
311
  }
333
- for (const registration of registrations) {
334
- const match = matches.get(registration.id);
312
+ for (const snapshot of registrations) {
313
+ const match = matches.get(snapshot.id);
335
314
  if (!match?.matched || match.score !== bestScore) continue;
315
+ const registration = this.#currentIn(snapshot, capture);
316
+ if (!registration) continue;
336
317
  if (eventType === "keyup") {
337
318
  this.#executeCallback(registration, event);
338
319
  continue;
@@ -340,16 +321,15 @@ var HotkeyManager = class HotkeyManager {
340
321
  if (registration.options.preventDefault) event.preventDefault();
341
322
  if (registration.options.stopPropagation) event.stopPropagation();
342
323
  if (!registration.options.requireReset || !registration.hasFired) {
343
- this.#executeCallback(registration, event);
344
324
  if (registration.options.requireReset) {
345
325
  registration.hasFired = true;
346
326
  registration.activeMatch = match.identity;
347
327
  }
328
+ this.#executeCallback(registration, event);
348
329
  }
349
330
  }
350
331
  }
351
- #processSequences(event, target, eventType) {
352
- const registrations = this.#registrationsOn(target, "sequence");
332
+ #processSequences(event, target, eventType, capture, registrations) {
353
333
  if (registrations.length === 0) return;
354
334
  if (isRecordingEvent(event)) {
355
335
  let changed = false;
@@ -367,7 +347,7 @@ var HotkeyManager = class HotkeyManager {
367
347
  const candidates = /* @__PURE__ */ new Map();
368
348
  let bestScore = 0;
369
349
  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;
350
+ if (!isEventForTarget(event, target) || !registration.options.enabled || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target) || registration.options.eventType !== eventType) continue;
371
351
  const timeout = registration.options.timeout ?? 1e3;
372
352
  if (registration.currentIndex > 0 && now - registration.lastKeyTime > timeout) {
373
353
  registration.currentIndex = 0;
@@ -407,7 +387,10 @@ var HotkeyManager = class HotkeyManager {
407
387
  }
408
388
  }
409
389
  if (changed && completed.length === 0) this.#notify();
410
- for (const registration of completed) this.#executeCallback(registration, event);
390
+ for (const snapshot of completed) {
391
+ const registration = this.#currentIn(snapshot, capture);
392
+ if (registration) this.#executeCallback(registration, event);
393
+ }
411
394
  }
412
395
  /**
413
396
  * Executes a hotkey callback with proper event handling.
@@ -514,11 +497,9 @@ var HotkeyManager = class HotkeyManager {
514
497
  * Destroys the manager and removes all listeners.
515
498
  */
516
499
  destroy() {
517
- for (const target of this.#targetListeners.keys()) this.#removeListenersForTarget(target);
500
+ this.#listeners.destroy();
518
501
  this.registrations.setState(() => /* @__PURE__ */ new Map());
519
502
  this.appHotkeys.setState(() => null);
520
- this.#targetListeners.clear();
521
- this.#targetRegistrations.clear();
522
503
  }
523
504
  };
524
505
  /**
@@ -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);
@@ -55,6 +55,7 @@ var KeyStateTracker = class KeyStateTracker {
55
55
  #heldEntries = /* @__PURE__ */ new Map();
56
56
  #keydownListener = null;
57
57
  #keyupListener = null;
58
+ #mouseListener = null;
58
59
  #blurListener = null;
59
60
  constructor() {
60
61
  this.#setupListeners();
@@ -81,6 +82,7 @@ var KeyStateTracker = class KeyStateTracker {
81
82
  #setupListeners() {
82
83
  if (typeof document === "undefined") return;
83
84
  this.#keydownListener = (event) => {
85
+ let changed = this.#reconcileModifiers(event);
84
86
  const key = normalizeKeyName(event.key);
85
87
  const identity = event.code || `key:${key}`;
86
88
  if (!this.#heldEntries.has(identity)) {
@@ -88,8 +90,9 @@ var KeyStateTracker = class KeyStateTracker {
88
90
  key,
89
91
  code: event.code
90
92
  });
91
- this.#syncState();
93
+ changed = true;
92
94
  }
95
+ if (changed) this.#syncState();
93
96
  };
94
97
  this.#keyupListener = (event) => {
95
98
  const key = normalizeKeyName(event.key);
@@ -98,22 +101,44 @@ var KeyStateTracker = class KeyStateTracker {
98
101
  if (!event.code) {
99
102
  for (const [entryIdentity, entry] of this.#heldEntries) if (entry.key === key) this.#heldEntries.delete(entryIdentity);
100
103
  }
104
+ this.#reconcileModifiers(event);
101
105
  if (MODIFIER_KEYS.has(key)) {
102
106
  for (const [entryIdentity, entry] of this.#heldEntries) if (!MODIFIER_KEYS.has(entry.key)) this.#heldEntries.delete(entryIdentity);
103
107
  }
104
108
  this.#syncState();
105
109
  };
110
+ this.#mouseListener = (event) => {
111
+ if (this.#reconcileModifiers(event)) this.#syncState();
112
+ };
106
113
  this.#blurListener = () => {
107
114
  if (this.#heldEntries.size > 0) {
108
115
  this.#heldEntries.clear();
109
116
  this.#syncState();
110
117
  }
111
118
  };
112
- document.addEventListener("keydown", this.#keydownListener, true);
113
- document.addEventListener("keyup", this.#keyupListener, true);
119
+ window.addEventListener("keydown", this.#keydownListener, true);
120
+ window.addEventListener("keyup", this.#keyupListener, true);
121
+ window.addEventListener("mousemove", this.#mouseListener, true);
122
+ window.addEventListener("mousedown", this.#mouseListener, true);
114
123
  window.addEventListener("blur", this.#blurListener);
115
124
  }
116
125
  /**
126
+ * Removes modifiers whose release was missed without a window blur.
127
+ * Event modifier state is shared by left/right keys, so a true flag keeps
128
+ * both physical entries until their individual keyup events arrive.
129
+ * Only real user input counts: events dispatched by page code report no
130
+ * modifiers and would clear keys the user is still holding.
131
+ */
132
+ #reconcileModifiers(event) {
133
+ if (!event.isTrusted) return false;
134
+ let changed = false;
135
+ for (const [identity, entry] of this.#heldEntries) if (entry.key === "Control" && !event.ctrlKey || entry.key === "Alt" && !event.altKey || entry.key === "Shift" && !event.shiftKey || entry.key === "Meta" && !event.metaKey) {
136
+ this.#heldEntries.delete(identity);
137
+ changed = true;
138
+ }
139
+ return changed;
140
+ }
141
+ /**
117
142
  * Syncs the internal Set to the Store state.
118
143
  */
119
144
  #syncState() {
@@ -130,13 +155,18 @@ var KeyStateTracker = class KeyStateTracker {
130
155
  #removeListeners() {
131
156
  if (typeof document === "undefined") return;
132
157
  if (this.#keydownListener) {
133
- document.removeEventListener("keydown", this.#keydownListener, true);
158
+ window.removeEventListener("keydown", this.#keydownListener, true);
134
159
  this.#keydownListener = null;
135
160
  }
136
161
  if (this.#keyupListener) {
137
- document.removeEventListener("keyup", this.#keyupListener, true);
162
+ window.removeEventListener("keyup", this.#keyupListener, true);
138
163
  this.#keyupListener = null;
139
164
  }
165
+ if (this.#mouseListener) {
166
+ window.removeEventListener("mousemove", this.#mouseListener, true);
167
+ window.removeEventListener("mousedown", this.#mouseListener, true);
168
+ this.#mouseListener = null;
169
+ }
140
170
  if (this.#blurListener) {
141
171
  window.removeEventListener("blur", this.#blurListener);
142
172
  this.#blurListener = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/hotkeys",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "description": "Type-safe, framework-agnostic keyboard hotkey management for the browser",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",