@tanstack/hotkeys 0.1.3 → 0.3.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.
@@ -3,6 +3,14 @@ import { detectPlatform, normalizeKeyName } from './constants'
3
3
  import { formatHotkey } from './format'
4
4
  import { parseHotkey, rawHotkeyToParsedHotkey } from './parse'
5
5
  import { matchesKeyboardEvent } from './match'
6
+ import {
7
+ defaultHotkeyOptions,
8
+ getDefaultIgnoreInputs,
9
+ handleConflict,
10
+ isEventForTarget,
11
+ isInputElement,
12
+ } from './manager.utils'
13
+ import type { ConflictBehavior } from './manager.utils'
6
14
  import type {
7
15
  Hotkey,
8
16
  HotkeyCallback,
@@ -11,15 +19,7 @@ import type {
11
19
  RegisterableHotkey,
12
20
  } from './hotkey'
13
21
 
14
- /**
15
- * Behavior when registering a hotkey that conflicts with an existing registration.
16
- *
17
- * - `'warn'` - Log a warning to the console but allow both registrations (default)
18
- * - `'error'` - Throw an error and prevent the new registration
19
- * - `'replace'` - Unregister the existing hotkey and register the new one
20
- * - `'allow'` - Allow multiple registrations of the same hotkey without warning
21
- */
22
- export type ConflictBehavior = 'warn' | 'error' | 'replace' | 'allow'
22
+ export type { ConflictBehavior }
23
23
 
24
24
  /**
25
25
  * Options for registering a hotkey.
@@ -109,22 +109,6 @@ export interface HotkeyRegistrationHandle {
109
109
  unregister: () => void
110
110
  }
111
111
 
112
- /**
113
- * Default options for hotkey registration.
114
- */
115
- const defaultHotkeyOptions: Omit<
116
- Required<HotkeyOptions>,
117
- 'platform' | 'target'
118
- > = {
119
- preventDefault: true,
120
- stopPropagation: true,
121
- eventType: 'keydown',
122
- requireReset: false,
123
- enabled: true,
124
- ignoreInputs: true,
125
- conflictBehavior: 'warn',
126
- }
127
-
128
112
  let registrationIdCounter = 0
129
113
 
130
114
  /**
@@ -134,16 +118,6 @@ function generateId(): string {
134
118
  return `hotkey_${++registrationIdCounter}`
135
119
  }
136
120
 
137
- /**
138
- * Computes the default ignoreInputs value based on the hotkey.
139
- * Ctrl/Meta shortcuts and Escape fire in inputs; single keys and Shift/Alt combos are ignored.
140
- */
141
- function getDefaultIgnoreInputs(parsedHotkey: ParsedHotkey): boolean {
142
- if (parsedHotkey.ctrl || parsedHotkey.meta) return false // Mod+S, Ctrl+C, etc.
143
- if (parsedHotkey.key === 'Escape') return false // Close modal, etc.
144
- return true // Single keys, Shift+key, Alt+key
145
- }
146
-
147
121
  /**
148
122
  * Singleton manager for hotkey registrations.
149
123
  *
@@ -296,7 +270,12 @@ export class HotkeyManager {
296
270
  )
297
271
 
298
272
  if (conflictingRegistration) {
299
- this.#handleConflict(conflictingRegistration, hotkeyStr, conflictBehavior)
273
+ handleConflict(
274
+ conflictingRegistration.id,
275
+ hotkeyStr,
276
+ conflictBehavior,
277
+ (id) => this.#unregister(id),
278
+ )
300
279
  }
301
280
 
302
281
  const resolvedIgnoreInputs =
@@ -304,6 +283,7 @@ export class HotkeyManager {
304
283
 
305
284
  const baseOptions = {
306
285
  ...defaultHotkeyOptions,
286
+ requireReset: false,
307
287
  ...options,
308
288
  platform,
309
289
  }
@@ -462,7 +442,7 @@ export class HotkeyManager {
462
442
  }
463
443
 
464
444
  // Check if event originated from or bubbled to this target
465
- if (!this.#isEventForTarget(event, target)) {
445
+ if (!isEventForTarget(event, target)) {
466
446
  continue
467
447
  }
468
448
 
@@ -472,7 +452,7 @@ export class HotkeyManager {
472
452
 
473
453
  // Check if we should ignore input elements (defaults to true)
474
454
  if (registration.options.ignoreInputs !== false) {
475
- if (this.#isInputElement(event.target)) {
455
+ if (isInputElement(event.target)) {
476
456
  // Don't ignore if the hotkey is explicitly scoped to this input element
477
457
  if (event.target !== registration.target) {
478
458
  continue
@@ -589,48 +569,6 @@ export class HotkeyManager {
589
569
  }
590
570
  }
591
571
 
592
- /**
593
- * Checks if an event is for the given target (originated from or bubbled to it).
594
- */
595
- #isEventForTarget(
596
- event: KeyboardEvent,
597
- target: HTMLElement | Document | Window,
598
- ): boolean {
599
- // For Document and Window, verify that our handler was indeed called for this target.
600
- //
601
- // Browser compatibility note:
602
- // Per the DOM spec, event.currentTarget should equal the element the listener was
603
- // attached to. However, some Chromium-based browsers (notably Brave) exhibit
604
- // non-standard behavior where event.currentTarget is set to document.documentElement
605
- // (<html>) instead of document when a listener is attached to document.
606
- // This may be related to privacy/fingerprinting protections.
607
- //
608
- // To ensure cross-browser compatibility, we accept both the expected target
609
- // and document.documentElement as valid currentTarget values.
610
- // See: https://dom.spec.whatwg.org/#dom-event-currenttarget
611
- if (target === document || target === window) {
612
- return (
613
- event.currentTarget === target ||
614
- event.currentTarget === document.documentElement
615
- )
616
- }
617
-
618
- // For HTMLElement, check if event originated from or bubbled to the element
619
- if (target instanceof HTMLElement) {
620
- // Check if the event's currentTarget is the target (capturing/bubbling)
621
- if (event.currentTarget === target) {
622
- return true
623
- }
624
-
625
- // Check if the event's target is a descendant of our target
626
- if (event.target instanceof Node && target.contains(event.target)) {
627
- return true
628
- }
629
- }
630
-
631
- return false
632
- }
633
-
634
572
  /**
635
573
  * Finds an existing registration with the same hotkey and target.
636
574
  */
@@ -646,82 +584,6 @@ export class HotkeyManager {
646
584
  return null
647
585
  }
648
586
 
649
- /**
650
- * Handles conflicts between hotkey registrations based on conflict behavior.
651
- */
652
- #handleConflict(
653
- conflictingRegistration: HotkeyRegistration,
654
- hotkey: Hotkey,
655
- conflictBehavior: ConflictBehavior,
656
- ): void {
657
- if (conflictBehavior === 'allow') {
658
- return
659
- }
660
-
661
- if (conflictBehavior === 'warn') {
662
- console.warn(
663
- `Hotkey '${hotkey}' is already registered. Multiple handlers will be triggered. ` +
664
- `Use conflictBehavior: 'replace' to replace the existing handler, ` +
665
- `or conflictBehavior: 'allow' to suppress this warning.`,
666
- )
667
- return
668
- }
669
-
670
- if (conflictBehavior === 'error') {
671
- throw new Error(
672
- `Hotkey '${hotkey}' is already registered. ` +
673
- `Use conflictBehavior: 'replace' to replace the existing handler, ` +
674
- `or conflictBehavior: 'allow' to allow multiple registrations.`,
675
- )
676
- }
677
-
678
- // At this point, conflictBehavior must be 'replace'
679
- this.#unregister(conflictingRegistration.id)
680
- }
681
-
682
- /**
683
- * Checks if an element is an input-like element that should be ignored.
684
- *
685
- * This includes:
686
- * - HTMLInputElement (all input types except button, submit, reset)
687
- * - HTMLTextAreaElement
688
- * - HTMLSelectElement
689
- * - Elements with contentEditable enabled
690
- *
691
- * Button-type inputs (button, submit, reset) are excluded so hotkeys like
692
- * Mod+S and Escape fire when the user has tabbed to a form button.
693
- */
694
- #isInputElement(element: EventTarget | null): boolean {
695
- if (!element) {
696
- return false
697
- }
698
-
699
- if (element instanceof HTMLInputElement) {
700
- const type = element.type.toLowerCase()
701
- if (type === 'button' || type === 'submit' || type === 'reset') {
702
- return false
703
- }
704
- return true
705
- }
706
-
707
- if (
708
- element instanceof HTMLTextAreaElement ||
709
- element instanceof HTMLSelectElement
710
- ) {
711
- return true
712
- }
713
-
714
- // Check for contenteditable elements
715
- if (element instanceof HTMLElement) {
716
- const contentEditable = element.contentEditable
717
- if (contentEditable === 'true' || contentEditable === '') {
718
- return true
719
- }
720
- }
721
-
722
- return false
723
- }
724
-
725
587
  /**
726
588
  * Determines if a registration should be reset based on the keyup event.
727
589
  */
package/src/index.ts CHANGED
@@ -6,5 +6,5 @@ export * from './key-state-tracker'
6
6
  export * from './match'
7
7
  export * from './parse'
8
8
  export * from './recorder'
9
- export * from './sequence'
9
+ export * from './sequence-manager'
10
10
  export * from './validate'
@@ -0,0 +1,171 @@
1
+ import type { ParsedHotkey } from './hotkey'
2
+
3
+ /**
4
+ * Behavior when registering a hotkey/sequence that conflicts with an existing registration.
5
+ *
6
+ * - `'warn'` - Log a warning to the console but allow both registrations (default)
7
+ * - `'error'` - Throw an error and prevent the new registration
8
+ * - `'replace'` - Unregister the existing registration and register the new one
9
+ * - `'allow'` - Allow multiple registrations without warning
10
+ */
11
+ export type ConflictBehavior = 'warn' | 'error' | 'replace' | 'allow'
12
+
13
+ /**
14
+ * Default options for hotkey/sequence registration.
15
+ * Omitted: platform, target (resolved at registration), requireReset (HotkeyManager only).
16
+ */
17
+ export const defaultHotkeyOptions = {
18
+ preventDefault: true,
19
+ stopPropagation: true,
20
+ eventType: 'keydown' as const,
21
+ enabled: true,
22
+ ignoreInputs: true,
23
+ conflictBehavior: 'warn' as ConflictBehavior,
24
+ }
25
+
26
+ /**
27
+ * Computes the default ignoreInputs value based on the hotkey.
28
+ * Ctrl/Meta shortcuts and Escape fire in inputs; single keys and Shift/Alt combos are ignored.
29
+ */
30
+ export function getDefaultIgnoreInputs(parsedHotkey: ParsedHotkey): boolean {
31
+ if (parsedHotkey.ctrl || parsedHotkey.meta) return false // Mod+S, Ctrl+C, etc.
32
+ if (parsedHotkey.key === 'Escape') return false // Close modal, etc.
33
+ return true // Single keys, Shift+key, Alt+key
34
+ }
35
+
36
+ /**
37
+ * Checks if an element is an input-like element that should be ignored for hotkeys.
38
+ *
39
+ * This includes:
40
+ * - HTMLInputElement (all input types except button, submit, reset)
41
+ * - HTMLTextAreaElement
42
+ * - HTMLSelectElement
43
+ * - Elements with contentEditable enabled
44
+ *
45
+ * Button-type inputs (button, submit, reset) are excluded so hotkeys like
46
+ * Mod+S and Escape fire when the user has tabbed to a form button.
47
+ */
48
+ export function isInputElement(element: EventTarget | null): boolean {
49
+ if (!element) {
50
+ return false
51
+ }
52
+
53
+ if (element instanceof HTMLInputElement) {
54
+ const type = element.type.toLowerCase()
55
+ if (type === 'button' || type === 'submit' || type === 'reset') {
56
+ return false
57
+ }
58
+ return true
59
+ }
60
+
61
+ if (
62
+ element instanceof HTMLTextAreaElement ||
63
+ element instanceof HTMLSelectElement
64
+ ) {
65
+ return true
66
+ }
67
+
68
+ // Check for contenteditable elements
69
+ if (element instanceof HTMLElement) {
70
+ const contentEditable = element.contentEditable
71
+ if (contentEditable === 'true' || contentEditable === '') {
72
+ return true
73
+ }
74
+ }
75
+
76
+ return false
77
+ }
78
+
79
+ /**
80
+ * Checks if an event is for the given target (originated from or bubbled to it).
81
+ *
82
+ * For document/window targets, also accepts document.documentElement as currentTarget
83
+ * to handle Brave and other browsers where currentTarget may be documentElement
84
+ * instead of document when listeners are attached to document.
85
+ */
86
+ export function isEventForTarget(
87
+ event: KeyboardEvent,
88
+ target: HTMLElement | Document | Window,
89
+ ): boolean {
90
+ // For Document and Window, verify that our handler was indeed called for this target.
91
+ //
92
+ // Browser compatibility note:
93
+ // Per the DOM spec, event.currentTarget should equal the element the listener was
94
+ // attached to. However, some Chromium-based browsers (notably Brave) exhibit
95
+ // non-standard behavior where event.currentTarget is set to document.documentElement
96
+ // (<html>) instead of document when a listener is attached to document.
97
+ // This may be related to privacy/fingerprinting protections.
98
+ //
99
+ // To ensure cross-browser compatibility, we accept both the expected target
100
+ // and document.documentElement as valid currentTarget values.
101
+ // See: https://dom.spec.whatwg.org/#dom-event-currenttarget
102
+ if (target === document || target === window) {
103
+ return (
104
+ event.currentTarget === target ||
105
+ event.currentTarget === document.documentElement
106
+ )
107
+ }
108
+
109
+ // For Window, accept window, document, or document.documentElement (browser quirks)
110
+ if (target === window) {
111
+ return (
112
+ event.currentTarget === window ||
113
+ event.currentTarget === document ||
114
+ event.currentTarget === document.documentElement
115
+ )
116
+ }
117
+
118
+ // For HTMLElement, check if event originated from or bubbled to the element
119
+ if (target instanceof HTMLElement) {
120
+ // Check if the event's currentTarget is the target (capturing/bubbling)
121
+ if (event.currentTarget === target) {
122
+ return true
123
+ }
124
+
125
+ // Check if the event's target is a descendant of our target
126
+ if (event.target instanceof Node && target.contains(event.target)) {
127
+ return true
128
+ }
129
+ }
130
+
131
+ return false
132
+ }
133
+
134
+ /**
135
+ * Handles conflicts between registrations based on conflict behavior.
136
+ *
137
+ * @param conflictingId - The ID of the conflicting registration
138
+ * @param keyDisplay - Display string for the conflicting key/sequence (for error messages)
139
+ * @param conflictBehavior - How to handle the conflict
140
+ * @param unregister - Function to unregister by ID
141
+ */
142
+ export function handleConflict(
143
+ conflictingId: string,
144
+ keyDisplay: string,
145
+ conflictBehavior: ConflictBehavior,
146
+ unregister: (id: string) => void,
147
+ ): void {
148
+ if (conflictBehavior === 'allow') {
149
+ return
150
+ }
151
+
152
+ if (conflictBehavior === 'warn') {
153
+ console.warn(
154
+ `'${keyDisplay}' is already registered. Multiple handlers will be triggered. ` +
155
+ `Use conflictBehavior: 'replace' to replace the existing handler, ` +
156
+ `or conflictBehavior: 'allow' to suppress this warning.`,
157
+ )
158
+ return
159
+ }
160
+
161
+ if (conflictBehavior === 'error') {
162
+ throw new Error(
163
+ `'${keyDisplay}' is already registered. ` +
164
+ `Use conflictBehavior: 'replace' to replace the existing handler, ` +
165
+ `or conflictBehavior: 'allow' to allow multiple registrations.`,
166
+ )
167
+ }
168
+
169
+ // At this point, conflictBehavior must be 'replace'
170
+ unregister(conflictingId)
171
+ }