@remcostoeten/use-shortcut 1.3.0 → 2.0.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.
@@ -0,0 +1,43 @@
1
+ import type { ParsedShortcut, ShortcutConflict } from "../types"
2
+ import type { ShortcutRegistry } from "./types"
3
+
4
+ import { _canonicalizeParsed } from "./keys"
5
+
6
+ function _isPrefix(a: string[], b: string[]): boolean {
7
+ if (a.length > b.length) return false
8
+ for (let i = 0; i < a.length; i += 1) {
9
+ if (a[i] !== b[i]) {
10
+ return false
11
+ }
12
+ }
13
+ return true
14
+ }
15
+
16
+ export function _detectConflict(newSteps: ParsedShortcut[], existingSteps: ParsedShortcut[]): ShortcutConflict["reason"] | null {
17
+ const newCanonicalSteps = newSteps.map(_canonicalizeParsed)
18
+ const existingCanonicalSteps = existingSteps.map(_canonicalizeParsed)
19
+ const newCombo = newCanonicalSteps.join(" ")
20
+ const existingCombo = existingCanonicalSteps.join(" ")
21
+
22
+ if (newCombo === existingCombo) return "exact"
23
+ if (_isPrefix(newCanonicalSteps, existingCanonicalSteps) || _isPrefix(existingCanonicalSteps, newCanonicalSteps)) {
24
+ return "sequence-prefix"
25
+ }
26
+
27
+ return null
28
+ }
29
+
30
+ export function _emitConflict(registry: ShortcutRegistry, conflict: ShortcutConflict) {
31
+ const conflictWarnings = registry.options.conflictWarnings ?? true
32
+
33
+ if (registry.options.onConflict) {
34
+ registry.options.onConflict(conflict)
35
+ return
36
+ }
37
+
38
+ if (!conflictWarnings) return
39
+
40
+ console.warn(
41
+ `[useShortcut] Conflict detected (${conflict.reason}) between "${conflict.combo}" and "${conflict.existingCombo}"`,
42
+ )
43
+ }
@@ -0,0 +1,6 @@
1
+ export function _debugLog(debug: boolean | undefined, ...args: unknown[]) {
2
+ if (debug) {
3
+ console.log("[useShortcut]", ...args)
4
+ }
5
+ }
6
+
@@ -0,0 +1,82 @@
1
+ import type { ExceptPreset, ExceptPredicate, ShortcutScope } from "../types"
2
+
3
+ export const _IGNORED_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"])
4
+
5
+ export const _EXCEPT_PREDICATES: Record<ExceptPreset, ExceptPredicate> = {
6
+ input: e => {
7
+ if (!(e.target instanceof HTMLElement)) return false
8
+ const target = e.target
9
+ return _IGNORED_TAGS.has(target.tagName)
10
+ },
11
+ editable: e => {
12
+ if (!(e.target instanceof HTMLElement)) return false
13
+ const target = e.target
14
+ return target.isContentEditable
15
+ },
16
+ typing: e => {
17
+ if (!(e.target instanceof HTMLElement)) return false
18
+ const target = e.target
19
+ return _IGNORED_TAGS.has(target.tagName) || target.isContentEditable
20
+ },
21
+ modal: () => {
22
+ if (
23
+ typeof document === "undefined" ||
24
+ typeof document.querySelector !== "function"
25
+ )
26
+ return false
27
+ return (
28
+ document.querySelector('[data-modal="true"], [role="dialog"]') !==
29
+ null
30
+ )
31
+ },
32
+ disabled: e => {
33
+ if (!(e.target instanceof HTMLElement)) return false
34
+ const target = e.target
35
+ return (
36
+ target.hasAttribute("disabled") ||
37
+ target.getAttribute("aria-disabled") === "true"
38
+ )
39
+ }
40
+ }
41
+
42
+ export function _shouldExcept(
43
+ event: KeyboardEvent,
44
+ except?: ExceptPreset | ExceptPreset[] | ExceptPredicate
45
+ ): boolean {
46
+ if (!except) return false
47
+
48
+ if (typeof except === "function") {
49
+ return except(event)
50
+ }
51
+
52
+ if (Array.isArray(except)) {
53
+ return except.some(preset => _EXCEPT_PREDICATES[preset]?.(event))
54
+ }
55
+
56
+ return _EXCEPT_PREDICATES[except]?.(event) ?? false
57
+ }
58
+
59
+ export function _normalizeScopes(scopes?: ShortcutScope): string[] {
60
+ if (!scopes) return []
61
+ return (Array.isArray(scopes) ? scopes : [scopes])
62
+ .map(scope => scope.trim())
63
+ .filter(Boolean)
64
+ }
65
+
66
+ export function _scopeMatch(
67
+ requiredScopes: Set<string>,
68
+ activeScopes: Set<string>
69
+ ): boolean {
70
+ if (requiredScopes.size === 0) return true
71
+ for (const required of requiredScopes) {
72
+ if (activeScopes.has(required)) return true
73
+ }
74
+ return false
75
+ }
76
+
77
+ export function _isPureModifier(event: KeyboardEvent): boolean {
78
+ const key = event.key.toLowerCase()
79
+ return (
80
+ key === "shift" || key === "control" || key === "alt" || key === "meta"
81
+ )
82
+ }
@@ -0,0 +1,63 @@
1
+ import { detectPlatform, ModifierDisplayOrder, ModifierKey } from "../constants"
2
+ import { formatShortcut } from "../formatter"
3
+ import type { ModifierFlags, ParsedShortcut } from "../types"
4
+
5
+ export function _getActiveModifierTokens(
6
+ modifiers: Partial<ModifierFlags>
7
+ ): string[] {
8
+ const platform = detectPlatform()
9
+ const order = ModifierDisplayOrder[platform]
10
+ const tokens: string[] = []
11
+
12
+ for (const key of order) {
13
+ if (key === ModifierKey.CTRL && modifiers.ctrl) tokens.push("ctrl")
14
+ if (key === ModifierKey.ALT && modifiers.alt) tokens.push("alt")
15
+ if (key === ModifierKey.SHIFT && modifiers.shift) tokens.push("shift")
16
+ if (key === ModifierKey.META && modifiers.cmd) tokens.push("cmd")
17
+ }
18
+
19
+ return tokens
20
+ }
21
+
22
+ export function _buildComboString(
23
+ modifiers: Partial<ModifierFlags>,
24
+ key: string
25
+ ): string {
26
+ const tokens = _getActiveModifierTokens(modifiers)
27
+ return [...tokens, key].join("+")
28
+ }
29
+
30
+ export function _formatSequenceDisplay(steps: string[]): string {
31
+ return steps.map(step => formatShortcut(step)).join(" then ")
32
+ }
33
+
34
+ export function _canonicalizeParsed(parsed: ParsedShortcut): string {
35
+ const modifiers: string[] = []
36
+ if (parsed.modifiers.ctrl) modifiers.push("ctrl")
37
+ if (parsed.modifiers.alt) modifiers.push("alt")
38
+ if (parsed.modifiers.shift) modifiers.push("shift")
39
+ if (parsed.modifiers.meta) modifiers.push("cmd")
40
+ const key =
41
+ parsed.key === " " || parsed.key === "Spacebar"
42
+ ? "space"
43
+ : parsed.key.toLowerCase()
44
+ return [...modifiers, key].join("+")
45
+ }
46
+
47
+ export function _eventToCombo(event: KeyboardEvent): string {
48
+ const modifiers: string[] = []
49
+ if (event.ctrlKey) modifiers.push("ctrl")
50
+ if (event.altKey) modifiers.push("alt")
51
+ if (event.shiftKey) modifiers.push("shift")
52
+ if (event.metaKey) modifiers.push("cmd")
53
+
54
+ const key =
55
+ event.key === " " || event.key === "Spacebar"
56
+ ? "space"
57
+ : event.key.toLowerCase()
58
+ return [...modifiers, key].join("+")
59
+ }
60
+
61
+ export function _eventToMatchStep(event: KeyboardEvent): string {
62
+ return _eventToCombo(event)
63
+ }
@@ -0,0 +1,142 @@
1
+ import { matchesShortcut } from "../parser"
2
+
3
+ import { _debugLog } from "./debug"
4
+ import { _eventToMatchStep } from "./keys"
5
+ import { _IGNORED_TAGS, _scopeMatch, _shouldExcept } from "./guards"
6
+ import type { RegistryEntry, ShortcutRegistry } from "./types"
7
+
8
+ function _sortEntries(entries: RegistryEntry[]): RegistryEntry[] {
9
+ if (entries.length <= 1) return entries
10
+
11
+ return [...entries].sort((a, b) => {
12
+ if (b.priority !== a.priority) return b.priority - a.priority
13
+ return a.id - b.id
14
+ })
15
+ }
16
+
17
+ function _dispatchRegistryEvent(registry: ShortcutRegistry, event: KeyboardEvent) {
18
+ const runtimeOptions = registry.options
19
+ if (runtimeOptions.disabled) return
20
+ if (runtimeOptions.eventFilter && !runtimeOptions.eventFilter(event)) return
21
+
22
+ const candidateCombos = new Set<string>()
23
+ const firstStepCombos = registry.firstStepIndex.get(_eventToMatchStep(event))
24
+ if (firstStepCombos) {
25
+ for (const combo of firstStepCombos) candidateCombos.add(combo)
26
+ }
27
+ for (const combo of registry.activeSequenceCombos) {
28
+ candidateCombos.add(combo)
29
+ }
30
+
31
+ for (const combo of candidateCombos) {
32
+ const comboEntries = registry.listeners.get(combo)
33
+ if (!comboEntries) continue
34
+
35
+ const orderedEntries = _sortEntries(comboEntries)
36
+
37
+ for (const item of orderedEntries) {
38
+ if (!item.isEnabled) continue
39
+
40
+ if (!_scopeMatch(item.scopes, registry.activeScopes)) {
41
+ continue
42
+ }
43
+
44
+ if (runtimeOptions.ignoreInputs !== false && !item.except) {
45
+ const targetEl = event.target as HTMLElement
46
+ if (targetEl && (_IGNORED_TAGS.has(targetEl.tagName) || targetEl.isContentEditable)) {
47
+ continue
48
+ }
49
+ }
50
+
51
+ if (_shouldExcept(event, item.except)) {
52
+ _debugLog(runtimeOptions.debug, "Skipped due to except condition:", combo)
53
+ continue
54
+ }
55
+
56
+ const expected = item.parsedSteps[item.progress]
57
+ const now = Date.now()
58
+
59
+ if (item.progress > 0 && now - item.lastMatchedAt > item.sequenceTimeout) {
60
+ item.progress = 0
61
+ }
62
+
63
+ let matched = false
64
+
65
+ if (matchesShortcut(event, expected)) {
66
+ item.progress += 1
67
+ item.lastMatchedAt = now
68
+
69
+ if (item.progress === item.parsedSteps.length) {
70
+ matched = true
71
+ item.progress = 0
72
+ }
73
+ } else if (item.progress > 0 && matchesShortcut(event, item.parsedSteps[0])) {
74
+ item.progress = 1
75
+ item.lastMatchedAt = now
76
+ } else {
77
+ item.progress = 0
78
+ }
79
+
80
+ for (const cb of item.attemptCallbacks) {
81
+ cb(matched, event)
82
+ }
83
+
84
+ if (!matched) continue
85
+
86
+ _debugLog(runtimeOptions.debug, "MATCHED:", combo)
87
+
88
+ if (item.preventDefault) {
89
+ event.preventDefault()
90
+ }
91
+
92
+ if (item.stopPropagation) {
93
+ event.stopPropagation()
94
+ }
95
+
96
+ const executeHandler = () => item.userHandler(event)
97
+
98
+ if (item.delay > 0) {
99
+ _debugLog(runtimeOptions.debug, "Delaying execution by", item.delay, "ms")
100
+ setTimeout(executeHandler, item.delay)
101
+ } else {
102
+ executeHandler()
103
+ }
104
+
105
+ if (item.stopOnMatch) {
106
+ break
107
+ }
108
+ }
109
+
110
+ if (comboEntries.some((entry) => entry.progress > 0)) {
111
+ registry.activeSequenceCombos.add(combo)
112
+ } else {
113
+ registry.activeSequenceCombos.delete(combo)
114
+ }
115
+ }
116
+ }
117
+
118
+ export function _attachRegistryListener(registry: ShortcutRegistry) {
119
+ if (registry.listener) return
120
+
121
+ const target = registry.options.target ?? (typeof window !== "undefined" ? window : null)
122
+ if (!target) return
123
+
124
+ const eventType = registry.options.eventType ?? "keydown"
125
+ const listener = (event: KeyboardEvent) => _dispatchRegistryEvent(registry, event)
126
+ target.addEventListener(eventType, listener as EventListener)
127
+
128
+ registry.listener = listener
129
+ registry.listenerTarget = target
130
+ registry.listenerEventType = eventType
131
+
132
+ _debugLog(registry.options.debug, "Listener attached")
133
+ }
134
+
135
+ export function _detachRegistryListener(registry: ShortcutRegistry) {
136
+ if (!registry.listener || !registry.listenerTarget) return
137
+
138
+ registry.listenerTarget.removeEventListener(registry.listenerEventType, registry.listener as EventListener)
139
+ registry.listener = null
140
+ registry.listenerTarget = null
141
+ _debugLog(registry.options.debug, "Listener detached")
142
+ }
@@ -0,0 +1,39 @@
1
+ import type { ShortcutRecordingOptions, UseShortcutOptions } from "../types"
2
+ import { _eventToCombo } from "./keys"
3
+ import { _isPureModifier } from "./guards"
4
+
5
+ export function _createRecorder(options: UseShortcutOptions) {
6
+ return (recordingOptions: ShortcutRecordingOptions = {}): Promise<string> => {
7
+ return new Promise((resolve, reject) => {
8
+ const target = recordingOptions.target ?? options.target ?? (typeof window !== "undefined" ? window : null)
9
+ const eventType = recordingOptions.eventType ?? options.eventType ?? "keydown"
10
+
11
+ if (!target) {
12
+ reject(new Error("[useShortcut] Cannot record shortcut without a target."))
13
+ return
14
+ }
15
+
16
+ let timeout: ReturnType<typeof setTimeout> | undefined
17
+
18
+ const listener = (event: Event) => {
19
+ const keyboardEvent = event as KeyboardEvent
20
+ if (_isPureModifier(keyboardEvent)) return
21
+
22
+ keyboardEvent.preventDefault()
23
+ target.removeEventListener(eventType, listener as EventListener)
24
+ if (timeout) clearTimeout(timeout)
25
+ resolve(_eventToCombo(keyboardEvent))
26
+ }
27
+
28
+ target.addEventListener(eventType, listener as EventListener)
29
+
30
+ const timeoutMs = recordingOptions.timeoutMs
31
+ if (timeoutMs && timeoutMs > 0) {
32
+ timeout = setTimeout(() => {
33
+ target.removeEventListener(eventType, listener as EventListener)
34
+ reject(new Error(`[useShortcut] Recording timed out after ${timeoutMs}ms.`))
35
+ }, timeoutMs)
36
+ }
37
+ })
38
+ }
39
+ }
@@ -0,0 +1,48 @@
1
+ import type {
2
+ ModifierFlags,
3
+ ShortcutHandler,
4
+ UseShortcutOptions,
5
+ ExceptPreset,
6
+ ExceptPredicate,
7
+ ShortcutScope,
8
+ ParsedShortcut,
9
+ } from "../types"
10
+
11
+ export type BuilderState = {
12
+ modifiers: Partial<ModifierFlags>
13
+ steps: string[]
14
+ options: UseShortcutOptions
15
+ except?: ExceptPreset | ExceptPreset[] | ExceptPredicate
16
+ scopes?: ShortcutScope
17
+ }
18
+
19
+ export type RegistryEntry = {
20
+ id: number
21
+ userHandler: ShortcutHandler
22
+ isEnabled: boolean
23
+ attemptCallbacks: Set<(matched: boolean, event: KeyboardEvent) => void>
24
+ parsedSteps: ParsedShortcut[]
25
+ scopes: Set<string>
26
+ progress: number
27
+ lastMatchedAt: number
28
+ except?: ExceptPreset | ExceptPreset[] | ExceptPredicate
29
+ delay: number
30
+ sequenceTimeout: number
31
+ preventDefault: boolean
32
+ stopPropagation: boolean
33
+ stopOnMatch: boolean
34
+ priority: number
35
+ }
36
+
37
+ export type ShortcutRegistry = {
38
+ listeners: Map<string, RegistryEntry[]>
39
+ firstStepIndex: Map<string, Set<string>>
40
+ activeSequenceCombos: Set<string>
41
+ options: UseShortcutOptions
42
+ activeScopes: Set<string>
43
+ nextId: number
44
+ listener: ((event: KeyboardEvent) => void) | null
45
+ listenerTarget: (HTMLElement | Window) | null
46
+ listenerEventType: "keydown" | "keyup"
47
+ }
48
+
package/src/types.ts CHANGED
@@ -83,8 +83,10 @@ export type ExceptPredicate = (event: KeyboardEvent) => boolean
83
83
  */
84
84
  export type ExceptPreset = "input" | "editable" | "typing" | "modal" | "disabled"
85
85
 
86
+ /** Scope selector used to enable/disable subsets of shortcuts at runtime. */
86
87
  export type ShortcutScope = string | string[]
87
88
 
89
+ /** Conflict metadata emitted when two registered shortcuts overlap. */
88
90
  export type ShortcutConflict = {
89
91
  combo: string
90
92
  existingCombo: string
@@ -105,8 +107,6 @@ export type HandlerOptions = {
105
107
  description?: string
106
108
  /** Disable this specific shortcut */
107
109
  disabled?: boolean
108
- /** Limit shortcut to a specific DOM element */
109
- scope?: HTMLElement | null
110
110
  /** Conditions to skip the shortcut */
111
111
  except?: ExceptPreset | ExceptPreset[] | ExceptPredicate
112
112
  /** Required named scopes that must be active */
@@ -142,14 +142,6 @@ export type ShortcutResult = {
142
142
  onAttempt?: (callback: (matched: boolean, event: KeyboardEvent) => void) => () => void
143
143
  }
144
144
 
145
- export type RemainingModifiers<Used extends Partial<ModifierFlags>> = Exclude<
146
- ModifierName,
147
- | (Used extends { ctrl: true } ? "ctrl" : never)
148
- | (Used extends { shift: true } ? "shift" : never)
149
- | (Used extends { alt: true } ? "alt" : never)
150
- | (Used extends { cmd: true } ? "cmd" | "mod" : never)
151
- >
152
-
153
145
  /**
154
146
  * Chainable modifier builder with type-safe exhaustion
155
147
  * Each modifier can only be used once in a chain
@@ -160,35 +152,36 @@ export type ModifierChain<Used extends Partial<ModifierFlags>> = {
160
152
  alt: Used["alt"] extends true ? never : ModifierChain<Used & { alt: true }>
161
153
  cmd: Used["cmd"] extends true ? never : ModifierChain<Used & { cmd: true }>
162
154
  mod: Used["cmd"] extends true ? never : ModifierChain<Used & { cmd: true }>
163
- key: <K extends ActionKey>(key: K) => KeyChain<Used, K>
155
+ key: <K extends ActionKey>(key: K) => KeyChain<K>
164
156
  in: (scopes: ShortcutScope) => ModifierChain<Used>
165
157
  }
166
158
 
167
159
  /**
168
160
  * Chain state after calling `.key()` - ready to attach a handler
169
161
  */
170
- export type KeyChain<Used extends Partial<ModifierFlags>, Key extends string> = {
162
+ export type KeyChain<Key extends string> = {
171
163
  /** Attach a handler to this shortcut */
172
164
  on: (handler: ShortcutHandler, options?: HandlerOptions) => ShortcutResult
173
165
  /** Attach a handler with inline options */
174
166
  handle: (options: HandlerOptions & { handler: ShortcutHandler }) => ShortcutResult
175
167
  /** Add exception conditions before attaching handler */
176
- except: (condition: ExceptPreset | ExceptPreset[] | ExceptPredicate) => KeyChainWithExcept<Used, Key>
168
+ except: (condition: ExceptPreset | ExceptPreset[] | ExceptPredicate) => KeyChainWithExcept<Key>
177
169
  /** Add required named scopes */
178
- in: (scopes: ShortcutScope) => KeyChain<Used, Key>
170
+ in: (scopes: ShortcutScope) => KeyChain<Key>
179
171
  /** Add the next step in a sequence */
180
- then: <K extends ActionKey | string>(key: K) => KeyChain<Used, `${Key} ${K}`>
172
+ then: <K extends ActionKey | string>(key: K) => KeyChain<`${Key} ${K}`>
181
173
  }
182
174
 
183
175
  /**
184
176
  * Chain state after calling `.except()` - ready to attach handler
185
177
  */
186
- export type KeyChainWithExcept<Used extends Partial<ModifierFlags>, Key extends string> = {
178
+ export type KeyChainWithExcept<Key extends string> = {
187
179
  on: (handler: ShortcutHandler, options?: Omit<HandlerOptions, "except">) => ShortcutResult
188
- in: (scopes: ShortcutScope) => KeyChainWithExcept<Used, Key>
189
- then: <K extends ActionKey | string>(key: K) => KeyChainWithExcept<Used, `${Key} ${K}`>
180
+ in: (scopes: ShortcutScope) => KeyChainWithExcept<Key>
181
+ then: <K extends ActionKey | string>(key: K) => KeyChainWithExcept<`${Key} ${K}`>
190
182
  }
191
183
 
184
+ /** Options for `ShortcutBuilder.record()` and low-level recording flows. */
192
185
  export type ShortcutRecordingOptions = {
193
186
  target?: HTMLElement | Window | null
194
187
  eventType?: "keydown" | "keyup"
@@ -204,7 +197,7 @@ export type ShortcutBuilder = ModifierChain<EmptyModifiers> & {
204
197
  alt: ModifierChain<{ alt: true }>
205
198
  cmd: ModifierChain<{ cmd: true }>
206
199
  mod: ModifierChain<{ cmd: true }>
207
- key: <K extends ActionKey>(key: K) => KeyChain<EmptyModifiers, K>
200
+ key: <K extends ActionKey>(key: K) => KeyChain<K>
208
201
  /** Set required scopes for upcoming chain calls */
209
202
  in: (scopes: ShortcutScope) => ShortcutBuilder
210
203
  /** Update active scopes at runtime */
@@ -249,18 +242,22 @@ export type UseShortcutOptions = {
249
242
  eventFilter?: (event: KeyboardEvent) => boolean
250
243
  }
251
244
 
245
+ /** Single shortcut-map entry used by `registerShortcutMap` and `useShortcutMap`. */
252
246
  export type ShortcutMapEntry = {
253
247
  keys: string | string[]
254
248
  handler: ShortcutHandler
255
249
  options?: HandlerOptions
256
250
  }
257
251
 
252
+ /** Bulk registration shape mapping action ids to key+handler definitions. */
258
253
  export type ShortcutMap = Record<string, ShortcutMapEntry>
259
254
 
255
+ /** Return type for map registrations, keyed by the same ids as the source map. */
260
256
  export type ShortcutMapResult<T extends ShortcutMap = ShortcutMap> = {
261
257
  [K in keyof T]: ShortcutResult
262
258
  }
263
259
 
260
+ /** Imperative grouping controller for binding/unbinding many shortcut registrations together. */
264
261
  export type ShortcutGroup = {
265
262
  add: (...results: ShortcutResult[]) => void
266
263
  addMany: (results: ShortcutResult[] | Record<string, ShortcutResult>) => void