@tanstack/hotkeys 0.2.0 → 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.
- package/dist/format.cjs +16 -0
- package/dist/format.cjs.map +1 -1
- package/dist/format.d.cts +14 -1
- package/dist/format.d.ts +14 -1
- package/dist/format.js +16 -1
- package/dist/format.js.map +1 -1
- package/dist/hotkey-manager.cjs +7 -75
- package/dist/hotkey-manager.cjs.map +1 -1
- package/dist/hotkey-manager.d.cts +2 -10
- package/dist/hotkey-manager.d.ts +2 -10
- package/dist/hotkey-manager.js +5 -73
- package/dist/hotkey-manager.js.map +1 -1
- package/dist/index.cjs +5 -4
- package/dist/index.d.cts +5 -4
- package/dist/index.d.ts +5 -4
- package/dist/index.js +3 -3
- package/dist/manager.utils.cjs +90 -0
- package/dist/manager.utils.cjs.map +1 -0
- package/dist/manager.utils.d.cts +13 -0
- package/dist/manager.utils.d.ts +13 -0
- package/dist/manager.utils.js +85 -0
- package/dist/manager.utils.js.map +1 -0
- package/dist/sequence-manager.cjs +391 -0
- package/dist/sequence-manager.cjs.map +1 -0
- package/dist/{sequence.d.cts → sequence-manager.d.cts} +50 -19
- package/dist/{sequence.d.ts → sequence-manager.d.ts} +50 -19
- package/dist/sequence-manager.js +389 -0
- package/dist/sequence-manager.js.map +1 -0
- package/package.json +1 -1
- package/src/format.ts +16 -0
- package/src/hotkey-manager.ts +18 -156
- package/src/index.ts +1 -1
- package/src/manager.utils.ts +171 -0
- package/src/sequence-manager.ts +693 -0
- package/dist/sequence.cjs +0 -242
- package/dist/sequence.cjs.map +0 -1
- package/dist/sequence.js +0 -240
- package/dist/sequence.js.map +0 -1
- package/src/sequence.ts +0 -391
package/src/hotkey-manager.ts
CHANGED
|
@@ -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
|
-
|
|
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 (!
|
|
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 (
|
|
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
|
@@ -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
|
+
}
|