@tanstack/hotkeys 0.23.0 → 0.25.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.
Files changed (53) hide show
  1. package/dist/_app-hotkeys-factory.d.ts +1 -3
  2. package/dist/_app-hotkeys-factory.js +2 -3
  3. package/dist/_event-target.js +40 -7
  4. package/dist/_keyboard-event.js +8 -5
  5. package/dist/_manager-ref.js +1 -2
  6. package/dist/_match.js +5 -3
  7. package/dist/_named-keys.d.ts +1 -3
  8. package/dist/_named-keys.js +1 -2
  9. package/dist/_recorder-chord.js +7 -13
  10. package/dist/_recording-guard.js +1 -2
  11. package/dist/_registration.d.ts +1 -3
  12. package/dist/_registration.js +11 -6
  13. package/dist/_server.js +3 -4
  14. package/dist/_store.d.ts +1 -3
  15. package/dist/_store.js +1 -2
  16. package/dist/_target-listeners.js +11 -3
  17. package/dist/adapter.d.ts +10 -8
  18. package/dist/adapter.js +9 -5
  19. package/dist/app-hotkeys.d.ts +4 -5
  20. package/dist/app-hotkeys.js +10 -21
  21. package/dist/conflicts.d.ts +2 -4
  22. package/dist/conflicts.js +5 -6
  23. package/dist/constants.d.ts +2 -4
  24. package/dist/constants.js +1 -2
  25. package/dist/display-labels.d.ts +1 -3
  26. package/dist/display-labels.js +1 -2
  27. package/dist/format.d.ts +2 -4
  28. package/dist/format.js +4 -5
  29. package/dist/hint.d.ts +1 -3
  30. package/dist/hint.js +1 -2
  31. package/dist/hotkey-manager.d.ts +23 -4
  32. package/dist/hotkey-manager.js +138 -30
  33. package/dist/hotkey-recorder.d.ts +19 -7
  34. package/dist/hotkey-recorder.js +8 -11
  35. package/dist/hotkey.types.d.ts +1 -3
  36. package/dist/index.d.ts +8 -8
  37. package/dist/index.js +3 -3
  38. package/dist/key-state-tracker.d.ts +1 -3
  39. package/dist/key-state-tracker.js +2 -3
  40. package/dist/key.types.d.ts +5 -6
  41. package/dist/match.d.ts +4 -5
  42. package/dist/match.js +7 -7
  43. package/dist/parse.d.ts +2 -3
  44. package/dist/parse.js +10 -19
  45. package/dist/platform.d.ts +1 -3
  46. package/dist/platform.js +1 -2
  47. package/dist/validate.d.ts +33 -13
  48. package/dist/validate.js +51 -34
  49. package/dist/vendor/@tanstack/store/dist/store.d.ts +0 -2
  50. package/dist/vendor/@tanstack/store/dist/types.d.ts +0 -2
  51. package/package.json +4 -4
  52. package/skills/app-hotkeys/SKILL.md +1 -1
  53. package/skills/hotkeys-core/SKILL.md +3 -1
@@ -1,5 +1,4 @@
1
1
  import { CanonicalModifier, EditingKey, FunctionKey, LetterKey, NavigationKey, NumberKey, PunctuationKey } from "./key.types.js";
2
- //#region src/constants.d.ts
3
2
  /**
4
3
  * Canonical order for modifiers in normalized hotkey strings.
5
4
  *
@@ -118,7 +117,7 @@ export declare const PUNCTUATION_CODE_MAP: Record<string, string>;
118
117
  * @see {@link EDITING_KEYS}
119
118
  * @see {@link PUNCTUATION_KEYS}
120
119
  */
121
- export declare const ALL_KEYS: Set<"CapsLock" | "ContextMenu" | "Convert" | "KanaMode" | "NonConvert" | "Help" | "Insert" | "NumLock" | "Fn" | "FnLock" | "PrintScreen" | "ScrollLock" | "Pause" | "BrowserBack" | "BrowserFavorites" | "BrowserForward" | "BrowserHome" | "BrowserRefresh" | "BrowserSearch" | "BrowserStop" | "Eject" | "LaunchMail" | "MediaPlayPause" | "MediaStop" | "MediaTrackNext" | "MediaTrackPrevious" | "Power" | "AudioVolumeDown" | "AudioVolumeMute" | "AudioVolumeUp" | "WakeUp" | "Hyper" | "Super" | "Again" | "Copy" | "Cut" | "Find" | "Open" | "Paste" | "Props" | "Select" | "Undo" | "Hiragana" | "Katakana" | "Clear" | "Cancel" | "Standby" | "LaunchApplication1" | "LaunchApplication2" | "LaunchMediaPlayer" | "HangulMode" | "HanjaMode" | "Zenkaku" | "Hankaku" | "ZenkakuHankaku" | LetterKey | NumberKey | FunctionKey | NavigationKey | EditingKey | PunctuationKey>;
120
+ export declare const ALL_KEYS: Set<LetterKey | NumberKey | FunctionKey | NavigationKey | EditingKey | PunctuationKey | "CapsLock" | "ContextMenu" | "Convert" | "KanaMode" | "NonConvert" | "Help" | "Insert" | "NumLock" | "Fn" | "FnLock" | "PrintScreen" | "ScrollLock" | "Pause" | "BrowserBack" | "BrowserFavorites" | "BrowserForward" | "BrowserHome" | "BrowserRefresh" | "BrowserSearch" | "BrowserStop" | "Eject" | "LaunchMail" | "MediaPlayPause" | "MediaStop" | "MediaTrackNext" | "MediaTrackPrevious" | "Power" | "AudioVolumeDown" | "AudioVolumeMute" | "AudioVolumeUp" | "WakeUp" | "Hyper" | "Super" | "Again" | "Copy" | "Cut" | "Find" | "Open" | "Paste" | "Props" | "Select" | "Undo" | "Hiragana" | "Katakana" | "Clear" | "Cancel" | "Standby" | "LaunchApplication1" | "LaunchApplication2" | "LaunchMediaPlayer" | "HangulMode" | "HanjaMode" | "Zenkaku" | "Hankaku" | "ZenkakuHankaku">;
122
121
  /** Whether a key is a single Unicode letter, such as `'a'` or `'é'`. */
123
122
  export declare function isSingleLetterKey(key: string): boolean;
124
123
  /**
@@ -136,5 +135,4 @@ export declare function isSingleLetterKey(key: string): boolean;
136
135
  * normalizeKeyName('ArrowUp') // 'ArrowUp' (already canonical)
137
136
  * ```
138
137
  */
139
- export declare function normalizeKeyName(key: string): string;
140
- //#endregion
138
+ export declare function normalizeKeyName(key: string): string;
package/dist/constants.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { EDITING_KEY_NAMES, LOGICAL_ONLY_NAMED_KEYS, NAVIGATION_KEY_NAMES, SHARED_NAMED_KEYS } from "./_named-keys.js";
2
2
 
3
- //#region src/constants.ts
3
+ // src/constants.ts
4
4
  /**
5
5
  * Canonical order for modifiers in normalized hotkey strings.
6
6
  *
@@ -193,5 +193,4 @@ function normalizeKeyName(key) {
193
193
  return key === " " ? "Space" : key;
194
194
  }
195
195
 
196
- //#endregion
197
196
  export { ALL_KEYS, EDITING_KEYS, FUNCTION_KEYS, LETTER_KEYS, MODIFIER_ALIASES, MODIFIER_KEYS, MODIFIER_ORDER, NAVIGATION_KEYS, NUMBER_KEYS, PUNCTUATION_CODE_MAP, PUNCTUATION_KEYS, isSingleLetterKey, normalizeKeyName };
@@ -1,5 +1,4 @@
1
1
  import { CanonicalModifier } from "./key.types.js";
2
- //#region src/display-labels.d.ts
3
2
  /**
4
3
  * Modifier key symbols for macOS display.
5
4
  *
@@ -86,5 +85,4 @@ export declare const KEY_DISPLAY_SYMBOLS: {
86
85
  readonly Delete: "⌦";
87
86
  readonly Tab: "⇥";
88
87
  readonly Space: "␣";
89
- };
90
- //#endregion
88
+ };
@@ -1,4 +1,4 @@
1
- //#region src/display-labels.ts
1
+ // src/display-labels.ts
2
2
  /**
3
3
  * Modifier key symbols for macOS display.
4
4
  *
@@ -108,5 +108,4 @@ const KEY_DISPLAY_SYMBOLS = {
108
108
  Space: "␣"
109
109
  };
110
110
 
111
- //#endregion
112
111
  export { KEY_DISPLAY_SYMBOLS, LINUX_MODIFIER_LABELS, MAC_MODIFIER_LABELS, MAC_MODIFIER_SYMBOLS, PUNCTUATION_KEY_DISPLAY_LABELS, WINDOWS_MODIFIER_LABELS };
package/dist/format.d.ts CHANGED
@@ -1,6 +1,5 @@
1
- import { CanonicalModifier } from "./key.types.js";
2
1
  import { ParsedHotkeyChord, RegisterableHotkeyChord } from "./hotkey.types.js";
3
- //#region src/format.d.ts
2
+ import { CanonicalModifier } from "./key.types.js";
4
3
  /**
5
4
  * Converts a parsed hotkey back to a string, with each platform modifier
6
5
  * spelled out, such as `Control+Shift+S`. A parsed chord returns a string, and
@@ -105,5 +104,4 @@ export interface FormatDisplayOptions {
105
104
  separatorToken?: string | null;
106
105
  /** Separator between sequence steps. Default: a space. */
107
106
  sequenceSeparator?: string;
108
- }
109
- //#endregion
107
+ }
package/dist/format.js CHANGED
@@ -1,11 +1,11 @@
1
+ import { assertLogicalKey, codeToLogicalKey, modifierAlias, physicalCodeOf, splitChordParts } from "./_keyboard-event.js";
2
+ import { warnServerPlatform } from "./_server.js";
1
3
  import { MODIFIER_ORDER, normalizeKeyName } from "./constants.js";
2
4
  import { KEY_DISPLAY_SYMBOLS, LINUX_MODIFIER_LABELS, MAC_MODIFIER_LABELS, MAC_MODIFIER_SYMBOLS, PUNCTUATION_KEY_DISPLAY_LABELS, WINDOWS_MODIFIER_LABELS } from "./display-labels.js";
3
- import { warnServerPlatform } from "./_server.js";
4
- import { detectPlatform } from "./platform.js";
5
- import { assertLogicalKey, codeToLogicalKey, modifierAlias, physicalCodeOf, splitChordParts } from "./_keyboard-event.js";
6
5
  import { normalizeHotkey } from "./parse.js";
6
+ import { detectPlatform } from "./platform.js";
7
7
 
8
- //#region src/format.ts
8
+ // src/format.ts
9
9
  function formatHotkey(parsed) {
10
10
  return Array.isArray(parsed) ? parsed.map((step) => formatParsedChord(step)) : formatParsedChord(parsed);
11
11
  }
@@ -72,5 +72,4 @@ function formatKey(segment, platform, modifierSymbols, keySymbols, options) {
72
72
  return numpad ? `Numpad ${display.replace(/([a-z])([A-Z])/g, "$1 $2")}` : display;
73
73
  }
74
74
 
75
- //#endregion
76
75
  export { formatForDisplay, formatHotkey };
package/dist/hint.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { RegisterableHotkey } from "./hotkey.types.js";
2
- //#region src/hint.d.ts
3
2
  export interface HeldModifierOptions {
4
3
  /** Resolve Mod for this platform. Defaults to platform detection. */
5
4
  platform?: 'mac' | 'windows' | 'linux';
@@ -18,5 +17,4 @@ export interface HeldModifierOptions {
18
17
  * matchesHeldModifiers('Alt+Shift+[KeyK]', ['Alt']) // true
19
18
  * matchesHeldModifiers('Alt+Shift+[KeyK]', ['Alt', 'Control']) // false
20
19
  */
21
- export declare function matchesHeldModifiers(hotkey: RegisterableHotkey | null | undefined, heldKeys: ReadonlyArray<string>, options?: HeldModifierOptions): boolean;
22
- //#endregion
20
+ export declare function matchesHeldModifiers(hotkey: RegisterableHotkey | null | undefined, heldKeys: ReadonlyArray<string>, options?: HeldModifierOptions): boolean;
package/dist/hint.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { MODIFIER_KEYS, normalizeKeyName } from "./constants.js";
2
2
  import { isHotkeySequence, parseHotkey } from "./parse.js";
3
3
 
4
- //#region src/hint.ts
4
+ // src/hint.ts
5
5
  /**
6
6
  * Returns whether held modifiers should reveal a shortcut hint.
7
7
  * By default, any nonempty subset of the binding's modifiers qualifies; an
@@ -24,5 +24,4 @@ function matchesHeldModifiers(hotkey, heldKeys, options = {}) {
24
24
  return held.length > 0 && held.every((key) => modifiers.includes(key)) && (!options.exact || held.length === modifiers.length);
25
25
  }
26
26
 
27
- //#endregion
28
27
  export { matchesHeldModifiers };
@@ -1,7 +1,6 @@
1
1
  import { Store } from "./_store.js";
2
- import { ConflictBehavior, HotkeyCallback, HotkeyCallbackContextFor, HotkeyChord, HotkeyMeta, HotkeySequence, ParsedHotkeyChord, RegisterableHotkey, RegisterableHotkeyChord, RegisterableHotkeySequence } from "./hotkey.types.js";
3
2
  import { AppHotkey, AppHotkeysStore } from "./app-hotkeys.js";
4
- //#region src/hotkey-manager.d.ts
3
+ import { ConflictBehavior, HotkeyCallback, HotkeyCallbackContextFor, HotkeyChord, HotkeyMeta, HotkeySequence, ParsedHotkeyChord, RegisterableHotkey, RegisterableHotkeyChord, RegisterableHotkeySequence } from "./hotkey.types.js";
5
4
  /** An element, document, or window that hotkeys can listen on. */
6
5
  export type Target = HTMLElement | Document | Window;
7
6
  /**
@@ -24,8 +23,19 @@ export interface HotkeyOptions {
24
23
  * existing handle via `setOptions` rather than unregistering. Defaults to `true`.
25
24
  */
26
25
  enabled?: boolean;
27
- /** The event type to listen for. Defaults to 'keydown' */
26
+ /**
27
+ * The event type to listen for. Defaults to 'keydown'. A 'keyup' chord fires
28
+ * when its key or one of its modifiers is released, whichever comes first,
29
+ * unless another key was pressed in between. `preventDefault` applies to
30
+ * its keydown too.
31
+ */
28
32
  eventType?: 'keydown' | 'keyup';
33
+ /**
34
+ * Skip a key press that a listener outside TanStack Hotkeys already handled
35
+ * by calling `preventDefault()`, such as a component library's dialog that
36
+ * closes on Escape. Defaults to `false`.
37
+ */
38
+ ignoreDefaultPrevented?: boolean;
29
39
  /**
30
40
  * Skip keys that a focused control inside the target uses itself: typing in
31
41
  * text inputs, textarea, select, and contenteditable; Enter/Space on buttons
@@ -70,6 +80,16 @@ export interface HotkeyOptions {
70
80
  scope?: string;
71
81
  /** Id of the app hotkey this registration implements. Adapters set this when registering an app hotkey from `defineAppHotkeys`. */
72
82
  appHotkeyId?: string;
83
+ /**
84
+ * Run after key handlers that a framework calls from one listener on its
85
+ * root or the document, such as React's `onKeyDown`, so handlers inside the
86
+ * target run first and can stop the hotkey. An element target then listens
87
+ * on its document, or on its closed shadow root, instead of the element. A
88
+ * stop by an earlier listener there skips the hotkey. Applies only to the
89
+ * bubble phase.
90
+ * Adapters for frameworks that delegate key events set this.
91
+ */
92
+ afterDelegatedHandlers?: boolean;
73
93
  }
74
94
  /** Options for a single chord such as `'Mod+S'`. */
75
95
  export interface HotkeyChordOptions extends HotkeyOptions {
@@ -400,5 +420,4 @@ export declare class HotkeyManager {
400
420
  * Convenience function for accessing the manager.
401
421
  */
402
422
  export declare function getHotkeyManager(): HotkeyManager;
403
- //#endregion
404
423
  export type { ConflictBehavior };
@@ -1,18 +1,18 @@
1
- import { normalizeKeyName } from "./constants.js";
2
- import { detectPlatform } from "./platform.js";
1
+ import { closesNativeLayer, getTargetDocument, isDocumentLevel, shouldIgnoreInputEvent, targetHasFocus } from "./_event-target.js";
3
2
  import { isKeyNameShape, keysEqual, normalizeKeyboardEvent } from "./_keyboard-event.js";
4
- import { isHotkeySequence, isModifierKey, parseHotkey, simplifyHotkey } from "./parse.js";
5
- import { formatHotkey } from "./format.js";
3
+ import { managerRef } from "./_manager-ref.js";
6
4
  import { matchKeyboardEvent, parsedChordsEqual } from "./_match.js";
5
+ import { isRecordingEvent } from "./_recording-guard.js";
7
6
  import { GLOBAL_SCOPE, chordHasKey, defaultHotkeyOptions, definedOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
8
- import { DEFAULT_SEQUENCE_TIMEOUT, areHotkeysEqual } from "./match.js";
9
- import { getTargetDocument, isDocumentLevel, shouldIgnoreInputEvent, targetHasFocus } from "./_event-target.js";
10
7
  import { createStore } from "./_store.js";
11
8
  import { TargetListeners } from "./_target-listeners.js";
12
- import { managerRef } from "./_manager-ref.js";
13
- import { isRecordingEvent } from "./_recording-guard.js";
9
+ import { normalizeKeyName } from "./constants.js";
10
+ import { formatHotkey } from "./format.js";
11
+ import { DEFAULT_SEQUENCE_TIMEOUT, areHotkeysEqual } from "./match.js";
12
+ import { isHotkeySequence, isModifierKey, parseHotkey, simplifyHotkey } from "./parse.js";
13
+ import { detectPlatform } from "./platform.js";
14
14
 
15
- //#region src/hotkey-manager.ts
15
+ // src/hotkey-manager.ts
16
16
  /** Whether a registration can fire now. Without a manager, scopes don't apply. */
17
17
  function getRegistrationStatus(options, manager) {
18
18
  if (options.enabled === false) return "disabled";
@@ -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. */
@@ -219,7 +248,7 @@ var HotkeyManager = class HotkeyManager {
219
248
  if (scope === "global") return () => {};
220
249
  const { exclusive } = options;
221
250
  const allow = exclusive ? exclusive === true ? [] : exclusive.allow : null;
222
- if (process.env.NODE_ENV !== "production" && allow && [...this.#scopeActivations].some((other) => other.allow && other.scope === scope)) console.warn(`Exclusive hotkey scope '${scope}' is already active. Hotkeys from both activations will fire; give each a unique scope.`);
251
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production" && allow && [...this.#scopeActivations].some((other) => other.allow && other.scope === scope)) console.warn(`[hotkeys] Exclusive scope '${scope}' is already active`);
223
252
  const activation = {
224
253
  scope,
225
254
  parent: options.parent ?? "global",
@@ -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;
@@ -362,12 +391,14 @@ var HotkeyManager = class HotkeyManager {
362
391
  }
363
392
  const steps = registration.kind === "sequence" ? registration.parsedSequence : [registration.parsedChord];
364
393
  if (!steps.every(chordHasKey)) return inactiveHandle(callback);
365
- if (process.env.NODE_ENV !== "production") {
366
- for (const { key } of steps) if (key !== void 0 && !isKeyNameShape(key)) console.warn(`Unknown key '${key}' in '${formatForMessage(registration.hotkey)}'. Key names are case-sensitive, such as 'Escape' and 'F1', so this hotkey may never fire.`);
394
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
395
+ for (const { key } of steps) if (key !== void 0 && !isKeyNameShape(key)) console.warn(`[hotkeys] Unknown key '${key}' in '${formatForMessage(registration.hotkey)}' (names are case-sensitive)`);
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
  }
@@ -731,5 +840,4 @@ function getHotkeyManager() {
731
840
  return HotkeyManager.getInstance();
732
841
  }
733
842
 
734
- //#endregion
735
843
  export { HotkeyManager, getHotkeyManager, toHotkeyRegistrationView };
@@ -1,12 +1,24 @@
1
1
  import { Store } from "./_store.js";
2
- import { Hotkey, HotkeyChord, HotkeySequence, ParsedHotkeyChord } from "./hotkey.types.js";
3
2
  import { HotkeyConflict, HotkeyConflictOptions } from "./conflicts.js";
4
- //#region src/hotkey-recorder.d.ts
3
+ import { Hotkey, HotkeyChord, HotkeySequence, ParsedHotkeyChord } from "./hotkey.types.js";
5
4
  /** Event property used to record a binding. Physical codes are the default. */
6
5
  export type RecorderKeyMode = 'code' | 'key';
6
+ /**
7
+ * Why the recorder rejected a key press or a recording:
8
+ *
9
+ * - `'alt-graph'`: AltGr typed a character, which physical recording can't
10
+ * record. Record by key to accept characters.
11
+ * - `'missing-code'`: the event has no usable physical key code.
12
+ * - `'whitespace'`: the key is a whitespace character, which a hotkey can't
13
+ * hold.
14
+ * - `'validation'`: your `validate` callback rejected the recording.
15
+ * - `'conflict'`: the recording conflicts with registered hotkeys.
16
+ */
17
+ export type RecorderRejectionReason = 'alt-graph' | 'missing-code' | 'whitespace' | 'validation' | 'conflict';
7
18
  export interface RecorderRejection {
8
- reason: 'missing-code' | 'alt-graph' | 'invalid' | 'conflict' | 'validation';
9
- message: string;
19
+ reason: RecorderRejectionReason;
20
+ /** The message your `validate` callback returned, when it returned one. */
21
+ message?: string;
10
22
  /** The rejected chord or sequence, when one was recorded. */
11
23
  hotkey?: Hotkey;
12
24
  conflicts?: Array<HotkeyConflict>;
@@ -74,7 +86,7 @@ interface HotkeyRecorderBaseOptions extends RecorderOptions {
74
86
  export interface HotkeyChordRecorderOptions extends HotkeyRecorderBaseOptions {
75
87
  /** Maximum number of chords to record. Default: 1 */
76
88
  maxSteps?: 1;
77
- /** Return true to accept, or false/a message to reject while staying in recording mode. */
89
+ /** Return true to accept, or false or a message to reject while staying in recording mode. The message is passed to `onReject`. */
78
90
  validate?: (hotkey: HotkeyChord, context: HotkeyChordRecorderValidationContext) => boolean | string;
79
91
  /** Callback when a chord is successfully recorded */
80
92
  onRecord: (hotkey: HotkeyChord) => void;
@@ -86,7 +98,7 @@ export interface HotkeyChordRecorderOptions extends HotkeyRecorderBaseOptions {
86
98
  export interface HotkeySequenceRecorderOptions extends HotkeyRecorderBaseOptions {
87
99
  /** Maximum number of chords to record. Reaching it commits automatically. Use `Infinity` for no limit. */
88
100
  maxSteps: number;
89
- /** Return true to accept, or false/a message to reject while staying in recording mode. */
101
+ /** Return true to accept, or false or a message to reject while staying in recording mode. The message is passed to `onReject`. */
90
102
  validate?: (hotkey: Hotkey, context: HotkeyRecorderValidationContext) => boolean | string;
91
103
  /** Callback when a hotkey is successfully recorded */
92
104
  onRecord: (hotkey: Hotkey) => void;
@@ -178,4 +190,4 @@ export declare class HotkeyRecorder {
178
190
  */
179
191
  destroy(): void;
180
192
  }
181
- //#endregion
193
+ export {};
@@ -1,13 +1,13 @@
1
- import { detectPlatform } from "./platform.js";
2
- import { normalizeKeyboardEvent, splitChordParts } from "./_keyboard-event.js";
3
- import { isModifierKey, parseHotkey, simplifyHotkey } from "./parse.js";
4
1
  import { isTypingEvent } from "./_event-target.js";
5
- import { createStore } from "./_store.js";
2
+ import { normalizeKeyboardEvent, splitChordParts } from "./_keyboard-event.js";
3
+ import { chordRejection, hotkeyChordFromKeydown } from "./_recorder-chord.js";
6
4
  import { beginRecording, captureRecordingEvent, endRecording } from "./_recording-guard.js";
5
+ import { createStore } from "./_store.js";
7
6
  import { findHotkeyConflicts } from "./conflicts.js";
8
- import { chordRejection, hotkeyChordFromKeydown } from "./_recorder-chord.js";
7
+ import { isModifierKey, parseHotkey, simplifyHotkey } from "./parse.js";
8
+ import { detectPlatform } from "./platform.js";
9
9
 
10
- //#region src/hotkey-recorder.ts
10
+ // src/hotkey-recorder.ts
11
11
  const initialState = {
12
12
  isRecording: false,
13
13
  steps: [],
@@ -157,8 +157,7 @@ var HotkeyRecorder = class {
157
157
  if (chord === null) return;
158
158
  if (splitChordParts(chord).some((part) => !part.trim())) {
159
159
  options.onReject?.({
160
- reason: "invalid",
161
- message: "Whitespace keys cannot be recorded",
160
+ reason: "whitespace",
162
161
  hotkey: chord
163
162
  });
164
163
  return;
@@ -211,7 +210,7 @@ var HotkeyRecorder = class {
211
210
  if (accepted !== void 0 && accepted !== true) {
212
211
  rejectAndKeepRecording({
213
212
  reason: "validation",
214
- message: typeof accepted === "string" ? accepted : `This ${single ? "shortcut" : "sequence"} is not allowed.`,
213
+ ...typeof accepted === "string" && { message: accepted },
215
214
  hotkey
216
215
  });
217
216
  return;
@@ -225,7 +224,6 @@ var HotkeyRecorder = class {
225
224
  if (conflicts.length) {
226
225
  rejectAndKeepRecording({
227
226
  reason: "conflict",
228
- message: `This ${single ? "shortcut" : "sequence"} conflicts with a registered hotkey.`,
229
227
  hotkey,
230
228
  conflicts
231
229
  });
@@ -286,5 +284,4 @@ var HotkeyRecorder = class {
286
284
  }
287
285
  };
288
286
 
289
- //#endregion
290
287
  export { HotkeyRecorder };
@@ -1,5 +1,4 @@
1
1
  import { CanonicalModifier, Key, LogicalKey } from "./key.types.js";
2
- //#region src/hotkey.types.d.ts
3
2
  /**
4
3
  * Single modifier + key combinations.
5
4
  * Uses canonical modifiers (4) + Mod (1) = 5 modifiers.
@@ -269,5 +268,4 @@ export interface HotkeyMeta {
269
268
  * - `'replace'` - Unregister the existing registration and register the new one
270
269
  * - `'allow'` - Allow multiple registrations without warning
271
270
  */
272
- export type ConflictBehavior = 'warn' | 'error' | 'replace' | 'allow';
273
- //#endregion
271
+ export type ConflictBehavior = 'warn' | 'error' | 'replace' | 'allow';