@tanstack/preact-hotkeys 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.
Files changed (56) hide show
  1. package/README.md +122 -0
  2. package/dist/HotkeysProvider.cjs +26 -0
  3. package/dist/HotkeysProvider.cjs.map +1 -0
  4. package/dist/HotkeysProvider.d.cts +28 -0
  5. package/dist/HotkeysProvider.d.ts +28 -0
  6. package/dist/HotkeysProvider.js +24 -0
  7. package/dist/HotkeysProvider.js.map +1 -0
  8. package/dist/index.cjs +25 -0
  9. package/dist/index.d.cts +9 -0
  10. package/dist/index.d.ts +9 -0
  11. package/dist/index.js +11 -0
  12. package/dist/useHeldKeyCodes.cjs +37 -0
  13. package/dist/useHeldKeyCodes.cjs.map +1 -0
  14. package/dist/useHeldKeyCodes.d.cts +31 -0
  15. package/dist/useHeldKeyCodes.d.ts +31 -0
  16. package/dist/useHeldKeyCodes.js +37 -0
  17. package/dist/useHeldKeyCodes.js.map +1 -0
  18. package/dist/useHeldKeys.cjs +33 -0
  19. package/dist/useHeldKeys.cjs.map +1 -0
  20. package/dist/useHeldKeys.d.cts +27 -0
  21. package/dist/useHeldKeys.d.ts +27 -0
  22. package/dist/useHeldKeys.js +33 -0
  23. package/dist/useHeldKeys.js.map +1 -0
  24. package/dist/useHotkey.cjs +118 -0
  25. package/dist/useHotkey.cjs.map +1 -0
  26. package/dist/useHotkey.d.cts +74 -0
  27. package/dist/useHotkey.d.ts +74 -0
  28. package/dist/useHotkey.js +118 -0
  29. package/dist/useHotkey.js.map +1 -0
  30. package/dist/useHotkeyRecorder.cjs +71 -0
  31. package/dist/useHotkeyRecorder.cjs.map +1 -0
  32. package/dist/useHotkeyRecorder.d.cts +57 -0
  33. package/dist/useHotkeyRecorder.d.ts +57 -0
  34. package/dist/useHotkeyRecorder.js +71 -0
  35. package/dist/useHotkeyRecorder.js.map +1 -0
  36. package/dist/useHotkeySequence.cjs +96 -0
  37. package/dist/useHotkeySequence.cjs.map +1 -0
  38. package/dist/useHotkeySequence.d.cts +48 -0
  39. package/dist/useHotkeySequence.d.ts +48 -0
  40. package/dist/useHotkeySequence.js +96 -0
  41. package/dist/useHotkeySequence.js.map +1 -0
  42. package/dist/useKeyHold.cjs +53 -0
  43. package/dist/useKeyHold.cjs.map +1 -0
  44. package/dist/useKeyHold.d.cts +47 -0
  45. package/dist/useKeyHold.d.ts +47 -0
  46. package/dist/useKeyHold.js +53 -0
  47. package/dist/useKeyHold.js.map +1 -0
  48. package/package.json +63 -0
  49. package/src/HotkeysProvider.tsx +52 -0
  50. package/src/index.ts +13 -0
  51. package/src/useHeldKeyCodes.ts +33 -0
  52. package/src/useHeldKeys.ts +29 -0
  53. package/src/useHotkey.ts +192 -0
  54. package/src/useHotkeyRecorder.ts +101 -0
  55. package/src/useHotkeySequence.ts +169 -0
  56. package/src/useKeyHold.ts +52 -0
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@tanstack/preact-hotkeys",
3
+ "version": "0.3.0",
4
+ "description": "Preact adapter for TanStack Hotkeys",
5
+ "author": "Tanner Linsley",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/TanStack/hotkeys.git",
10
+ "directory": "packages/preact-hotkeys"
11
+ },
12
+ "homepage": "https://tanstack.com/hotkeys",
13
+ "funding": {
14
+ "type": "github",
15
+ "url": "https://github.com/sponsors/tannerlinsley"
16
+ },
17
+ "keywords": [
18
+ "preact",
19
+ "tanstack",
20
+ "keys"
21
+ ],
22
+ "scripts": {
23
+ "clean": "premove ./build ./dist",
24
+ "lint": "eslint ./src",
25
+ "lint:fix": "eslint ./src --fix",
26
+ "test:eslint": "eslint ./src",
27
+ "test:lib": "vitest --passWithNoTests",
28
+ "test:lib:dev": "pnpm test:lib --watch",
29
+ "test:types": "tsc",
30
+ "build": "tsdown"
31
+ },
32
+ "type": "module",
33
+ "main": "./dist/index.cjs",
34
+ "module": "./dist/index.js",
35
+ "types": "./dist/index.d.cts",
36
+ "exports": {
37
+ ".": {
38
+ "import": "./dist/index.js",
39
+ "require": "./dist/index.cjs"
40
+ },
41
+ "./package.json": "./package.json"
42
+ },
43
+ "sideEffects": false,
44
+ "engines": {
45
+ "node": ">=18"
46
+ },
47
+ "files": [
48
+ "dist",
49
+ "src"
50
+ ],
51
+ "dependencies": {
52
+ "@tanstack/hotkeys": "workspace:*",
53
+ "@tanstack/preact-store": "^0.11.0"
54
+ },
55
+ "devDependencies": {
56
+ "@preact/preset-vite": "^2.10.2",
57
+ "@testing-library/preact": "^3.2.4",
58
+ "preact": "^10.27.2"
59
+ },
60
+ "peerDependencies": {
61
+ "preact": ">=10.0.0"
62
+ }
63
+ }
@@ -0,0 +1,52 @@
1
+ import { createContext } from 'preact'
2
+ import { useContext, useMemo } from 'preact/hooks'
3
+ import type { ComponentChildren } from 'preact'
4
+ import type { HotkeyRecorderOptions } from '@tanstack/hotkeys'
5
+ import type { UseHotkeyOptions } from './useHotkey'
6
+ import type { UseHotkeySequenceOptions } from './useHotkeySequence'
7
+
8
+ export interface HotkeysProviderOptions {
9
+ hotkey?: Partial<UseHotkeyOptions>
10
+ hotkeyRecorder?: Partial<HotkeyRecorderOptions>
11
+ hotkeySequence?: Partial<UseHotkeySequenceOptions>
12
+ }
13
+
14
+ interface HotkeysContextValue {
15
+ defaultOptions: HotkeysProviderOptions
16
+ }
17
+
18
+ const HotkeysContext = createContext<HotkeysContextValue | null>(null)
19
+
20
+ export interface HotkeysProviderProps {
21
+ children: ComponentChildren
22
+ defaultOptions?: HotkeysProviderOptions
23
+ }
24
+
25
+ const DEFAULT_OPTIONS: HotkeysProviderOptions = {}
26
+
27
+ export function HotkeysProvider({
28
+ children,
29
+ defaultOptions = DEFAULT_OPTIONS,
30
+ }: HotkeysProviderProps) {
31
+ const contextValue: HotkeysContextValue = useMemo(
32
+ () => ({
33
+ defaultOptions,
34
+ }),
35
+ [defaultOptions],
36
+ )
37
+
38
+ return (
39
+ <HotkeysContext.Provider value={contextValue}>
40
+ {children}
41
+ </HotkeysContext.Provider>
42
+ )
43
+ }
44
+
45
+ export function useHotkeysContext() {
46
+ return useContext(HotkeysContext)
47
+ }
48
+
49
+ export function useDefaultHotkeysOptions() {
50
+ const context = useContext(HotkeysContext)
51
+ return context?.defaultOptions ?? {}
52
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ // Re-export everything from the core package
2
+ export * from '@tanstack/hotkeys'
3
+
4
+ // provider
5
+ export * from './HotkeysProvider'
6
+
7
+ // Preact-specific exports
8
+ export * from './useHotkey'
9
+ export * from './useHeldKeys'
10
+ export * from './useHeldKeyCodes'
11
+ export * from './useKeyHold'
12
+ export * from './useHotkeySequence'
13
+ export * from './useHotkeyRecorder'
@@ -0,0 +1,33 @@
1
+ import { useStore } from '@tanstack/preact-store'
2
+ import { getKeyStateTracker } from '@tanstack/hotkeys'
3
+
4
+ /**
5
+ * Preact hook that returns a map of currently held key names to their physical `event.code` values.
6
+ *
7
+ * This is useful for debugging which physical key was pressed (e.g. distinguishing
8
+ * left vs right Shift via "ShiftLeft" / "ShiftRight").
9
+ *
10
+ * @returns Record mapping normalized key names to their `event.code` values
11
+ *
12
+ * @example
13
+ * ```tsx
14
+ * function KeyDebugDisplay() {
15
+ * const heldKeys = useHeldKeys()
16
+ * const heldCodes = useHeldKeyCodes()
17
+ *
18
+ * return (
19
+ * <div>
20
+ * {heldKeys.map((key) => (
21
+ * <kbd key={key}>
22
+ * {key} <small>{heldCodes[key]}</small>
23
+ * </kbd>
24
+ * ))}
25
+ * </div>
26
+ * )
27
+ * }
28
+ * ```
29
+ */
30
+ export function useHeldKeyCodes(): Record<string, string> {
31
+ const tracker = getKeyStateTracker()
32
+ return useStore(tracker.store, (state) => state.heldCodes)
33
+ }
@@ -0,0 +1,29 @@
1
+ import { useStore } from '@tanstack/preact-store'
2
+ import { getKeyStateTracker } from '@tanstack/hotkeys'
3
+
4
+ /**
5
+ * Preact hook that returns an array of currently held keyboard keys.
6
+ *
7
+ * This hook uses `useStore` from `@tanstack/preact-store` to subscribe
8
+ * to the global KeyStateTracker and updates whenever keys are pressed
9
+ * or released.
10
+ *
11
+ * @returns Array of currently held key names
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * function KeyDisplay() {
16
+ * const heldKeys = useHeldKeys()
17
+ *
18
+ * return (
19
+ * <div>
20
+ * Currently pressed: {heldKeys.join(' + ') || 'None'}
21
+ * </div>
22
+ * )
23
+ * }
24
+ * ```
25
+ */
26
+ export function useHeldKeys(): Array<string> {
27
+ const tracker = getKeyStateTracker()
28
+ return useStore(tracker.store, (state) => state.heldKeys)
29
+ }
@@ -0,0 +1,192 @@
1
+ import { useEffect, useRef } from 'preact/hooks'
2
+ import {
3
+ detectPlatform,
4
+ formatHotkey,
5
+ getHotkeyManager,
6
+ rawHotkeyToParsedHotkey,
7
+ } from '@tanstack/hotkeys'
8
+ import { useDefaultHotkeysOptions } from './HotkeysProvider'
9
+ import type { RefObject } from 'preact'
10
+ import type {
11
+ Hotkey,
12
+ HotkeyCallback,
13
+ HotkeyOptions,
14
+ HotkeyRegistrationHandle,
15
+ RegisterableHotkey,
16
+ } from '@tanstack/hotkeys'
17
+
18
+ export interface UseHotkeyOptions extends Omit<HotkeyOptions, 'target'> {
19
+ /**
20
+ * The DOM element to attach the event listener to.
21
+ * Can be a Preact ref, direct DOM element, or null.
22
+ * Defaults to document.
23
+ */
24
+ target?:
25
+ | RefObject<HTMLElement | null>
26
+ | HTMLElement
27
+ | Document
28
+ | Window
29
+ | null
30
+ }
31
+
32
+ /**
33
+ * Preact hook for registering a keyboard hotkey.
34
+ *
35
+ * Uses the singleton HotkeyManager for efficient event handling.
36
+ * The callback receives both the keyboard event and a context object
37
+ * containing the hotkey string and parsed hotkey.
38
+ *
39
+ * This hook syncs the callback and options on every render to avoid
40
+ * stale closures. This means
41
+ * callbacks that reference Preact state will always have access to
42
+ * the latest values.
43
+ *
44
+ * @param hotkey - The hotkey string (e.g., 'Mod+S', 'Escape') or RawHotkey object (supports `mod` for cross-platform)
45
+ * @param callback - The function to call when the hotkey is pressed
46
+ * @param options - Options for the hotkey behavior
47
+ *
48
+ * @example
49
+ * ```tsx
50
+ * function SaveButton() {
51
+ * const [count, setCount] = useState(0)
52
+ *
53
+ * // Callback always has access to latest count value
54
+ * useHotkey('Mod+S', (event, { hotkey }) => {
55
+ * console.log(`Save triggered, count is ${count}`)
56
+ * handleSave()
57
+ * })
58
+ *
59
+ * return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
60
+ * }
61
+ * ```
62
+ *
63
+ * @example
64
+ * ```tsx
65
+ * function Modal({ isOpen, onClose }) {
66
+ * // enabled option is synced on every render
67
+ * useHotkey('Escape', () => {
68
+ * onClose()
69
+ * }, { enabled: isOpen })
70
+ *
71
+ * if (!isOpen) return null
72
+ * return <div className="modal">...</div>
73
+ * }
74
+ * ```
75
+ *
76
+ * @example
77
+ * ```tsx
78
+ * function Editor() {
79
+ * const editorRef = useRef<HTMLDivElement>(null)
80
+ *
81
+ * // Scoped to a specific element
82
+ * useHotkey('Mod+S', () => {
83
+ * save()
84
+ * }, { target: editorRef })
85
+ *
86
+ * return <div ref={editorRef}>...</div>
87
+ * }
88
+ * ```
89
+ */
90
+ export function useHotkey(
91
+ hotkey: RegisterableHotkey,
92
+ callback: HotkeyCallback,
93
+ options: UseHotkeyOptions = {},
94
+ ): void {
95
+ const mergedOptions = {
96
+ ...useDefaultHotkeysOptions().hotkey,
97
+ ...options,
98
+ } as UseHotkeyOptions
99
+
100
+ const manager = getHotkeyManager()
101
+
102
+ // Stable ref for registration handle
103
+ const registrationRef = useRef<HotkeyRegistrationHandle | null>(null)
104
+
105
+ // Refs to capture current values for use in effect without adding dependencies
106
+ const callbackRef = useRef(callback)
107
+ const optionsRef = useRef(mergedOptions)
108
+ const managerRef = useRef(manager)
109
+
110
+ // Update refs on every render
111
+ callbackRef.current = callback
112
+ optionsRef.current = mergedOptions
113
+ managerRef.current = manager
114
+
115
+ // Track previous target and hotkey to detect changes requiring re-registration
116
+ const prevTargetRef = useRef<HTMLElement | Document | Window | null>(null)
117
+ const prevHotkeyRef = useRef<string | null>(null)
118
+
119
+ // Normalize to hotkey string
120
+ const platform = mergedOptions.platform ?? detectPlatform()
121
+ const hotkeyString: Hotkey =
122
+ typeof hotkey === 'string'
123
+ ? hotkey
124
+ : (formatHotkey(rawHotkeyToParsedHotkey(hotkey, platform)) as Hotkey)
125
+
126
+ // Extract options without target (target is handled separately)
127
+ const { target: _target, ...optionsWithoutTarget } = mergedOptions
128
+
129
+ useEffect(() => {
130
+ // Resolve target inside the effect so refs are already attached after mount
131
+ const resolvedTarget = isRef(optionsRef.current.target)
132
+ ? optionsRef.current.target.current
133
+ : (optionsRef.current.target ??
134
+ (typeof document !== 'undefined' ? document : null))
135
+
136
+ // Skip if no valid target (SSR or ref still null)
137
+ if (!resolvedTarget) {
138
+ return
139
+ }
140
+
141
+ // Check if we need to re-register (target or hotkey changed)
142
+ const targetChanged =
143
+ prevTargetRef.current !== null && prevTargetRef.current !== resolvedTarget
144
+ const hotkeyChanged =
145
+ prevHotkeyRef.current !== null && prevHotkeyRef.current !== hotkeyString
146
+
147
+ // If we have an active registration and target/hotkey changed, unregister first
148
+ if (registrationRef.current?.isActive && (targetChanged || hotkeyChanged)) {
149
+ registrationRef.current.unregister()
150
+ registrationRef.current = null
151
+ }
152
+
153
+ // Register if needed (no active registration)
154
+ // Use refs to access current values without adding them to dependencies
155
+ if (!registrationRef.current || !registrationRef.current.isActive) {
156
+ registrationRef.current = managerRef.current.register(
157
+ hotkeyString,
158
+ callbackRef.current,
159
+ {
160
+ ...optionsRef.current,
161
+ target: resolvedTarget,
162
+ },
163
+ )
164
+ }
165
+
166
+ // Update tracking refs
167
+ prevTargetRef.current = resolvedTarget
168
+ prevHotkeyRef.current = hotkeyString
169
+
170
+ // Cleanup on unmount
171
+ return () => {
172
+ if (registrationRef.current?.isActive) {
173
+ registrationRef.current.unregister()
174
+ registrationRef.current = null
175
+ }
176
+ }
177
+ }, [hotkeyString, options.enabled])
178
+
179
+ // Sync callback and options on EVERY render (outside useEffect)
180
+ // This avoids stale closures - the callback always has access to latest state
181
+ if (registrationRef.current?.isActive) {
182
+ registrationRef.current.callback = callback
183
+ registrationRef.current.setOptions(optionsWithoutTarget)
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Type guard to check if a value is a Preact ref-like object.
189
+ */
190
+ function isRef(value: unknown): value is RefObject<HTMLElement | null> {
191
+ return value !== null && typeof value === 'object' && 'current' in value
192
+ }
@@ -0,0 +1,101 @@
1
+ import { useEffect, useRef } from 'preact/hooks'
2
+ import { useStore } from '@tanstack/preact-store'
3
+ import { HotkeyRecorder } from '@tanstack/hotkeys'
4
+ import { useDefaultHotkeysOptions } from './HotkeysProvider'
5
+ import type { Hotkey, HotkeyRecorderOptions } from '@tanstack/hotkeys'
6
+
7
+ export interface PreactHotkeyRecorder {
8
+ /** Whether recording is currently active */
9
+ isRecording: boolean
10
+ /** The currently recorded hotkey (for live preview) */
11
+ recordedHotkey: Hotkey | null
12
+ /** Start recording a new hotkey */
13
+ startRecording: () => void
14
+ /** Stop recording (same as cancel) */
15
+ stopRecording: () => void
16
+ /** Cancel recording without saving */
17
+ cancelRecording: () => void
18
+ }
19
+
20
+ /**
21
+ * Preact hook for recording keyboard shortcuts.
22
+ *
23
+ * This hook provides a thin wrapper around the framework-agnostic `HotkeyRecorder`
24
+ * class, managing all the complexity of capturing keyboard events, converting them
25
+ * to hotkey strings, and handling edge cases like Escape to cancel or Backspace/Delete
26
+ * to clear.
27
+ *
28
+ * @param options - Configuration options for the recorder
29
+ * @returns An object with recording state and control functions
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * function ShortcutSettings() {
34
+ * const [shortcut, setShortcut] = useState<Hotkey>('Mod+S')
35
+ *
36
+ * const recorder = useHotkeyRecorder({
37
+ * onRecord: (hotkey) => {
38
+ * setShortcut(hotkey)
39
+ * },
40
+ * onCancel: () => {
41
+ * console.log('Recording cancelled')
42
+ * },
43
+ * })
44
+ *
45
+ * return (
46
+ * <div>
47
+ * <button onClick={recorder.startRecording}>
48
+ * {recorder.isRecording ? 'Recording...' : 'Edit Shortcut'}
49
+ * </button>
50
+ * {recorder.recordedHotkey && (
51
+ * <div>Recording: {recorder.recordedHotkey}</div>
52
+ * )}
53
+ * </div>
54
+ * )
55
+ * }
56
+ * ```
57
+ */
58
+ export function useHotkeyRecorder(
59
+ options: HotkeyRecorderOptions,
60
+ ): PreactHotkeyRecorder {
61
+ const mergedOptions = {
62
+ ...useDefaultHotkeysOptions().hotkeyRecorder,
63
+ ...options,
64
+ } as HotkeyRecorderOptions
65
+
66
+ const recorderRef = useRef<HotkeyRecorder | null>(null)
67
+
68
+ // Create recorder instance once
69
+ if (!recorderRef.current) {
70
+ recorderRef.current = new HotkeyRecorder(mergedOptions)
71
+ }
72
+
73
+ // Sync options on every render (same pattern as useHotkey)
74
+ // This ensures callbacks always have access to latest values
75
+ recorderRef.current.setOptions(mergedOptions)
76
+
77
+ // Subscribe to recorder state using useStore (same pattern as useHeldKeys)
78
+ const isRecording = useStore(
79
+ recorderRef.current.store,
80
+ (state) => state.isRecording,
81
+ )
82
+ const recordedHotkey = useStore(
83
+ recorderRef.current.store,
84
+ (state) => state.recordedHotkey,
85
+ )
86
+
87
+ // Cleanup on unmount
88
+ useEffect(() => {
89
+ return () => {
90
+ recorderRef.current?.destroy()
91
+ }
92
+ }, [])
93
+
94
+ return {
95
+ isRecording,
96
+ recordedHotkey,
97
+ startRecording: () => recorderRef.current?.start(),
98
+ stopRecording: () => recorderRef.current?.stop(),
99
+ cancelRecording: () => recorderRef.current?.cancel(),
100
+ }
101
+ }
@@ -0,0 +1,169 @@
1
+ import { useEffect, useRef } from 'preact/hooks'
2
+ import { formatHotkeySequence, getSequenceManager } from '@tanstack/hotkeys'
3
+ import { useDefaultHotkeysOptions } from './HotkeysProvider'
4
+ import type { RefObject } from 'preact'
5
+ import type {
6
+ HotkeyCallback,
7
+ HotkeyCallbackContext,
8
+ HotkeySequence,
9
+ SequenceOptions,
10
+ SequenceRegistrationHandle,
11
+ } from '@tanstack/hotkeys'
12
+
13
+ export interface UseHotkeySequenceOptions extends Omit<
14
+ SequenceOptions,
15
+ 'target'
16
+ > {
17
+ /**
18
+ * The DOM element to attach the event listener to.
19
+ * Can be a Preact ref, direct DOM element, or null.
20
+ * Defaults to document.
21
+ */
22
+ target?:
23
+ | RefObject<HTMLElement | null>
24
+ | HTMLElement
25
+ | Document
26
+ | Window
27
+ | null
28
+ }
29
+
30
+ /**
31
+ * Preact hook for registering a keyboard shortcut sequence (Vim-style).
32
+ *
33
+ * This hook allows you to register multi-key sequences like 'g g' or 'd d'
34
+ * that trigger when the full sequence is pressed within a timeout.
35
+ *
36
+ * @param sequence - Array of hotkey strings that form the sequence
37
+ * @param callback - Function to call when the sequence is completed
38
+ * @param options - Options for the sequence behavior
39
+ *
40
+ * @example
41
+ * ```tsx
42
+ * function VimEditor() {
43
+ * // 'g g' to go to top
44
+ * useHotkeySequence(['G', 'G'], () => {
45
+ * scrollToTop()
46
+ * })
47
+ *
48
+ * // 'd d' to delete line
49
+ * useHotkeySequence(['D', 'D'], () => {
50
+ * deleteLine()
51
+ * })
52
+ *
53
+ * // 'd i w' to delete inner word
54
+ * useHotkeySequence(['D', 'I', 'W'], () => {
55
+ * deleteInnerWord()
56
+ * }, { timeout: 500 })
57
+ *
58
+ * return <div>...</div>
59
+ * }
60
+ * ```
61
+ */
62
+ export function useHotkeySequence(
63
+ sequence: HotkeySequence,
64
+ callback: HotkeyCallback,
65
+ options: UseHotkeySequenceOptions = {},
66
+ ): void {
67
+ const mergedOptions = {
68
+ ...useDefaultHotkeysOptions().hotkeySequence,
69
+ ...options,
70
+ } as UseHotkeySequenceOptions
71
+
72
+ const manager = getSequenceManager()
73
+
74
+ // Stable ref for registration handle
75
+ const registrationRef = useRef<SequenceRegistrationHandle | null>(null)
76
+
77
+ // Refs to capture current values for use in effect without adding dependencies
78
+ const callbackRef = useRef(callback)
79
+ const optionsRef = useRef(mergedOptions)
80
+ const managerRef = useRef(manager)
81
+
82
+ // Update refs on every render
83
+ callbackRef.current = callback
84
+ optionsRef.current = mergedOptions
85
+ managerRef.current = manager
86
+
87
+ // Track previous target and sequence to detect changes requiring re-registration
88
+ const prevTargetRef = useRef<HTMLElement | Document | Window | null>(null)
89
+ const prevSequenceRef = useRef<string | null>(null)
90
+
91
+ // Normalize to hotkey sequence string (join with spaces)
92
+ const hotkeySequenceString = formatHotkeySequence(sequence)
93
+
94
+ // Extract options without target (target is handled separately)
95
+ const { target: _target, ...optionsWithoutTarget } = mergedOptions
96
+
97
+ useEffect(() => {
98
+ if (sequence.length === 0) {
99
+ return
100
+ }
101
+
102
+ // Resolve target inside the effect so refs are already attached after mount
103
+ const resolvedTarget = isRef(optionsRef.current.target)
104
+ ? optionsRef.current.target.current
105
+ : (optionsRef.current.target ??
106
+ (typeof document !== 'undefined' ? document : null))
107
+
108
+ // Skip if no valid target (SSR or ref still null)
109
+ if (!resolvedTarget) {
110
+ return
111
+ }
112
+
113
+ // Check if we need to re-register (target or sequence changed)
114
+ const targetChanged =
115
+ prevTargetRef.current !== null && prevTargetRef.current !== resolvedTarget
116
+ const sequenceChanged =
117
+ prevSequenceRef.current !== null &&
118
+ prevSequenceRef.current !== hotkeySequenceString
119
+
120
+ // If we have an active registration and target/sequence changed, unregister first
121
+ if (
122
+ registrationRef.current?.isActive &&
123
+ (targetChanged || sequenceChanged)
124
+ ) {
125
+ registrationRef.current.unregister()
126
+ registrationRef.current = null
127
+ }
128
+
129
+ // Register if needed (no active registration)
130
+ if (!registrationRef.current || !registrationRef.current.isActive) {
131
+ registrationRef.current = managerRef.current.register(
132
+ sequence,
133
+ (event, context) => callbackRef.current(event, context),
134
+ {
135
+ ...optionsRef.current,
136
+ target: resolvedTarget,
137
+ },
138
+ )
139
+ }
140
+
141
+ // Update tracking refs
142
+ prevTargetRef.current = resolvedTarget
143
+ prevSequenceRef.current = hotkeySequenceString
144
+
145
+ // Cleanup on unmount
146
+ return () => {
147
+ if (registrationRef.current?.isActive) {
148
+ registrationRef.current.unregister()
149
+ registrationRef.current = null
150
+ }
151
+ }
152
+ }, [hotkeySequenceString, mergedOptions.enabled, sequence])
153
+
154
+ // Sync callback and options on EVERY render (outside useEffect)
155
+ if (registrationRef.current?.isActive) {
156
+ registrationRef.current.callback = (
157
+ event: KeyboardEvent,
158
+ context: HotkeyCallbackContext,
159
+ ) => callbackRef.current(event, context)
160
+ registrationRef.current.setOptions(optionsWithoutTarget)
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Type guard to check if a value is a Preact ref-like object.
166
+ */
167
+ function isRef(value: unknown): value is RefObject<HTMLElement | null> {
168
+ return value !== null && typeof value === 'object' && 'current' in value
169
+ }