@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,249 @@
1
+ # Boundary
2
+
3
+ React error boundary primitive with four visual variants, a `useBoundary()` hook for async errors, and safe-by-default behaviour (no infinite loops, normalized errors, accessible fallbacks).
4
+
5
+ Designed as a drop-in replacement for `react-error-boundary` with batteries included: built-in fallback variants, dev logging, optional context-based escape hatch.
6
+
7
+ ---
8
+
9
+ ## When to use
10
+
11
+ | Situation | Component |
12
+ |---|---|
13
+ | Wrap a non-critical widget (chat launcher, embed, ad) | `<Boundary variant="silent">` |
14
+ | Wrap a row / inline block (table cell, list item) | `<Boundary variant="inline">` |
15
+ | Wrap a feature panel (analytics card, settings group) | `<Boundary>` (card, default) |
16
+ | Wrap an entire screen / root layout | `<Boundary variant="fullscreen">` |
17
+ | Page/layout level with backend reporting | `<MonitorBoundary>` from `@djangocfg/layouts` |
18
+
19
+ **Granularity matters.** One global boundary at the app root hides bugs and ruins UX (whole page = error screen). Prefer many small per-feature boundaries.
20
+
21
+ ---
22
+
23
+ ## Basic usage
24
+
25
+ ```tsx
26
+ import { Boundary } from '@djangocfg/ui-core';
27
+
28
+ <Boundary variant="card" name="dashboard-stats">
29
+ <StatsPanel />
30
+ </Boundary>
31
+ ```
32
+
33
+ The `name` prop is used in dev console logs — set it to something findable.
34
+
35
+ ---
36
+
37
+ ## Variants
38
+
39
+ ```tsx
40
+ <Boundary variant="silent">…</Boundary> // renders null on error
41
+ <Boundary variant="inline">…</Boundary> // compact one-line alert + Retry
42
+ <Boundary variant="card">…</Boundary> // bordered card with Retry (default)
43
+ <Boundary variant="fullscreen">…</Boundary> // centered fullscreen + Refresh page
44
+ ```
45
+
46
+ All visible variants get `role="alert"` and `aria-live="assertive"` so screen readers announce them.
47
+
48
+ ---
49
+
50
+ ## Custom fallback
51
+
52
+ Two forms — pick by use case:
53
+
54
+ ```tsx
55
+ // Render-prop: simple inline cases
56
+ <Boundary fallback={({ error, reset }) => <MyError onRetry={reset} />}>…</Boundary>
57
+
58
+ // Component: better for memoization / testing
59
+ function MyFallback({ error, errorInfo, reset }: BoundaryRenderProps) {
60
+ return <ErrorCard message={error.message} onRetry={reset} />;
61
+ }
62
+ <Boundary FallbackComponent={MyFallback}>…</Boundary>
63
+ ```
64
+
65
+ `fallback` (function form) takes precedence over `FallbackComponent`. Both receive `{ error, errorInfo, reset }`.
66
+
67
+ ---
68
+
69
+ ## Reset patterns
70
+
71
+ ### 1. Auto-reset on route change
72
+
73
+ ```tsx
74
+ const pathname = usePathname();
75
+ <Boundary resetKeys={[pathname]}>{children}</Boundary>
76
+ ```
77
+
78
+ When any value in `resetKeys` changes (shallow `Object.is` per index), the boundary clears its error state.
79
+
80
+ **⚠ Anti-pattern:** `resetKeys={[Math.random()]}` or `resetKeys={[{}, []]}` causes an infinite reset loop because the array contents change on every render.
81
+
82
+ ### 2. React Query / refetch on recovery
83
+
84
+ ```tsx
85
+ <Boundary
86
+ onReset={({ reason }) => {
87
+ // reason === 'imperative' (Retry button) or 'keys' (resetKeys changed)
88
+ queryClient.invalidateQueries({ queryKey: ['dashboard'] });
89
+ }}
90
+ >
91
+ <Dashboard />
92
+ </Boundary>
93
+ ```
94
+
95
+ `onReset` is called in a microtask **after** state is cleared — your refetch sees a fresh component.
96
+
97
+ ### 3. Full remount (rare)
98
+
99
+ If you need to fully discard component state (not just the error), use React's `key` prop instead:
100
+
101
+ ```tsx
102
+ <Boundary key={feature.id} resetKeys={[feature.id]}>…</Boundary>
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Async / event-handler errors
108
+
109
+ **React error boundaries do not catch async errors.** That's a React limitation, not a bug.
110
+
111
+ Use `useBoundary()` to push them into the nearest boundary:
112
+
113
+ ```tsx
114
+ import { useBoundary } from '@djangocfg/ui-core';
115
+
116
+ function LoadButton() {
117
+ const { showBoundary, resetBoundary } = useBoundary();
118
+
119
+ return (
120
+ <Button onClick={async () => {
121
+ try {
122
+ await api.loadData();
123
+ } catch (err) {
124
+ showBoundary(err); // bubbles up to the closest <Boundary>
125
+ }
126
+ }}>
127
+ Load
128
+ </Button>
129
+ );
130
+ }
131
+ ```
132
+
133
+ `useBoundary()` works inside any component rendered under a `<Boundary>`. Throws if used outside one.
134
+
135
+ ---
136
+
137
+ ## Logging
138
+
139
+ By default, caught errors are logged with `console.error` in development and silenced in production. Wire to your telemetry by passing `logger` or `onError`:
140
+
141
+ ```tsx
142
+ <Boundary
143
+ onError={(error, info) => {
144
+ Sentry.captureException(error, { extra: { componentStack: info.componentStack } });
145
+ }}
146
+ >
147
+
148
+ </Boundary>
149
+ ```
150
+
151
+ Or use `MonitorBoundary` from `@djangocfg/layouts` for automatic reporting to `@djangocfg/monitor`.
152
+
153
+ ---
154
+
155
+ ## Safety guarantees
156
+
157
+ - **Fallback can throw safely.** If the user's fallback render itself errors, the boundary degrades to a minimal static alert instead of looping forever.
158
+ - **`onError` / `onReset` can throw safely.** Caught and logged via `logger`. Won't crash the boundary.
159
+ - **Non-`Error` throws are normalized.** `throw 'oops'`, `throw { code: 500 }` and bare objects become real `Error` instances with stack traces (where possible).
160
+ - **No duplicate `onError` calls.** Internal tracking prevents firing twice for the same error instance.
161
+ - **Reset clears state before `onReset`.** Errors thrown inside `onReset` don't re-trigger the just-cleared boundary.
162
+
163
+ ---
164
+
165
+ ## Pairing with `<Suspense>`
166
+
167
+ Put `<Boundary>` **outside** `<Suspense>` — otherwise errors thrown by a suspended component bubble past it.
168
+
169
+ ```tsx
170
+ <Boundary variant="card">
171
+ <Suspense fallback={<Spinner />}>
172
+ <DataPanel />
173
+ </Suspense>
174
+ </Boundary>
175
+ ```
176
+
177
+ ---
178
+
179
+ ## Edge cases & gotchas
180
+
181
+ | Issue | Cause | Solution |
182
+ |---|---|---|
183
+ | Boundary doesn't catch error | Error thrown in async code / event handler | Use `useBoundary().showBoundary(err)` |
184
+ | Boundary doesn't catch error (SSR) | `componentDidCatch` doesn't run on server | Errors are caught on client hydration. For SSR-only errors, use Next.js `error.tsx` |
185
+ | Infinite reset loop | `resetKeys` contains unstable values | Don't put new objects/arrays/random in `resetKeys` |
186
+ | Fallback renders briefly with stale data | React 18 concurrent rendering / `useDeferredValue` | Expected — the boundary unmounts children on error |
187
+ | `error.stack` is missing | Code threw a non-`Error` value | Boundary normalizes, but stack is reconstructed from current line. Prefer `throw new Error(...)` |
188
+ | Component stack only in dev | React strips it in prod by default | Use `onError`'s `info.componentStack` server-side via monitor |
189
+
190
+ ---
191
+
192
+ ## API
193
+
194
+ ```ts
195
+ interface BoundaryProps {
196
+ children: ReactNode;
197
+ variant?: 'silent' | 'inline' | 'card' | 'fullscreen'; // default 'card'
198
+ fallback?: ReactNode | ((props: BoundaryRenderProps) => ReactNode);
199
+ FallbackComponent?: ComponentType<BoundaryRenderProps>;
200
+ resetKeys?: ReadonlyArray<unknown>;
201
+ onError?: (error: Error, info: ErrorInfo) => void;
202
+ onReset?: (details: BoundaryResetDetails) => void;
203
+ name?: string; // dev log tag
204
+ className?: string; // fallback wrapper className
205
+ logger?: BoundaryLogger; // override default console.error in dev
206
+ }
207
+
208
+ interface BoundaryRenderProps {
209
+ error: Error;
210
+ errorInfo: ErrorInfo | null;
211
+ reset: () => void;
212
+ }
213
+
214
+ interface BoundaryResetDetails {
215
+ reason: 'imperative' | 'keys';
216
+ prevResetKeys?: ReadonlyArray<unknown>;
217
+ nextResetKeys?: ReadonlyArray<unknown>;
218
+ }
219
+
220
+ function useBoundary(): {
221
+ showBoundary: (error: unknown) => void;
222
+ resetBoundary: () => void;
223
+ };
224
+ ```
225
+
226
+ ---
227
+
228
+ ## Comparison with `react-error-boundary`
229
+
230
+ | Feature | `react-error-boundary` | `@djangocfg/ui-core` `Boundary` |
231
+ |---|---|---|
232
+ | `fallback` / `fallbackRender` / `FallbackComponent` | ✅ separate props | ✅ unified: `fallback` (node or fn) + `FallbackComponent` |
233
+ | `resetKeys` | ✅ | ✅ (shallow `Object.is` per index) |
234
+ | `onError` | ✅ | ✅ |
235
+ | `onReset` | ✅ | ✅ (with `reason` + prev/next keys) |
236
+ | `useErrorBoundary()` / `useBoundary()` | ✅ | ✅ |
237
+ | Built-in visual variants | ❌ | ✅ silent / inline / card / fullscreen |
238
+ | Safe fallback rendering | ❌ (can loop) | ✅ |
239
+ | `onError` / `onReset` thrown errors caught | ❌ | ✅ |
240
+ | Non-Error normalization | ❌ | ✅ |
241
+ | Accessible defaults (`role`, `aria-live`) | ❌ | ✅ |
242
+ | Backend telemetry integration | manual | `MonitorBoundary` wrapper |
243
+
244
+ ---
245
+
246
+ ## Related
247
+
248
+ - **`MonitorBoundary`** (`@djangocfg/layouts`) — wraps `Boundary` and reports to `@djangocfg/monitor` automatically.
249
+ - **Next.js `error.tsx`** — handles route-level errors. Use `Boundary` *inside* pages for finer granularity.
@@ -1,2 +1,9 @@
1
- export { Boundary } from './Boundary';
2
- export type { BoundaryProps, BoundaryVariant, BoundaryRenderProps } from './Boundary';
1
+ export { Boundary, useBoundary } from './Boundary';
2
+ export type {
3
+ BoundaryProps,
4
+ BoundaryVariant,
5
+ BoundaryRenderProps,
6
+ BoundaryResetReason,
7
+ BoundaryResetDetails,
8
+ BoundaryLogger,
9
+ } from './Boundary';
@@ -165,8 +165,15 @@ export { Toaster } from './feedback/sonner';
165
165
  // ─────────────────────────────────────────────────────────────────────────────
166
166
  // Boundary
167
167
  // ─────────────────────────────────────────────────────────────────────────────
168
- export { Boundary } from './boundary';
169
- export type { BoundaryProps, BoundaryVariant, BoundaryRenderProps } from './boundary';
168
+ export { Boundary, useBoundary } from './boundary';
169
+ export type {
170
+ BoundaryProps,
171
+ BoundaryVariant,
172
+ BoundaryRenderProps,
173
+ BoundaryResetReason,
174
+ BoundaryResetDetails,
175
+ BoundaryLogger,
176
+ } from './boundary';
170
177
 
171
178
  // ─────────────────────────────────────────────────────────────────────────────
172
179
  // Specialized
@@ -38,6 +38,23 @@ export interface ComboboxProps {
38
38
  disabled?: boolean
39
39
  renderOption?: (option: ComboboxOption) => React.ReactNode
40
40
  renderValue?: (option: ComboboxOption | undefined) => React.ReactNode
41
+ /**
42
+ * Replace the default `<Button variant="outline" w-full>` trigger
43
+ * entirely. Use when the default trigger is too wide / too heavy for
44
+ * the host (e.g. a 28×28 flag icon in a chat header). The element
45
+ * receives Radix `PopoverTrigger asChild` props (refs + a11y) so
46
+ * pass any native element here — `button`, `div role="button"`, etc.
47
+ */
48
+ renderTrigger?: (
49
+ selected: ComboboxOption | undefined,
50
+ open: boolean,
51
+ ) => React.ReactElement
52
+ /** Forwarded to `PopoverContent.className` — width, padding, etc. */
53
+ contentClassName?: string
54
+ /** Forwarded to `PopoverContent.style` — host can bump z-index when
55
+ * the combobox is rendered above another overlay (chat dock,
56
+ * modal, …) whose stacking context outranks ui-core's default. */
57
+ contentStyle?: React.CSSProperties
41
58
  /** Custom filter function. If provided, replaces default filtering logic. */
42
59
  filterFunction?: (option: ComboboxOption, search: string) => boolean
43
60
  /**
@@ -80,6 +97,9 @@ export function Combobox({
80
97
  disabled = false,
81
98
  renderOption,
82
99
  renderValue,
100
+ renderTrigger,
101
+ contentClassName,
102
+ contentStyle,
83
103
  filterFunction,
84
104
  storageKey,
85
105
  storageType,
@@ -242,26 +262,34 @@ export function Combobox({
242
262
  }}
243
263
  >
244
264
  <PopoverTrigger asChild>
245
- <Button
246
- variant="outline"
247
- role="combobox"
248
- aria-expanded={open}
249
- className={cn(
250
- "w-full justify-between",
251
- !value && "text-muted-foreground",
252
- className
253
- )}
254
- disabled={disabled}
255
- >
256
- {renderValue && selectedOption
257
- ? renderValue(selectedOption)
258
- : selectedOption
259
- ? renderSelectedBadge(selectedOption)
260
- : resolvedPlaceholder}
261
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
262
- </Button>
265
+ {renderTrigger ? (
266
+ renderTrigger(selectedOption, open)
267
+ ) : (
268
+ <Button
269
+ variant="outline"
270
+ role="combobox"
271
+ aria-expanded={open}
272
+ className={cn(
273
+ "w-full justify-between",
274
+ !value && "text-muted-foreground",
275
+ className
276
+ )}
277
+ disabled={disabled}
278
+ >
279
+ {renderValue && selectedOption
280
+ ? renderValue(selectedOption)
281
+ : selectedOption
282
+ ? renderSelectedBadge(selectedOption)
283
+ : resolvedPlaceholder}
284
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
285
+ </Button>
286
+ )}
263
287
  </PopoverTrigger>
264
- <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
288
+ <PopoverContent
289
+ className={cn("w-[var(--radix-popover-trigger-width)] p-0", contentClassName)}
290
+ style={contentStyle}
291
+ align="start"
292
+ >
265
293
  <Command shouldFilter={false} className="flex flex-col">
266
294
  <CommandInput
267
295
  placeholder={resolvedSearchPlaceholder}
@@ -0,0 +1,172 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Generic notification-sound bus.
5
+ *
6
+ * Lifted from `ui-tools/Chat/core/audio/audioBus` and generalised over an
7
+ * arbitrary event-key type so any feature (chat, presence, alerts, …)
8
+ * can reuse the same Safari unlock + multi-fire-safe playback infra.
9
+ *
10
+ * Pitfalls this addresses:
11
+ * - Safari needs a user-gesture transaction to unlock playback. We
12
+ * pre-allocate an `<audio>` per cached URL and play() each (muted)
13
+ * during the unlock event so the whole bus lifts at once.
14
+ * - Rapid play() calls on the same element cancel each other — we
15
+ * clone a fresh `HTMLAudioElement` per fire (HTTP cache reuses the
16
+ * underlying bytes for free).
17
+ * - SSR safety: all DOM access is gated; module imports never touch
18
+ * `window`.
19
+ * - `play()` returns a Promise — we attach `.catch()` everywhere so
20
+ * blocked-autoplay warnings don't reach the console.
21
+ */
22
+
23
+ export interface SoundBusOptions<E extends string> {
24
+ /** Map an event key to a sound URL. `false`/missing silences the event. */
25
+ sounds: Partial<Record<E, string | false>>;
26
+ /**
27
+ * Volume 0..1 used for the next `play()` call. Receives the event so
28
+ * callers can scale per-event (e.g. quieter error sounds).
29
+ */
30
+ getVolume: (event?: E) => number;
31
+ /** Master mute. Read on every play. */
32
+ getMuted: () => boolean;
33
+ /** Per-event predicate. Return `false` to suppress one event. */
34
+ isEnabled: (event: E) => boolean;
35
+ }
36
+
37
+ export interface SoundBus<E extends string> {
38
+ play: (event: E) => void;
39
+ preload: (event: E) => void;
40
+ unlock: () => void;
41
+ isUnlocked: () => boolean;
42
+ subscribeUnlock: (cb: (unlocked: boolean) => void) => () => void;
43
+ setSounds: (sounds: Partial<Record<E, string | false>>) => void;
44
+ dispose: () => void;
45
+ }
46
+
47
+ // One unlock state per tab — first gesture inside ANY bus unlocks every
48
+ // bus. Matches AudioPlayer's ADR-004 "global per tab" rule.
49
+ let unlocked = false;
50
+ const unlockListeners = new Set<(v: boolean) => void>();
51
+
52
+ function setUnlocked(value: boolean) {
53
+ if (unlocked === value) return;
54
+ unlocked = value;
55
+ for (const cb of unlockListeners) cb(value);
56
+ }
57
+
58
+ /** Test helper — resets unlock state between tests. */
59
+ export function _resetUnlockForTesting(): void {
60
+ unlocked = false;
61
+ unlockListeners.clear();
62
+ }
63
+
64
+ export function createSoundBus<E extends string>(
65
+ options: SoundBusOptions<E>,
66
+ ): SoundBus<E> {
67
+ if (typeof window === 'undefined') return noopBus<E>();
68
+
69
+ let sounds = options.sounds;
70
+ const cache = new Map<string, HTMLAudioElement>();
71
+
72
+ const getOrCreate = (url: string): HTMLAudioElement => {
73
+ const hit = cache.get(url);
74
+ if (hit) return hit;
75
+ const el = new Audio(url);
76
+ el.preload = 'auto';
77
+ el.crossOrigin = 'anonymous';
78
+ cache.set(url, el);
79
+ return el;
80
+ };
81
+
82
+ const resolveUrl = (event: E): string | null => {
83
+ const v = sounds[event];
84
+ if (!v) return null;
85
+ return v;
86
+ };
87
+
88
+ const play = (event: E) => {
89
+ if (options.getMuted()) return;
90
+ if (!options.isEnabled(event)) return;
91
+ const url = resolveUrl(event);
92
+ if (!url) return;
93
+
94
+ getOrCreate(url);
95
+ const fresh = new Audio(url);
96
+ fresh.preload = 'auto';
97
+ fresh.volume = clamp01(options.getVolume(event));
98
+ const p = fresh.play();
99
+ if (p && typeof p.catch === 'function') {
100
+ p.catch(() => {
101
+ // Browser blocked playback (no gesture yet) — ignore.
102
+ });
103
+ }
104
+ };
105
+
106
+ const preload = (event: E) => {
107
+ const url = resolveUrl(event);
108
+ if (!url) return;
109
+ const el = getOrCreate(url);
110
+ try {
111
+ el.load();
112
+ } catch {
113
+ // ignore
114
+ }
115
+ };
116
+
117
+ const unlock = () => {
118
+ if (unlocked) return;
119
+ for (const el of cache.values()) {
120
+ const wasMuted = el.muted;
121
+ el.muted = true;
122
+ const p = el.play();
123
+ if (p && typeof p.then === 'function') {
124
+ p.then(() => {
125
+ el.pause();
126
+ el.currentTime = 0;
127
+ el.muted = wasMuted;
128
+ }).catch(() => {
129
+ el.muted = wasMuted;
130
+ });
131
+ } else {
132
+ el.pause();
133
+ el.muted = wasMuted;
134
+ }
135
+ }
136
+ setUnlocked(true);
137
+ };
138
+
139
+ return {
140
+ play,
141
+ preload,
142
+ unlock,
143
+ isUnlocked: () => unlocked,
144
+ subscribeUnlock(cb) {
145
+ unlockListeners.add(cb);
146
+ return () => unlockListeners.delete(cb);
147
+ },
148
+ setSounds(next) {
149
+ sounds = next;
150
+ },
151
+ dispose() {
152
+ cache.clear();
153
+ },
154
+ };
155
+ }
156
+
157
+ function clamp01(v: number): number {
158
+ if (!Number.isFinite(v)) return 1;
159
+ return v < 0 ? 0 : v > 1 ? 1 : v;
160
+ }
161
+
162
+ function noopBus<E extends string>(): SoundBus<E> {
163
+ return {
164
+ play: () => undefined,
165
+ preload: () => undefined,
166
+ unlock: () => undefined,
167
+ isUnlocked: () => false,
168
+ subscribeUnlock: () => () => undefined,
169
+ setSounds: () => undefined,
170
+ dispose: () => undefined,
171
+ };
172
+ }
@@ -0,0 +1,21 @@
1
+ export {
2
+ createSoundBus,
3
+ _resetUnlockForTesting,
4
+ type SoundBus,
5
+ type SoundBusOptions,
6
+ } from './createSoundBus';
7
+ export {
8
+ createAudioPrefsStore,
9
+ useAudioPrefs,
10
+ type AudioPrefsState,
11
+ } from './useAudioPrefs';
12
+ export {
13
+ useNotificationSounds,
14
+ type NotificationSoundsConfig,
15
+ type NotificationSoundsApi,
16
+ } from './useNotificationSounds';
17
+ export {
18
+ useSoundEffect,
19
+ type SoundEffectOptions,
20
+ type SoundEffectApi,
21
+ } from './useSoundEffect';
@@ -0,0 +1,91 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Persistent audio preferences (volume / mute / per-event toggles).
5
+ *
6
+ * One zustand store per `storageKey` — instances with different keys
7
+ * persist independently (chat / alerts / per-product). Cross-tab sync
8
+ * via the `storage` event ships with zustand's persist middleware.
9
+ */
10
+
11
+ import { create, type StoreApi, type UseBoundStore } from 'zustand';
12
+ import { persist, createJSONStorage } from 'zustand/middleware';
13
+
14
+ export interface AudioPrefsState<E extends string = string> {
15
+ /** 0..1 master volume. */
16
+ volume: number;
17
+ /** Master mute (overrides per-event toggles). */
18
+ muted: boolean;
19
+ /** Per-event opt-out — `false` silences a single trigger. */
20
+ enabled: Partial<Record<E, boolean>>;
21
+ setVolume: (v: number) => void;
22
+ setMuted: (m: boolean) => void;
23
+ setEventEnabled: (event: E, enabled: boolean) => void;
24
+ }
25
+
26
+ const clamp01 = (v: number): number => {
27
+ if (!Number.isFinite(v)) return 1;
28
+ return v < 0 ? 0 : v > 1 ? 1 : v;
29
+ };
30
+
31
+ /**
32
+ * Per-key registry so repeated calls with the same `storageKey` return
33
+ * the same store instance — necessary for cross-component state sharing
34
+ * (e.g. ChatHeader audio toggle + ChatLauncher bus reading the same key).
35
+ */
36
+ const registry = new Map<string, UseBoundStore<StoreApi<AudioPrefsState<string>>>>();
37
+
38
+ export function createAudioPrefsStore<E extends string = string>(
39
+ storageKey: string,
40
+ ): UseBoundStore<StoreApi<AudioPrefsState<E>>> {
41
+ const cached = registry.get(storageKey);
42
+ if (cached) return cached as unknown as UseBoundStore<StoreApi<AudioPrefsState<E>>>;
43
+
44
+ const store = create<AudioPrefsState<E>>()(
45
+ persist(
46
+ (set) => ({
47
+ volume: 1,
48
+ muted: false,
49
+ enabled: {},
50
+ setVolume: (v) => set({ volume: clamp01(v) }),
51
+ setMuted: (m) => set({ muted: !!m }),
52
+ setEventEnabled: (event, enabled) =>
53
+ set((s) => ({ enabled: { ...s.enabled, [event]: enabled } })),
54
+ }),
55
+ {
56
+ name: storageKey,
57
+ storage: createJSONStorage(() => {
58
+ if (typeof window === 'undefined') {
59
+ return {
60
+ getItem: () => null,
61
+ setItem: () => undefined,
62
+ removeItem: () => undefined,
63
+ };
64
+ }
65
+ return window.localStorage;
66
+ }),
67
+ partialize: (s) => ({ volume: s.volume, muted: s.muted, enabled: s.enabled }),
68
+ version: 1,
69
+ },
70
+ ),
71
+ );
72
+
73
+ registry.set(storageKey, store as unknown as UseBoundStore<StoreApi<AudioPrefsState<string>>>);
74
+ return store;
75
+ }
76
+
77
+ /**
78
+ * React hook helper — returns the persisted prefs store hook for a given
79
+ * `storageKey`. Callers can use it as a normal zustand hook with selectors.
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * const usePrefs = useAudioPrefs<'sent' | 'received'>('myapp.audio');
84
+ * const muted = usePrefs((s) => s.muted);
85
+ * ```
86
+ */
87
+ export function useAudioPrefs<E extends string = string>(
88
+ storageKey: string,
89
+ ): UseBoundStore<StoreApi<AudioPrefsState<E>>> {
90
+ return createAudioPrefsStore<E>(storageKey);
91
+ }