@djangocfg/ui-core 2.1.381 → 2.1.383
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 +85 -21
- package/package.json +5 -12
- package/src/components/boundary/Boundary.tsx +204 -33
- package/src/components/boundary/README.md +249 -0
- package/src/components/boundary/index.ts +9 -2
- package/src/components/index.ts +9 -2
- 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
- package/src/components/boundary/boundary.story.tsx +0 -109
- package/src/components/data/avatar/avatar.story.tsx +0 -115
- package/src/components/data/badge/badge.story.tsx +0 -56
- package/src/components/data/calendar/calendar.story.tsx +0 -127
- package/src/components/data/carousel/carousel.story.tsx +0 -122
- package/src/components/data/progress/progress.story.tsx +0 -97
- package/src/components/data/table/table.story.tsx +0 -148
- package/src/components/data/toggle/toggle.story.tsx +0 -104
- package/src/components/data/toggle-group/toggle-group.story.tsx +0 -118
- package/src/components/feedback/alert/alert.story.tsx +0 -77
- package/src/components/feedback/empty/empty.story.tsx +0 -115
- package/src/components/feedback/preloader/preloader.story.tsx +0 -86
- package/src/components/feedback/spinner/spinner.story.tsx +0 -66
- package/src/components/forms/button/button.story.tsx +0 -116
- package/src/components/forms/button-download/button-download.story.tsx +0 -112
- package/src/components/forms/button-group/button-group.story.tsx +0 -79
- package/src/components/forms/checkbox/checkbox.story.tsx +0 -89
- package/src/components/forms/input/input.story.tsx +0 -77
- package/src/components/forms/input-group/input-group.story.tsx +0 -119
- package/src/components/forms/input-otp/input-otp.story.tsx +0 -105
- package/src/components/forms/label/label.story.tsx +0 -52
- package/src/components/forms/radio-group/radio-group.story.tsx +0 -113
- package/src/components/forms/slider/slider.story.tsx +0 -134
- package/src/components/forms/switch/switch.story.tsx +0 -98
- package/src/components/forms/textarea/textarea.story.tsx +0 -94
- package/src/components/layout/aspect-ratio/aspect-ratio.story.tsx +0 -94
- package/src/components/layout/card/card.story.tsx +0 -105
- package/src/components/layout/resizable/resizable.story.tsx +0 -119
- package/src/components/layout/scroll-area/scroll-area.story.tsx +0 -172
- package/src/components/layout/separator/separator.story.tsx +0 -69
- package/src/components/layout/skeleton/skeleton.story.tsx +0 -101
- package/src/components/navigation/accordion/accordion.story.tsx +0 -110
- package/src/components/navigation/collapsible/collapsible.story.tsx +0 -133
- package/src/components/navigation/command/command.story.tsx +0 -121
- package/src/components/navigation/context-menu/context-menu.story.tsx +0 -125
- package/src/components/navigation/dropdown-menu/dropdown-menu.story.tsx +0 -208
- package/src/components/navigation/menubar/menubar.story.tsx +0 -152
- package/src/components/navigation/navigation-menu/navigation-menu.story.tsx +0 -154
- package/src/components/navigation/tabs/tabs.story.tsx +0 -98
- package/src/components/overlay/alert-dialog/alert-dialog.story.tsx +0 -104
- package/src/components/overlay/dialog/dialog.story.tsx +0 -212
- package/src/components/overlay/drawer/drawer.story.tsx +0 -359
- package/src/components/overlay/hover-card/hover-card.story.tsx +0 -102
- package/src/components/overlay/popover/popover.story.tsx +0 -127
- package/src/components/overlay/responsive-sheet/responsive-sheet.story.tsx +0 -117
- package/src/components/overlay/sheet/sheet.story.tsx +0 -148
- package/src/components/overlay/tooltip/tooltip.story.tsx +0 -139
- package/src/components/select/combobox-async.story.tsx +0 -215
- package/src/components/select/combobox.story.tsx +0 -226
- package/src/components/select/country-select.story.tsx +0 -261
- package/src/components/select/language-select.story.tsx +0 -264
- package/src/components/select/multi-select.story.tsx +0 -122
- package/src/components/select/select.story.tsx +0 -112
- package/src/components/specialized/copy/copy.story.tsx +0 -77
- package/src/components/specialized/flag/flag.story.tsx +0 -82
- package/src/components/specialized/image-with-fallback/image-with-fallback.story.tsx +0 -105
- package/src/components/specialized/kbd/kbd.story.tsx +0 -113
- package/src/lib/dialog-service/dialog-service.story.tsx +0 -263
- package/src/stories/index.ts +0 -28
- package/src/styles/theme/theme-tokens.story.tsx +0 -157
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
|
4
|
+
|
|
5
|
+
import { createSoundBus, type SoundBus } from './createSoundBus';
|
|
6
|
+
import { useAudioPrefs } from './useAudioPrefs';
|
|
7
|
+
|
|
8
|
+
export interface NotificationSoundsConfig<E extends string> {
|
|
9
|
+
/**
|
|
10
|
+
* Persistence key for the mute / volume / per-event toggles. Use one
|
|
11
|
+
* key per product surface (e.g. `'chat.audio'`, `'alerts.audio'`).
|
|
12
|
+
*/
|
|
13
|
+
storageKey: string;
|
|
14
|
+
/** Event → URL map. `false`/missing silences the event. */
|
|
15
|
+
sounds?: Partial<Record<E, string | false>>;
|
|
16
|
+
/** Master volume override. When set, the persisted value is ignored. */
|
|
17
|
+
volume?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Per-event volume multipliers (0..1). Final per-play volume is
|
|
20
|
+
* `master × eventVolumes[event] ?? 1`. Use this to dim error / status
|
|
21
|
+
* sounds so they don't feel jarring next to message dings. Slack /
|
|
22
|
+
* Linear / Intercom-style defaults: error ≈ 0.25, mention ≈ 1.0,
|
|
23
|
+
* received/sent ≈ 0.6.
|
|
24
|
+
*/
|
|
25
|
+
eventVolumes?: Partial<Record<E, number>>;
|
|
26
|
+
/** Master mute override. When set, the persisted value is ignored. */
|
|
27
|
+
muted?: boolean;
|
|
28
|
+
/** Custom predicate — return `false` to suppress one event. */
|
|
29
|
+
shouldPlay?: (event: E) => boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Skip all playback when the user prefers reduced motion. @default true
|
|
32
|
+
*/
|
|
33
|
+
respectReducedMotion?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Skip all playback when the user prefers reduced data. @default true
|
|
36
|
+
*/
|
|
37
|
+
respectReducedData?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Skip all playback when the tab is hidden. @default true
|
|
40
|
+
*/
|
|
41
|
+
muteWhenHidden?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Disable the hook entirely — bus is not created, prefs are not read,
|
|
44
|
+
* `play()` is a no-op. Useful when the surrounding host plays sounds
|
|
45
|
+
* itself (e.g. Electron / Wails backend) and the React layer should
|
|
46
|
+
* stay silent.
|
|
47
|
+
*/
|
|
48
|
+
silenced?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Side-channel fired whenever `play()` would have triggered an event.
|
|
51
|
+
* Stays active even when `silenced=true`. Use to bridge into a native
|
|
52
|
+
* audio backend (cmdop_go, Tauri, …).
|
|
53
|
+
*/
|
|
54
|
+
onSoundEvent?: (event: E) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface NotificationSoundsApi<E extends string> {
|
|
58
|
+
play: (event: E) => void;
|
|
59
|
+
preload: (event: E) => void;
|
|
60
|
+
unlock: () => void;
|
|
61
|
+
isUnlocked: boolean;
|
|
62
|
+
/** Resolved mute state — combines persisted prefs + reduced-motion + hidden. */
|
|
63
|
+
muted: boolean;
|
|
64
|
+
setMuted: (m: boolean) => void;
|
|
65
|
+
toggleMute: () => void;
|
|
66
|
+
volume: number;
|
|
67
|
+
setVolume: (v: number) => void;
|
|
68
|
+
isEventEnabled: (event: E) => boolean;
|
|
69
|
+
setEventEnabled: (event: E, enabled: boolean) => void;
|
|
70
|
+
/** True when no sounds map is configured (or `silenced`). */
|
|
71
|
+
isSilent: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readReducedMotion(): boolean {
|
|
75
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
|
76
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
77
|
+
}
|
|
78
|
+
function readReducedData(): boolean {
|
|
79
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
|
80
|
+
return window.matchMedia('(prefers-reduced-data: reduce)').matches;
|
|
81
|
+
}
|
|
82
|
+
function readVisibilityHidden(): boolean {
|
|
83
|
+
if (typeof document === 'undefined') return false;
|
|
84
|
+
return document.visibilityState === 'hidden';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Mid-level notification-sounds hook.
|
|
89
|
+
*
|
|
90
|
+
* Use when you need a small named map (`received`, `error`, `mention`)
|
|
91
|
+
* with persisted mute + Safari unlock + reduced-motion / hidden-tab
|
|
92
|
+
* guards. Pair with `useAudioPrefs(...)` if you need cross-component
|
|
93
|
+
* access to the same mute state.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```tsx
|
|
97
|
+
* type Event = 'received' | 'error';
|
|
98
|
+
* const sounds = useNotificationSounds<Event>({
|
|
99
|
+
* storageKey: 'myapp.audio',
|
|
100
|
+
* sounds: { received: '/r.mp3', error: '/e.mp3' },
|
|
101
|
+
* });
|
|
102
|
+
*
|
|
103
|
+
* onMessage = (m) => sounds.play('received');
|
|
104
|
+
* onMute = () => sounds.toggleMute();
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
export function useNotificationSounds<E extends string>(
|
|
108
|
+
config: NotificationSoundsConfig<E>,
|
|
109
|
+
): NotificationSoundsApi<E> {
|
|
110
|
+
const {
|
|
111
|
+
storageKey,
|
|
112
|
+
sounds = {},
|
|
113
|
+
volume: volumeOverride,
|
|
114
|
+
eventVolumes,
|
|
115
|
+
muted: mutedOverride,
|
|
116
|
+
shouldPlay,
|
|
117
|
+
respectReducedMotion = true,
|
|
118
|
+
respectReducedData = true,
|
|
119
|
+
muteWhenHidden = true,
|
|
120
|
+
silenced = false,
|
|
121
|
+
onSoundEvent,
|
|
122
|
+
} = config;
|
|
123
|
+
|
|
124
|
+
const usePrefs = useAudioPrefs<E>(storageKey);
|
|
125
|
+
const volumeP = usePrefs((s) => s.volume);
|
|
126
|
+
const mutedP = usePrefs((s) => s.muted);
|
|
127
|
+
const enabledMap = usePrefs((s) => s.enabled);
|
|
128
|
+
const setVolumeP = usePrefs((s) => s.setVolume);
|
|
129
|
+
const setMutedP = usePrefs((s) => s.setMuted);
|
|
130
|
+
const setEventEnabledP = usePrefs((s) => s.setEventEnabled);
|
|
131
|
+
|
|
132
|
+
const volume = volumeOverride != null ? volumeOverride : volumeP;
|
|
133
|
+
const muted = mutedOverride != null ? mutedOverride : mutedP;
|
|
134
|
+
|
|
135
|
+
// Refs for stable getters inside the bus.
|
|
136
|
+
const volumeRef = useRef(volume);
|
|
137
|
+
volumeRef.current = volume;
|
|
138
|
+
const eventVolumesRef = useRef(eventVolumes);
|
|
139
|
+
eventVolumesRef.current = eventVolumes;
|
|
140
|
+
const mutedRef = useRef(muted);
|
|
141
|
+
mutedRef.current = muted;
|
|
142
|
+
const enabledRef = useRef(enabledMap);
|
|
143
|
+
enabledRef.current = enabledMap;
|
|
144
|
+
const reducedMotionRef = useRef(readReducedMotion());
|
|
145
|
+
const reducedDataRef = useRef(readReducedData());
|
|
146
|
+
const hiddenRef = useRef(readVisibilityHidden());
|
|
147
|
+
const shouldPlayRef = useRef(shouldPlay);
|
|
148
|
+
shouldPlayRef.current = shouldPlay;
|
|
149
|
+
const silencedRef = useRef(silenced);
|
|
150
|
+
silencedRef.current = silenced;
|
|
151
|
+
const onSoundEventRef = useRef(onSoundEvent);
|
|
152
|
+
onSoundEventRef.current = onSoundEvent;
|
|
153
|
+
|
|
154
|
+
// Watch reduced-motion / reduced-data preference changes.
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
|
157
|
+
const mqMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
158
|
+
const mqData = window.matchMedia('(prefers-reduced-data: reduce)');
|
|
159
|
+
const onMotion = () => {
|
|
160
|
+
reducedMotionRef.current = mqMotion.matches;
|
|
161
|
+
};
|
|
162
|
+
const onData = () => {
|
|
163
|
+
reducedDataRef.current = mqData.matches;
|
|
164
|
+
};
|
|
165
|
+
mqMotion.addEventListener('change', onMotion);
|
|
166
|
+
mqData.addEventListener('change', onData);
|
|
167
|
+
return () => {
|
|
168
|
+
mqMotion.removeEventListener('change', onMotion);
|
|
169
|
+
mqData.removeEventListener('change', onData);
|
|
170
|
+
};
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
// Visibility tracking — mute while tab is hidden.
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (!muteWhenHidden || typeof document === 'undefined') return;
|
|
176
|
+
const onVis = () => {
|
|
177
|
+
hiddenRef.current = document.visibilityState === 'hidden';
|
|
178
|
+
};
|
|
179
|
+
document.addEventListener('visibilitychange', onVis);
|
|
180
|
+
return () => document.removeEventListener('visibilitychange', onVis);
|
|
181
|
+
}, [muteWhenHidden]);
|
|
182
|
+
|
|
183
|
+
const effectiveMuted = useCallback((): boolean => {
|
|
184
|
+
if (silencedRef.current) return true;
|
|
185
|
+
if (mutedRef.current) return true;
|
|
186
|
+
if (muteWhenHidden && hiddenRef.current) return true;
|
|
187
|
+
if (respectReducedMotion && reducedMotionRef.current) return true;
|
|
188
|
+
if (respectReducedData && reducedDataRef.current) return true;
|
|
189
|
+
return false;
|
|
190
|
+
}, [muteWhenHidden, respectReducedMotion, respectReducedData]);
|
|
191
|
+
|
|
192
|
+
const isEnabledImpl = useCallback((event: E): boolean => {
|
|
193
|
+
if (shouldPlayRef.current && shouldPlayRef.current(event) === false) return false;
|
|
194
|
+
const flag = enabledRef.current[event];
|
|
195
|
+
if (flag === false) return false;
|
|
196
|
+
return true;
|
|
197
|
+
}, []);
|
|
198
|
+
|
|
199
|
+
// Bus instance — created once per hook lifetime, sounds map hot-swapped.
|
|
200
|
+
const busRef = useRef<SoundBus<E> | null>(null);
|
|
201
|
+
if (busRef.current === null) {
|
|
202
|
+
busRef.current = createSoundBus<E>({
|
|
203
|
+
sounds,
|
|
204
|
+
getVolume: (event) => {
|
|
205
|
+
const master = volumeRef.current;
|
|
206
|
+
if (event === undefined) return master;
|
|
207
|
+
const scale = eventVolumesRef.current?.[event];
|
|
208
|
+
if (scale === undefined) return master;
|
|
209
|
+
return master * scale;
|
|
210
|
+
},
|
|
211
|
+
getMuted: () => effectiveMuted(),
|
|
212
|
+
isEnabled: (event) => isEnabledImpl(event),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
busRef.current?.setSounds(sounds);
|
|
218
|
+
}, [sounds]);
|
|
219
|
+
|
|
220
|
+
// Preload all listed sounds once.
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
const bus = busRef.current;
|
|
223
|
+
if (!bus) return;
|
|
224
|
+
for (const ev of Object.keys(sounds) as E[]) bus.preload(ev);
|
|
225
|
+
}, [sounds]);
|
|
226
|
+
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
const bus = busRef.current;
|
|
229
|
+
return () => bus?.dispose();
|
|
230
|
+
}, []);
|
|
231
|
+
|
|
232
|
+
const isUnlocked = useSyncExternalStore(
|
|
233
|
+
useCallback((cb) => busRef.current?.subscribeUnlock(cb) ?? (() => undefined), []),
|
|
234
|
+
() => busRef.current?.isUnlocked() ?? false,
|
|
235
|
+
() => false,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const play = useCallback(
|
|
239
|
+
(event: E) => {
|
|
240
|
+
onSoundEventRef.current?.(event);
|
|
241
|
+
busRef.current?.play(event);
|
|
242
|
+
},
|
|
243
|
+
[],
|
|
244
|
+
);
|
|
245
|
+
const preload = useCallback((event: E) => busRef.current?.preload(event), []);
|
|
246
|
+
const unlock = useCallback(() => busRef.current?.unlock(), []);
|
|
247
|
+
|
|
248
|
+
const toggleMute = useCallback(() => setMutedP(!mutedRef.current), [setMutedP]);
|
|
249
|
+
|
|
250
|
+
const isEventEnabled = useCallback((event: E) => isEnabledImpl(event), [isEnabledImpl]);
|
|
251
|
+
|
|
252
|
+
const isSilent = useMemo(() => {
|
|
253
|
+
if (silenced) return true;
|
|
254
|
+
return !sounds || Object.keys(sounds).length === 0;
|
|
255
|
+
}, [silenced, sounds]);
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
play,
|
|
259
|
+
preload,
|
|
260
|
+
unlock,
|
|
261
|
+
isUnlocked,
|
|
262
|
+
muted,
|
|
263
|
+
setMuted: (m: boolean) => setMutedP(m),
|
|
264
|
+
toggleMute,
|
|
265
|
+
volume,
|
|
266
|
+
setVolume: (v: number) => setVolumeP(v),
|
|
267
|
+
isEventEnabled,
|
|
268
|
+
setEventEnabled: (event, enabled) => setEventEnabledP(event, enabled),
|
|
269
|
+
isSilent,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface SoundEffectOptions {
|
|
6
|
+
/** 0..1 master volume. @default 1 */
|
|
7
|
+
volume?: number;
|
|
8
|
+
/** Set to true to skip play() entirely (e.g. user-prefs gating). @default false */
|
|
9
|
+
silenced?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SoundEffectApi {
|
|
13
|
+
/** Trigger playback. Fire-and-forget; cancel-safe. */
|
|
14
|
+
play: () => void;
|
|
15
|
+
/** Eagerly preload the asset. */
|
|
16
|
+
preload: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One-shot single-asset hook — the minimal "play a ding" helper.
|
|
21
|
+
*
|
|
22
|
+
* Lower level than `useNotificationSounds` (no persisted mute, no
|
|
23
|
+
* per-event toggles). Use when you have ONE asset, ONE trigger, and
|
|
24
|
+
* don't need cross-component sync.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```tsx
|
|
28
|
+
* const submitDing = useSoundEffect('/sfx/ding.mp3');
|
|
29
|
+
* <Button onClick={() => { submitDing.play(); doStuff(); }} />
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function useSoundEffect(
|
|
33
|
+
url: string | null | undefined,
|
|
34
|
+
options: SoundEffectOptions = {},
|
|
35
|
+
): SoundEffectApi {
|
|
36
|
+
const { volume = 1, silenced = false } = options;
|
|
37
|
+
const templateRef = useRef<HTMLAudioElement | null>(null);
|
|
38
|
+
const volumeRef = useRef(volume);
|
|
39
|
+
volumeRef.current = volume;
|
|
40
|
+
const silencedRef = useRef(silenced);
|
|
41
|
+
silencedRef.current = silenced;
|
|
42
|
+
const urlRef = useRef(url);
|
|
43
|
+
urlRef.current = url;
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (typeof window === 'undefined' || !url) return;
|
|
47
|
+
const el = new Audio(url);
|
|
48
|
+
el.preload = 'auto';
|
|
49
|
+
el.crossOrigin = 'anonymous';
|
|
50
|
+
templateRef.current = el;
|
|
51
|
+
return () => {
|
|
52
|
+
templateRef.current = null;
|
|
53
|
+
};
|
|
54
|
+
}, [url]);
|
|
55
|
+
|
|
56
|
+
const play = useCallback(() => {
|
|
57
|
+
if (silencedRef.current) return;
|
|
58
|
+
const u = urlRef.current;
|
|
59
|
+
if (!u || typeof window === 'undefined') return;
|
|
60
|
+
const fresh = new Audio(u);
|
|
61
|
+
fresh.preload = 'auto';
|
|
62
|
+
fresh.volume = volumeRef.current;
|
|
63
|
+
const p = fresh.play();
|
|
64
|
+
if (p && typeof p.catch === 'function') p.catch(() => undefined);
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
const preload = useCallback(() => {
|
|
68
|
+
const el = templateRef.current;
|
|
69
|
+
if (!el) return;
|
|
70
|
+
try {
|
|
71
|
+
el.load();
|
|
72
|
+
} catch {
|
|
73
|
+
// ignore
|
|
74
|
+
}
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
return { play, preload };
|
|
78
|
+
}
|
|
@@ -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
|
|