@tanstack/hotkeys 0.15.0 → 0.16.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.
@@ -1,14 +1,15 @@
1
1
  import { normalizeKeyName } from "./constants.js";
2
2
  import { detectPlatform } from "./platform.js";
3
- import { normalizeKeyboardEvent } from "./_keyboard-event.js";
4
- import { isHotkeySequence, isModifierKey, parseHotkey, rawHotkeyToParsedHotkey, simplifyHotkey } from "./parse.js";
3
+ import { keysEqual, normalizeKeyboardEvent } from "./_keyboard-event.js";
4
+ import { isHotkeySequence, isModifierKey, parseHotkey, simplifyHotkey } from "./parse.js";
5
5
  import { formatHotkey } from "./format.js";
6
- import { matchKeyboardEvent } from "./_match.js";
6
+ import { matchKeyboardEvent, parsedChordsEqual } from "./_match.js";
7
7
  import { DEFAULT_SEQUENCE_TIMEOUT, areHotkeysEqual } from "./match.js";
8
8
  import { getTargetDocument, isEventForTarget, shouldIgnoreInputEvent } from "./_event-target.js";
9
9
  import { TargetListeners } from "./_target-listeners.js";
10
+ import { managerRef } from "./_manager-ref.js";
10
11
  import { isRecordingEvent } from "./_recording-guard.js";
11
- import { defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
12
+ import { GLOBAL_SCOPE, defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
12
13
  import { Store } from "@tanstack/store";
13
14
 
14
15
  //#region src/hotkey-manager.ts
@@ -24,7 +25,7 @@ function toHotkeyRegistrationView(reg) {
24
25
  ...base,
25
26
  kind: "chord",
26
27
  hotkey: reg.hotkey,
27
- parsedHotkey: reg.parsedHotkey,
28
+ parsedChord: reg.parsedChord,
28
29
  options: reg.options
29
30
  };
30
31
  const length = reg.parsedSequence.length;
@@ -40,6 +41,10 @@ function toHotkeyRegistrationView(reg) {
40
41
  };
41
42
  }
42
43
  let registrationIdCounter = 0;
44
+ /** The string a chord registers as: strings as written, objects formatted from their parsed form. */
45
+ function chordString(chord, parsed) {
46
+ return typeof chord === "string" ? chord : formatHotkey(parsed);
47
+ }
43
48
  /**
44
49
  * Generates a unique ID for hotkey registrations.
45
50
  */
@@ -57,7 +62,7 @@ function optionsForKind(kind, options) {
57
62
  }
58
63
  /** Steps with Mod already resolved for the registration's platform. */
59
64
  function resolvedSteps(registration) {
60
- return registration.kind === "chord" ? [registration.parsedHotkey] : registration.parsedSequence;
65
+ return registration.kind === "chord" ? [registration.parsedChord] : registration.parsedSequence;
61
66
  }
62
67
  /** Whether a registration skips keys that focused controls use, resolving `'auto'`. */
63
68
  function ignoresInputs(registration) {
@@ -65,7 +70,7 @@ function ignoresInputs(registration) {
65
70
  return ignoreInputs === "auto" ? getDefaultIgnoreInputs(resolvedSteps(registration)[0]) : ignoreInputs;
66
71
  }
67
72
  function sameResolvedSteps(left, right) {
68
- return left.length === right.length && left.every((step, index) => areHotkeysEqual(step, right[index]));
73
+ return left.length === right.length && left.every((step, index) => parsedChordsEqual(step, right[index]));
69
74
  }
70
75
  function formatForMessage(hotkey) {
71
76
  return isHotkeySequence(hotkey) ? hotkey.join(" ") : hotkey;
@@ -97,7 +102,6 @@ function formatForMessage(hotkey) {
97
102
  * ```
98
103
  */
99
104
  var HotkeyManager = class HotkeyManager {
100
- static #instance = null;
101
105
  /**
102
106
  * The TanStack Store containing all hotkey registrations, chords and sequences.
103
107
  * Use this to subscribe to registration changes or access current registrations.
@@ -123,12 +127,27 @@ var HotkeyManager = class HotkeyManager {
123
127
  * devtools can list declared hotkeys that are not registered.
124
128
  */
125
129
  appHotkeys = new Store(null);
130
+ /** Active scopes. Subscribe to show which hotkeys are suspended. */
131
+ scopes = new Store({
132
+ active: [],
133
+ exclusive: null,
134
+ allowed: [],
135
+ suspended: []
136
+ });
126
137
  #platform;
138
+ #scopeActivations = /* @__PURE__ */ new Set();
139
+ #scopeActivationCount = 0;
140
+ /** Active scopes, lowest precedence first, derived from activations. */
141
+ #scopeOrder = [];
142
+ /** Scopes that can fire while a scope is exclusive, or `null` when none is. */
143
+ #liveScopes = null;
127
144
  #listeners = new TargetListeners((...args) => this.#processTargetEvent(...args));
128
145
  /** Events that continue a sequence in progress, decided before any listener runs. */
129
146
  #sequenceOwned = /* @__PURE__ */ new WeakMap();
130
147
  /** Physical keys whose keydown a sequence owned, so their repeats and keyup are owned too. */
131
148
  #sequenceOwnedKeys = /* @__PURE__ */ new Set();
149
+ /** Timers that clear a sequence's progress once its timeout passes. */
150
+ #sequenceResetTimers = /* @__PURE__ */ new Map();
132
151
  constructor() {
133
152
  this.#platform = detectPlatform();
134
153
  }
@@ -136,17 +155,122 @@ var HotkeyManager = class HotkeyManager {
136
155
  * Gets the singleton instance of HotkeyManager.
137
156
  */
138
157
  static getInstance() {
139
- if (!HotkeyManager.#instance) HotkeyManager.#instance = new HotkeyManager();
140
- return HotkeyManager.#instance;
158
+ return managerRef.current ??= new HotkeyManager();
141
159
  }
142
160
  /**
143
161
  * Resets the singleton instance. Useful for testing.
144
162
  */
145
163
  static resetInstance() {
146
- if (HotkeyManager.#instance) {
147
- HotkeyManager.#instance.destroy();
148
- HotkeyManager.#instance = null;
164
+ managerRef.current?.destroy();
165
+ managerRef.current = null;
166
+ }
167
+ /**
168
+ * Activates a scope until the returned function is called. Activations are
169
+ * counted, so a scope stays active until every activation is released.
170
+ * Framework providers call this while mounted.
171
+ *
172
+ * When active scopes share a hotkey, a nested scope takes precedence over
173
+ * its ancestors, and otherwise the most recently activated scope wins. The
174
+ * same order picks the exclusive scope when several are active, and only
175
+ * that scope's `allow` list applies.
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * const release = manager.activateScope('dialog', { exclusive: true })
180
+ * // Only hotkeys with scope 'dialog', or scopes nested in it, fire now.
181
+ * release()
182
+ *
183
+ * manager.activateScope('dialog', { exclusive: { allow: ['global'] } })
184
+ * // Hotkeys with scope 'global' fire too.
185
+ * ```
186
+ */
187
+ activateScope(scope, options = {}) {
188
+ if (scope === "global") return () => {};
189
+ const { exclusive } = options;
190
+ const allow = exclusive ? exclusive === true ? [] : exclusive.allow : null;
191
+ if (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.`);
192
+ const activation = {
193
+ scope,
194
+ parent: options.parent ?? "global",
195
+ allow,
196
+ order: ++this.#scopeActivationCount
197
+ };
198
+ this.#scopeActivations.add(activation);
199
+ this.#updateScopes();
200
+ return () => {
201
+ if (this.#scopeActivations.delete(activation)) this.#updateScopes();
202
+ };
203
+ }
204
+ /**
205
+ * Whether hotkeys in a scope can fire: the scope is `'global'` or
206
+ * activated, and either no scope is exclusive or this scope is the
207
+ * exclusive one, nested in it, or allowed by it.
208
+ */
209
+ isScopeActive(scope = GLOBAL_SCOPE) {
210
+ if (this.#liveScopes) return this.#liveScopes.has(scope);
211
+ return scope === "global" || this.#scopeOrder.includes(scope);
212
+ }
213
+ /** Higher for scopes with higher precedence; `'global'` is 0. */
214
+ #scopeRank(scope = GLOBAL_SCOPE) {
215
+ return this.#scopeOrder.indexOf(scope) + 1;
216
+ }
217
+ /** Recomputes scope precedence and publishes it. */
218
+ #updateScopes() {
219
+ const parents = /* @__PURE__ */ new Map();
220
+ const latest = /* @__PURE__ */ new Map();
221
+ for (const activation of this.#scopeActivations) {
222
+ parents.set(activation.scope, activation.parent);
223
+ latest.set(activation.scope, Math.max(latest.get(activation.scope) ?? 0, activation.order));
224
+ }
225
+ const depth = (scope) => {
226
+ let result = 0;
227
+ const seen = /* @__PURE__ */ new Set();
228
+ for (let current = parents.get(scope); current !== void 0 && !seen.has(current); current = parents.get(current)) {
229
+ seen.add(current);
230
+ result++;
231
+ }
232
+ return result;
233
+ };
234
+ const since = /* @__PURE__ */ new Map();
235
+ for (const [scope, order] of latest) {
236
+ const seen = /* @__PURE__ */ new Set();
237
+ for (let current = scope; current !== void 0 && !seen.has(current); current = parents.get(current)) {
238
+ seen.add(current);
239
+ const own = latest.get(current);
240
+ if (own !== void 0) since.set(current, Math.min(since.get(current) ?? own, order));
241
+ }
242
+ }
243
+ const compare = (a, b) => since.get(a) - since.get(b) || depth(a) - depth(b) || latest.get(a) - latest.get(b);
244
+ this.#scopeOrder = [...latest.keys()].sort(compare);
245
+ const exclusive = [...this.#scopeActivations].filter((activation) => activation.allow).sort((a, b) => compare(a.scope, b.scope) || a.order - b.order).pop();
246
+ const activated = [GLOBAL_SCOPE, ...this.#scopeOrder];
247
+ let allowed = [];
248
+ this.#liveScopes = null;
249
+ if (exclusive?.allow) {
250
+ const { allow } = exclusive;
251
+ const isNested = (scope) => {
252
+ const seen = /* @__PURE__ */ new Set();
253
+ for (let current = scope; current !== void 0 && !seen.has(current); current = parents.get(current)) {
254
+ if (current === exclusive.scope) return true;
255
+ seen.add(current);
256
+ }
257
+ return false;
258
+ };
259
+ allowed = activated.filter((scope) => !isNested(scope) && allow.includes(scope));
260
+ this.#liveScopes = /* @__PURE__ */ new Set([...activated.filter(isNested), ...allowed]);
149
261
  }
262
+ const live = this.#liveScopes;
263
+ this.scopes.setState(() => ({
264
+ active: this.#scopeOrder,
265
+ exclusive: exclusive?.scope ?? null,
266
+ allowed,
267
+ suspended: live ? activated.filter((scope) => !live.has(scope)) : []
268
+ }));
269
+ this.#notify();
270
+ }
271
+ /** Whether a registration can fire now: enabled, in an active scope. */
272
+ #isLive(registration) {
273
+ return registration.options.enabled !== false && this.isScopeActive(registration.options.scope);
150
274
  }
151
275
  /** Notifies subscribers after in-place updates such as counts and sequence progress. */
152
276
  #notify() {
@@ -175,44 +299,39 @@ var HotkeyManager = class HotkeyManager {
175
299
  const id = generateId();
176
300
  const platform = options.platform ?? this.#platform;
177
301
  const target = options.target ?? document;
178
- let registration;
179
- if (isHotkeySequence(hotkey)) registration = {
180
- kind: "sequence",
302
+ const sequence = isHotkeySequence(hotkey);
303
+ const common = {
181
304
  id,
182
- hotkey,
183
- parsedSequence: hotkey.map((step) => parseHotkey(step, platform)),
184
305
  callback,
185
306
  options: {
186
307
  ...defaultHotkeyOptions,
187
- timeout: DEFAULT_SEQUENCE_TIMEOUT,
188
- ...optionsForKind("sequence", options),
308
+ ...sequence ? { timeout: DEFAULT_SEQUENCE_TIMEOUT } : { requireReset: false },
309
+ ...optionsForKind(sequence ? "sequence" : "chord", options),
189
310
  platform,
190
311
  ignoreInputs: options.ignoreInputs ?? "auto"
191
312
  },
192
313
  target,
193
- currentIndex: 0,
194
- lastKeyTime: 0,
195
314
  triggerCount: 0,
196
315
  hasFired: false
197
316
  };
198
- else {
199
- const parsedHotkey = typeof hotkey === "string" ? parseHotkey(hotkey, platform) : rawHotkeyToParsedHotkey(hotkey, platform);
317
+ let registration;
318
+ if (sequence) {
319
+ const parsedSequence = parseHotkey(hotkey, platform);
200
320
  registration = {
321
+ ...common,
322
+ kind: "sequence",
323
+ hotkey: hotkey.map((step, index) => chordString(step, parsedSequence[index])),
324
+ parsedSequence,
325
+ currentIndex: 0,
326
+ lastKeyTime: 0
327
+ };
328
+ } else {
329
+ const parsedChord = parseHotkey(hotkey, platform);
330
+ registration = {
331
+ ...common,
201
332
  kind: "chord",
202
- id,
203
- hotkey: typeof hotkey === "string" ? hotkey : formatHotkey(parsedHotkey),
204
- parsedHotkey,
205
- callback,
206
- options: {
207
- ...defaultHotkeyOptions,
208
- requireReset: false,
209
- ...optionsForKind("chord", options),
210
- platform,
211
- ignoreInputs: options.ignoreInputs ?? "auto"
212
- },
213
- target,
214
- hasFired: false,
215
- triggerCount: 0
333
+ hotkey: chordString(hotkey, parsedChord),
334
+ parsedChord
216
335
  };
217
336
  }
218
337
  const conflicting = this.#findConflictingRegistration(registration);
@@ -272,6 +391,35 @@ var HotkeyManager = class HotkeyManager {
272
391
  return next;
273
392
  });
274
393
  this.#listeners.remove(target, !!registration.options.capture, id);
394
+ this.#clearSequenceReset(id);
395
+ }
396
+ /**
397
+ * Clears a sequence's progress, and notifies subscribers, once its timeout
398
+ * passes without another step. Key presses check the timeout too, so this
399
+ * only keeps views such as `matchedStepCount` current.
400
+ */
401
+ #scheduleSequenceReset(id, delay) {
402
+ this.#clearSequenceReset(id);
403
+ this.#sequenceResetTimers.set(id, setTimeout(() => {
404
+ this.#sequenceResetTimers.delete(id);
405
+ const registration = this.registrations.state.get(id);
406
+ if (registration?.kind !== "sequence" || registration.currentIndex === 0) return;
407
+ const timeout = registration.options.timeout ?? 1e3;
408
+ const remaining = registration.lastKeyTime + timeout - Date.now();
409
+ if (remaining >= 0) {
410
+ this.#scheduleSequenceReset(id, remaining + 1);
411
+ return;
412
+ }
413
+ registration.currentIndex = 0;
414
+ registration.lastKeyTime = 0;
415
+ this.#notify();
416
+ }, delay));
417
+ }
418
+ #clearSequenceReset(id) {
419
+ const timer = this.#sequenceResetTimers.get(id);
420
+ if (timer === void 0) return;
421
+ clearTimeout(timer);
422
+ this.#sequenceResetTimers.delete(id);
275
423
  }
276
424
  /** Registrations of one kind in the listener phase, in registration order. */
277
425
  #registrationsIn(ids, capture, kind) {
@@ -308,30 +456,31 @@ var HotkeyManager = class HotkeyManager {
308
456
  const matches = /* @__PURE__ */ new Map();
309
457
  let bestScore = 0;
310
458
  for (const registration of registrations) {
311
- if (!registration.options.enabled || registration.options.eventType !== eventType || !isEventForTarget(event, target) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target)) continue;
312
- const result = matchKeyboardEvent(event, registration.parsedHotkey, registration.options.platform);
459
+ if (!this.#isLive(registration) || registration.options.eventType !== eventType || !isEventForTarget(event, target) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target)) continue;
460
+ const result = matchKeyboardEvent(event, registration.parsedChord, registration.options.platform);
313
461
  if (result.matched) {
314
462
  matches.set(registration.id, result);
315
463
  bestScore = Math.max(bestScore, result.score);
316
464
  }
317
465
  }
466
+ let bestRank = -1;
467
+ for (const registration of registrations) if (matches.get(registration.id)?.score === bestScore) bestRank = Math.max(bestRank, this.#scopeRank(registration.options.scope));
318
468
  for (const snapshot of registrations) {
319
469
  const match = matches.get(snapshot.id);
320
- if (!match?.matched || match.score !== bestScore) continue;
470
+ if (!match?.matched || match.score !== bestScore || this.#scopeRank(snapshot.options.scope) !== bestRank) continue;
321
471
  const registration = this.#currentIn(snapshot, capture);
322
472
  if (!registration) continue;
473
+ this.#consume(registration, event);
323
474
  if (eventType === "keyup") {
324
- this.#executeCallback(registration, event);
475
+ this.#fire(registration, event);
325
476
  continue;
326
477
  }
327
- if (registration.options.preventDefault) event.preventDefault();
328
- if (registration.options.stopPropagation) event.stopPropagation();
329
478
  if (!registration.options.requireReset || !registration.hasFired) {
330
479
  if (registration.options.requireReset) {
331
480
  registration.hasFired = true;
332
481
  registration.activeMatch = match.identity;
333
482
  }
334
- this.#executeCallback(registration, event);
483
+ this.#fire(registration, event);
335
484
  }
336
485
  }
337
486
  }
@@ -357,7 +506,7 @@ var HotkeyManager = class HotkeyManager {
357
506
  const path = event.composedPath();
358
507
  const now = Date.now();
359
508
  for (const registration of this.registrations.state.values()) {
360
- if (registration.kind !== "sequence" || registration.currentIndex === 0 || !registration.options.enabled || registration.options.eventType !== eventType || now - registration.lastKeyTime > (registration.options.timeout ?? 1e3)) continue;
509
+ if (registration.kind !== "sequence" || registration.currentIndex === 0 || !this.#isLive(registration) || registration.options.eventType !== eventType || now - registration.lastKeyTime > (registration.options.timeout ?? 1e3)) continue;
361
510
  const target = registration.target;
362
511
  if (path.includes(target.nodeType === 1 ? target : getTargetDocument(target)) && !(ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, target)) && matchKeyboardEvent(event, registration.parsedSequence[registration.currentIndex], registration.options.platform).matched) return true;
363
512
  }
@@ -369,6 +518,7 @@ var HotkeyManager = class HotkeyManager {
369
518
  let changed = false;
370
519
  for (const registration of registrations) if (registration.currentIndex > 0) {
371
520
  registration.currentIndex = 0;
521
+ this.#clearSequenceReset(registration.id);
372
522
  changed = true;
373
523
  }
374
524
  if (changed) this.#notify();
@@ -381,7 +531,7 @@ var HotkeyManager = class HotkeyManager {
381
531
  const candidates = /* @__PURE__ */ new Map();
382
532
  let bestScore = 0;
383
533
  for (const registration of registrations) {
384
- if (!isEventForTarget(event, target) || !registration.options.enabled || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target) || registration.options.eventType !== eventType) continue;
534
+ if (!isEventForTarget(event, target) || !this.#isLive(registration) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target) || registration.options.eventType !== eventType) continue;
385
535
  const timeout = registration.options.timeout ?? 1e3;
386
536
  if (registration.currentIndex > 0 && now - registration.lastKeyTime > timeout) {
387
537
  registration.currentIndex = 0;
@@ -410,67 +560,69 @@ var HotkeyManager = class HotkeyManager {
410
560
  registration.currentIndex = candidate.nextIndex;
411
561
  changed = true;
412
562
  if (registration.currentIndex >= registration.parsedSequence.length) {
413
- if (registration.options.preventDefault) event.preventDefault();
414
- if (registration.options.stopPropagation) event.stopPropagation();
563
+ this.#consume(registration, event);
415
564
  registration.currentIndex = 0;
416
565
  completed.push(registration);
417
- }
566
+ } else if (registration.options.preventDefault) event.preventDefault();
418
567
  } else if (registration.currentIndex > 0) {
419
568
  registration.currentIndex = 0;
420
569
  changed = true;
421
570
  }
422
571
  }
572
+ for (const registration of registrations) {
573
+ if (!candidates.has(registration.id)) continue;
574
+ if (registration.currentIndex > 0) {
575
+ const timeout = registration.options.timeout ?? 1e3;
576
+ this.#scheduleSequenceReset(registration.id, timeout + 1);
577
+ } else this.#clearSequenceReset(registration.id);
578
+ }
423
579
  if (changed && completed.length === 0) this.#notify();
424
580
  for (const snapshot of completed) {
425
581
  const registration = this.#currentIn(snapshot, capture);
426
- if (registration) this.#executeCallback(registration, event);
582
+ if (registration) this.#fire(registration, event);
427
583
  }
428
584
  }
429
- /**
430
- * Executes a hotkey callback with proper event handling.
431
- */
432
- #executeCallback(registration, event) {
585
+ /** Applies a matched registration's preventDefault and stopPropagation options. */
586
+ #consume(registration, event) {
433
587
  if (registration.options.preventDefault) event.preventDefault();
434
588
  if (registration.options.stopPropagation) event.stopPropagation();
589
+ }
590
+ /** Counts a trigger, notifies subscribers (such as devtools), and runs the callback. */
591
+ #fire(registration, event, context = this.#callbackContext(registration)) {
435
592
  registration.triggerCount++;
436
593
  if (registration.kind === "sequence") registration.hasFired = true;
437
594
  this.#notify();
438
- registration.callback(event, this.#callbackContext(registration));
595
+ registration.callback(event, context);
439
596
  }
440
597
  #callbackContext(registration) {
441
- return {
598
+ return registration.kind === "chord" ? {
599
+ kind: "chord",
600
+ hotkey: registration.hotkey,
601
+ parsedChord: registration.parsedChord
602
+ } : {
603
+ kind: "sequence",
442
604
  hotkey: registration.hotkey,
443
- parsedHotkey: registration.kind === "chord" ? registration.parsedHotkey : registration.parsedSequence[registration.parsedSequence.length - 1]
605
+ parsedSequence: registration.parsedSequence
444
606
  };
445
607
  }
446
608
  /**
447
- * Finds an existing registration on the same target whose steps resolve to
448
- * the same identity. Each side keeps its own platform, so `Mod+S` on macOS
609
+ * Finds an existing registration on the same target and scope whose steps
610
+ * resolve to the same identity. Each side keeps its own platform, so `Mod+S` on macOS
449
611
  * and `Mod+S` on Windows are different bindings.
450
612
  */
451
613
  #findConflictingRegistration(candidate) {
452
614
  const steps = resolvedSteps(candidate);
453
- for (const registration of this.registrations.state.values()) if (registration.target === candidate.target && sameResolvedSteps(resolvedSteps(registration), steps)) return registration;
615
+ for (const registration of this.registrations.state.values()) if (registration.target === candidate.target && registration.options.scope === candidate.options.scope && sameResolvedSteps(resolvedSteps(registration), steps)) return registration;
454
616
  return null;
455
617
  }
456
618
  /**
457
619
  * Determines if a registration should be reset based on the keyup event.
458
620
  */
459
621
  #shouldResetRegistration(registration, event) {
460
- const parsed = registration.parsedHotkey;
622
+ const parsed = registration.parsedChord;
461
623
  const releasedKey = normalizeKeyName(event.key);
462
624
  if (registration.activeMatch?.code && event.code === registration.activeMatch.code) return true;
463
- if (parsed.code !== void 0) {
464
- if (event.code === parsed.code) return true;
465
- } else {
466
- const parsedKeyNormalized = parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key;
467
- if ((releasedKey.length === 1 ? releasedKey.toUpperCase() : releasedKey) === parsedKeyNormalized) return true;
468
- }
469
- if (parsed.ctrl && releasedKey === "Control") return true;
470
- if (parsed.shift && releasedKey === "Shift") return true;
471
- if (parsed.alt && releasedKey === "Alt") return true;
472
- if (parsed.meta && releasedKey === "Meta") return true;
473
- return false;
625
+ return (parsed.code !== void 0 ? event.code === parsed.code : keysEqual(releasedKey, parsed.key)) || parsed.modifiers.includes(releasedKey);
474
626
  }
475
627
  /**
476
628
  * Resets the progress of every in-progress sequence.
@@ -480,6 +632,8 @@ var HotkeyManager = class HotkeyManager {
480
632
  registration.currentIndex = 0;
481
633
  registration.lastKeyTime = 0;
482
634
  }
635
+ for (const timer of this.#sequenceResetTimers.values()) clearTimeout(timer);
636
+ this.#sequenceResetTimers.clear();
483
637
  this.#notify();
484
638
  }
485
639
  /**
@@ -494,7 +648,7 @@ var HotkeyManager = class HotkeyManager {
494
648
  const registration = this.registrations.state.get(id);
495
649
  if (!registration) return false;
496
650
  const context = this.#callbackContext(registration);
497
- const parsed = context.parsedHotkey;
651
+ const parsed = registration.kind === "chord" ? registration.parsedChord : registration.parsedSequence[registration.parsedSequence.length - 1];
498
652
  const syntheticEvent = new KeyboardEvent(registration.options.eventType ?? "keydown", {
499
653
  ...parsed.code !== void 0 ? { code: parsed.code } : { key: parsed.key },
500
654
  ctrlKey: parsed.ctrl,
@@ -504,10 +658,7 @@ var HotkeyManager = class HotkeyManager {
504
658
  bubbles: true,
505
659
  cancelable: true
506
660
  });
507
- registration.triggerCount++;
508
- if (registration.kind === "sequence") registration.hasFired = true;
509
- this.#notify();
510
- registration.callback(syntheticEvent, context);
661
+ this.#fire(registration, syntheticEvent, context);
511
662
  return true;
512
663
  }
513
664
  /**
@@ -531,6 +682,8 @@ var HotkeyManager = class HotkeyManager {
531
682
  * Destroys the manager and removes all listeners.
532
683
  */
533
684
  destroy() {
685
+ for (const timer of this.#sequenceResetTimers.values()) clearTimeout(timer);
686
+ this.#sequenceResetTimers.clear();
534
687
  this.#listeners.destroy();
535
688
  this.registrations.setState(() => /* @__PURE__ */ new Map());
536
689
  this.appHotkeys.setState(() => null);
@@ -1,7 +1,47 @@
1
- import { Hotkey, HotkeyChord, HotkeySequence } from "./hotkey.types.js";
2
- import { HotkeyRecorderValidationContext, RecorderOptions } from "./recorder-options.js";
1
+ import { Hotkey, HotkeyChord, HotkeySequence, ParsedHotkeyChord } from "./hotkey.types.js";
2
+ import { HotkeyConflict, HotkeyConflictOptions } from "./conflicts.js";
3
3
  import { Store } from "@tanstack/store";
4
4
  //#region src/hotkey-recorder.d.ts
5
+ /** Event property used to record a binding. Physical codes are the default. */
6
+ export type RecorderKeyMode = 'code' | 'key';
7
+ export interface RecorderRejection {
8
+ reason: 'missing-code' | 'alt-graph' | 'invalid' | 'conflict' | 'validation';
9
+ message: string;
10
+ /** The rejected chord or sequence, when one was recorded. */
11
+ hotkey?: Hotkey;
12
+ conflicts?: Array<HotkeyConflict>;
13
+ }
14
+ export interface RecorderOptions {
15
+ /** Physical code recording is the default. Choose key to record logical characters. */
16
+ recordBy?: RecorderKeyMode;
17
+ /** Platform used for Mod and AltGraph handling. Defaults to platform detection. */
18
+ platform?: 'mac' | 'windows' | 'linux';
19
+ /** Opt in to live-registry conflicts, including sequence prefixes. Default: false. */
20
+ detectConflicts?: boolean | HotkeyConflictOptions;
21
+ /** Rejected candidates leave recording active. */
22
+ onReject?: (rejection: RecorderRejection) => void;
23
+ }
24
+ /** Validation context for a recorded chord. */
25
+ export interface HotkeyChordRecorderValidationContext {
26
+ kind: 'chord';
27
+ /** The event that recorded the chord. */
28
+ event: KeyboardEvent;
29
+ /** The chord with `Mod` resolved for the recorder's platform. */
30
+ parsedChord: ParsedHotkeyChord;
31
+ }
32
+ /** Validation context for a recorded sequence. */
33
+ export interface HotkeySequenceRecorderValidationContext {
34
+ kind: 'sequence';
35
+ /** The event that recorded each step. */
36
+ events: ReadonlyArray<KeyboardEvent>;
37
+ /** Each step with `Mod` resolved for the recorder's platform. */
38
+ parsedSequence: Array<ParsedHotkeyChord>;
39
+ }
40
+ /**
41
+ * Validation context for a recorded chord or sequence. Narrow with `kind` to
42
+ * read `parsedChord` or `parsedSequence`.
43
+ */
44
+ export type HotkeyRecorderValidationContext = HotkeyChordRecorderValidationContext | HotkeySequenceRecorderValidationContext;
5
45
  /**
6
46
  * State interface for the HotkeyRecorder.
7
47
  */
@@ -35,7 +75,7 @@ export interface HotkeyChordRecorderOptions extends HotkeyRecorderBaseOptions {
35
75
  /** Maximum number of chords to record. Default: 1 */
36
76
  maxSteps?: 1;
37
77
  /** Return true to accept, or false/a message to reject while staying in recording mode. */
38
- validate?: (hotkey: HotkeyChord, context: HotkeyRecorderValidationContext) => boolean | string;
78
+ validate?: (hotkey: HotkeyChord, context: HotkeyChordRecorderValidationContext) => boolean | string;
39
79
  /** Callback when a chord is successfully recorded */
40
80
  onRecord: (hotkey: HotkeyChord) => void;
41
81
  }
@@ -83,7 +123,7 @@ export type HotkeyRecorderOptions = HotkeyChordRecorderOptions | HotkeySequenceR
83
123
  * })
84
124
  *
85
125
  * const sequenceRecorder = new HotkeyRecorder({
86
- * maxSteps: 3,
126
+ * maxSteps: Infinity,
87
127
  * onRecord: (hotkey) => {
88
128
  * console.log('Recorded:', hotkey) // 'Mod+S' or ['G', 'H']
89
129
  * },
@@ -37,7 +37,7 @@ const initialState = {
37
37
  * })
38
38
  *
39
39
  * const sequenceRecorder = new HotkeyRecorder({
40
- * maxSteps: 3,
40
+ * maxSteps: Infinity,
41
41
  * onRecord: (hotkey) => {
42
42
  * console.log('Recorded:', hotkey) // 'Mod+S' or ['G', 'H']
43
43
  * },
@@ -198,13 +198,17 @@ var HotkeyRecorder = class {
198
198
  }
199
199
  options.onReject?.(rejection);
200
200
  };
201
- const parsedSequence = steps.map((step) => parseHotkey(step, platform));
202
- const accepted = options.validate?.(hotkey, {
201
+ const parsedSequence = parseHotkey(steps, platform);
202
+ const context = steps.length === 1 ? {
203
+ kind: "chord",
203
204
  event: this.#events[this.#events.length - 1],
204
- parsedHotkey: parsedSequence[parsedSequence.length - 1],
205
+ parsedChord: parsedSequence[0]
206
+ } : {
207
+ kind: "sequence",
205
208
  events: [...this.#events],
206
209
  parsedSequence
207
- });
210
+ };
211
+ const accepted = options.validate?.(hotkey, context);
208
212
  if (accepted !== void 0 && accepted !== true) {
209
213
  rejectAndKeepRecording({
210
214
  reason: "validation",