@djangocfg/ui-core 2.1.381 → 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 +85 -21
- package/package.json +4 -4
- package/src/components/boundary/Boundary.tsx +204 -33
- package/src/components/boundary/README.md +249 -0
- package/src/components/boundary/boundary.story.tsx +87 -5
- 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
|
@@ -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.
|
|
@@ -2,17 +2,17 @@ import { defineStory, useSelect } from '@djangocfg/playground';
|
|
|
2
2
|
import { useState } from 'react';
|
|
3
3
|
|
|
4
4
|
import { Button } from '../forms/button';
|
|
5
|
-
import { Boundary } from '.';
|
|
6
|
-
import type { BoundaryVariant } from '.';
|
|
5
|
+
import { Boundary, useBoundary } from '.';
|
|
6
|
+
import type { BoundaryRenderProps, BoundaryVariant } from '.';
|
|
7
7
|
|
|
8
8
|
export default defineStory({
|
|
9
9
|
title: 'Core/Boundary',
|
|
10
10
|
component: Boundary,
|
|
11
11
|
description:
|
|
12
|
-
'React error boundary with multiple visual variants. Wrap untrusted subtrees (widgets, third-party iframes, dynamic renderers) so a single render error does not crash the whole page.',
|
|
12
|
+
'React error boundary with multiple visual variants. Wrap untrusted subtrees (widgets, third-party iframes, dynamic renderers) so a single render error does not crash the whole page. Includes `useBoundary()` hook for async / event-handler errors.',
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
-
function BoomButton({ label = 'Throw error' }: { label?: string }) {
|
|
15
|
+
function BoomButton({ label = 'Throw render error' }: { label?: string }) {
|
|
16
16
|
const [boom, setBoom] = useState(false);
|
|
17
17
|
if (boom) throw new Error('Demo crash from BoomButton');
|
|
18
18
|
return (
|
|
@@ -90,13 +90,36 @@ export const CustomFallback = () => (
|
|
|
90
90
|
</div>
|
|
91
91
|
);
|
|
92
92
|
|
|
93
|
+
function MyFallback({ error, reset }: BoundaryRenderProps) {
|
|
94
|
+
return (
|
|
95
|
+
<div className="rounded-md border border-blue-500/40 bg-blue-500/5 p-4">
|
|
96
|
+
<p className="text-sm font-semibold text-blue-700">FallbackComponent prop</p>
|
|
97
|
+
<p className="mt-1 text-xs text-blue-700/80">{error.message}</p>
|
|
98
|
+
<Button size="sm" variant="outline" className="mt-2" onClick={reset}>
|
|
99
|
+
Reset
|
|
100
|
+
</Button>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const FallbackComponentProp = () => (
|
|
106
|
+
<div className="max-w-lg space-y-2">
|
|
107
|
+
<p className="text-sm text-muted-foreground">
|
|
108
|
+
Pass a component type instead of a render function — better memoization, easier to test.
|
|
109
|
+
</p>
|
|
110
|
+
<Boundary FallbackComponent={MyFallback}>
|
|
111
|
+
<BoomButton />
|
|
112
|
+
</Boundary>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
|
|
93
116
|
export const ResetKeys = () => {
|
|
94
117
|
const [key, setKey] = useState(0);
|
|
95
118
|
return (
|
|
96
119
|
<div className="max-w-lg space-y-3">
|
|
97
120
|
<p className="text-sm text-muted-foreground">
|
|
98
121
|
Pass <code className="text-xs">resetKeys</code> — when any value in the array changes, the boundary auto-resets.
|
|
99
|
-
Good for clearing errors on route change.
|
|
122
|
+
Good for clearing errors on route change (e.g. <code className="text-xs">[pathname]</code>).
|
|
100
123
|
</p>
|
|
101
124
|
<Button size="sm" onClick={() => setKey((k) => k + 1)}>
|
|
102
125
|
Bump resetKey ({key})
|
|
@@ -107,3 +130,62 @@ export const ResetKeys = () => {
|
|
|
107
130
|
</div>
|
|
108
131
|
);
|
|
109
132
|
};
|
|
133
|
+
|
|
134
|
+
export const OnResetCallback = () => {
|
|
135
|
+
const [resets, setResets] = useState<string[]>([]);
|
|
136
|
+
return (
|
|
137
|
+
<div className="max-w-lg space-y-3">
|
|
138
|
+
<p className="text-sm text-muted-foreground">
|
|
139
|
+
<code className="text-xs">onReset</code> fires when the boundary recovers — wire it to React Query invalidation, refetch, etc.
|
|
140
|
+
</p>
|
|
141
|
+
<Boundary
|
|
142
|
+
variant="card"
|
|
143
|
+
onReset={(details) =>
|
|
144
|
+
setResets((prev) => [...prev, `reset @ ${new Date().toISOString().slice(11, 19)} (${details.reason})`])
|
|
145
|
+
}
|
|
146
|
+
>
|
|
147
|
+
<BoomButton />
|
|
148
|
+
</Boundary>
|
|
149
|
+
<div className="text-xs text-muted-foreground">
|
|
150
|
+
Resets:
|
|
151
|
+
<ul className="mt-1 space-y-0.5">
|
|
152
|
+
{resets.map((r, i) => (
|
|
153
|
+
<li key={i}>· {r}</li>
|
|
154
|
+
))}
|
|
155
|
+
</ul>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
function AsyncCrasher() {
|
|
162
|
+
const { showBoundary } = useBoundary();
|
|
163
|
+
return (
|
|
164
|
+
<Button
|
|
165
|
+
variant="outline"
|
|
166
|
+
size="sm"
|
|
167
|
+
onClick={async () => {
|
|
168
|
+
// Regular React boundaries DON'T catch this. useBoundary() does.
|
|
169
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
170
|
+
try {
|
|
171
|
+
throw new Error('Async fetch failed after 200ms');
|
|
172
|
+
} catch (err) {
|
|
173
|
+
showBoundary(err);
|
|
174
|
+
}
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
Throw async error
|
|
178
|
+
</Button>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export const AsyncErrorsViaHook = () => (
|
|
183
|
+
<div className="max-w-lg space-y-2">
|
|
184
|
+
<p className="text-sm text-muted-foreground">
|
|
185
|
+
React error boundaries only catch <em>render</em> errors. Use <code className="text-xs">useBoundary().showBoundary(err)</code> to push async / event-handler errors into the nearest boundary.
|
|
186
|
+
</p>
|
|
187
|
+
<Boundary variant="card" name="async-demo">
|
|
188
|
+
<AsyncCrasher />
|
|
189
|
+
</Boundary>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
@@ -1,2 +1,9 @@
|
|
|
1
|
-
export { Boundary } from './Boundary';
|
|
2
|
-
export type {
|
|
1
|
+
export { Boundary, useBoundary } from './Boundary';
|
|
2
|
+
export type {
|
|
3
|
+
BoundaryProps,
|
|
4
|
+
BoundaryVariant,
|
|
5
|
+
BoundaryRenderProps,
|
|
6
|
+
BoundaryResetReason,
|
|
7
|
+
BoundaryResetDetails,
|
|
8
|
+
BoundaryLogger,
|
|
9
|
+
} from './Boundary';
|
package/src/components/index.ts
CHANGED
|
@@ -165,8 +165,15 @@ export { Toaster } from './feedback/sonner';
|
|
|
165
165
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
166
166
|
// Boundary
|
|
167
167
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
168
|
-
export { Boundary } from './boundary';
|
|
169
|
-
export type {
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
"
|
|
251
|
-
|
|
252
|
-
className
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
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';
|