@tanstack/hotkeys 0.0.1 → 0.1.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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -45
  3. package/dist/constants.cjs +444 -0
  4. package/dist/constants.cjs.map +1 -0
  5. package/dist/constants.d.cts +226 -0
  6. package/dist/constants.d.ts +226 -0
  7. package/dist/constants.js +428 -0
  8. package/dist/constants.js.map +1 -0
  9. package/dist/format.cjs +178 -0
  10. package/dist/format.cjs.map +1 -0
  11. package/dist/format.d.cts +110 -0
  12. package/dist/format.d.ts +110 -0
  13. package/dist/format.js +175 -0
  14. package/dist/format.js.map +1 -0
  15. package/dist/hotkey-manager.cjs +420 -0
  16. package/dist/hotkey-manager.cjs.map +1 -0
  17. package/dist/hotkey-manager.d.cts +207 -0
  18. package/dist/hotkey-manager.d.ts +207 -0
  19. package/dist/hotkey-manager.js +419 -0
  20. package/dist/hotkey-manager.js.map +1 -0
  21. package/dist/hotkey.d.cts +278 -0
  22. package/dist/hotkey.d.ts +278 -0
  23. package/dist/index.cjs +54 -0
  24. package/dist/index.d.cts +11 -0
  25. package/dist/index.d.ts +11 -0
  26. package/dist/index.js +11 -0
  27. package/dist/key-state-tracker.cjs +197 -0
  28. package/dist/key-state-tracker.cjs.map +1 -0
  29. package/dist/key-state-tracker.d.cts +107 -0
  30. package/dist/key-state-tracker.d.ts +107 -0
  31. package/dist/key-state-tracker.js +196 -0
  32. package/dist/key-state-tracker.js.map +1 -0
  33. package/dist/match.cjs +143 -0
  34. package/dist/match.cjs.map +1 -0
  35. package/dist/match.d.cts +79 -0
  36. package/dist/match.d.ts +79 -0
  37. package/dist/match.js +141 -0
  38. package/dist/match.js.map +1 -0
  39. package/dist/parse.cjs +266 -0
  40. package/dist/parse.cjs.map +1 -0
  41. package/dist/parse.d.cts +169 -0
  42. package/dist/parse.d.ts +169 -0
  43. package/dist/parse.js +258 -0
  44. package/dist/parse.js.map +1 -0
  45. package/dist/recorder.cjs +177 -0
  46. package/dist/recorder.cjs.map +1 -0
  47. package/dist/recorder.d.cts +108 -0
  48. package/dist/recorder.d.ts +108 -0
  49. package/dist/recorder.js +177 -0
  50. package/dist/recorder.js.map +1 -0
  51. package/dist/sequence.cjs +242 -0
  52. package/dist/sequence.cjs.map +1 -0
  53. package/dist/sequence.d.cts +109 -0
  54. package/dist/sequence.d.ts +109 -0
  55. package/dist/sequence.js +240 -0
  56. package/dist/sequence.js.map +1 -0
  57. package/dist/validate.cjs +116 -0
  58. package/dist/validate.cjs.map +1 -0
  59. package/dist/validate.d.cts +56 -0
  60. package/dist/validate.d.ts +56 -0
  61. package/dist/validate.js +114 -0
  62. package/dist/validate.js.map +1 -0
  63. package/package.json +55 -7
  64. package/src/constants.ts +514 -0
  65. package/src/format.ts +261 -0
  66. package/src/hotkey-manager.ts +822 -0
  67. package/src/hotkey.ts +411 -0
  68. package/src/index.ts +10 -0
  69. package/src/key-state-tracker.ts +249 -0
  70. package/src/match.ts +222 -0
  71. package/src/parse.ts +368 -0
  72. package/src/recorder.ts +266 -0
  73. package/src/sequence.ts +391 -0
  74. package/src/validate.ts +171 -0
package/src/match.ts ADDED
@@ -0,0 +1,222 @@
1
+ import { detectPlatform, normalizeKeyName } from './constants'
2
+ import { parseHotkey } from './parse'
3
+ import type {
4
+ Hotkey,
5
+ HotkeyCallback,
6
+ HotkeyCallbackContext,
7
+ ParsedHotkey,
8
+ } from './hotkey'
9
+
10
+ /**
11
+ * Checks if a KeyboardEvent matches a hotkey.
12
+ *
13
+ * Uses the `key` property from KeyboardEvent for matching, with a fallback to `code`
14
+ * for letter keys (A-Z) and digit keys (0-9) when `key` produces special characters
15
+ * (e.g., macOS Option+letter or Shift+number). Letter keys are matched case-insensitively.
16
+ *
17
+ * @param event - The KeyboardEvent to check
18
+ * @param hotkey - The hotkey string or ParsedHotkey to match against
19
+ * @param platform - The target platform for resolving 'Mod' (defaults to auto-detection)
20
+ * @returns True if the event matches the hotkey
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * document.addEventListener('keydown', (event) => {
25
+ * if (matchesKeyboardEvent(event, 'Mod+S')) {
26
+ * event.preventDefault()
27
+ * handleSave()
28
+ * }
29
+ * })
30
+ * ```
31
+ */
32
+ export function matchesKeyboardEvent(
33
+ event: KeyboardEvent,
34
+ hotkey: Hotkey | ParsedHotkey,
35
+ platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
36
+ ): boolean {
37
+ const parsed =
38
+ typeof hotkey === 'string' ? parseHotkey(hotkey, platform) : hotkey
39
+
40
+ // Check modifiers
41
+ if (event.ctrlKey !== parsed.ctrl) {
42
+ return false
43
+ }
44
+ if (event.shiftKey !== parsed.shift) {
45
+ return false
46
+ }
47
+ if (event.altKey !== parsed.alt) {
48
+ return false
49
+ }
50
+ if (event.metaKey !== parsed.meta) {
51
+ return false
52
+ }
53
+
54
+ // Check key (case-insensitive for letters)
55
+ const eventKey = normalizeKeyName(event.key)
56
+ const hotkeyKey = parsed.key
57
+
58
+ // For single letters, compare case-insensitively
59
+ if (eventKey.length === 1 && hotkeyKey.length === 1) {
60
+ // First try matching with event.key
61
+ if (eventKey.toUpperCase() === hotkeyKey.toUpperCase()) {
62
+ return true
63
+ }
64
+
65
+ // Fallback to event.code for letter keys when event.key doesn't match
66
+ // This handles cases like Command+Option+T on macOS where event.key is '†' instead of 'T'
67
+ // event.code format for letter keys is "KeyA", "KeyB", etc. (always uppercase in browsers)
68
+ if (event.code && event.code.startsWith('Key')) {
69
+ const codeLetter = event.code.slice(3) // Remove "Key" prefix
70
+ if (codeLetter.length === 1 && /^[A-Za-z]$/.test(codeLetter)) {
71
+ return codeLetter.toUpperCase() === hotkeyKey.toUpperCase()
72
+ }
73
+ }
74
+
75
+ // Fallback to event.code for digit keys when event.key doesn't match
76
+ // This handles cases like Shift+4 where event.key is '$' instead of '4'
77
+ // event.code format for digit keys is "Digit0", "Digit1", etc.
78
+ if (event.code && event.code.startsWith('Digit')) {
79
+ const codeDigit = event.code.slice(5) // Remove "Digit" prefix
80
+ if (codeDigit.length === 1 && /^[0-9]$/.test(codeDigit)) {
81
+ return codeDigit === hotkeyKey
82
+ }
83
+ }
84
+
85
+ return false
86
+ }
87
+
88
+ // For special keys, compare exactly (after normalization)
89
+ return eventKey === hotkeyKey
90
+ }
91
+
92
+ /**
93
+ * Options for creating a hotkey handler.
94
+ */
95
+ export interface CreateHotkeyHandlerOptions {
96
+ /** Prevent the default browser action when the hotkey matches. Defaults to true */
97
+ preventDefault?: boolean
98
+ /** Stop event propagation when the hotkey matches. Defaults to true */
99
+ stopPropagation?: boolean
100
+ /** The target platform for resolving 'Mod' */
101
+ platform?: 'mac' | 'windows' | 'linux'
102
+ }
103
+
104
+ /**
105
+ * Creates a keyboard event handler that calls the callback when the hotkey matches.
106
+ *
107
+ * @param hotkey - The hotkey string or ParsedHotkey to match
108
+ * @param callback - The function to call when the hotkey matches
109
+ * @param options - Options for matching and handling
110
+ * @returns A function that can be used as an event handler
111
+ *
112
+ * @example
113
+ * ```ts
114
+ * const handler = createHotkeyHandler('Mod+S', (event, { hotkey, parsedHotkey }) => {
115
+ * console.log(`${hotkey} was pressed`)
116
+ * handleSave()
117
+ * })
118
+ *
119
+ * document.addEventListener('keydown', handler)
120
+ * ```
121
+ */
122
+ export function createHotkeyHandler(
123
+ hotkey: Hotkey | ParsedHotkey,
124
+ callback: HotkeyCallback,
125
+ options: CreateHotkeyHandlerOptions = {},
126
+ ): (event: KeyboardEvent) => void {
127
+ const { preventDefault = true, stopPropagation = true, platform } = options
128
+ const resolvedPlatform = platform ?? detectPlatform()
129
+
130
+ const hotkeyString: Hotkey =
131
+ typeof hotkey === 'string' ? hotkey : formatParsedHotkey(hotkey)
132
+ const parsed =
133
+ typeof hotkey === 'string' ? parseHotkey(hotkey, resolvedPlatform) : hotkey
134
+
135
+ const context: HotkeyCallbackContext = {
136
+ hotkey: hotkeyString,
137
+ parsedHotkey: parsed,
138
+ }
139
+
140
+ return (event: KeyboardEvent) => {
141
+ if (matchesKeyboardEvent(event, parsed, resolvedPlatform)) {
142
+ if (preventDefault) {
143
+ event.preventDefault()
144
+ }
145
+ if (stopPropagation) {
146
+ event.stopPropagation()
147
+ }
148
+ callback(event, context)
149
+ }
150
+ }
151
+ }
152
+
153
+ type MultiHotkeyHandler = { [K in Hotkey]?: HotkeyCallback }
154
+
155
+ /**
156
+ * Creates a handler that matches multiple hotkeys.
157
+ *
158
+ * @param handlers - A map of hotkey strings to their handlers
159
+ * @param options - Options for matching and handling
160
+ * @returns A function that can be used as an event handler
161
+ *
162
+ * @example
163
+ * ```ts
164
+ * const handler = createMultiHotkeyHandler({
165
+ * 'Mod+S': (event, { hotkey }) => handleSave(),
166
+ * 'Mod+Z': (event, { hotkey }) => handleUndo(),
167
+ * 'Mod+Shift+Z': (event, { hotkey }) => handleRedo(),
168
+ * })
169
+ *
170
+ * document.addEventListener('keydown', handler)
171
+ * ```
172
+ */
173
+ export function createMultiHotkeyHandler(
174
+ handlers: MultiHotkeyHandler,
175
+ options: CreateHotkeyHandlerOptions = {},
176
+ ): (event: KeyboardEvent) => void {
177
+ const { preventDefault = true, stopPropagation = true, platform } = options
178
+ const resolvedPlatform = platform ?? detectPlatform()
179
+
180
+ // Pre-parse all hotkeys for efficiency
181
+ const parsedHandlers = Object.entries(handlers)
182
+ .filter((entry): entry is [string, HotkeyCallback] => Boolean(entry[1]))
183
+ .map(([hotkey, handler]) => {
184
+ const parsed = parseHotkey(hotkey as Hotkey, resolvedPlatform)
185
+ const context: HotkeyCallbackContext = {
186
+ hotkey: hotkey as Hotkey,
187
+ parsedHotkey: parsed,
188
+ }
189
+ return { parsed, handler, context }
190
+ })
191
+
192
+ return (event: KeyboardEvent) => {
193
+ for (const { parsed, handler, context } of parsedHandlers) {
194
+ if (matchesKeyboardEvent(event, parsed, resolvedPlatform)) {
195
+ if (preventDefault) {
196
+ event.preventDefault()
197
+ }
198
+ if (stopPropagation) {
199
+ event.stopPropagation()
200
+ }
201
+ handler(event, context)
202
+ return // Only handle the first match
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Formats a ParsedHotkey back to a hotkey string.
210
+ * Used internally to provide the hotkey string in callback context.
211
+ */
212
+ function formatParsedHotkey(parsed: ParsedHotkey): Hotkey {
213
+ const parts: Array<string> = []
214
+
215
+ if (parsed.ctrl) parts.push('Control')
216
+ if (parsed.alt) parts.push('Alt')
217
+ if (parsed.shift) parts.push('Shift')
218
+ if (parsed.meta) parts.push('Meta')
219
+ parts.push(parsed.key)
220
+
221
+ return parts.join('+') as Hotkey
222
+ }
package/src/parse.ts ADDED
@@ -0,0 +1,368 @@
1
+ import {
2
+ MODIFIER_ALIASES,
3
+ MODIFIER_ORDER,
4
+ detectPlatform,
5
+ normalizeKeyName,
6
+ resolveModifier,
7
+ } from './constants'
8
+ import type {
9
+ CanonicalModifier,
10
+ Hotkey,
11
+ Key,
12
+ ParsedHotkey,
13
+ RawHotkey,
14
+ } from './hotkey'
15
+
16
+ /**
17
+ * Parses a hotkey string into its component parts.
18
+ *
19
+ * @param hotkey - The hotkey string to parse (e.g., 'Mod+Shift+S')
20
+ * @param platform - The target platform for resolving 'Mod' (defaults to auto-detection)
21
+ * @returns A ParsedHotkey object with the key and modifier flags
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * parseHotkey('Mod+S') // On Mac: { key: 'S', ctrl: false, shift: false, alt: false, meta: true, modifiers: ['Meta'] }
26
+ * parseHotkey('Mod+S') // On Windows: { key: 'S', ctrl: true, shift: false, alt: false, meta: false, modifiers: ['Control'] }
27
+ * parseHotkey('Control+Shift+A') // { key: 'A', ctrl: true, shift: true, alt: false, meta: false, modifiers: ['Control', 'Shift'] }
28
+ * ```
29
+ */
30
+ export function parseHotkey(
31
+ hotkey: Hotkey | (string & {}),
32
+ platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
33
+ ): ParsedHotkey {
34
+ const parts = hotkey.split('+')
35
+ const modifiers: Set<CanonicalModifier> = new Set()
36
+ let key = ''
37
+
38
+ for (let i = 0; i < parts.length; i++) {
39
+ const part = parts[i]!.trim()
40
+
41
+ if (i === parts.length - 1) {
42
+ // Last part is always the key
43
+ key = normalizeKeyName(part)
44
+ } else {
45
+ // All other parts are modifiers
46
+ const alias =
47
+ MODIFIER_ALIASES[part] ?? MODIFIER_ALIASES[part.toLowerCase()]
48
+
49
+ if (alias) {
50
+ const resolved = resolveModifier(alias, platform)
51
+ modifiers.add(resolved)
52
+ } else {
53
+ // Unknown modifier, treat as part of the key if it's the only part
54
+ // or ignore if there are more parts
55
+ if (parts.length === 1) {
56
+ key = normalizeKeyName(part)
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ // If no key was found (empty string), use the last part as-is
63
+ if (!key && parts.length > 0) {
64
+ key = normalizeKeyName(parts[parts.length - 1]!.trim())
65
+ }
66
+
67
+ return {
68
+ key,
69
+ ctrl: modifiers.has('Control'),
70
+ shift: modifiers.has('Shift'),
71
+ alt: modifiers.has('Alt'),
72
+ meta: modifiers.has('Meta'),
73
+ modifiers: MODIFIER_ORDER.filter((m) => modifiers.has(m)),
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Converts a RawHotkey object to a ParsedHotkey.
79
+ * Optional modifier booleans default to false; modifiers array is derived from them.
80
+ * When `mod` is true, it is resolved to Control or Meta based on platform.
81
+ *
82
+ * @param raw - The raw hotkey object
83
+ * @param platform - The target platform for resolving 'Mod' (defaults to auto-detection)
84
+ * @returns A ParsedHotkey suitable for matching and formatting
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * rawHotkeyToParsedHotkey({ key: 'Escape' })
89
+ * // { key: 'Escape', ctrl: false, shift: false, alt: false, meta: false, modifiers: [] }
90
+ *
91
+ * rawHotkeyToParsedHotkey({ key: 'S', mod: true }, 'mac')
92
+ * // { key: 'S', ctrl: false, shift: false, alt: false, meta: true, modifiers: ['Meta'] }
93
+ *
94
+ * rawHotkeyToParsedHotkey({ key: 'S', mod: true, shift: true }, 'windows')
95
+ * // { key: 'S', ctrl: true, shift: true, alt: false, meta: false, modifiers: ['Control', 'Shift'] }
96
+ * ```
97
+ */
98
+ export function rawHotkeyToParsedHotkey(
99
+ raw: RawHotkey,
100
+ platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
101
+ ): ParsedHotkey {
102
+ let ctrl = raw.ctrl ?? false
103
+ const shift = raw.shift ?? false
104
+ const alt = raw.alt ?? false
105
+ let meta = raw.meta ?? false
106
+
107
+ if (raw.mod) {
108
+ const resolved = resolveModifier('Mod', platform)
109
+ if (resolved === 'Control') {
110
+ ctrl = true
111
+ } else {
112
+ meta = true
113
+ }
114
+ }
115
+
116
+ const modifiers: Array<CanonicalModifier> = MODIFIER_ORDER.filter((m) => {
117
+ switch (m) {
118
+ case 'Control':
119
+ return ctrl
120
+ case 'Shift':
121
+ return shift
122
+ case 'Alt':
123
+ return alt
124
+ case 'Meta':
125
+ return meta
126
+ default:
127
+ return false
128
+ }
129
+ })
130
+ return {
131
+ key: raw.key,
132
+ ctrl,
133
+ shift,
134
+ alt,
135
+ meta,
136
+ modifiers,
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Normalizes a hotkey string to its canonical form.
142
+ *
143
+ * The canonical form uses:
144
+ * - Full modifier names (Control, Alt, Shift, Meta)
145
+ * - Modifiers in order: Control+Alt+Shift+Meta
146
+ * - Uppercase letters for single-character keys
147
+ * - Proper casing for special keys (Escape, not escape)
148
+ *
149
+ * @param hotkey - The hotkey string to normalize
150
+ * @param platform - The target platform for resolving 'Mod' (defaults to auto-detection)
151
+ * @returns The normalized hotkey string
152
+ *
153
+ * @example
154
+ * ```ts
155
+ * normalizeHotkey('mod+shift+s') // On Mac: 'Shift+Meta+S'
156
+ * normalizeHotkey('ctrl+a') // 'Control+A'
157
+ * normalizeHotkey('esc') // 'Escape'
158
+ * ```
159
+ */
160
+ export function normalizeHotkey(
161
+ hotkey: Key | (string & {}),
162
+ platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
163
+ ): string {
164
+ const parsed = parseHotkey(hotkey, platform)
165
+ const parts: Array<string> = []
166
+
167
+ // Add modifiers in canonical order
168
+ for (const modifier of MODIFIER_ORDER) {
169
+ if (parsed.modifiers.includes(modifier)) {
170
+ parts.push(modifier)
171
+ }
172
+ }
173
+
174
+ // Add the key
175
+ parts.push(parsed.key)
176
+
177
+ return parts.join('+')
178
+ }
179
+
180
+ /**
181
+ * Checks if a string represents a modifier key.
182
+ *
183
+ * @param key - The string to check
184
+ * @returns True if the string is a recognized modifier
185
+ */
186
+ export function isModifier(key: string): boolean {
187
+ return key in MODIFIER_ALIASES || key.toLowerCase() in MODIFIER_ALIASES
188
+ }
189
+
190
+ /**
191
+ * Parses a KeyboardEvent into a ParsedHotkey object.
192
+ *
193
+ * This function extracts the key and modifier state from a keyboard event
194
+ * and converts it into the same format used by `parseHotkey()`.
195
+ *
196
+ * @param event - The KeyboardEvent to parse
197
+ * @param platform - The target platform for resolving modifiers (defaults to auto-detection)
198
+ * @returns A ParsedHotkey object representing the keyboard event
199
+ *
200
+ * @example
201
+ * ```ts
202
+ * document.addEventListener('keydown', (event) => {
203
+ * const parsed = parseKeyboardEvent(event)
204
+ * console.log(parsed) // { key: 'S', ctrl: true, shift: false, ... }
205
+ * })
206
+ * ```
207
+ */
208
+ export function parseKeyboardEvent(event: KeyboardEvent): ParsedHotkey {
209
+ const normalizedKey = normalizeKeyName(event.key)
210
+
211
+ // Build modifiers array in canonical order
212
+ const modifiers: Array<CanonicalModifier> = []
213
+ if (event.ctrlKey) modifiers.push('Control')
214
+ if (event.altKey) modifiers.push('Alt')
215
+ if (event.shiftKey) modifiers.push('Shift')
216
+ if (event.metaKey) modifiers.push('Meta')
217
+
218
+ return {
219
+ key: normalizedKey,
220
+ ctrl: event.ctrlKey,
221
+ shift: event.shiftKey,
222
+ alt: event.altKey,
223
+ meta: event.metaKey,
224
+ modifiers,
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Converts a KeyboardEvent directly to a hotkey string.
230
+ *
231
+ * This is a convenience function that combines `parseKeyboardEvent()` and formatting.
232
+ * The resulting hotkey string uses canonical modifier names (Control, Alt, Shift, Meta)
233
+ * and is suitable for use with `useHotkey()` and other hotkey functions.
234
+ *
235
+ * @param event - The KeyboardEvent to convert
236
+ * @param platform - The target platform (defaults to auto-detection)
237
+ * @returns A hotkey string in canonical form (e.g., 'Control+Shift+S')
238
+ *
239
+ * @example
240
+ * ```ts
241
+ * document.addEventListener('keydown', (event) => {
242
+ * const hotkey = keyboardEventToHotkey(event)
243
+ * console.log(hotkey) // 'Control+Shift+S'
244
+ * useHotkey(hotkey, () => console.log('Shortcut triggered'))
245
+ * })
246
+ * ```
247
+ */
248
+ export function keyboardEventToHotkey(event: KeyboardEvent): Hotkey {
249
+ const parsed = parseKeyboardEvent(event)
250
+
251
+ // Build hotkey string in canonical order (same as formatHotkey)
252
+ const parts: Array<string> = []
253
+ for (const modifier of MODIFIER_ORDER) {
254
+ if (parsed.modifiers.includes(modifier)) {
255
+ parts.push(modifier)
256
+ }
257
+ }
258
+ parts.push(parsed.key)
259
+
260
+ return parts.join('+') as Hotkey
261
+ }
262
+
263
+ /**
264
+ * Checks if a KeyboardEvent represents a modifier-only key press.
265
+ *
266
+ * Modifier-only keys are keys like 'Control', 'Shift', 'Alt', 'Meta', etc.
267
+ * that don't have an associated character or action key. This is useful
268
+ * for filtering out modifier key presses when recording shortcuts.
269
+ *
270
+ * @param event - The KeyboardEvent to check
271
+ * @returns True if the event represents a modifier-only key
272
+ *
273
+ * @example
274
+ * ```ts
275
+ * document.addEventListener('keydown', (event) => {
276
+ * if (isModifierKey(event)) {
277
+ * console.log('Modifier key pressed, waiting for action key...')
278
+ * return
279
+ * }
280
+ * // Process non-modifier key
281
+ * })
282
+ * ```
283
+ */
284
+ export function isModifierKey(event: KeyboardEvent): boolean {
285
+ const key = event.key
286
+ return (
287
+ key === 'Control' ||
288
+ key === 'Shift' ||
289
+ key === 'Alt' ||
290
+ key === 'Meta' ||
291
+ key === 'Command' ||
292
+ key === 'OS' ||
293
+ key === 'Win'
294
+ )
295
+ }
296
+
297
+ /**
298
+ * Checks if a hotkey or ParsedHotkey contains at least one non-modifier key.
299
+ *
300
+ * This is useful for validating that a recorded hotkey is complete and not
301
+ * just a combination of modifiers without an action key.
302
+ *
303
+ * @param hotkey - The hotkey string or ParsedHotkey to check
304
+ * @param platform - The target platform for parsing (defaults to auto-detection)
305
+ * @returns True if the hotkey contains at least one non-modifier key
306
+ *
307
+ * @example
308
+ * ```ts
309
+ * hasNonModifierKey('Control+Shift+S') // true
310
+ * hasNonModifierKey('Control+Shift') // false (no action key)
311
+ * hasNonModifierKey(parseHotkey('Mod+A')) // true
312
+ * ```
313
+ */
314
+ export function hasNonModifierKey(
315
+ hotkey: Hotkey | ParsedHotkey | (string & {}),
316
+ platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
317
+ ): boolean {
318
+ const parsed =
319
+ typeof hotkey === 'string' ? parseHotkey(hotkey, platform) : hotkey
320
+
321
+ // Check if the key part is actually a modifier
322
+ const keyIsModifier = isModifier(parsed.key)
323
+
324
+ // A valid hotkey must have a non-modifier key
325
+ return !keyIsModifier && parsed.key.length > 0
326
+ }
327
+
328
+ /**
329
+ * Converts a hotkey string to use 'Mod' format for portability.
330
+ *
331
+ * On macOS, converts 'Meta' to 'Mod'. On Windows/Linux, converts 'Control' to 'Mod'.
332
+ * This enables cross-platform hotkey definitions that work consistently.
333
+ *
334
+ * @param hotkey - The hotkey string to convert
335
+ * @param platform - The target platform (defaults to auto-detection)
336
+ * @returns The hotkey string with 'Mod' format applied
337
+ *
338
+ * @example
339
+ * ```ts
340
+ * convertToModFormat('Meta+S', 'mac') // 'Mod+S'
341
+ * convertToModFormat('Control+S', 'windows') // 'Mod+S'
342
+ * convertToModFormat('Control+Meta+S', 'mac') // 'Control+Meta+S' (both present, no conversion)
343
+ * ```
344
+ */
345
+ export function convertToModFormat(
346
+ hotkey: Hotkey | (string & {}),
347
+ platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
348
+ ): Hotkey {
349
+ const parsed = parseHotkey(hotkey, platform)
350
+
351
+ // Only convert if we have exactly one primary modifier
352
+ if (platform === 'mac' && parsed.meta && !parsed.ctrl) {
353
+ // Convert Meta to Mod on Mac
354
+ const parts = hotkey.split('+')
355
+ return parts
356
+ .map((part) => (part === 'Meta' ? 'Mod' : part))
357
+ .join('+') as Hotkey
358
+ } else if (platform !== 'mac' && parsed.ctrl && !parsed.meta) {
359
+ // Convert Control to Mod on Windows/Linux
360
+ const parts = hotkey.split('+')
361
+ return parts
362
+ .map((part) => (part === 'Control' ? 'Mod' : part))
363
+ .join('+') as Hotkey
364
+ }
365
+
366
+ // No conversion needed
367
+ return hotkey as Hotkey
368
+ }