@tanstack/hotkeys 0.22.1 → 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 };
@@ -0,0 +1,21 @@
1
+ //#region src/_server.ts
2
+ const SERVER_AGENT = /^(?:Node\.js\/|Deno\/|Bun\/|Cloudflare-Workers)/;
3
+ /** Whether this runs on a server: no `navigator`, or a server runtime's. */
4
+ function isServerRuntime() {
5
+ return typeof navigator === "undefined" || SERVER_AGENT.test(String(navigator.userAgent));
6
+ }
7
+ let warned = false;
8
+ /**
9
+ * Warns once, in development, when a label's platform is detected during
10
+ * server rendering. The server then renders 'linux' labels, which differ for
11
+ * users on macOS and Windows after hydration.
12
+ */
13
+ function warnServerPlatform() {
14
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production" && !warned && isServerRuntime()) {
15
+ warned = true;
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
+ }
18
+ }
19
+
20
+ //#endregion
21
+ export { isServerRuntime, warnServerPlatform };
@@ -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
@@ -2,6 +2,7 @@ import { Store, createStore } from "./_store.js";
2
2
  import { HotkeyCallback, HotkeyChord, HotkeySequence, RegisterableHotkey, RegisterableHotkeyChord, RegisterableHotkeySequence } from "./hotkey.types.js";
3
3
  import { ActivateHotkeyScopeOptions, HotkeyChordOptions, HotkeyOptions, HotkeySequenceOptions, Target } from "./hotkey-manager.js";
4
4
  import { AppHotkey, AppHotkeysStore } from "./app-hotkeys.js";
5
+ import { HotkeyPlatform } from "./platform.js";
5
6
  import { definedOptions, withDefaults } from "./_registration.js";
6
7
  import { createAppHotkeysStore } from "@tanstack/hotkeys";
7
8
  //#region src/adapter.d.ts
@@ -25,8 +26,12 @@ export type HotkeyDefinition = {
25
26
  };
26
27
  /** Options shared by every definition. Each option applies only to the kinds that support it. */
27
28
  export type HotkeyBindingsCommonOptions = HotkeyChordOptions & HotkeySequenceOptions;
28
- /** Reconciles adapter registrations while retaining handles for option updates. */
29
- 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'>): {
30
35
  /**
31
36
  * Registers new definitions, updates existing ones in place, and removes
32
37
  * stale ones. Definitions with no hotkey (undefined, null, empty string, or
@@ -36,7 +41,7 @@ export declare function createHotkeyBindings(): {
36
41
  destroy(): void;
37
42
  };
38
43
  /** Options with a framework-specific target, such as a ref. */
39
- export type WithTarget<TOptions, TTarget> = Omit<TOptions, 'target' | 'appHotkeyId'> & {
44
+ export type WithTarget<TOptions, TTarget> = Omit<TOptions, 'target' | 'appHotkeyId' | 'afterDelegatedHandlers'> & {
40
45
  /** The element to listen on. Defaults to document. */
41
46
  target?: TTarget;
42
47
  };
@@ -147,11 +152,20 @@ export interface HotkeysProviderScopeProps {
147
152
  /**
148
153
  * Merges a provider's default options over its parent's. A provider with a
149
154
  * `scope` sets `hotkey.scope`, so hotkeys registered inside it use that scope.
155
+ * A provider's `platform` is inherited, and is the default platform for its
156
+ * hotkeys and recorders; `hotkey.platform` and `hotkeyRecorder.platform` win.
150
157
  */
151
158
  export declare function mergeHotkeysProviderDefaults<T extends {
159
+ platform?: HotkeyPlatform;
152
160
  hotkey?: object;
153
161
  hotkeyRecorder?: object;
154
162
  }>(parent: T | undefined, own: T | undefined, scope: string | undefined): T;
163
+ /**
164
+ * The platform to render with during server rendering and hydration when no
165
+ * provider sets one, for a `useSyncExternalStore` server snapshot. Warns once
166
+ * in development on the server.
167
+ */
168
+ export declare function getServerPlatformSnapshot(): HotkeyPlatform;
155
169
  /**
156
170
  * A key for a provider's scope activation, or `null` when the scope should not
157
171
  * be active. Equal keys mean the same activation, so adapters reactivate only
package/dist/adapter.js CHANGED
@@ -1,10 +1,15 @@
1
+ import { warnServerPlatform } from "./_server.js";
1
2
  import { chordHasKey, defaultHotkeyOptions, definedOptions, withDefaults } from "./_registration.js";
2
3
  import { createStore } from "./_store.js";
3
4
  import { createAppHotkeysStore, detectPlatform, getAppHotkeyRegistrationOptions, getHotkeyManager, isAppHotkey, isHotkeySequence, normalizeHotkey, parseHotkey, resolveAppHotkey } from "@tanstack/hotkeys";
4
5
 
5
6
  //#region src/adapter.ts
6
- /** Reconciles adapter registrations while retaining handles for option updates. */
7
- 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 = {}) {
8
13
  const registrations = /* @__PURE__ */ new Map();
9
14
  return {
10
15
  /**
@@ -19,6 +24,7 @@ function createHotkeyBindings() {
19
24
  const options = {
20
25
  ...defaultHotkeyOptions,
21
26
  ...merged,
27
+ ...adapterOptions,
22
28
  target: "target" in merged ? merged.target ?? null : typeof document === "undefined" ? null : document,
23
29
  platform: merged.platform ?? detectPlatform()
24
30
  };
@@ -122,17 +128,31 @@ function subscribePublishedAppHotkeys(onChange) {
122
128
  /**
123
129
  * Merges a provider's default options over its parent's. A provider with a
124
130
  * `scope` sets `hotkey.scope`, so hotkeys registered inside it use that scope.
131
+ * A provider's `platform` is inherited, and is the default platform for its
132
+ * hotkeys and recorders; `hotkey.platform` and `hotkeyRecorder.platform` win.
125
133
  */
126
134
  function mergeHotkeysProviderDefaults(parent, own, scope) {
135
+ const parentPlatform = { platform: parent?.platform };
136
+ const ownPlatform = { platform: own?.platform };
127
137
  return {
138
+ ...definedOptions(parentPlatform, ownPlatform),
128
139
  hotkey: {
129
- ...definedOptions(parent?.hotkey, own?.hotkey),
140
+ ...definedOptions(parentPlatform, parent?.hotkey, ownPlatform, own?.hotkey),
130
141
  ...scope && { scope }
131
142
  },
132
- hotkeyRecorder: definedOptions(parent?.hotkeyRecorder, own?.hotkeyRecorder)
143
+ hotkeyRecorder: definedOptions(parentPlatform, parent?.hotkeyRecorder, ownPlatform, own?.hotkeyRecorder)
133
144
  };
134
145
  }
135
146
  /**
147
+ * The platform to render with during server rendering and hydration when no
148
+ * provider sets one, for a `useSyncExternalStore` server snapshot. Warns once
149
+ * in development on the server.
150
+ */
151
+ function getServerPlatformSnapshot() {
152
+ warnServerPlatform();
153
+ return "linux";
154
+ }
155
+ /**
136
156
  * A key for a provider's scope activation, or `null` when the scope should not
137
157
  * be active. Equal keys mean the same activation, so adapters reactivate only
138
158
  * when the key changes, not when an inline `exclusive` object is recreated.
@@ -159,4 +179,4 @@ function activateHotkeysProviderScope(key) {
159
179
  }
160
180
 
161
181
  //#endregion
162
- export { activateHotkeysProviderScope, createAppHotkeysStore, createHotkeyBindings, createStore, definedOptions, getHotkeysProviderScopeKey, mergeHotkeysProviderDefaults, prepareHotkeyDefinitions, subscribePublishedAppHotkeys, withDefaults };
182
+ export { activateHotkeysProviderScope, createAppHotkeysStore, createHotkeyBindings, createStore, definedOptions, getHotkeysProviderScopeKey, getServerPlatformSnapshot, mergeHotkeysProviderDefaults, prepareHotkeyDefinitions, subscribePublishedAppHotkeys, withDefaults };
package/dist/format.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { MODIFIER_ORDER, normalizeKeyName } from "./constants.js";
2
2
  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";
3
4
  import { detectPlatform } from "./platform.js";
4
5
  import { assertLogicalKey, codeToLogicalKey, modifierAlias, physicalCodeOf, splitChordParts } from "./_keyboard-event.js";
5
6
  import { normalizeHotkey } from "./parse.js";
@@ -23,16 +24,21 @@ function formatForDisplay(hotkey, options = {}) {
23
24
  const parts = formatChordParts(hotkey, options);
24
25
  return options.parts ? parts : parts.join(chordSeparator(options));
25
26
  }
27
+ /** Detects the platform for a label, warning in development on a server. */
28
+ function detectDisplayPlatform() {
29
+ warnServerPlatform();
30
+ return detectPlatform();
31
+ }
26
32
  function modifierSymbolsEnabled(options) {
27
33
  const symbols = options.useSymbols ?? true;
28
34
  return typeof symbols === "boolean" ? symbols : symbols.modifiers ?? true;
29
35
  }
30
36
  function chordSeparator(options) {
31
- const platform = options.platform ?? detectPlatform();
37
+ const platform = options.platform ?? detectDisplayPlatform();
32
38
  return options.separatorToken ?? (platform === "mac" && modifierSymbolsEnabled(options) ? " " : "+");
33
39
  }
34
40
  function formatChordParts(hotkey, options) {
35
- const platform = options.platform ?? detectPlatform();
41
+ const platform = options.platform ?? detectDisplayPlatform();
36
42
  const symbols = options.useSymbols ?? true;
37
43
  const modifierSymbols = modifierSymbolsEnabled(options);
38
44
  const keySymbols = typeof symbols === "boolean" ? symbols : symbols.keys ?? true;
@@ -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/dist/index.d.ts CHANGED
@@ -2,9 +2,9 @@ import { CanonicalModifier, EditingKey, FunctionKey, IndividualKey, Key, LetterK
2
2
  import { ConflictBehavior, FourModifierHotkeyChord, Hotkey, HotkeyCallback, HotkeyCallbackContext, HotkeyCallbackContextFor, HotkeyChord, HotkeyChordCallbackContext, HotkeyMeta, HotkeySequence, HotkeySequenceCallbackContext, ParsedHotkeyChord, ParsedModifiers, RawHotkeyChord, RawModifiers, RegisterableHotkey, RegisterableHotkeyChord, RegisterableHotkeySequence, SingleModifierHotkeyChord, ThreeModifierHotkeyChord, TwoModifierHotkeyChord } from "./hotkey.types.js";
3
3
  import { ActivateHotkeyScopeOptions, HotkeyChordOptions, HotkeyChordRegistration, HotkeyChordRegistrationView, HotkeyManager, HotkeyOptions, HotkeyOptionsFor, HotkeyRegistration, HotkeyRegistrationHandle, HotkeyRegistrationStatus, HotkeyRegistrationView, HotkeyScopesState, HotkeySequenceOptions, HotkeySequenceRegistration, HotkeySequenceRegistrationView, Target, getHotkeyManager, toHotkeyRegistrationView } from "./hotkey-manager.js";
4
4
  import { AppHotkey, AppHotkeyChordOptions, AppHotkeyDefaultKind, AppHotkeyId, AppHotkeyInput, AppHotkeyListItem, AppHotkeyOptions, AppHotkeySequenceOptions, AppHotkeyStatus, AppHotkeys, AppHotkeysState, AppHotkeysStore, AppHotkeysStoreOptions, CustomHotkeys, DefinedAppHotkeys, Register, RegisteredAppHotkeys, defineAppHotkeys, getAppHotkeyRegistrationOptions, isAppHotkey, isCustomHotkey, provideAppHotkeys, publishAppHotkeysStore, resolveAppHotkey } from "./app-hotkeys.js";
5
+ import { HotkeyPlatform, detectPlatform, resolveModifier } from "./platform.js";
5
6
  import { 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 } from "./constants.js";
6
7
  import { KEY_DISPLAY_SYMBOLS, LINUX_MODIFIER_LABELS, MAC_MODIFIER_LABELS, MAC_MODIFIER_SYMBOLS, PUNCTUATION_KEY_DISPLAY_LABELS, WINDOWS_MODIFIER_LABELS } from "./display-labels.js";
7
- import { detectPlatform, resolveModifier } from "./platform.js";
8
8
  import { DisplayHotkey, DisplayHotkeyChord, FormatDisplayOptions, formatForDisplay, formatHotkey } from "./format.js";
9
9
  import { hasNonModifierKey, isHotkeySequence, isModifierKey, normalizeHotkey, parseHotkey, parseKeyboardEvent, simplifyHotkey } from "./parse.js";
10
10
  import { ValidatableHotkey, ValidationResult, assertValidHotkey, checkHotkey, validateHotkey } from "./validate.js";
@@ -14,4 +14,4 @@ import { HotkeyConflict, HotkeyConflictOptions, findHotkeyConflicts } from "./co
14
14
  import { KeyStateTracker, KeyStateTrackerState, getKeyStateTracker } from "./key-state-tracker.js";
15
15
  import { HotkeyChordRecorderOptions, HotkeyChordRecorderValidationContext, HotkeyRecorder, HotkeyRecorderCommitKeys, HotkeyRecorderOptions, HotkeyRecorderState, HotkeyRecorderValidationContext, HotkeySequenceRecorderOptions, HotkeySequenceRecorderValidationContext, RecorderKeyMode, RecorderOptions, RecorderRejection } from "./hotkey-recorder.js";
16
16
  import { createAppHotkeysStore } from "./_app-hotkeys-factory.js";
17
- export { ALL_KEYS, ActivateHotkeyScopeOptions, AppHotkey, AppHotkeyChordOptions, AppHotkeyDefaultKind, AppHotkeyId, AppHotkeyInput, AppHotkeyListItem, AppHotkeyOptions, AppHotkeySequenceOptions, AppHotkeyStatus, AppHotkeys, AppHotkeysState, AppHotkeysStore, AppHotkeysStoreOptions, CanonicalModifier, ConflictBehavior, CreateHotkeyChordHandlerOptions, CustomHotkeys, DEFAULT_SEQUENCE_TIMEOUT, DefinedAppHotkeys, DisplayHotkey, DisplayHotkeyChord, EDITING_KEYS, EditingKey, FUNCTION_KEYS, FormatDisplayOptions, FourModifierHotkeyChord, FunctionKey, HeldModifierOptions, Hotkey, HotkeyCallback, HotkeyCallbackContext, HotkeyCallbackContextFor, HotkeyChord, HotkeyChordCallbackContext, HotkeyChordOptions, HotkeyChordRecorderOptions, HotkeyChordRecorderValidationContext, HotkeyChordRegistration, HotkeyChordRegistrationView, HotkeyConflict, HotkeyConflictOptions, HotkeyManager, HotkeyMeta, HotkeyOptions, HotkeyOptionsFor, HotkeyRecorder, HotkeyRecorderCommitKeys, HotkeyRecorderOptions, HotkeyRecorderState, HotkeyRecorderValidationContext, HotkeyRegistration, HotkeyRegistrationHandle, HotkeyRegistrationStatus, HotkeyRegistrationView, HotkeyScopesState, HotkeySequence, HotkeySequenceCallbackContext, HotkeySequenceOptions, HotkeySequenceRecorderOptions, HotkeySequenceRecorderValidationContext, HotkeySequenceRegistration, HotkeySequenceRegistrationView, IndividualKey, KEY_DISPLAY_SYMBOLS, Key, KeyStateTracker, KeyStateTrackerState, KeyboardEventMatch, LETTER_KEYS, LINUX_MODIFIER_LABELS, LetterKey, LogicalKey, MAC_MODIFIER_LABELS, MAC_MODIFIER_SYMBOLS, MODIFIER_ALIASES, MODIFIER_KEYS, MODIFIER_ORDER, Modifier, MultiHotkeyChordHandler, NAVIGATION_KEYS, NUMBER_KEYS, NamedKey, NavigationKey, NonPunctuationKey, NormalizedKeyboardEvent, NumberKey, PUNCTUATION_CODE_MAP, PUNCTUATION_KEYS, PUNCTUATION_KEY_DISPLAY_LABELS, ParsedHotkeyChord, ParsedModifiers, PhysicalKey, PhysicalKeyCode, PunctuationKey, RawHotkeyChord, RawModifiers, RecorderKeyMode, RecorderOptions, RecorderRejection, Register, RegisterableHotkey, RegisterableHotkeyChord, RegisterableHotkeySequence, RegisteredAppHotkeys, SingleModifierHotkeyChord, Target, ThreeModifierHotkeyChord, TwoModifierHotkeyChord, ValidatableHotkey, ValidationResult, WINDOWS_MODIFIER_LABELS, areHotkeysEqual, assertValidHotkey, checkHotkey, createAppHotkeysStore, createHotkeyChordHandler, createMultiHotkeyChordHandler, createSequenceMatcher, defineAppHotkeys, detectPlatform, findHotkeyConflicts, formatForDisplay, formatHotkey, getAppHotkeyRegistrationOptions, getHotkeyManager, getKeyStateTracker, hasNonModifierKey, isAppHotkey, isCustomHotkey, isHotkeySequence, isModifierKey, isSingleLetterKey, matchesHeldModifiers, matchesKeyboardEvent, normalizeHotkey, normalizeKeyName, parseHotkey, parseKeyboardEvent, provideAppHotkeys, publishAppHotkeysStore, resolveAppHotkey, resolveModifier, simplifyHotkey, toHotkeyRegistrationView, validateHotkey };
17
+ export { ALL_KEYS, ActivateHotkeyScopeOptions, AppHotkey, AppHotkeyChordOptions, AppHotkeyDefaultKind, AppHotkeyId, AppHotkeyInput, AppHotkeyListItem, AppHotkeyOptions, AppHotkeySequenceOptions, AppHotkeyStatus, AppHotkeys, AppHotkeysState, AppHotkeysStore, AppHotkeysStoreOptions, CanonicalModifier, ConflictBehavior, CreateHotkeyChordHandlerOptions, CustomHotkeys, DEFAULT_SEQUENCE_TIMEOUT, DefinedAppHotkeys, DisplayHotkey, DisplayHotkeyChord, EDITING_KEYS, EditingKey, FUNCTION_KEYS, FormatDisplayOptions, FourModifierHotkeyChord, FunctionKey, HeldModifierOptions, Hotkey, HotkeyCallback, HotkeyCallbackContext, HotkeyCallbackContextFor, HotkeyChord, HotkeyChordCallbackContext, HotkeyChordOptions, HotkeyChordRecorderOptions, HotkeyChordRecorderValidationContext, HotkeyChordRegistration, HotkeyChordRegistrationView, HotkeyConflict, HotkeyConflictOptions, HotkeyManager, HotkeyMeta, HotkeyOptions, HotkeyOptionsFor, HotkeyPlatform, HotkeyRecorder, HotkeyRecorderCommitKeys, HotkeyRecorderOptions, HotkeyRecorderState, HotkeyRecorderValidationContext, HotkeyRegistration, HotkeyRegistrationHandle, HotkeyRegistrationStatus, HotkeyRegistrationView, HotkeyScopesState, HotkeySequence, HotkeySequenceCallbackContext, HotkeySequenceOptions, HotkeySequenceRecorderOptions, HotkeySequenceRecorderValidationContext, HotkeySequenceRegistration, HotkeySequenceRegistrationView, IndividualKey, KEY_DISPLAY_SYMBOLS, Key, KeyStateTracker, KeyStateTrackerState, KeyboardEventMatch, LETTER_KEYS, LINUX_MODIFIER_LABELS, LetterKey, LogicalKey, MAC_MODIFIER_LABELS, MAC_MODIFIER_SYMBOLS, MODIFIER_ALIASES, MODIFIER_KEYS, MODIFIER_ORDER, Modifier, MultiHotkeyChordHandler, NAVIGATION_KEYS, NUMBER_KEYS, NamedKey, NavigationKey, NonPunctuationKey, NormalizedKeyboardEvent, NumberKey, PUNCTUATION_CODE_MAP, PUNCTUATION_KEYS, PUNCTUATION_KEY_DISPLAY_LABELS, ParsedHotkeyChord, ParsedModifiers, PhysicalKey, PhysicalKeyCode, PunctuationKey, RawHotkeyChord, RawModifiers, RecorderKeyMode, RecorderOptions, RecorderRejection, Register, RegisterableHotkey, RegisterableHotkeyChord, RegisterableHotkeySequence, RegisteredAppHotkeys, SingleModifierHotkeyChord, Target, ThreeModifierHotkeyChord, TwoModifierHotkeyChord, ValidatableHotkey, ValidationResult, WINDOWS_MODIFIER_LABELS, areHotkeysEqual, assertValidHotkey, checkHotkey, createAppHotkeysStore, createHotkeyChordHandler, createMultiHotkeyChordHandler, createSequenceMatcher, defineAppHotkeys, detectPlatform, findHotkeyConflicts, formatForDisplay, formatHotkey, getAppHotkeyRegistrationOptions, getHotkeyManager, getKeyStateTracker, hasNonModifierKey, isAppHotkey, isCustomHotkey, isHotkeySequence, isModifierKey, isSingleLetterKey, matchesHeldModifiers, matchesKeyboardEvent, normalizeHotkey, normalizeKeyName, parseHotkey, parseKeyboardEvent, provideAppHotkeys, publishAppHotkeysStore, resolveAppHotkey, resolveModifier, simplifyHotkey, toHotkeyRegistrationView, validateHotkey };
package/dist/match.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { HotkeyCallback, HotkeyChord, HotkeyChordCallbackContext, ParsedHotkeyChord, RegisterableHotkey, RegisterableHotkeySequence } from "./hotkey.types.js";
2
+ import { HotkeyPlatform } from "./platform.js";
2
3
  //#region src/match.d.ts
3
4
  export interface KeyboardEventMatch {
4
5
  matched: boolean;
@@ -96,7 +97,7 @@ export declare function createMultiHotkeyChordHandler(handlers: MultiHotkeyChord
96
97
  * Aliases resolve on the supplied platform. A physical code and a logical key
97
98
  * remain distinct even when one keyboard event could match both.
98
99
  */
99
- export declare function areHotkeysEqual(left: RegisterableHotkey, right: RegisterableHotkey, platform?: "mac" | "windows" | "linux"): boolean;
100
+ export declare function areHotkeysEqual(left: RegisterableHotkey, right: RegisterableHotkey, platform?: HotkeyPlatform): boolean;
100
101
  /** Default timeout between sequence steps (in milliseconds). */
101
102
  export declare const DEFAULT_SEQUENCE_TIMEOUT = 1000;
102
103
  /**
@@ -1,21 +1,33 @@
1
1
  import { CanonicalModifier } from "./key.types.js";
2
2
  //#region src/platform.d.ts
3
+ /** A platform for resolving `Mod` and formatting labels. */
4
+ export type HotkeyPlatform = 'mac' | 'windows' | 'linux';
3
5
  /**
4
- * Detects the current platform based on browser navigator properties.
6
+ * Detects a platform from a user agent string, or from the browser's
7
+ * `navigator` when called without one.
5
8
  *
6
9
  * Used internally to resolve platform-adaptive modifiers like 'Mod' (Command on Mac,
7
- * Control elsewhere) and for platform-specific hotkey formatting.
10
+ * Control elsewhere) and for platform-specific hotkey formatting. During server
11
+ * rendering, pass the request's `User-Agent` header to detect the user's
12
+ * platform instead of the server's.
8
13
  *
14
+ * @param userAgent - A user agent string, such as a request's `User-Agent` header.
15
+ * When omitted or `null` (a missing header), reads `navigator`.
9
16
  * @returns The detected platform: 'mac', 'windows', or 'linux'
10
- * @remarks Defaults to 'linux' in SSR environments where navigator is undefined
17
+ * @remarks Without a user agent, returns 'linux' on a server runtime (no
18
+ * `navigator`, or Node, Deno, Bun, or Cloudflare Workers), where `navigator`
19
+ * describes the server.
11
20
  *
12
21
  * @example
13
22
  * ```ts
14
23
  * const platform = detectPlatform() // 'mac' | 'windows' | 'linux'
15
24
  * const modifier = resolveModifier('Mod', platform) // 'Meta' on Mac, 'Control' elsewhere
25
+ *
26
+ * // On the server
27
+ * detectPlatform(request.headers.get('user-agent'))
16
28
  * ```
17
29
  */
18
- export declare function detectPlatform(): 'mac' | 'windows' | 'linux';
30
+ export declare function detectPlatform(userAgent?: string | null): HotkeyPlatform;
19
31
  /**
20
32
  * Resolves the platform-adaptive 'Mod' modifier to the appropriate canonical modifier.
21
33
  *
package/dist/platform.js CHANGED
@@ -1,25 +1,42 @@
1
+ import { isServerRuntime } from "./_server.js";
2
+
1
3
  //#region src/platform.ts
2
4
  /**
3
- * Detects the current platform based on browser navigator properties.
5
+ * Detects a platform from a user agent string, or from the browser's
6
+ * `navigator` when called without one.
4
7
  *
5
8
  * Used internally to resolve platform-adaptive modifiers like 'Mod' (Command on Mac,
6
- * Control elsewhere) and for platform-specific hotkey formatting.
9
+ * Control elsewhere) and for platform-specific hotkey formatting. During server
10
+ * rendering, pass the request's `User-Agent` header to detect the user's
11
+ * platform instead of the server's.
7
12
  *
13
+ * @param userAgent - A user agent string, such as a request's `User-Agent` header.
14
+ * When omitted or `null` (a missing header), reads `navigator`.
8
15
  * @returns The detected platform: 'mac', 'windows', or 'linux'
9
- * @remarks Defaults to 'linux' in SSR environments where navigator is undefined
16
+ * @remarks Without a user agent, returns 'linux' on a server runtime (no
17
+ * `navigator`, or Node, Deno, Bun, or Cloudflare Workers), where `navigator`
18
+ * describes the server.
10
19
  *
11
20
  * @example
12
21
  * ```ts
13
22
  * const platform = detectPlatform() // 'mac' | 'windows' | 'linux'
14
23
  * const modifier = resolveModifier('Mod', platform) // 'Meta' on Mac, 'Control' elsewhere
24
+ *
25
+ * // On the server
26
+ * detectPlatform(request.headers.get('user-agent'))
15
27
  * ```
16
28
  */
17
- function detectPlatform() {
18
- if (typeof navigator === "undefined") return "linux";
19
- const platform = navigator.platform?.toLowerCase() ?? "";
20
- const userAgent = navigator.userAgent?.toLowerCase() ?? "";
21
- if (platform.includes("mac") || userAgent.includes("mac")) return "mac";
22
- if (platform.includes("win") || userAgent.includes("win")) return "windows";
29
+ function detectPlatform(userAgent) {
30
+ let platform = "";
31
+ let agent = userAgent;
32
+ if (agent == null) {
33
+ if (isServerRuntime()) return "linux";
34
+ platform = navigator.platform?.toLowerCase() ?? "";
35
+ agent = navigator.userAgent ?? "";
36
+ }
37
+ agent = agent.toLowerCase();
38
+ if (platform.includes("mac") || agent.includes("mac")) return "mac";
39
+ if (platform.includes("win") || agent.includes("win")) return "windows";
23
40
  return "linux";
24
41
  }
25
42
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/hotkeys",
3
- "version": "0.22.1",
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,9 +63,12 @@ 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.
71
+ - **Server-rendered labels need the user's platform.** On a server, `formatForDisplay` renders Linux labels, because the server can't see the user's OS. Read the request's `User-Agent` header, pass `detectPlatform(userAgent)` as the platform of the adapter's provider or scope, and format labels with the adapter's formatter, such as `useFormatForDisplay`. Those pages then depend on the user agent, so keep them out of shared caches or add the platform to the cache key.
69
72
 
70
73
  When writing tests for hotkeys, read [testing](references/testing.md).
71
74