@djangocfg/ui-core 2.1.380 → 2.1.382
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/README.md +96 -1
- package/package.json +4 -4
- package/src/components/boundary/Boundary.tsx +365 -0
- package/src/components/boundary/README.md +249 -0
- package/src/components/boundary/boundary.story.tsx +191 -0
- package/src/components/boundary/index.ts +9 -0
- package/src/components/index.ts +13 -0
- package/src/components/select/combobox.tsx +47 -19
- package/src/hooks/audio/createSoundBus.ts +172 -0
- package/src/hooks/audio/index.ts +21 -0
- package/src/hooks/audio/useAudioPrefs.ts +91 -0
- package/src/hooks/audio/useNotificationSounds.ts +271 -0
- package/src/hooks/audio/useSoundEffect.ts +78 -0
- package/src/hooks/hotkey/formatHotkey.ts +96 -0
- package/src/hooks/hotkey/index.ts +10 -0
- package/src/hooks/hotkey/useHotkey.ts +106 -34
- package/src/hooks/hotkey/useHotkeyChord.ts +96 -0
- package/src/hooks/hotkey/useHotkeyHelp.ts +68 -0
- package/src/hooks/index.ts +1 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Human-readable hotkey formatter.
|
|
5
|
+
*
|
|
6
|
+
* Translates a `react-hotkeys-hook` combo string into the glyphs users
|
|
7
|
+
* expect to see in tooltips and cheat-sheets. Honours macOS conventions
|
|
8
|
+
* (⌘ ⌃ ⌥ ⇧) on Apple devices and falls back to text labels elsewhere.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* formatHotkey('mod+k') // → '⌘K' on Mac, 'Ctrl+K' elsewhere
|
|
12
|
+
* formatHotkey('shift+/') // → '⇧/'
|
|
13
|
+
* formatHotkey('escape') // → 'Esc'
|
|
14
|
+
* formatHotkey('g t') // → 'G then T' (chord)
|
|
15
|
+
*/
|
|
16
|
+
export function formatHotkey(combo: string, opts: { mac?: boolean } = {}): string {
|
|
17
|
+
const mac = opts.mac ?? detectMac();
|
|
18
|
+
|
|
19
|
+
// Chord: tokens separated by spaces.
|
|
20
|
+
if (combo.includes(' ')) {
|
|
21
|
+
return combo
|
|
22
|
+
.split(/\s+/)
|
|
23
|
+
.map((part) => formatHotkey(part, { mac }))
|
|
24
|
+
.join(' then ');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Multiple alternatives via comma — show the first.
|
|
28
|
+
if (combo.includes(',')) {
|
|
29
|
+
const first = combo.split(',')[0]?.trim();
|
|
30
|
+
return first ? formatHotkey(first, { mac }) : combo;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return combo
|
|
34
|
+
.split('+')
|
|
35
|
+
.map((token) => token.trim().toLowerCase())
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.map((token) => formatToken(token, mac))
|
|
38
|
+
.join(mac ? '' : '+');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function detectMac(): boolean {
|
|
42
|
+
if (typeof navigator === 'undefined') return false;
|
|
43
|
+
// navigator.userAgentData is Chromium-only; fall back to platform sniff.
|
|
44
|
+
const ua = (navigator.userAgent || '').toLowerCase();
|
|
45
|
+
const platform = (navigator.platform || '').toLowerCase();
|
|
46
|
+
return /mac|iphone|ipad|ipod/.test(platform) || /mac os|macintosh/.test(ua);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatToken(token: string, mac: boolean): string {
|
|
50
|
+
switch (token) {
|
|
51
|
+
case 'mod':
|
|
52
|
+
return mac ? '⌘' : 'Ctrl';
|
|
53
|
+
case 'meta':
|
|
54
|
+
case 'cmd':
|
|
55
|
+
case 'command':
|
|
56
|
+
return mac ? '⌘' : 'Win';
|
|
57
|
+
case 'ctrl':
|
|
58
|
+
case 'control':
|
|
59
|
+
return mac ? '⌃' : 'Ctrl';
|
|
60
|
+
case 'alt':
|
|
61
|
+
case 'option':
|
|
62
|
+
return mac ? '⌥' : 'Alt';
|
|
63
|
+
case 'shift':
|
|
64
|
+
return mac ? '⇧' : 'Shift';
|
|
65
|
+
case 'escape':
|
|
66
|
+
case 'esc':
|
|
67
|
+
return 'Esc';
|
|
68
|
+
case 'enter':
|
|
69
|
+
case 'return':
|
|
70
|
+
return mac ? '⏎' : 'Enter';
|
|
71
|
+
case 'tab':
|
|
72
|
+
return mac ? '⇥' : 'Tab';
|
|
73
|
+
case 'space':
|
|
74
|
+
return mac ? 'Space' : 'Space';
|
|
75
|
+
case 'backspace':
|
|
76
|
+
return mac ? '⌫' : 'Backspace';
|
|
77
|
+
case 'delete':
|
|
78
|
+
case 'del':
|
|
79
|
+
return mac ? '⌦' : 'Del';
|
|
80
|
+
case 'arrowup':
|
|
81
|
+
case 'up':
|
|
82
|
+
return '↑';
|
|
83
|
+
case 'arrowdown':
|
|
84
|
+
case 'down':
|
|
85
|
+
return '↓';
|
|
86
|
+
case 'arrowleft':
|
|
87
|
+
case 'left':
|
|
88
|
+
return '←';
|
|
89
|
+
case 'arrowright':
|
|
90
|
+
case 'right':
|
|
91
|
+
return '→';
|
|
92
|
+
default:
|
|
93
|
+
if (token.length === 1) return token.toUpperCase();
|
|
94
|
+
return token.charAt(0).toUpperCase() + token.slice(1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -2,3 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
export { useHotkey, useHotkeysContext, HotkeysProvider, isHotkeyPressed } from './useHotkey';
|
|
4
4
|
export type { UseHotkeyOptions, HotkeyCallback, Keys, HotkeyRefType } from './useHotkey';
|
|
5
|
+
|
|
6
|
+
export { formatHotkey } from './formatHotkey';
|
|
7
|
+
export { useHotkeyChord, type UseHotkeyChordOptions } from './useHotkeyChord';
|
|
8
|
+
export {
|
|
9
|
+
useHotkeyHelp,
|
|
10
|
+
useRegisterHotkey,
|
|
11
|
+
getRegisteredHotkeys,
|
|
12
|
+
registerHotkey,
|
|
13
|
+
type RegisteredHotkey,
|
|
14
|
+
} from './useHotkeyHelp';
|
|
@@ -1,26 +1,54 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useEffect } from 'react';
|
|
3
4
|
import { Options as HotkeysOptions, useHotkeys } from 'react-hotkeys-hook';
|
|
4
5
|
import type { HotkeyCallback, Keys } from 'react-hotkeys-hook';
|
|
5
6
|
|
|
7
|
+
import { registerHotkey } from './useHotkeyHelp';
|
|
8
|
+
|
|
6
9
|
/** Ref type for hotkey target element */
|
|
7
10
|
export type HotkeyRefType<T> = T | null;
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
|
-
* Options for the useHotkey hook
|
|
13
|
+
* Options for the useHotkey hook.
|
|
14
|
+
*
|
|
15
|
+
* Most of the time you only need `{ inInput }` or nothing — sensible
|
|
16
|
+
* defaults are derived from the key combo (see below).
|
|
11
17
|
*/
|
|
12
18
|
export interface UseHotkeyOptions extends Omit<HotkeysOptions, 'enabled'> {
|
|
13
19
|
/** Whether the hotkey is enabled (default: true) */
|
|
14
20
|
enabled?: boolean;
|
|
15
|
-
/** Scope for the hotkey
|
|
21
|
+
/** Scope for the hotkey — useful for context-specific shortcuts */
|
|
16
22
|
scope?: string;
|
|
17
23
|
/** Only trigger when focus is within a specific element */
|
|
18
24
|
scopes?: string[];
|
|
19
25
|
/** Prevent default browser behavior */
|
|
20
26
|
preventDefault?: boolean;
|
|
21
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Whether the shortcut should also fire when focus is inside an input,
|
|
29
|
+
* textarea, select, or contenteditable element.
|
|
30
|
+
*
|
|
31
|
+
* Default policy (when not specified):
|
|
32
|
+
* - Modifier-bearing combos (`cmd+*`, `ctrl+*`, `alt+*`, `shift+*`)
|
|
33
|
+
* → `true`. Global app shortcuts (⌘K, Ctrl+S, ⌘/) must work no
|
|
34
|
+
* matter where the user is.
|
|
35
|
+
* - `escape` → `true`. Escape semantics (blur / close) are universal.
|
|
36
|
+
* - Bare single-character keys (`/`, `?`, `j`, …) → `false`. They
|
|
37
|
+
* would otherwise eat keystrokes the user is typing into inputs.
|
|
38
|
+
* - Function keys, arrows, etc. → `false`.
|
|
39
|
+
*
|
|
40
|
+
* Pass `true` or `false` explicitly to override.
|
|
41
|
+
*/
|
|
42
|
+
inInput?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* @deprecated Use `inInput` instead. Forwarded to react-hotkeys-hook
|
|
45
|
+
* when explicitly set; otherwise derived from `inInput` / policy.
|
|
46
|
+
*/
|
|
22
47
|
enableOnFormTags?: boolean | readonly ('input' | 'textarea' | 'select')[];
|
|
23
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* @deprecated Use `inInput` instead. Same forwarding rules as
|
|
50
|
+
* `enableOnFormTags`.
|
|
51
|
+
*/
|
|
24
52
|
enableOnContentEditable?: boolean;
|
|
25
53
|
/** Split key for multiple hotkey combinations (default: ',') */
|
|
26
54
|
splitKey?: string;
|
|
@@ -32,40 +60,24 @@ export interface UseHotkeyOptions extends Omit<HotkeysOptions, 'enabled'> {
|
|
|
32
60
|
}
|
|
33
61
|
|
|
34
62
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* @example
|
|
38
|
-
* // Single key
|
|
39
|
-
* useHotkey('escape', () => closeModal());
|
|
63
|
+
* Wrapper around react-hotkeys-hook with an opinionated `inInput` policy.
|
|
40
64
|
*
|
|
41
65
|
* @example
|
|
42
|
-
* //
|
|
43
|
-
* useHotkey('
|
|
44
|
-
* e.preventDefault();
|
|
45
|
-
* saveDocument();
|
|
46
|
-
* });
|
|
66
|
+
* // ⌘K palette — works everywhere, including inside text fields
|
|
67
|
+
* useHotkey('mod+k', openPalette);
|
|
47
68
|
*
|
|
48
69
|
* @example
|
|
49
|
-
* //
|
|
50
|
-
*
|
|
51
|
-
* useHotkey(
|
|
70
|
+
* // `/` focuses search — auto-disabled inside text fields so typing
|
|
71
|
+
* // a `/` into a textarea doesn't yank focus away
|
|
72
|
+
* useHotkey('/', focusSearch);
|
|
52
73
|
*
|
|
53
74
|
* @example
|
|
54
|
-
* //
|
|
55
|
-
* useHotkey('
|
|
56
|
-
* preventDefault: true,
|
|
57
|
-
* enableOnFormTags: false,
|
|
58
|
-
* description: 'Focus search input'
|
|
59
|
-
* });
|
|
75
|
+
* // Escape — defaults to fire in text fields too (blur / close pattern)
|
|
76
|
+
* useHotkey('escape', closeModal);
|
|
60
77
|
*
|
|
61
78
|
* @example
|
|
62
|
-
* //
|
|
63
|
-
* useHotkey('
|
|
64
|
-
*
|
|
65
|
-
* @param keys - Hotkey or array of hotkeys (e.g., 'ctrl+s', 'ArrowLeft', ['[', 'ArrowLeft'])
|
|
66
|
-
* @param callback - Function to call when hotkey is pressed
|
|
67
|
-
* @param options - Configuration options
|
|
68
|
-
* @returns Ref callback to attach to element for scoped hotkeys
|
|
79
|
+
* // Explicit opt-in for a bare key inside inputs
|
|
80
|
+
* useHotkey('?', openHelp, { inInput: true });
|
|
69
81
|
*/
|
|
70
82
|
export function useHotkey<T extends HTMLElement = HTMLElement>(
|
|
71
83
|
keys: Keys,
|
|
@@ -74,13 +86,35 @@ export function useHotkey<T extends HTMLElement = HTMLElement>(
|
|
|
74
86
|
): (instance: HotkeyRefType<T>) => void {
|
|
75
87
|
const {
|
|
76
88
|
enabled = true,
|
|
77
|
-
preventDefault
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
89
|
+
preventDefault: explicitPreventDefault,
|
|
90
|
+
inInput,
|
|
91
|
+
enableOnFormTags: explicitFormTags,
|
|
92
|
+
enableOnContentEditable: explicitContentEditable,
|
|
93
|
+
description,
|
|
94
|
+
scope,
|
|
81
95
|
...restOptions
|
|
82
96
|
} = options;
|
|
83
97
|
|
|
98
|
+
const policyAllowsInInput = resolveInInput(keys, inInput);
|
|
99
|
+
// Smart default — modifier combos preventDefault by default
|
|
100
|
+
// (cmd+s, cmd+k, etc. would otherwise fall through to the browser).
|
|
101
|
+
const preventDefault = explicitPreventDefault ?? hasModifier(keys);
|
|
102
|
+
|
|
103
|
+
// Register in the cheat-sheet help registry when described.
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!description) return;
|
|
106
|
+
return registerHotkey({
|
|
107
|
+
combo: normalizeKeys(keys).join(','),
|
|
108
|
+
description,
|
|
109
|
+
scope,
|
|
110
|
+
});
|
|
111
|
+
}, [keys, description, scope]);
|
|
112
|
+
|
|
113
|
+
const enableOnFormTags =
|
|
114
|
+
explicitFormTags !== undefined ? explicitFormTags : policyAllowsInInput;
|
|
115
|
+
const enableOnContentEditable =
|
|
116
|
+
explicitContentEditable !== undefined ? explicitContentEditable : policyAllowsInInput;
|
|
117
|
+
|
|
84
118
|
return useHotkeys<T>(
|
|
85
119
|
keys,
|
|
86
120
|
(event, handler) => {
|
|
@@ -98,6 +132,44 @@ export function useHotkey<T extends HTMLElement = HTMLElement>(
|
|
|
98
132
|
);
|
|
99
133
|
}
|
|
100
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Decide whether a given key combo should fire when focus is inside a
|
|
137
|
+
* text input. Honours the caller's explicit `inInput` first, otherwise
|
|
138
|
+
* applies the policy described on `UseHotkeyOptions.inInput`.
|
|
139
|
+
*/
|
|
140
|
+
function resolveInInput(keys: Keys, explicit?: boolean): boolean {
|
|
141
|
+
if (explicit !== undefined) return explicit;
|
|
142
|
+
|
|
143
|
+
const list = normalizeKeys(keys);
|
|
144
|
+
// If ANY of the listed combos qualifies as "input-safe", treat the
|
|
145
|
+
// whole binding as input-safe — the caller declared them as
|
|
146
|
+
// alternatives, so the more permissive policy wins.
|
|
147
|
+
return list.some(isInputSafeByPolicy);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalizeKeys(keys: Keys): string[] {
|
|
151
|
+
if (Array.isArray(keys)) return keys.map((k) => String(k).toLowerCase());
|
|
152
|
+
return [String(keys).toLowerCase()];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const ESCAPE_ALIASES = new Set(['escape', 'esc']);
|
|
156
|
+
const MODIFIER_ALIASES = ['mod+', 'meta+', 'cmd+', 'ctrl+', 'control+', 'alt+', 'option+', 'shift+'];
|
|
157
|
+
|
|
158
|
+
function isInputSafeByPolicy(combo: string): boolean {
|
|
159
|
+
if (ESCAPE_ALIASES.has(combo)) return true;
|
|
160
|
+
return MODIFIER_ALIASES.some((mod) => combo.startsWith(mod));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Does the combo include a modifier key? Used to default `preventDefault`
|
|
165
|
+
* so global app shortcuts (⌘S, ⌘K, …) never fall through to the browser.
|
|
166
|
+
*/
|
|
167
|
+
function hasModifier(keys: Keys): boolean {
|
|
168
|
+
return normalizeKeys(keys).some((combo) =>
|
|
169
|
+
MODIFIER_ALIASES.some((mod) => combo.startsWith(mod)),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
101
173
|
// Re-export useful utilities from react-hotkeys-hook
|
|
102
174
|
export { useHotkeysContext, HotkeysProvider, isHotkeyPressed } from 'react-hotkeys-hook';
|
|
103
175
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface UseHotkeyChordOptions {
|
|
6
|
+
/** Whether the chord is active. @default true */
|
|
7
|
+
enabled?: boolean;
|
|
8
|
+
/** Max delay between consecutive keys, in ms. @default 800 */
|
|
9
|
+
window?: number;
|
|
10
|
+
/**
|
|
11
|
+
* Fire even when focus is inside an input / textarea / contenteditable.
|
|
12
|
+
* @default false — chord sequences shouldn't hijack typing
|
|
13
|
+
*/
|
|
14
|
+
enableOnFormTags?: boolean;
|
|
15
|
+
/** Prevent default browser behaviour on each key. @default false */
|
|
16
|
+
preventDefault?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const FORM_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
|
20
|
+
|
|
21
|
+
function isEditableTarget(el: EventTarget | null): boolean {
|
|
22
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
23
|
+
if (FORM_TAGS.has(el.tagName)) return true;
|
|
24
|
+
if (el.isContentEditable) return true;
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Linear-style chord shortcuts — fire `callback` when the user presses
|
|
30
|
+
* a sequence of bare keys within a time window.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```tsx
|
|
34
|
+
* useHotkeyChord(['g', 't'], () => navigate('/tasks'));
|
|
35
|
+
* useHotkeyChord(['g', 'i'], () => navigate('/inbox'), { window: 1200 });
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* Each step is a single bare key (no modifiers). Pressing any
|
|
39
|
+
* non-sequence key resets progress, so partially-typed sequences don't
|
|
40
|
+
* fire accidentally.
|
|
41
|
+
*/
|
|
42
|
+
export function useHotkeyChord(
|
|
43
|
+
keys: readonly string[],
|
|
44
|
+
callback: (event: KeyboardEvent) => void,
|
|
45
|
+
options: UseHotkeyChordOptions = {},
|
|
46
|
+
): void {
|
|
47
|
+
const { enabled = true, window: chordWindow = 800, enableOnFormTags = false, preventDefault = false } = options;
|
|
48
|
+
const callbackRef = useRef(callback);
|
|
49
|
+
callbackRef.current = callback;
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!enabled) return;
|
|
53
|
+
if (typeof window === 'undefined') return;
|
|
54
|
+
if (!keys.length) return;
|
|
55
|
+
|
|
56
|
+
const sequence = keys.map((k) => k.toLowerCase());
|
|
57
|
+
let progress = 0;
|
|
58
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
59
|
+
|
|
60
|
+
const reset = () => {
|
|
61
|
+
progress = 0;
|
|
62
|
+
if (timer) {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
timer = null;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const onKey = (e: KeyboardEvent) => {
|
|
69
|
+
if (!enableOnFormTags && isEditableTarget(e.target)) return;
|
|
70
|
+
// Chords don't carry modifiers.
|
|
71
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return reset();
|
|
72
|
+
|
|
73
|
+
const expected = sequence[progress];
|
|
74
|
+
if (!expected) return;
|
|
75
|
+
const pressed = e.key.toLowerCase();
|
|
76
|
+
if (pressed !== expected) return reset();
|
|
77
|
+
|
|
78
|
+
if (preventDefault) e.preventDefault();
|
|
79
|
+
progress++;
|
|
80
|
+
if (progress >= sequence.length) {
|
|
81
|
+
callbackRef.current(e);
|
|
82
|
+
reset();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (timer) clearTimeout(timer);
|
|
87
|
+
timer = setTimeout(reset, chordWindow);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
window.addEventListener('keydown', onKey);
|
|
91
|
+
return () => {
|
|
92
|
+
window.removeEventListener('keydown', onKey);
|
|
93
|
+
if (timer) clearTimeout(timer);
|
|
94
|
+
};
|
|
95
|
+
}, [keys.join('|'), enabled, chordWindow, enableOnFormTags, preventDefault]);
|
|
96
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useSyncExternalStore } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Module-level store of every hotkey that registered itself with a
|
|
7
|
+
* `description`. Lets you render a `?` cheat-sheet without prop-drilling.
|
|
8
|
+
*/
|
|
9
|
+
export interface RegisteredHotkey {
|
|
10
|
+
/** Key combo (raw string passed to `useHotkey` — format with `formatHotkey()` for display). */
|
|
11
|
+
combo: string;
|
|
12
|
+
/** Human-readable purpose. */
|
|
13
|
+
description: string;
|
|
14
|
+
/** Optional scope label for grouping (e.g. `'chat'`, `'global'`). */
|
|
15
|
+
scope?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const registry = new Map<symbol, RegisteredHotkey>();
|
|
19
|
+
const listeners = new Set<() => void>();
|
|
20
|
+
|
|
21
|
+
function notify() {
|
|
22
|
+
for (const cb of listeners) cb();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Register a hotkey entry while a component is mounted. Internal —
|
|
27
|
+
* called by `useHotkey` when a `description` is provided.
|
|
28
|
+
*/
|
|
29
|
+
export function registerHotkey(entry: RegisteredHotkey): () => void {
|
|
30
|
+
const key = Symbol(entry.combo);
|
|
31
|
+
registry.set(key, entry);
|
|
32
|
+
notify();
|
|
33
|
+
return () => {
|
|
34
|
+
registry.delete(key);
|
|
35
|
+
notify();
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Read the live list of registered hotkeys. SSR-safe. */
|
|
40
|
+
export function useHotkeyHelp(): RegisteredHotkey[] {
|
|
41
|
+
return useSyncExternalStore(
|
|
42
|
+
(cb) => {
|
|
43
|
+
listeners.add(cb);
|
|
44
|
+
return () => {
|
|
45
|
+
listeners.delete(cb);
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
() => Array.from(registry.values()),
|
|
49
|
+
() => [],
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Imperative read for non-React consumers (debug panels, etc.). */
|
|
54
|
+
export function getRegisteredHotkeys(): RegisteredHotkey[] {
|
|
55
|
+
return Array.from(registry.values());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Hook variant — call alongside `useHotkey` when the binding lives
|
|
60
|
+
* outside the hook (e.g. raw `window.addEventListener` consumers that
|
|
61
|
+
* still want to appear in the cheat sheet).
|
|
62
|
+
*/
|
|
63
|
+
export function useRegisterHotkey(entry: RegisteredHotkey | null): void {
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!entry) return;
|
|
66
|
+
return registerHotkey(entry);
|
|
67
|
+
}, [entry?.combo, entry?.description, entry?.scope]);
|
|
68
|
+
}
|
package/src/hooks/index.ts
CHANGED