Skip to content

Commit c7c2d11

Browse files
committed
refactor useHotkey to make more sense
1 parent 56f7095 commit c7c2d11

File tree

8 files changed

+603
-167
lines changed

8 files changed

+603
-167
lines changed

package.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"packageManager": "pnpm@10.28.2",
99
"type": "module",
1010
"scripts": {
11-
"build": "nx affected --skip-nx-cache --targets=build --exclude=examples/**",
11+
"build": "nx affected --skip-nx-cache --targets=build --exclude=examples/** && size-limit",
1212
"build:all": "nx run-many --targets=build --exclude=examples/**",
1313
"build:core": "nx run-many --targets=build --projects=packages/keys",
1414
"changeset": "changeset",
@@ -36,6 +36,12 @@
3636
"test:types": "nx affected --targets=test:types --exclude=examples/**",
3737
"watch": "pnpm run build:all && nx watch --all -- pnpm run build:all"
3838
},
39+
"size-limit": [
40+
{
41+
"path": "packages/keys/dist/index.js",
42+
"limit": "8 KB"
43+
}
44+
],
3945
"nx": {
4046
"includedScripts": [
4147
"test:docs",
@@ -46,6 +52,7 @@
4652
"devDependencies": {
4753
"@changesets/cli": "^2.29.8",
4854
"@faker-js/faker": "^10.2.0",
55+
"@size-limit/preset-small-lib": "^12.0.0",
4956
"@svitejs/changesets-changelog-github-compact": "^1.2.0",
5057
"@tanstack/eslint-config": "0.3.4",
5158
"@tanstack/typedoc-config": "0.3.3",
@@ -62,6 +69,7 @@
6269
"prettier-plugin-svelte": "^3.4.1",
6370
"publint": "^0.3.17",
6471
"sherif": "^1.10.0",
72+
"size-limit": "^12.0.0",
6573
"tinyglobby": "^0.2.15",
6674
"tsdown": "^0.20.1",
6775
"typescript": "5.9.3",

packages/keys/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export type {
3030
SequenceOptions,
3131
// Registration types
3232
HotkeyRegistration,
33+
HotkeyRegistrationHandle,
3334
} from './types'
3435

3536
// =============================================================================

packages/keys/src/manager.ts

Lines changed: 76 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,24 @@ import type {
77
HotkeyCallbackContext,
88
HotkeyOptions,
99
HotkeyRegistration,
10+
HotkeyRegistrationHandle,
1011
} from './types'
1112

13+
/**
14+
* Default options for hotkey registration.
15+
*/
16+
const defaultHotkeyOptions: Omit<
17+
Required<HotkeyOptions>,
18+
'platform' | 'target'
19+
> = {
20+
preventDefault: false,
21+
stopPropagation: false,
22+
eventType: 'keydown',
23+
requireReset: false,
24+
enabled: true,
25+
ignoreInputs: true,
26+
}
27+
1228
let registrationIdCounter = 0
1329

1430
/**
@@ -79,18 +95,35 @@ export class HotkeyManager {
7995
}
8096

8197
/**
82-
* Registers a hotkey handler.
98+
* Registers a hotkey handler and returns a handle for updating the registration.
99+
*
100+
* The returned handle allows updating the callback and options without
101+
* re-registering, which is useful for avoiding stale closures in React.
83102
*
84103
* @param hotkey - The hotkey string to listen for
85104
* @param callback - The function to call when the hotkey is pressed
86105
* @param options - Options for the hotkey behavior
87-
* @returns A function to unregister the hotkey
106+
* @returns A handle for managing the registration
107+
*
108+
* @example
109+
* ```ts
110+
* const handle = manager.register('Mod+S', callback, { preventDefault: true })
111+
*
112+
* // Update callback without re-registering (avoids stale closures)
113+
* handle.callback = newCallback
114+
*
115+
* // Update options
116+
* handle.setOptions({ enabled: false })
117+
*
118+
* // Unregister when done
119+
* handle.unregister()
120+
* ```
88121
*/
89122
register(
90123
hotkey: Hotkey,
91124
callback: HotkeyCallback,
92125
options: HotkeyOptions = {},
93-
): () => void {
126+
): HotkeyRegistrationHandle {
94127
const id = generateId()
95128
const platform = options.platform ?? this.platform
96129
const parsedHotkey = parseHotkey(hotkey, platform)
@@ -105,12 +138,7 @@ export class HotkeyManager {
105138
parsedHotkey,
106139
callback,
107140
options: {
108-
preventDefault: false,
109-
stopPropagation: false,
110-
eventType: 'keydown',
111-
requireReset: false,
112-
enabled: true,
113-
ignoreInputs: true,
141+
...defaultHotkeyOptions,
114142
...options,
115143
platform,
116144
},
@@ -129,9 +157,37 @@ export class HotkeyManager {
129157
// Ensure listeners are attached for this target
130158
this.ensureListenersForTarget(target)
131159

132-
return () => {
133-
this.unregister(id)
160+
// Create and return the handle
161+
const manager = this
162+
const handle: HotkeyRegistrationHandle = {
163+
get id() {
164+
return id
165+
},
166+
unregister: () => {
167+
manager.unregister(id)
168+
},
169+
get callback() {
170+
const reg = manager.registrations.get(id)
171+
return reg?.callback ?? callback
172+
},
173+
set callback(newCallback: HotkeyCallback) {
174+
const reg = manager.registrations.get(id)
175+
if (reg) {
176+
reg.callback = newCallback
177+
}
178+
},
179+
setOptions: (newOptions: Partial<HotkeyOptions>) => {
180+
const reg = manager.registrations.get(id)
181+
if (reg) {
182+
reg.options = { ...reg.options, ...newOptions }
183+
}
184+
},
185+
get isActive() {
186+
return manager.registrations.has(id)
187+
},
134188
}
189+
190+
return handle
135191
}
136192

137193
/**
@@ -443,11 +499,18 @@ export class HotkeyManager {
443499

444500
/**
445501
* Checks if a specific hotkey is registered.
502+
*
503+
* @param hotkey - The hotkey string to check
504+
* @param target - Optional target element to match (if provided, both hotkey and target must match)
505+
* @returns True if a matching registration exists
446506
*/
447-
isRegistered(hotkey: Hotkey): boolean {
507+
isRegistered(hotkey: Hotkey, target?: HTMLElement | Document | Window): boolean {
448508
for (const registration of this.registrations.values()) {
449509
if (registration.hotkey === hotkey) {
450-
return true
510+
// If target is specified, both must match
511+
if (target === undefined || registration.target === target) {
512+
return true
513+
}
451514
}
452515
}
453516
return false

packages/keys/src/types.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,3 +462,52 @@ export interface HotkeyRegistration {
462462
/** The resolved target element for this registration */
463463
target: HTMLElement | Document | Window
464464
}
465+
466+
/**
467+
* A handle returned from HotkeyManager.register() that allows updating
468+
* the callback and options without re-registering the hotkey.
469+
*
470+
* This pattern is similar to TanStack Pacer's Debouncer, where the function
471+
* and options can be synced on every render to avoid stale closures.
472+
*
473+
* @example
474+
* ```ts
475+
* const handle = manager.register('Mod+S', callback, options)
476+
*
477+
* // Update callback without re-registering (avoids stale closures)
478+
* handle.callback = newCallback
479+
*
480+
* // Update options without re-registering
481+
* handle.setOptions({ enabled: false })
482+
*
483+
* // Check if still active
484+
* if (handle.isActive) {
485+
* // ...
486+
* }
487+
*
488+
* // Unregister when done
489+
* handle.unregister()
490+
* ```
491+
*/
492+
export interface HotkeyRegistrationHandle {
493+
/** Unique identifier for this registration */
494+
readonly id: string
495+
496+
/** Unregister this hotkey */
497+
unregister: () => void
498+
499+
/**
500+
* The callback function. Can be set directly to update without re-registering.
501+
* This avoids stale closures when the callback references React state.
502+
*/
503+
callback: HotkeyCallback
504+
505+
/**
506+
* Update options (merged with existing options).
507+
* Useful for updating `enabled`, `preventDefault`, etc. without re-registering.
508+
*/
509+
setOptions: (options: Partial<HotkeyOptions>) => void
510+
511+
/** Check if this registration is still active (not unregistered) */
512+
readonly isActive: boolean
513+
}

0 commit comments

Comments
 (0)