@tanstack/hotkeys 0.23.0 → 0.24.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.
@@ -250,6 +250,29 @@ function findBlocker(event, doc, test) {
250
250
  function blocks({ elements, index }, target) {
251
251
  return index >= 0 && (isDocumentLevel(target) || index < elements.indexOf(target));
252
252
  }
253
+ /** Open modal dialogs and popovers that close on Escape, per event. */
254
+ const layerCache = /* @__PURE__ */ new WeakMap();
255
+ /**
256
+ * Returns whether Escape will close a modal `<dialog>` or an open popover that
257
+ * `target` isn't inside. The browser closes it only when Escape's keydown
258
+ * isn't prevented, so hotkeys outside it leave Escape alone.
259
+ */
260
+ function closesNativeLayer(event, target) {
261
+ if (event.type !== "keydown" || event.key !== "Escape") return false;
262
+ let layers = layerCache.get(event);
263
+ if (!layers) {
264
+ const found = [];
265
+ for (const selector of ["dialog:modal", ":popover-open"]) try {
266
+ found.push(...getTargetDocument(target).querySelectorAll(selector));
267
+ } catch {}
268
+ layers = found.filter((layer) => layer.popover !== "manual");
269
+ layerCache.set(event, layers);
270
+ }
271
+ return layers.some((layer) => {
272
+ for (let node = isDocumentLevel(target) ? null : target; node; node = composedParent(node)) if (node === layer) return false;
273
+ return true;
274
+ });
275
+ }
253
276
  const ignoreCache = /* @__PURE__ */ new WeakMap();
254
277
  /**
255
278
  * Returns whether a hotkey on `target` should skip an event because a control
@@ -280,4 +303,4 @@ function isTypingEvent(event, target) {
280
303
  }
281
304
 
282
305
  //#endregion
283
- export { getTargetDocument, isDocumentLevel, isInputElement, isTypingEvent, shouldIgnoreInputEvent, targetHasFocus };
306
+ export { closesNativeLayer, getTargetDocument, isDocumentLevel, isInputElement, isTypingEvent, shouldIgnoreInputEvent, targetHasFocus };
package/dist/_server.js CHANGED
@@ -11,7 +11,7 @@ let warned = false;
11
11
  * users on macOS and Windows after hydration.
12
12
  */
13
13
  function warnServerPlatform() {
14
- if (process.env.NODE_ENV !== "production" && !warned && isServerRuntime()) {
14
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production" && !warned && isServerRuntime()) {
15
15
  warned = true;
16
16
  console.warn("[TanStack Hotkeys] A hotkey label was formatted during server rendering without a platform, so it uses 'linux'. Mac and Windows users get a different label after hydration. Read the platform from the request with `detectPlatform(userAgent)`, set it as the `platform` of your hotkeys provider or scope, and format labels with your adapter's formatter, such as `useFormatForDisplay`. Or render the labels only in the browser. See the server rendering section of the Displaying Hotkeys guide.");
17
17
  }
@@ -7,10 +7,19 @@ var TargetListeners = class {
7
7
  constructor(dispatch) {
8
8
  this.dispatch = dispatch;
9
9
  }
10
- add(target, capture, id) {
10
+ /**
11
+ * Adds a registration to the target's shared listener. With `last`, an
12
+ * existing listener moves after listeners added since it, such as one a
13
+ * framework attached later to delegate key events.
14
+ */
15
+ add(target, capture, id, last = false) {
11
16
  if (typeof document === "undefined") return;
12
17
  const phases = this.#targets.get(target) ?? /* @__PURE__ */ new Map();
13
18
  let group = phases.get(capture);
19
+ if (group && last) for (const type of ["keydown", "keyup"]) {
20
+ target.removeEventListener(type, group[type], capture);
21
+ target.addEventListener(type, group[type], capture);
22
+ }
14
23
  if (!group) {
15
24
  const ids = /* @__PURE__ */ new Set();
16
25
  const listener = (eventType) => (rawEvent) => {
package/dist/adapter.d.ts CHANGED
@@ -26,8 +26,12 @@ export type HotkeyDefinition = {
26
26
  };
27
27
  /** Options shared by every definition. Each option applies only to the kinds that support it. */
28
28
  export type HotkeyBindingsCommonOptions = HotkeyChordOptions & HotkeySequenceOptions;
29
- /** Reconciles adapter registrations while retaining handles for option updates. */
30
- export declare function createHotkeyBindings(): {
29
+ /**
30
+ * Reconciles adapter registrations while retaining handles for option updates.
31
+ * Adapters for frameworks that delegate key events, such as React, pass
32
+ * `{ afterDelegatedHandlers: true }`.
33
+ */
34
+ export declare function createHotkeyBindings(adapterOptions?: Pick<HotkeyOptions, 'afterDelegatedHandlers'>): {
31
35
  /**
32
36
  * Registers new definitions, updates existing ones in place, and removes
33
37
  * stale ones. Definitions with no hotkey (undefined, null, empty string, or
@@ -37,7 +41,7 @@ export declare function createHotkeyBindings(): {
37
41
  destroy(): void;
38
42
  };
39
43
  /** Options with a framework-specific target, such as a ref. */
40
- export type WithTarget<TOptions, TTarget> = Omit<TOptions, 'target' | 'appHotkeyId'> & {
44
+ export type WithTarget<TOptions, TTarget> = Omit<TOptions, 'target' | 'appHotkeyId' | 'afterDelegatedHandlers'> & {
41
45
  /** The element to listen on. Defaults to document. */
42
46
  target?: TTarget;
43
47
  };
package/dist/adapter.js CHANGED
@@ -4,8 +4,12 @@ import { createStore } from "./_store.js";
4
4
  import { createAppHotkeysStore, detectPlatform, getAppHotkeyRegistrationOptions, getHotkeyManager, isAppHotkey, isHotkeySequence, normalizeHotkey, parseHotkey, resolveAppHotkey } from "@tanstack/hotkeys";
5
5
 
6
6
  //#region src/adapter.ts
7
- /** Reconciles adapter registrations while retaining handles for option updates. */
8
- function createHotkeyBindings() {
7
+ /**
8
+ * Reconciles adapter registrations while retaining handles for option updates.
9
+ * Adapters for frameworks that delegate key events, such as React, pass
10
+ * `{ afterDelegatedHandlers: true }`.
11
+ */
12
+ function createHotkeyBindings(adapterOptions = {}) {
9
13
  const registrations = /* @__PURE__ */ new Map();
10
14
  return {
11
15
  /**
@@ -20,6 +24,7 @@ function createHotkeyBindings() {
20
24
  const options = {
21
25
  ...defaultHotkeyOptions,
22
26
  ...merged,
27
+ ...adapterOptions,
23
28
  target: "target" in merged ? merged.target ?? null : typeof document === "undefined" ? null : document,
24
29
  platform: merged.platform ?? detectPlatform()
25
30
  };
@@ -24,8 +24,19 @@ export interface HotkeyOptions {
24
24
  * existing handle via `setOptions` rather than unregistering. Defaults to `true`.
25
25
  */
26
26
  enabled?: boolean;
27
- /** The event type to listen for. Defaults to 'keydown' */
27
+ /**
28
+ * The event type to listen for. Defaults to 'keydown'. A 'keyup' chord fires
29
+ * when its key or one of its modifiers is released, whichever comes first,
30
+ * unless another key was pressed in between. `preventDefault` applies to
31
+ * its keydown too.
32
+ */
28
33
  eventType?: 'keydown' | 'keyup';
34
+ /**
35
+ * Skip a key press that a listener outside TanStack Hotkeys already handled
36
+ * by calling `preventDefault()`, such as a component library's dialog that
37
+ * closes on Escape. Defaults to `false`.
38
+ */
39
+ ignoreDefaultPrevented?: boolean;
29
40
  /**
30
41
  * Skip keys that a focused control inside the target uses itself: typing in
31
42
  * text inputs, textarea, select, and contenteditable; Enter/Space on buttons
@@ -70,6 +81,16 @@ export interface HotkeyOptions {
70
81
  scope?: string;
71
82
  /** Id of the app hotkey this registration implements. Adapters set this when registering an app hotkey from `defineAppHotkeys`. */
72
83
  appHotkeyId?: string;
84
+ /**
85
+ * Run after key handlers that a framework calls from one listener on its
86
+ * root or the document, such as React's `onKeyDown`, so handlers inside the
87
+ * target run first and can stop the hotkey. An element target then listens
88
+ * on its document, or on its closed shadow root, instead of the element. A
89
+ * stop by an earlier listener there skips the hotkey. Applies only to the
90
+ * bubble phase.
91
+ * Adapters for frameworks that delegate key events set this.
92
+ */
93
+ afterDelegatedHandlers?: boolean;
73
94
  }
74
95
  /** Options for a single chord such as `'Mod+S'`. */
75
96
  export interface HotkeyChordOptions extends HotkeyOptions {
@@ -6,7 +6,7 @@ import { formatHotkey } from "./format.js";
6
6
  import { matchKeyboardEvent, parsedChordsEqual } from "./_match.js";
7
7
  import { GLOBAL_SCOPE, chordHasKey, defaultHotkeyOptions, definedOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
8
8
  import { DEFAULT_SEQUENCE_TIMEOUT, areHotkeysEqual } from "./match.js";
9
- import { getTargetDocument, isDocumentLevel, shouldIgnoreInputEvent, targetHasFocus } from "./_event-target.js";
9
+ import { closesNativeLayer, getTargetDocument, isDocumentLevel, shouldIgnoreInputEvent, targetHasFocus } from "./_event-target.js";
10
10
  import { createStore } from "./_store.js";
11
11
  import { TargetListeners } from "./_target-listeners.js";
12
12
  import { managerRef } from "./_manager-ref.js";
@@ -91,6 +91,23 @@ function optionsForKind(kind, options) {
91
91
  const { requireReset: _requireReset, ...rest } = options;
92
92
  return rest;
93
93
  }
94
+ /**
95
+ * Where a registration listens: its target, or for an element target that
96
+ * runs after delegated handlers in the bubble phase, the node where handlers
97
+ * delegated around it have already run. That is its document, unless the
98
+ * element is in a closed shadow root, which hides it from listeners outside,
99
+ * or in a tree outside any document, which events never leave.
100
+ */
101
+ function listenerTarget({ target, options }) {
102
+ if (!options.afterDelegatedHandlers || options.capture || isDocumentLevel(target)) return target;
103
+ let root = target.getRootNode();
104
+ while ("host" in root && root.mode === "open") root = root.host.getRootNode();
105
+ return root;
106
+ }
107
+ /** Whether a registration's shared listener moves after listeners added before it joins. */
108
+ function movesAfterDelegatedHandlers(options) {
109
+ return !!options.afterDelegatedHandlers && !options.capture;
110
+ }
94
111
  /** Steps with Mod already resolved for the registration's platform. */
95
112
  function resolvedSteps(registration) {
96
113
  return registration.kind === "chord" ? [registration.parsedChord] : registration.parsedSequence;
@@ -173,8 +190,20 @@ var HotkeyManager = class HotkeyManager {
173
190
  /** Scopes that can fire while a scope is exclusive, or `null` when none is. */
174
191
  #liveScopes = null;
175
192
  #listeners = new TargetListeners((...args) => this.#processTargetEvent(...args));
193
+ /** Where each registration listens, kept so cleanup finds a target that moved documents. */
194
+ #listenerTargets = /* @__PURE__ */ new Map();
176
195
  /** Events that continue a sequence in progress, decided before any listener runs. */
177
196
  #sequenceOwned = /* @__PURE__ */ new WeakMap();
197
+ /**
198
+ * Keyup chords whose keydown matched. Releasing one of the chord's
199
+ * modifiers fires them, because the key's own keyup may come after the
200
+ * modifier's or, on macOS while Command is held, not at all. `null` marks a
201
+ * press that another key cancelled or that `ignoreDefaultPrevented`
202
+ * skipped, so its release doesn't fire.
203
+ */
204
+ #pendingReleases = /* @__PURE__ */ new Map();
205
+ /** Events this manager prevented, so its own prevention never skips a hotkey. */
206
+ #prevented = /* @__PURE__ */ new WeakSet();
178
207
  /** Physical keys whose keydown a sequence owned, so their repeats and keyup are owned too. */
179
208
  #sequenceOwnedKeys = /* @__PURE__ */ new Set();
180
209
  /** Timers that clear a sequence's progress once its timeout passes. */
@@ -250,7 +279,7 @@ var HotkeyManager = class HotkeyManager {
250
279
  const parents = /* @__PURE__ */ new Map();
251
280
  const latest = /* @__PURE__ */ new Map();
252
281
  for (const activation of this.#scopeActivations) {
253
- parents.set(activation.scope, activation.parent);
282
+ if (activation.parent !== activation.scope) parents.set(activation.scope, activation.parent);
254
283
  latest.set(activation.scope, Math.max(latest.get(activation.scope) ?? 0, activation.order));
255
284
  }
256
285
  const depth = (scope) => {
@@ -311,7 +340,7 @@ var HotkeyManager = class HotkeyManager {
311
340
  #handledByLiveHotkey(event, eventType) {
312
341
  const path = event.composedPath();
313
342
  for (const registration of this.registrations.state.values()) {
314
- if (!this.#isLive(registration) || registration.options.eventType !== eventType || !path.includes(registration.target) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, registration.target)) continue;
343
+ if (!this.#isLive(registration) || registration.options.eventType !== eventType || this.#skips(registration, event) || !path.includes(registration.target) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, registration.target)) continue;
315
344
  if ((registration.kind === "chord" ? [registration.parsedChord] : [registration.parsedSequence[registration.currentIndex], registration.parsedSequence[0]]).some((step) => matchKeyboardEvent(event, step, registration.options.platform).matched)) return true;
316
345
  }
317
346
  return false;
@@ -367,7 +396,9 @@ var HotkeyManager = class HotkeyManager {
367
396
  }
368
397
  const conflicting = this.#findConflictingRegistration(registration);
369
398
  if (conflicting) handleConflict(conflicting.id, formatForMessage(registration.hotkey), options.conflictBehavior ?? "warn", (conflictId) => this.#unregister(conflictId));
370
- this.#listeners.add(target, !!registration.options.capture, id);
399
+ const listenOn = listenerTarget(registration);
400
+ this.#listeners.add(listenOn, !!registration.options.capture, id, movesAfterDelegatedHandlers(registration.options));
401
+ this.#listenerTargets.set(id, listenOn);
371
402
  this.registrations.setState((prev) => new Map(prev).set(id, registration));
372
403
  const manager = this;
373
404
  return {
@@ -399,10 +430,16 @@ var HotkeyManager = class HotkeyManager {
399
430
  else delete options[key];
400
431
  if (optionsEqual(reg.options, options)) return prev;
401
432
  const capture = !!reg.options.capture;
402
- if (capture !== !!options.capture) {
403
- manager.#listeners.remove(reg.target, capture, id);
404
- manager.#listeners.add(reg.target, !!options.capture, id);
405
- }
433
+ const from = manager.#listenerTargets.get(id) ?? reg.target;
434
+ const to = listenerTarget({
435
+ target: reg.target,
436
+ options
437
+ });
438
+ if (capture !== !!options.capture || from !== to) {
439
+ manager.#listeners.remove(from, capture, id);
440
+ manager.#listeners.add(to, !!options.capture, id, movesAfterDelegatedHandlers(options));
441
+ manager.#listenerTargets.set(id, to);
442
+ } else if (movesAfterDelegatedHandlers(options) && !movesAfterDelegatedHandlers(reg.options)) manager.#listeners.add(to, capture, id, true);
406
443
  return new Map(prev).set(id, {
407
444
  ...reg,
408
445
  options
@@ -420,13 +457,14 @@ var HotkeyManager = class HotkeyManager {
420
457
  #unregister(id) {
421
458
  const registration = this.registrations.state.get(id);
422
459
  if (!registration) return;
423
- const target = registration.target;
424
460
  this.registrations.setState((prev) => {
425
461
  const next = new Map(prev);
426
462
  next.delete(id);
427
463
  return next;
428
464
  });
429
- this.#listeners.remove(target, !!registration.options.capture, id);
465
+ this.#listeners.remove(this.#listenerTargets.get(id) ?? registration.target, !!registration.options.capture, id);
466
+ this.#listenerTargets.delete(id);
467
+ this.#pendingReleases.delete(id);
430
468
  this.#clearSequenceReset(id);
431
469
  }
432
470
  /**
@@ -478,6 +516,30 @@ var HotkeyManager = class HotkeyManager {
478
516
  */
479
517
  #processTargetEvent(event, target, eventType, capture, ids, first) {
480
518
  if (first) this.#sequenceOwned.set(event, this.#isOwnedBySequence(event, eventType));
519
+ if (capture) {
520
+ this.#processRound(event, target, eventType, capture, ids);
521
+ return;
522
+ }
523
+ const rounds = /* @__PURE__ */ new Map();
524
+ for (const id of ids) {
525
+ const registration = this.registrations.state.get(id);
526
+ if (!registration) continue;
527
+ const round = rounds.get(registration.target) ?? /* @__PURE__ */ new Set();
528
+ rounds.set(registration.target, round.add(id));
529
+ }
530
+ const path = rounds.size > 1 || !rounds.has(target) ? event.composedPath() : [];
531
+ const order = [...rounds.keys()].filter((other) => other === target || path.includes(other)).sort((a, b) => a === target ? 1 : b === target ? -1 : path.indexOf(a) - path.indexOf(b));
532
+ const arrivedStopped = event.cancelBubble;
533
+ for (const roundTarget of order) {
534
+ const round = /* @__PURE__ */ new Set();
535
+ for (const id of rounds.get(roundTarget)) {
536
+ const delegated = this.registrations.state.get(id)?.options.afterDelegatedHandlers;
537
+ if (!event.cancelBubble || !delegated && arrivedStopped) round.add(id);
538
+ }
539
+ this.#processRound(event, roundTarget, eventType, capture, round);
540
+ }
541
+ }
542
+ #processRound(event, target, eventType, capture, ids) {
481
543
  this.#processChords(event, target, eventType, capture, this.#registrationsIn(ids, capture, "chord"));
482
544
  this.#processSequences(event, target, eventType, capture, this.#registrationsIn(ids, capture, "sequence"));
483
545
  }
@@ -488,15 +550,47 @@ var HotkeyManager = class HotkeyManager {
488
550
  registration.activeMatch = void 0;
489
551
  }
490
552
  }
553
+ if (eventType === "keydown" && !event.repeat) {
554
+ for (const { id } of registrations) if (this.#pendingReleases.get(id)) this.#pendingReleases.set(id, null);
555
+ }
491
556
  if (isRecordingEvent(event) || this.#sequenceOwned.get(event)) return;
492
557
  const matches = /* @__PURE__ */ new Map();
493
558
  let bestScore = 0;
494
559
  let reserved = false;
560
+ const eventKey = normalizeKeyName(event.key);
495
561
  for (const registration of registrations) {
496
562
  const live = this.#isLive(registration);
497
- if (!live && registration.options.preventDefault !== "always" || registration.options.eventType !== eventType || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target)) continue;
498
- const result = matchKeyboardEvent(event, registration.parsedChord, registration.options.platform);
499
- if (!result.matched) continue;
563
+ const { eventType: firesOn, preventDefault } = registration.options;
564
+ const press = eventType === "keydown" && firesOn === "keyup";
565
+ const skipped = this.#skips(registration, event);
566
+ const { parsedChord, id } = registration;
567
+ const { platform } = registration.options;
568
+ let result;
569
+ let released;
570
+ if (eventType === "keyup" && firesOn === "keyup" && this.#pendingReleases.has(id)) {
571
+ result = matchKeyboardEvent(event, parsedChord, platform);
572
+ if (result.matched || parsedChord.modifiers.includes(eventKey) && !event.getModifierState(eventKey)) {
573
+ released = this.#pendingReleases.get(id);
574
+ this.#pendingReleases.delete(id);
575
+ }
576
+ }
577
+ if (!live && preventDefault !== "always" || firesOn !== eventType && !press || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target)) continue;
578
+ result ??= matchKeyboardEvent(event, parsedChord, platform);
579
+ if (press) {
580
+ if (result.matched) {
581
+ if (skipped) this.#pendingReleases.set(id, null);
582
+ else {
583
+ if (preventDefault) this.#prevent(event);
584
+ if (live) this.#pendingReleases.set(id, result);
585
+ }
586
+ }
587
+ continue;
588
+ }
589
+ if (released !== void 0) {
590
+ if (!released || skipped) continue;
591
+ if (!result.matched) result = released;
592
+ }
593
+ if (skipped || !result.matched) continue;
500
594
  if (!live) {
501
595
  reserved = true;
502
596
  continue;
@@ -504,7 +598,7 @@ var HotkeyManager = class HotkeyManager {
504
598
  matches.set(registration.id, result);
505
599
  bestScore = Math.max(bestScore, result.score);
506
600
  }
507
- if (reserved && matches.size === 0 && !this.#handledByLiveHotkey(event, eventType)) event.preventDefault();
601
+ if (reserved && matches.size === 0 && !this.#handledByLiveHotkey(event, eventType)) this.#prevent(event);
508
602
  let bestRank = -1;
509
603
  for (const registration of registrations) if (matches.get(registration.id)?.score === bestScore) bestRank = Math.max(bestRank, this.#scopeRank(registration.options.scope));
510
604
  for (const snapshot of registrations) {
@@ -548,7 +642,7 @@ var HotkeyManager = class HotkeyManager {
548
642
  const path = event.composedPath();
549
643
  const now = Date.now();
550
644
  for (const registration of this.registrations.state.values()) {
551
- if (registration.kind !== "sequence" || registration.currentIndex === 0 || !this.#isLive(registration) || registration.options.eventType !== eventType || now - registration.lastKeyTime > (registration.options.timeout ?? 1e3)) continue;
645
+ if (registration.kind !== "sequence" || registration.currentIndex === 0 || !this.#isLive(registration) || registration.options.eventType !== eventType || this.#skips(registration, event) || now - registration.lastKeyTime > (registration.options.timeout ?? 1e3)) continue;
552
646
  const target = registration.target;
553
647
  if (path.includes(isDocumentLevel(target) ? getTargetDocument(target) : target) && !(ignoresInputs(registration) && shouldIgnoreInputEvent(event, target)) && matchKeyboardEvent(event, registration.parsedSequence[registration.currentIndex], registration.options.platform).matched) return true;
554
648
  }
@@ -573,7 +667,7 @@ var HotkeyManager = class HotkeyManager {
573
667
  const candidates = /* @__PURE__ */ new Map();
574
668
  let bestScore = 0;
575
669
  for (const registration of registrations) {
576
- if (!this.#isLive(registration) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target) || registration.options.eventType !== eventType) continue;
670
+ if (!this.#isLive(registration) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target) || registration.options.eventType !== eventType || this.#skips(registration, event)) continue;
577
671
  const timeout = registration.options.timeout ?? 1e3;
578
672
  if (registration.currentIndex > 0 && now - registration.lastKeyTime > timeout) {
579
673
  registration.currentIndex = 0;
@@ -605,7 +699,7 @@ var HotkeyManager = class HotkeyManager {
605
699
  this.#consume(registration, event);
606
700
  registration.currentIndex = 0;
607
701
  completed.push(registration);
608
- } else if (registration.options.preventDefault) event.preventDefault();
702
+ } else if (registration.options.preventDefault) this.#prevent(event);
609
703
  } else if (registration.currentIndex > 0) {
610
704
  registration.currentIndex = 0;
611
705
  changed = true;
@@ -624,9 +718,21 @@ var HotkeyManager = class HotkeyManager {
624
718
  if (registration) this.#fire(registration, event);
625
719
  }
626
720
  }
721
+ #prevent(event) {
722
+ if (!event.defaultPrevented) this.#prevented.add(event);
723
+ event.preventDefault();
724
+ }
725
+ /**
726
+ * Whether the registration skips the event: Escape belongs to an open modal
727
+ * dialog or popover outside its target, or another listener prevented the
728
+ * event and the registration skips such events.
729
+ */
730
+ #skips(registration, event) {
731
+ return closesNativeLayer(event, registration.target) || !!registration.options.ignoreDefaultPrevented && event.defaultPrevented && !this.#prevented.has(event);
732
+ }
627
733
  /** Applies a matched registration's preventDefault and stopPropagation options. */
628
734
  #consume(registration, event) {
629
- if (registration.options.preventDefault) event.preventDefault();
735
+ if (registration.options.preventDefault) this.#prevent(event);
630
736
  if (registration.options.stopPropagation) event.stopPropagation();
631
737
  }
632
738
  /** Counts a trigger, notifies subscribers (such as devtools), and runs the callback. */
@@ -634,7 +740,9 @@ var HotkeyManager = class HotkeyManager {
634
740
  registration.triggerCount++;
635
741
  if (registration.kind === "sequence") registration.hasFired = true;
636
742
  this.#notify();
743
+ const prevented = event.defaultPrevented;
637
744
  registration.callback(event, context);
745
+ if (!prevented && event.defaultPrevented) this.#prevented.add(event);
638
746
  }
639
747
  #callbackContext(registration) {
640
748
  return registration.kind === "chord" ? {
@@ -719,6 +827,7 @@ var HotkeyManager = class HotkeyManager {
719
827
  for (const timer of this.#sequenceResetTimers.values()) clearTimeout(timer);
720
828
  this.#sequenceResetTimers.clear();
721
829
  this.#listeners.destroy();
830
+ this.#listenerTargets.clear();
722
831
  this.registrations.setState(() => /* @__PURE__ */ new Map());
723
832
  this.appHotkeys.setState(() => null);
724
833
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/hotkeys",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Type-safe, framework-agnostic keyboard hotkey management for the browser",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -63,6 +63,8 @@ When nesting scopes, stacking dialogs, or keeping some hotkeys working during an
63
63
  - **Letters follow the typed character.** For position-based controls such as WASD, use physical bindings such as `'[KeyW]'`. Keep letters for mnemonic commands such as `Mod+S`.
64
64
  - **Each open exclusive scope needs its own name.** A reusable dialog with a fixed scope name breaks when one dialog opens another: both share one scope and both handle `Escape`. Build the name per instance.
65
65
  - **A chord on a sequence's first key runs every time the sequence starts.** With `G` and `['G', 'G']`, the first `G` runs the chord. Later keys belong to the sequence. To show "waiting for the next key", read `matchedStepCount` from the adapter's registrations list.
66
+ - **A sequence that starts a longer one doesn't wait for it.** With `['G', 'G']` and `['G', 'G', 'G']`, three G presses run both, with no warning. Pick sequences where neither one starts the other.
67
+ - **Component libraries handle Escape too.** A global `Escape` hotkey can run alongside a library's dropdown or dialog closing, or keep a library's overlay open. Native modal dialogs and popovers get Escape on their own, except `popover="manual"`: hotkeys outside them skip it. Set `ignoreDefaultPrevented: true` in the adapter's shared defaults (on each hotkey in Lit) for libraries that call `preventDefault()` before the hotkey runs, such as Radix UI and shadcn/ui in React. The option can't see a `preventDefault()` that comes after the hotkey, as in Bits UI, so give open overlays an exclusive scope for those and the rest. The sensible defaults guide lists libraries per framework.
66
68
  - **Single-character shortcuts need an off switch.** For WCAG 2.1.4, `K`, `?`, and `['G', 'H']` need a user setting passed as `enabled`, or an app hotkey users can remap.
67
69
  - **react-hotkeys-hook names don't carry over.** `useHotkeys` and `HotkeysProvider` share names, not signatures: there's no dependency array, `preventDefault` is on by default, and sequences are arrays. Remove the old library, so two libraries don't handle the same keys.
68
70
  - **Install only the adapter.** A separately installed `@tanstack/hotkeys` at another version is a second copy, and scopes don't apply across copies.