@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.
Files changed (78) hide show
  1. package/README.md +85 -21
  2. package/package.json +5 -12
  3. package/src/components/boundary/Boundary.tsx +204 -33
  4. package/src/components/boundary/README.md +249 -0
  5. package/src/components/boundary/index.ts +9 -2
  6. package/src/components/index.ts +9 -2
  7. package/src/components/select/combobox.tsx +47 -19
  8. package/src/hooks/audio/createSoundBus.ts +172 -0
  9. package/src/hooks/audio/index.ts +21 -0
  10. package/src/hooks/audio/useAudioPrefs.ts +91 -0
  11. package/src/hooks/audio/useNotificationSounds.ts +271 -0
  12. package/src/hooks/audio/useSoundEffect.ts +78 -0
  13. package/src/hooks/hotkey/formatHotkey.ts +96 -0
  14. package/src/hooks/hotkey/index.ts +10 -0
  15. package/src/hooks/hotkey/useHotkey.ts +106 -34
  16. package/src/hooks/hotkey/useHotkeyChord.ts +96 -0
  17. package/src/hooks/hotkey/useHotkeyHelp.ts +68 -0
  18. package/src/hooks/index.ts +1 -0
  19. package/src/components/boundary/boundary.story.tsx +0 -109
  20. package/src/components/data/avatar/avatar.story.tsx +0 -115
  21. package/src/components/data/badge/badge.story.tsx +0 -56
  22. package/src/components/data/calendar/calendar.story.tsx +0 -127
  23. package/src/components/data/carousel/carousel.story.tsx +0 -122
  24. package/src/components/data/progress/progress.story.tsx +0 -97
  25. package/src/components/data/table/table.story.tsx +0 -148
  26. package/src/components/data/toggle/toggle.story.tsx +0 -104
  27. package/src/components/data/toggle-group/toggle-group.story.tsx +0 -118
  28. package/src/components/feedback/alert/alert.story.tsx +0 -77
  29. package/src/components/feedback/empty/empty.story.tsx +0 -115
  30. package/src/components/feedback/preloader/preloader.story.tsx +0 -86
  31. package/src/components/feedback/spinner/spinner.story.tsx +0 -66
  32. package/src/components/forms/button/button.story.tsx +0 -116
  33. package/src/components/forms/button-download/button-download.story.tsx +0 -112
  34. package/src/components/forms/button-group/button-group.story.tsx +0 -79
  35. package/src/components/forms/checkbox/checkbox.story.tsx +0 -89
  36. package/src/components/forms/input/input.story.tsx +0 -77
  37. package/src/components/forms/input-group/input-group.story.tsx +0 -119
  38. package/src/components/forms/input-otp/input-otp.story.tsx +0 -105
  39. package/src/components/forms/label/label.story.tsx +0 -52
  40. package/src/components/forms/radio-group/radio-group.story.tsx +0 -113
  41. package/src/components/forms/slider/slider.story.tsx +0 -134
  42. package/src/components/forms/switch/switch.story.tsx +0 -98
  43. package/src/components/forms/textarea/textarea.story.tsx +0 -94
  44. package/src/components/layout/aspect-ratio/aspect-ratio.story.tsx +0 -94
  45. package/src/components/layout/card/card.story.tsx +0 -105
  46. package/src/components/layout/resizable/resizable.story.tsx +0 -119
  47. package/src/components/layout/scroll-area/scroll-area.story.tsx +0 -172
  48. package/src/components/layout/separator/separator.story.tsx +0 -69
  49. package/src/components/layout/skeleton/skeleton.story.tsx +0 -101
  50. package/src/components/navigation/accordion/accordion.story.tsx +0 -110
  51. package/src/components/navigation/collapsible/collapsible.story.tsx +0 -133
  52. package/src/components/navigation/command/command.story.tsx +0 -121
  53. package/src/components/navigation/context-menu/context-menu.story.tsx +0 -125
  54. package/src/components/navigation/dropdown-menu/dropdown-menu.story.tsx +0 -208
  55. package/src/components/navigation/menubar/menubar.story.tsx +0 -152
  56. package/src/components/navigation/navigation-menu/navigation-menu.story.tsx +0 -154
  57. package/src/components/navigation/tabs/tabs.story.tsx +0 -98
  58. package/src/components/overlay/alert-dialog/alert-dialog.story.tsx +0 -104
  59. package/src/components/overlay/dialog/dialog.story.tsx +0 -212
  60. package/src/components/overlay/drawer/drawer.story.tsx +0 -359
  61. package/src/components/overlay/hover-card/hover-card.story.tsx +0 -102
  62. package/src/components/overlay/popover/popover.story.tsx +0 -127
  63. package/src/components/overlay/responsive-sheet/responsive-sheet.story.tsx +0 -117
  64. package/src/components/overlay/sheet/sheet.story.tsx +0 -148
  65. package/src/components/overlay/tooltip/tooltip.story.tsx +0 -139
  66. package/src/components/select/combobox-async.story.tsx +0 -215
  67. package/src/components/select/combobox.story.tsx +0 -226
  68. package/src/components/select/country-select.story.tsx +0 -261
  69. package/src/components/select/language-select.story.tsx +0 -264
  70. package/src/components/select/multi-select.story.tsx +0 -122
  71. package/src/components/select/select.story.tsx +0 -112
  72. package/src/components/specialized/copy/copy.story.tsx +0 -77
  73. package/src/components/specialized/flag/flag.story.tsx +0 -82
  74. package/src/components/specialized/image-with-fallback/image-with-fallback.story.tsx +0 -105
  75. package/src/components/specialized/kbd/kbd.story.tsx +0 -113
  76. package/src/lib/dialog-service/dialog-service.story.tsx +0 -263
  77. package/src/stories/index.ts +0 -28
  78. 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 - useful for context-specific shortcuts */
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
- /** Enable in input fields and textareas */
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
- /** Enable when contentEditable element is focused */
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
- * Simple wrapper hook for react-hotkeys-hook
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
- * // Key combination
43
- * useHotkey('ctrl+s', (e) => {
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
- * // Multiple keys (any of them will trigger)
50
- * useHotkey(['ArrowLeft', '['], () => goToPrevious());
51
- * useHotkey(['ArrowRight', ']'], () => goToNext());
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
- * // With options
55
- * useHotkey('/', () => focusSearch(), {
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
- * // Scoped hotkeys
63
- * useHotkey('delete', () => deleteItem(), { scopes: ['list-view'] });
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 = false,
78
- enableOnFormTags = false,
79
- enableOnContentEditable = false,
80
- description: _description,
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