@djangocfg/ui-core 2.1.380 → 2.1.382
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -1
- package/package.json +4 -4
- package/src/components/boundary/Boundary.tsx +365 -0
- package/src/components/boundary/README.md +249 -0
- package/src/components/boundary/boundary.story.tsx +191 -0
- package/src/components/boundary/index.ts +9 -0
- package/src/components/index.ts +13 -0
- package/src/components/select/combobox.tsx +47 -19
- package/src/hooks/audio/createSoundBus.ts +172 -0
- package/src/hooks/audio/index.ts +21 -0
- package/src/hooks/audio/useAudioPrefs.ts +91 -0
- package/src/hooks/audio/useNotificationSounds.ts +271 -0
- package/src/hooks/audio/useSoundEffect.ts +78 -0
- package/src/hooks/hotkey/formatHotkey.ts +96 -0
- package/src/hooks/hotkey/index.ts +10 -0
- package/src/hooks/hotkey/useHotkey.ts +106 -34
- package/src/hooks/hotkey/useHotkeyChord.ts +96 -0
- package/src/hooks/hotkey/useHotkeyHelp.ts +68 -0
- package/src/hooks/index.ts +1 -0
package/README.md
CHANGED
|
@@ -37,9 +37,26 @@ Organized in `components/` by category — everything re-exported from the root
|
|
|
37
37
|
| **Layout** | `Card`, `Section`, `Sticky`, `ScrollArea`, `Resizable`, `Separator`, `Skeleton`, `AspectRatio` |
|
|
38
38
|
| **Data** | `Table`, `Badge`, `Avatar`, `Progress`, `Carousel`, `Calendar`, `DatePicker`, `DateRangePicker`, `Toggle`, `ToggleGroup`, `Chart*` |
|
|
39
39
|
| **Feedback** | `Alert`, `Spinner`, `Empty`, `Preloader`, `Toaster` (Sonner) |
|
|
40
|
+
| **Boundary** | `Boundary` (React error boundary with `silent`/`inline`/`card`/`fullscreen` variants, `resetKeys`, custom `fallback`) |
|
|
40
41
|
| **Specialized** | `Kbd`, `CopyButton`, `CopyField`, `TokenIcon`, `Item`, `Portal`, `ImageWithFallback`, `Flag`, `LanguageFlag` |
|
|
41
42
|
| **Effects** | `GlowBackground` |
|
|
42
43
|
|
|
44
|
+
### Boundary
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
import { Boundary, useBoundary } from '@djangocfg/ui-core';
|
|
48
|
+
|
|
49
|
+
<Boundary variant="silent"><ChatLauncher /></Boundary>
|
|
50
|
+
<Boundary variant="card" resetKeys={[pathname]}><Panel /></Boundary>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Variants: `silent` / `inline` / `card` / `fullscreen`. Plus `useBoundary()` hook for async errors, `resetKeys`, `onReset`, custom `fallback` / `FallbackComponent`, and safe re-render guarantees.
|
|
54
|
+
|
|
55
|
+
Full docs and patterns: [`src/components/boundary/README.md`](./src/components/boundary/README.md).
|
|
56
|
+
|
|
57
|
+
For automatic backend reporting use `MonitorBoundary` from `@djangocfg/layouts`.
|
|
58
|
+
|
|
59
|
+
|
|
43
60
|
> Pagination, breadcrumb and sidebar live here (not in ui-nextjs). `SSRPagination` reads URL state through `useLocation` + `useQueryParams`, so it works under any router adapter.
|
|
44
61
|
|
|
45
62
|
## Hooks
|
|
@@ -57,7 +74,8 @@ import { useNavigate, useLocation, useQueryParams, useRouter, useIsActive } from
|
|
|
57
74
|
| **State** | `useDebounce`, `useDebouncedCallback`, `useCountdown`, `useImageLoader`, `useMounted` |
|
|
58
75
|
| **DOM** | `useEventListener` |
|
|
59
76
|
| **Theme** | `useThemeColor`, `useThemePalette` (palette-aware hex colors for Canvas/SVG) |
|
|
60
|
-
| **Hotkey** | `useHotkey
|
|
77
|
+
| **Hotkey** | `useHotkey` (smart `inInput` + `preventDefault` policy), `useHotkeyChord` (sequences), `formatHotkey('mod+k') → ⌘K`, `useHotkeyHelp` (auto cheat-sheet) |
|
|
78
|
+
| **Audio** | `createSoundBus`, `useNotificationSounds`, `useAudioPrefs`, `useSoundEffect` — Safari unlock, mute persist, per-event toggles + volume scale, native-host bridge |
|
|
61
79
|
| **Feedback** | `useToast`, `toast` (Sonner) |
|
|
62
80
|
| **Debug** | `useDebugTools` |
|
|
63
81
|
|
|
@@ -75,6 +93,83 @@ import NextLink from 'next/link';
|
|
|
75
93
|
|
|
76
94
|
When no adapter is mounted, `<Link>` falls back to a plain `<a>` and routes clicks through the History API. `@djangocfg/layouts/BaseApp` mounts the Next adapters by default.
|
|
77
95
|
|
|
96
|
+
## Hotkeys
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
import { useHotkey, formatHotkey, useHotkeyChord } from '@djangocfg/ui-core/hooks';
|
|
100
|
+
|
|
101
|
+
// Smart defaults — auto-detects modifiers vs bare keys:
|
|
102
|
+
useHotkey('mod+k', openPalette); // ⌘K everywhere, incl. inputs; preventDefault auto
|
|
103
|
+
useHotkey('/', focusSearch); // bare key — skipped inside inputs
|
|
104
|
+
useHotkey('escape', closeModal); // always fires in inputs (blur / close pattern)
|
|
105
|
+
useHotkey('?', openHelp, { description: 'Show shortcuts' }); // registers in cheat-sheet
|
|
106
|
+
|
|
107
|
+
// Linear-style chords:
|
|
108
|
+
useHotkeyChord(['g', 't'], () => router.push('/tasks'));
|
|
109
|
+
|
|
110
|
+
// OS-aware tooltips:
|
|
111
|
+
formatHotkey('mod+/') // → '⌘/' on Mac, 'Ctrl+/' elsewhere
|
|
112
|
+
formatHotkey('g t') // → 'G then T'
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Built on `react-hotkeys-hook` with an opinionated policy: modifier-combos and `escape` fire inside `<input>` / `<textarea>` / `[contenteditable]` by default; bare keys don't, so they won't hijack typing. Override per-call with `inInput: true | false`.
|
|
116
|
+
|
|
117
|
+
`useHotkeyHelp()` reads a module-level registry populated by every `useHotkey(..., { description })` call — drop it into a `?` cheat-sheet dialog with two lines.
|
|
118
|
+
|
|
119
|
+
## Audio
|
|
120
|
+
|
|
121
|
+
Notification sounds with Safari unlock, persisted mute, and a side-channel for native hosts.
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
import { useNotificationSounds, useSoundEffect } from '@djangocfg/ui-core/hooks';
|
|
125
|
+
|
|
126
|
+
// Map event → URL + persisted mute + per-event toggles
|
|
127
|
+
type Event = 'received' | 'mention' | 'error';
|
|
128
|
+
const sounds = useNotificationSounds<Event>({
|
|
129
|
+
storageKey: 'myapp.audio',
|
|
130
|
+
sounds: { received: '/sfx/r.mp3', mention: '/sfx/m.mp3', error: '/sfx/e.mp3' },
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
onMessage = (m) => sounds.play('received');
|
|
134
|
+
toggle = () => sounds.toggleMute();
|
|
135
|
+
sounds.muted // boolean, persisted
|
|
136
|
+
sounds.isSilent // true if no sounds wired or silenced
|
|
137
|
+
|
|
138
|
+
// One-shot single asset (no persistence, no toggles)
|
|
139
|
+
const ding = useSoundEffect('/sfx/ding.mp3');
|
|
140
|
+
<Button onClick={() => { ding.play(); submit(); }} />
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Native-host bridge.** When a wrapper (Electron / Wails / Tauri) plays sounds outside the browser, set `silenced: true` and pipe `onSoundEvent` to your bridge — web playback stays silent while the backend gets the trigger:
|
|
144
|
+
|
|
145
|
+
```tsx
|
|
146
|
+
useNotificationSounds({
|
|
147
|
+
storageKey: 'cmdop.audio',
|
|
148
|
+
silenced: true,
|
|
149
|
+
onSoundEvent: (event) => window.go.playSound(event),
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Per-event volume scale.** Master volume is multiplied by an optional per-event factor so different sounds can be balanced without juggling source files:
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
useNotificationSounds<Event>({
|
|
157
|
+
storageKey: 'myapp.audio',
|
|
158
|
+
sounds: { received: '/r.mp3', error: '/e.mp3', mention: '/m.mp3' },
|
|
159
|
+
eventVolumes: {
|
|
160
|
+
error: 0.25, // soft ack — destructive UI is the loud signal
|
|
161
|
+
mention: 1, // personal — louder than baseline
|
|
162
|
+
received: 0.7,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The bus respects `prefers-reduced-motion`, `prefers-reduced-data`, and `visibilityState === 'hidden'` by default (each is an opt-out). Multi-component sync: same `storageKey` from different surfaces shares the same store.
|
|
168
|
+
|
|
169
|
+
Lower level: `createSoundBus<E>({ sounds, getMuted, getVolume, isEnabled })` exposes the raw bus if you need to wire your own React state. `getVolume(event)` receives the event so you can scale per-fire.
|
|
170
|
+
|
|
171
|
+
For an opinionated, fully-wired example with bundled audio assets see `useChatAudio` in [`@djangocfg/ui-tools`](../../ui-tools/src/tools/Chat/README.md#audio) — it inlines six notification mp3s into the lazy chat chunk as `data:`-URLs so consumers need no asset setup.
|
|
172
|
+
|
|
78
173
|
## Schema-driven configurators
|
|
79
174
|
|
|
80
175
|
`@djangocfg/ui-core/lib` exports a portable JSON Schema 7 subset (`CustomJsonSchema7`, `CustomJsonUiSchema7`, `CustomJsonUiGroup`, `CustomJsonUiDisabledWhenRule`) for packages that ship configurator schemas without taking a runtime dependency on RJSF.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.382",
|
|
4
4
|
"description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-components",
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
"playground": "playground dev"
|
|
97
97
|
},
|
|
98
98
|
"peerDependencies": {
|
|
99
|
-
"@djangocfg/i18n": "^2.1.
|
|
99
|
+
"@djangocfg/i18n": "^2.1.382",
|
|
100
100
|
"consola": "^3.4.2",
|
|
101
101
|
"lucide-react": "^0.545.0",
|
|
102
102
|
"moment": "^2.30.1",
|
|
@@ -166,9 +166,9 @@
|
|
|
166
166
|
"vaul": "1.1.2"
|
|
167
167
|
},
|
|
168
168
|
"devDependencies": {
|
|
169
|
-
"@djangocfg/i18n": "^2.1.
|
|
169
|
+
"@djangocfg/i18n": "^2.1.382",
|
|
170
170
|
"@djangocfg/playground": "workspace:*",
|
|
171
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
171
|
+
"@djangocfg/typescript-config": "^2.1.382",
|
|
172
172
|
"@types/node": "^24.7.2",
|
|
173
173
|
"@types/react": "^19.1.0",
|
|
174
174
|
"@types/react-dom": "^19.1.0",
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { AlertTriangle, RotateCcw } from 'lucide-react';
|
|
4
|
+
import {
|
|
5
|
+
Component,
|
|
6
|
+
createContext,
|
|
7
|
+
useContext,
|
|
8
|
+
type ComponentType,
|
|
9
|
+
type ErrorInfo,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from 'react';
|
|
12
|
+
|
|
13
|
+
import { isDev } from '../../lib/env';
|
|
14
|
+
import { cn } from '../../lib/utils';
|
|
15
|
+
import { Button } from '../forms/button';
|
|
16
|
+
|
|
17
|
+
export type BoundaryVariant = 'silent' | 'inline' | 'card' | 'fullscreen';
|
|
18
|
+
|
|
19
|
+
export interface BoundaryRenderProps {
|
|
20
|
+
error: Error;
|
|
21
|
+
errorInfo: ErrorInfo | null;
|
|
22
|
+
reset: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type BoundaryResetReason = 'imperative' | 'keys';
|
|
26
|
+
|
|
27
|
+
export interface BoundaryResetDetails {
|
|
28
|
+
reason: BoundaryResetReason;
|
|
29
|
+
prevResetKeys?: ReadonlyArray<unknown>;
|
|
30
|
+
nextResetKeys?: ReadonlyArray<unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BoundaryLogger {
|
|
34
|
+
(message: string, error: Error, info: ErrorInfo | null): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BoundaryProps {
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
/**
|
|
40
|
+
* Visual style of the fallback.
|
|
41
|
+
* - silent: render nothing (good for non-critical widgets like a chat launcher)
|
|
42
|
+
* - inline: compact one-line warning (good for inline blocks inside a page)
|
|
43
|
+
* - card: bordered card with retry button (default; good for panels/features)
|
|
44
|
+
* - fullscreen: centered fullscreen fallback (good for top-level layout)
|
|
45
|
+
* @default 'card'
|
|
46
|
+
*/
|
|
47
|
+
variant?: BoundaryVariant;
|
|
48
|
+
/**
|
|
49
|
+
* Custom fallback. Receives the caught error and a reset() function.
|
|
50
|
+
* Overrides `variant` rendering when provided.
|
|
51
|
+
* Takes precedence over `FallbackComponent`.
|
|
52
|
+
*/
|
|
53
|
+
fallback?: ReactNode | ((props: BoundaryRenderProps) => ReactNode);
|
|
54
|
+
/**
|
|
55
|
+
* Custom fallback component. Use this form instead of `fallback` for
|
|
56
|
+
* better memoization (no inline closures).
|
|
57
|
+
*/
|
|
58
|
+
FallbackComponent?: ComponentType<BoundaryRenderProps>;
|
|
59
|
+
/**
|
|
60
|
+
* Auto-reset the boundary when any of these values change (shallow Object.is per index).
|
|
61
|
+
* Common use: pass `[pathname]` to clear errors on route change.
|
|
62
|
+
* AVOID passing volatile values like `Math.random()` or new object/array literals — causes reset loops.
|
|
63
|
+
*/
|
|
64
|
+
resetKeys?: ReadonlyArray<unknown>;
|
|
65
|
+
/**
|
|
66
|
+
* Called when an error is caught.
|
|
67
|
+
* Hook up Sentry / logging / @djangocfg/monitor here.
|
|
68
|
+
* Use the wrapping `MonitorBoundary` from @djangocfg/layouts for automatic reporting.
|
|
69
|
+
*/
|
|
70
|
+
onError?: (error: Error, info: ErrorInfo) => void;
|
|
71
|
+
/**
|
|
72
|
+
* Called when the boundary recovers (via Retry button or resetKeys change).
|
|
73
|
+
* Use it to refetch data / invalidate caches (e.g. React Query queryClient.invalidateQueries).
|
|
74
|
+
*/
|
|
75
|
+
onReset?: (details: BoundaryResetDetails) => void;
|
|
76
|
+
/**
|
|
77
|
+
* Optional label shown in dev console logs to help locate the source.
|
|
78
|
+
*/
|
|
79
|
+
name?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Extra className for the fallback wrapper (variant: inline / card / fullscreen).
|
|
82
|
+
*/
|
|
83
|
+
className?: string;
|
|
84
|
+
/**
|
|
85
|
+
* Replace default dev console logger. Defaults to `console.error` in development, no-op in production.
|
|
86
|
+
*/
|
|
87
|
+
logger?: BoundaryLogger;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface BoundaryState {
|
|
91
|
+
error: Error | null;
|
|
92
|
+
errorInfo: ErrorInfo | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface BoundaryContextValue {
|
|
96
|
+
/** Programmatically push an error into the nearest boundary (use for async / event handler errors). */
|
|
97
|
+
showBoundary: (error: unknown) => void;
|
|
98
|
+
/** Programmatically clear the boundary (same as the Retry button). */
|
|
99
|
+
resetBoundary: () => void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const BoundaryContext = createContext<BoundaryContextValue | null>(null);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Access the nearest `<Boundary>` programmatically.
|
|
106
|
+
* Lets you funnel errors from async code / event handlers into the boundary
|
|
107
|
+
* (regular React error boundaries don't catch those automatically).
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* const { showBoundary } = useBoundary();
|
|
111
|
+
* useEffect(() => {
|
|
112
|
+
* fetchData().catch(showBoundary);
|
|
113
|
+
* }, [showBoundary]);
|
|
114
|
+
*/
|
|
115
|
+
export function useBoundary(): BoundaryContextValue {
|
|
116
|
+
const ctx = useContext(BoundaryContext);
|
|
117
|
+
if (!ctx) {
|
|
118
|
+
throw new Error('useBoundary must be used inside <Boundary>');
|
|
119
|
+
}
|
|
120
|
+
return ctx;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function arraysShallowEqual(a: ReadonlyArray<unknown>, b: ReadonlyArray<unknown>): boolean {
|
|
124
|
+
if (a.length !== b.length) return false;
|
|
125
|
+
for (let i = 0; i < a.length; i++) {
|
|
126
|
+
if (!Object.is(a[i], b[i])) return false;
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function toError(value: unknown): Error {
|
|
132
|
+
if (value instanceof Error) return value;
|
|
133
|
+
if (typeof value === 'string') return new Error(value);
|
|
134
|
+
try {
|
|
135
|
+
return new Error(JSON.stringify(value));
|
|
136
|
+
} catch {
|
|
137
|
+
return new Error('Unknown error');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const defaultLogger: BoundaryLogger = (message, error, info) => {
|
|
142
|
+
if (!isDev) return;
|
|
143
|
+
console.error(message, error, info ?? '');
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export class Boundary extends Component<BoundaryProps, BoundaryState> {
|
|
147
|
+
state: BoundaryState = { error: null, errorInfo: null };
|
|
148
|
+
|
|
149
|
+
/** Stable context value identity — recreated only when error/reset functions change (i.e. on mount). */
|
|
150
|
+
private contextValue: BoundaryContextValue = {
|
|
151
|
+
showBoundary: (error: unknown) => this.showBoundary(error),
|
|
152
|
+
resetBoundary: () => this.reset('imperative'),
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** Prevents `onError` from firing twice for the same error instance. */
|
|
156
|
+
private lastReportedError: Error | null = null;
|
|
157
|
+
|
|
158
|
+
static getDerivedStateFromError(error: unknown): BoundaryState {
|
|
159
|
+
return { error: toError(error), errorInfo: null };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
163
|
+
const normalized = toError(error);
|
|
164
|
+
this.setState({ errorInfo: info });
|
|
165
|
+
|
|
166
|
+
if (this.lastReportedError === normalized) return;
|
|
167
|
+
this.lastReportedError = normalized;
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
this.props.onError?.(normalized, info);
|
|
171
|
+
} catch (callbackErr) {
|
|
172
|
+
// Never let user's onError crash the boundary.
|
|
173
|
+
(this.props.logger ?? defaultLogger)(
|
|
174
|
+
`[Boundary${this.props.name ? `:${this.props.name}` : ''}] onError handler threw`,
|
|
175
|
+
toError(callbackErr),
|
|
176
|
+
null,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const tag = this.props.name ? `[Boundary:${this.props.name}]` : '[Boundary]';
|
|
181
|
+
(this.props.logger ?? defaultLogger)(`${tag} caught error`, normalized, info);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
componentDidUpdate(prevProps: BoundaryProps) {
|
|
185
|
+
if (!this.state.error) return;
|
|
186
|
+
const prevKeys = prevProps.resetKeys;
|
|
187
|
+
const nextKeys = this.props.resetKeys;
|
|
188
|
+
if (prevKeys && nextKeys && !arraysShallowEqual(prevKeys, nextKeys)) {
|
|
189
|
+
this.reset('keys', prevKeys, nextKeys);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Imperatively raise an error from async code / event handlers via `useBoundary()`. */
|
|
194
|
+
showBoundary = (raw: unknown) => {
|
|
195
|
+
const error = toError(raw);
|
|
196
|
+
this.setState({ error, errorInfo: null });
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
reset = (
|
|
200
|
+
reason: BoundaryResetReason = 'imperative',
|
|
201
|
+
prevResetKeys?: ReadonlyArray<unknown>,
|
|
202
|
+
nextResetKeys?: ReadonlyArray<unknown>,
|
|
203
|
+
) => {
|
|
204
|
+
// Clear state BEFORE calling onReset — otherwise an error thrown
|
|
205
|
+
// inside onReset would re-trigger the boundary before state is cleared.
|
|
206
|
+
this.lastReportedError = null;
|
|
207
|
+
this.setState({ error: null, errorInfo: null });
|
|
208
|
+
|
|
209
|
+
// Defer onReset to a microtask so the state clear is flushed first.
|
|
210
|
+
queueMicrotask(() => {
|
|
211
|
+
try {
|
|
212
|
+
this.props.onReset?.({ reason, prevResetKeys, nextResetKeys });
|
|
213
|
+
} catch (err) {
|
|
214
|
+
(this.props.logger ?? defaultLogger)(
|
|
215
|
+
`[Boundary${this.props.name ? `:${this.props.name}` : ''}] onReset handler threw`,
|
|
216
|
+
toError(err),
|
|
217
|
+
null,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/** Wraps fallback rendering so errors in the fallback itself can't crash the boundary. */
|
|
224
|
+
private safeRenderFallback(): ReactNode {
|
|
225
|
+
const { error, errorInfo } = this.state;
|
|
226
|
+
if (!error) return null;
|
|
227
|
+
|
|
228
|
+
const { fallback, FallbackComponent, variant = 'card', className } = this.props;
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
if (typeof fallback === 'function') {
|
|
232
|
+
return fallback({ error, errorInfo, reset: () => this.reset('imperative') });
|
|
233
|
+
}
|
|
234
|
+
if (fallback !== undefined) {
|
|
235
|
+
return fallback;
|
|
236
|
+
}
|
|
237
|
+
if (FallbackComponent) {
|
|
238
|
+
return (
|
|
239
|
+
<FallbackComponent
|
|
240
|
+
error={error}
|
|
241
|
+
errorInfo={errorInfo}
|
|
242
|
+
reset={() => this.reset('imperative')}
|
|
243
|
+
/>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
return renderVariant(variant, error, () => this.reset('imperative'), className);
|
|
247
|
+
} catch (fallbackError) {
|
|
248
|
+
// Last-resort static fallback — prevents infinite loops if user fallback throws.
|
|
249
|
+
(this.props.logger ?? defaultLogger)(
|
|
250
|
+
`[Boundary${this.props.name ? `:${this.props.name}` : ''}] fallback render threw`,
|
|
251
|
+
toError(fallbackError),
|
|
252
|
+
null,
|
|
253
|
+
);
|
|
254
|
+
return (
|
|
255
|
+
<div
|
|
256
|
+
role="alert"
|
|
257
|
+
aria-live="assertive"
|
|
258
|
+
className="p-2 text-sm text-destructive border border-destructive/40 rounded"
|
|
259
|
+
>
|
|
260
|
+
Something went wrong, and the error fallback also failed to render.
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
render() {
|
|
267
|
+
if (this.state.error) {
|
|
268
|
+
return this.safeRenderFallback();
|
|
269
|
+
}
|
|
270
|
+
return (
|
|
271
|
+
<BoundaryContext.Provider value={this.contextValue}>
|
|
272
|
+
{this.props.children}
|
|
273
|
+
</BoundaryContext.Provider>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function renderVariant(
|
|
279
|
+
variant: BoundaryVariant,
|
|
280
|
+
error: Error,
|
|
281
|
+
reset: () => void,
|
|
282
|
+
className?: string,
|
|
283
|
+
): ReactNode {
|
|
284
|
+
if (variant === 'silent') return null;
|
|
285
|
+
|
|
286
|
+
const message = isDev ? error.message : null;
|
|
287
|
+
|
|
288
|
+
if (variant === 'inline') {
|
|
289
|
+
return (
|
|
290
|
+
<div
|
|
291
|
+
role="alert"
|
|
292
|
+
aria-live="assertive"
|
|
293
|
+
className={cn(
|
|
294
|
+
'flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive',
|
|
295
|
+
className,
|
|
296
|
+
)}
|
|
297
|
+
>
|
|
298
|
+
<AlertTriangle className="h-4 w-4 shrink-0" />
|
|
299
|
+
<span className="flex-1 truncate">{message ?? 'Something went wrong.'}</span>
|
|
300
|
+
<button
|
|
301
|
+
type="button"
|
|
302
|
+
onClick={reset}
|
|
303
|
+
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium underline-offset-2 hover:underline"
|
|
304
|
+
>
|
|
305
|
+
<RotateCcw className="h-3 w-3" />
|
|
306
|
+
Retry
|
|
307
|
+
</button>
|
|
308
|
+
</div>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (variant === 'fullscreen') {
|
|
313
|
+
return (
|
|
314
|
+
<div
|
|
315
|
+
role="alert"
|
|
316
|
+
aria-live="assertive"
|
|
317
|
+
className={cn('flex min-h-screen items-center justify-center bg-background p-4', className)}
|
|
318
|
+
>
|
|
319
|
+
<div className="max-w-md w-full space-y-4 text-center">
|
|
320
|
+
<AlertTriangle className="mx-auto h-10 w-10 text-destructive" />
|
|
321
|
+
<h1 className="text-2xl font-bold text-foreground">Something went wrong</h1>
|
|
322
|
+
<p className="text-muted-foreground">
|
|
323
|
+
We're sorry, but something unexpected happened. Please try refreshing the page.
|
|
324
|
+
</p>
|
|
325
|
+
{message && (
|
|
326
|
+
<pre className="text-left text-xs text-muted-foreground bg-muted rounded p-2 overflow-auto max-h-40">
|
|
327
|
+
{message}
|
|
328
|
+
</pre>
|
|
329
|
+
)}
|
|
330
|
+
<div className="flex justify-center gap-2">
|
|
331
|
+
<Button variant="outline" onClick={reset}>
|
|
332
|
+
<RotateCcw className="mr-2 h-4 w-4" />
|
|
333
|
+
Try again
|
|
334
|
+
</Button>
|
|
335
|
+
<Button onClick={() => window.location.reload()}>Refresh page</Button>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// card (default)
|
|
343
|
+
return (
|
|
344
|
+
<div
|
|
345
|
+
role="alert"
|
|
346
|
+
aria-live="assertive"
|
|
347
|
+
className={cn(
|
|
348
|
+
'rounded-[var(--radius)] border border-destructive/40 bg-destructive/5 p-4 text-sm',
|
|
349
|
+
className,
|
|
350
|
+
)}
|
|
351
|
+
>
|
|
352
|
+
<div className="flex items-start gap-3">
|
|
353
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
|
354
|
+
<div className="flex-1 space-y-2">
|
|
355
|
+
<p className="font-medium text-destructive">Something went wrong</p>
|
|
356
|
+
{message && <p className="text-xs text-muted-foreground break-words">{message}</p>}
|
|
357
|
+
<Button size="sm" variant="outline" onClick={reset}>
|
|
358
|
+
<RotateCcw className="mr-2 h-3 w-3" />
|
|
359
|
+
Try again
|
|
360
|
+
</Button>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
);
|
|
365
|
+
}
|