@tanstack/hotkeys 0.13.0 → 0.15.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,73 @@
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
+ const first = !processed || processed.groups.has(ids);
24
+ if (!processed || first) {
25
+ processed = {
26
+ ids: /* @__PURE__ */ new Set(),
27
+ groups: /* @__PURE__ */ new Set()
28
+ };
29
+ this.#processed.set(event, processed);
30
+ }
31
+ processed.groups.add(ids);
32
+ const pending = /* @__PURE__ */ new Set();
33
+ for (const registrationId of ids) if (!processed.ids.has(registrationId)) {
34
+ processed.ids.add(registrationId);
35
+ pending.add(registrationId);
36
+ }
37
+ this.dispatch(event, target, eventType, capture, pending, first);
38
+ };
39
+ group = {
40
+ ids,
41
+ keydown: listener("keydown"),
42
+ keyup: listener("keyup")
43
+ };
44
+ phases.set(capture, group);
45
+ target.addEventListener("keydown", group.keydown, capture);
46
+ target.addEventListener("keyup", group.keyup, capture);
47
+ }
48
+ group.ids.add(id);
49
+ }
50
+ remove(target, capture, id) {
51
+ const phases = this.#targets.get(target);
52
+ const group = phases?.get(capture);
53
+ if (!group) return;
54
+ group.ids.delete(id);
55
+ if (group.ids.size === 0) {
56
+ target.removeEventListener("keydown", group.keydown, capture);
57
+ target.removeEventListener("keyup", group.keyup, capture);
58
+ phases.delete(capture);
59
+ if (phases.size === 0) this.#targets.delete(target);
60
+ }
61
+ }
62
+ destroy() {
63
+ for (const [target, phases] of this.#targets) for (const [capture, group] of phases) {
64
+ target.removeEventListener("keydown", group.keydown, capture);
65
+ target.removeEventListener("keyup", group.keyup, capture);
66
+ }
67
+ this.#targets.clear();
68
+ this.#processed = /* @__PURE__ */ new WeakMap();
69
+ }
70
+ };
71
+
72
+ //#endregion
73
+ 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
@@ -5,7 +5,8 @@ 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
+ import { getTargetDocument, 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,11 @@ 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
+ /** Events that continue a sequence in progress, decided before any listener runs. */
129
+ #sequenceOwned = /* @__PURE__ */ new WeakMap();
130
+ /** Physical keys whose keydown a sequence owned, so their repeats and keyup are owned too. */
131
+ #sequenceOwnedKeys = /* @__PURE__ */ new Set();
128
132
  constructor() {
129
133
  this.#platform = detectPlatform();
130
134
  }
@@ -214,9 +218,7 @@ var HotkeyManager = class HotkeyManager {
214
218
  const conflicting = this.#findConflictingRegistration(registration);
215
219
  if (conflicting) handleConflict(conflicting.id, formatForMessage(registration.hotkey), options.conflictBehavior ?? "warn", (conflictId) => this.#unregister(conflictId));
216
220
  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);
221
+ this.#listeners.add(target, !!registration.options.capture, id);
220
222
  const manager = this;
221
223
  return {
222
224
  get id() {
@@ -241,6 +243,11 @@ var HotkeyManager = class HotkeyManager {
241
243
  ...optionsForKind(reg.kind, newOptions)
242
244
  };
243
245
  if (optionsEqual(reg.options, options)) return prev;
246
+ const capture = !!reg.options.capture;
247
+ if (capture !== !!options.capture) {
248
+ manager.#listeners.remove(reg.target, capture, id);
249
+ manager.#listeners.add(reg.target, !!options.capture, id);
250
+ }
244
251
  return new Map(prev).set(id, {
245
252
  ...reg,
246
253
  options
@@ -264,65 +271,40 @@ var HotkeyManager = class HotkeyManager {
264
271
  next.delete(id);
265
272
  return next;
266
273
  });
267
- const targetRegs = this.#targetRegistrations.get(target);
268
- if (targetRegs) {
269
- targetRegs.delete(id);
270
- if (targetRegs.size === 0) this.#removeListenersForTarget(target);
271
- }
272
- }
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
- });
274
+ this.#listeners.remove(target, !!registration.options.capture, id);
287
275
  }
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) {
276
+ /** Registrations of one kind in the listener phase, in registration order. */
277
+ #registrationsIn(ids, capture, kind) {
302
278
  const result = [];
303
- for (const id of this.#targetRegistrations.get(target) ?? []) {
279
+ for (const id of ids) {
304
280
  const registration = this.registrations.state.get(id);
305
- if (registration?.kind === kind) result.push(registration);
281
+ if (registration?.kind === kind && !!registration.options.capture === capture) result.push(registration);
306
282
  }
307
283
  return result;
308
284
  }
285
+ /** The live registration, unless it was removed or moved to another phase. */
286
+ #currentIn(registration, capture) {
287
+ const current = this.registrations.state.get(registration.id);
288
+ return current && !!current.options.capture === capture ? current : void 0;
289
+ }
309
290
  /**
310
291
  * Processes keyboard events for a specific target and event type.
311
- * Chords and sequences match independently; both may fire for one event.
292
+ * A key that continues a sequence in progress belongs to that sequence, so
293
+ * chords skip it. Chords and first steps of sequences may both fire.
312
294
  */
313
- #processTargetEvent(event, target, eventType) {
314
- this.#processChords(event, target, eventType);
315
- this.#processSequences(event, target, eventType);
295
+ #processTargetEvent(event, target, eventType, capture, ids, first) {
296
+ if (first) this.#sequenceOwned.set(event, this.#isOwnedBySequence(event, eventType));
297
+ this.#processChords(event, target, eventType, capture, this.#registrationsIn(ids, capture, "chord"));
298
+ this.#processSequences(event, target, eventType, capture, this.#registrationsIn(ids, capture, "sequence"));
316
299
  }
317
- #processChords(event, target, eventType) {
318
- const registrations = this.#registrationsOn(target, "chord");
300
+ #processChords(event, target, eventType, capture, registrations) {
319
301
  if (eventType === "keyup") {
320
302
  for (const registration of registrations) if (registration.hasFired && this.#shouldResetRegistration(registration, event)) {
321
303
  registration.hasFired = false;
322
304
  registration.activeMatch = void 0;
323
305
  }
324
306
  }
325
- if (isRecordingEvent(event)) return;
307
+ if (isRecordingEvent(event) || this.#sequenceOwned.get(event)) return;
326
308
  const matches = /* @__PURE__ */ new Map();
327
309
  let bestScore = 0;
328
310
  for (const registration of registrations) {
@@ -333,9 +315,11 @@ var HotkeyManager = class HotkeyManager {
333
315
  bestScore = Math.max(bestScore, result.score);
334
316
  }
335
317
  }
336
- for (const registration of registrations) {
337
- const match = matches.get(registration.id);
318
+ for (const snapshot of registrations) {
319
+ const match = matches.get(snapshot.id);
338
320
  if (!match?.matched || match.score !== bestScore) continue;
321
+ const registration = this.#currentIn(snapshot, capture);
322
+ if (!registration) continue;
339
323
  if (eventType === "keyup") {
340
324
  this.#executeCallback(registration, event);
341
325
  continue;
@@ -343,16 +327,43 @@ var HotkeyManager = class HotkeyManager {
343
327
  if (registration.options.preventDefault) event.preventDefault();
344
328
  if (registration.options.stopPropagation) event.stopPropagation();
345
329
  if (!registration.options.requireReset || !registration.hasFired) {
346
- this.#executeCallback(registration, event);
347
330
  if (registration.options.requireReset) {
348
331
  registration.hasFired = true;
349
332
  registration.activeMatch = match.identity;
350
333
  }
334
+ this.#executeCallback(registration, event);
351
335
  }
352
336
  }
353
337
  }
354
- #processSequences(event, target, eventType) {
355
- const registrations = this.#registrationsOn(target, "sequence");
338
+ /**
339
+ * Whether the event continues or completes a sequence in progress whose
340
+ * target the event reaches. The repeats and keyup of a keydown that a
341
+ * sequence owns belong to the sequence too.
342
+ */
343
+ #isOwnedBySequence(event, eventType) {
344
+ const key = event.code || normalizeKeyName(event.key);
345
+ if (eventType === "keyup") {
346
+ const ownedKeydown = this.#sequenceOwnedKeys.delete(key);
347
+ return this.#continuesSequence(event, eventType) || ownedKeydown;
348
+ }
349
+ if (event.repeat) return this.#sequenceOwnedKeys.has(key);
350
+ const owned = this.#continuesSequence(event, eventType);
351
+ if (owned) this.#sequenceOwnedKeys.add(key);
352
+ else this.#sequenceOwnedKeys.delete(key);
353
+ return owned;
354
+ }
355
+ #continuesSequence(event, eventType) {
356
+ if (isRecordingEvent(event) || normalizeKeyboardEvent(event).isComposing || isModifierKey(normalizeKeyName(event.key))) return false;
357
+ const path = event.composedPath();
358
+ const now = Date.now();
359
+ for (const registration of this.registrations.state.values()) {
360
+ if (registration.kind !== "sequence" || registration.currentIndex === 0 || !registration.options.enabled || registration.options.eventType !== eventType || now - registration.lastKeyTime > (registration.options.timeout ?? 1e3)) continue;
361
+ const target = registration.target;
362
+ 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;
363
+ }
364
+ return false;
365
+ }
366
+ #processSequences(event, target, eventType, capture, registrations) {
356
367
  if (registrations.length === 0) return;
357
368
  if (isRecordingEvent(event)) {
358
369
  let changed = false;
@@ -410,7 +421,10 @@ var HotkeyManager = class HotkeyManager {
410
421
  }
411
422
  }
412
423
  if (changed && completed.length === 0) this.#notify();
413
- for (const registration of completed) this.#executeCallback(registration, event);
424
+ for (const snapshot of completed) {
425
+ const registration = this.#currentIn(snapshot, capture);
426
+ if (registration) this.#executeCallback(registration, event);
427
+ }
414
428
  }
415
429
  /**
416
430
  * Executes a hotkey callback with proper event handling.
@@ -517,11 +531,9 @@ var HotkeyManager = class HotkeyManager {
517
531
  * Destroys the manager and removes all listeners.
518
532
  */
519
533
  destroy() {
520
- for (const target of this.#targetListeners.keys()) this.#removeListenersForTarget(target);
534
+ this.#listeners.destroy();
521
535
  this.registrations.setState(() => /* @__PURE__ */ new Map());
522
536
  this.appHotkeys.setState(() => null);
523
- this.#targetListeners.clear();
524
- this.#targetRegistrations.clear();
525
537
  }
526
538
  };
527
539
  /**
@@ -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.15.0",
4
4
  "description": "Type-safe, framework-agnostic keyboard hotkey management for the browser",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",