@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.
- package/dist/_event-target.js +24 -1
- package/dist/_server.js +1 -1
- package/dist/_target-listeners.js +10 -1
- package/dist/adapter.d.ts +7 -3
- package/dist/adapter.js +7 -2
- package/dist/hotkey-manager.d.ts +22 -1
- package/dist/hotkey-manager.js +127 -18
- package/package.json +1 -1
- package/skills/hotkeys-core/SKILL.md +2 -0
package/dist/_event-target.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
30
|
-
|
|
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
|
-
/**
|
|
8
|
-
|
|
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
|
};
|
package/dist/hotkey-manager.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 {
|
package/dist/hotkey-manager.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
498
|
-
const
|
|
499
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
@@ -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.
|