@tanstack/hotkeys 0.13.0 → 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.
@@ -4,6 +4,7 @@
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",
@@ -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 };
@@ -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. */
@@ -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
  /**
@@ -156,6 +164,9 @@ export interface HotkeyRegistrationHandle<TOptions extends HotkeyOptions = Hotke
156
164
  /**
157
165
  * Update options (merged with existing options).
158
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.
159
170
  */
160
171
  setOptions: (options: Partial<TOptions>) => void;
161
172
  /** Unregister this hotkey */
@@ -165,8 +176,8 @@ export interface HotkeyRegistrationHandle<TOptions extends HotkeyOptions = Hotke
165
176
  * Singleton manager for hotkey registrations, including sequences.
166
177
  *
167
178
  * This class provides a centralized way to register and manage keyboard
168
- * hotkeys. It attaches one keydown and one keyup listener per target,
169
- * 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.
170
181
  *
171
182
  * Sequences (Vim-style hotkeys such as `['G', 'G']`) match when every step is
172
183
  * pressed within the timeout. Modifier-only events (Shift, Control, Alt, or
@@ -6,6 +6,7 @@ import { formatHotkey } from "./format.js";
6
6
  import { matchKeyboardEvent } from "./_match.js";
7
7
  import { DEFAULT_SEQUENCE_TIMEOUT, areHotkeysEqual } from "./match.js";
8
8
  import { isEventForTarget, shouldIgnoreInputEvent } from "./_event-target.js";
9
+ import { TargetListeners } from "./_target-listeners.js";
9
10
  import { isRecordingEvent } from "./_recording-guard.js";
10
11
  import { defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
11
12
  import { Store } from "@tanstack/store";
@@ -73,8 +74,8 @@ function formatForMessage(hotkey) {
73
74
  * Singleton manager for hotkey registrations, including sequences.
74
75
  *
75
76
  * This class provides a centralized way to register and manage keyboard
76
- * hotkeys. It attaches one keydown and one keyup listener per target,
77
- * 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.
78
79
  *
79
80
  * Sequences (Vim-style hotkeys such as `['G', 'G']`) match when every step is
80
81
  * pressed within the timeout. Modifier-only events (Shift, Control, Alt, or
@@ -123,8 +124,7 @@ var HotkeyManager = class HotkeyManager {
123
124
  */
124
125
  appHotkeys = new Store(null);
125
126
  #platform;
126
- #targetListeners = /* @__PURE__ */ new Map();
127
- #targetRegistrations = /* @__PURE__ */ new Map();
127
+ #listeners = new TargetListeners((...args) => this.#processTargetEvent(...args));
128
128
  constructor() {
129
129
  this.#platform = detectPlatform();
130
130
  }
@@ -214,9 +214,7 @@ var HotkeyManager = class HotkeyManager {
214
214
  const conflicting = this.#findConflictingRegistration(registration);
215
215
  if (conflicting) handleConflict(conflicting.id, formatForMessage(registration.hotkey), options.conflictBehavior ?? "warn", (conflictId) => this.#unregister(conflictId));
216
216
  this.registrations.setState((prev) => new Map(prev).set(id, registration));
217
- if (!this.#targetRegistrations.has(target)) this.#targetRegistrations.set(target, /* @__PURE__ */ new Set());
218
- this.#targetRegistrations.get(target).add(id);
219
- this.#ensureListenersForTarget(target);
217
+ this.#listeners.add(target, !!registration.options.capture, id);
220
218
  const manager = this;
221
219
  return {
222
220
  get id() {
@@ -241,6 +239,11 @@ var HotkeyManager = class HotkeyManager {
241
239
  ...optionsForKind(reg.kind, newOptions)
242
240
  };
243
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
+ }
244
247
  return new Map(prev).set(id, {
245
248
  ...reg,
246
249
  options
@@ -264,58 +267,31 @@ var HotkeyManager = class HotkeyManager {
264
267
  next.delete(id);
265
268
  return next;
266
269
  });
267
- const targetRegs = this.#targetRegistrations.get(target);
268
- if (targetRegs) {
269
- targetRegs.delete(id);
270
- if (targetRegs.size === 0) this.#removeListenersForTarget(target);
271
- }
270
+ this.#listeners.remove(target, !!registration.options.capture, id);
272
271
  }
273
- /**
274
- * Ensures event listeners are attached for a specific target.
275
- */
276
- #ensureListenersForTarget(target) {
277
- if (typeof document === "undefined") return;
278
- if (this.#targetListeners.has(target)) return;
279
- const keydown = (event) => this.#processTargetEvent(event, target, "keydown");
280
- const keyup = (event) => this.#processTargetEvent(event, target, "keyup");
281
- target.addEventListener("keydown", keydown);
282
- target.addEventListener("keyup", keyup);
283
- this.#targetListeners.set(target, {
284
- keydown,
285
- keyup
286
- });
287
- }
288
- /**
289
- * Removes event listeners for a specific target.
290
- */
291
- #removeListenersForTarget(target) {
292
- if (typeof document === "undefined") return;
293
- const listeners = this.#targetListeners.get(target);
294
- if (!listeners) return;
295
- target.removeEventListener("keydown", listeners.keydown);
296
- target.removeEventListener("keyup", listeners.keyup);
297
- this.#targetListeners.delete(target);
298
- this.#targetRegistrations.delete(target);
299
- }
300
- /** Registrations of one kind on a target, in registration order. */
301
- #registrationsOn(target, kind) {
272
+ /** Registrations of one kind in the listener phase, in registration order. */
273
+ #registrationsIn(ids, capture, kind) {
302
274
  const result = [];
303
- for (const id of this.#targetRegistrations.get(target) ?? []) {
275
+ for (const id of ids) {
304
276
  const registration = this.registrations.state.get(id);
305
- if (registration?.kind === kind) result.push(registration);
277
+ if (registration?.kind === kind && !!registration.options.capture === capture) result.push(registration);
306
278
  }
307
279
  return result;
308
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
+ }
309
286
  /**
310
287
  * Processes keyboard events for a specific target and event type.
311
288
  * Chords and sequences match independently; both may fire for one event.
312
289
  */
313
- #processTargetEvent(event, target, eventType) {
314
- this.#processChords(event, target, eventType);
315
- 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"));
316
293
  }
317
- #processChords(event, target, eventType) {
318
- const registrations = this.#registrationsOn(target, "chord");
294
+ #processChords(event, target, eventType, capture, registrations) {
319
295
  if (eventType === "keyup") {
320
296
  for (const registration of registrations) if (registration.hasFired && this.#shouldResetRegistration(registration, event)) {
321
297
  registration.hasFired = false;
@@ -333,9 +309,11 @@ var HotkeyManager = class HotkeyManager {
333
309
  bestScore = Math.max(bestScore, result.score);
334
310
  }
335
311
  }
336
- for (const registration of registrations) {
337
- const match = matches.get(registration.id);
312
+ for (const snapshot of registrations) {
313
+ const match = matches.get(snapshot.id);
338
314
  if (!match?.matched || match.score !== bestScore) continue;
315
+ const registration = this.#currentIn(snapshot, capture);
316
+ if (!registration) continue;
339
317
  if (eventType === "keyup") {
340
318
  this.#executeCallback(registration, event);
341
319
  continue;
@@ -343,16 +321,15 @@ var HotkeyManager = class HotkeyManager {
343
321
  if (registration.options.preventDefault) event.preventDefault();
344
322
  if (registration.options.stopPropagation) event.stopPropagation();
345
323
  if (!registration.options.requireReset || !registration.hasFired) {
346
- this.#executeCallback(registration, event);
347
324
  if (registration.options.requireReset) {
348
325
  registration.hasFired = true;
349
326
  registration.activeMatch = match.identity;
350
327
  }
328
+ this.#executeCallback(registration, event);
351
329
  }
352
330
  }
353
331
  }
354
- #processSequences(event, target, eventType) {
355
- const registrations = this.#registrationsOn(target, "sequence");
332
+ #processSequences(event, target, eventType, capture, registrations) {
356
333
  if (registrations.length === 0) return;
357
334
  if (isRecordingEvent(event)) {
358
335
  let changed = false;
@@ -410,7 +387,10 @@ var HotkeyManager = class HotkeyManager {
410
387
  }
411
388
  }
412
389
  if (changed && completed.length === 0) this.#notify();
413
- 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
+ }
414
394
  }
415
395
  /**
416
396
  * Executes a hotkey callback with proper event handling.
@@ -517,11 +497,9 @@ var HotkeyManager = class HotkeyManager {
517
497
  * Destroys the manager and removes all listeners.
518
498
  */
519
499
  destroy() {
520
- for (const target of this.#targetListeners.keys()) this.#removeListenersForTarget(target);
500
+ this.#listeners.destroy();
521
501
  this.registrations.setState(() => /* @__PURE__ */ new Map());
522
502
  this.appHotkeys.setState(() => null);
523
- this.#targetListeners.clear();
524
- this.#targetRegistrations.clear();
525
503
  }
526
504
  };
527
505
  /**
@@ -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.13.0",
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",