@djangocfg/ui-core 2.1.379 → 2.1.381
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
CHANGED
|
@@ -37,9 +37,40 @@ 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
|
+
Wrap untrusted subtrees so a single render error does not crash the whole page.
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
import { Boundary } from '@djangocfg/ui-core';
|
|
50
|
+
|
|
51
|
+
// non-critical widget — disappears on error, page stays alive
|
|
52
|
+
<Boundary variant="silent"><ChatLauncher /></Boundary>
|
|
53
|
+
|
|
54
|
+
// inline status — compact alert with Retry
|
|
55
|
+
<Boundary variant="inline" name="catalog-row"><Row /></Boundary>
|
|
56
|
+
|
|
57
|
+
// feature panel — card with Retry button (default)
|
|
58
|
+
<Boundary><AnalyticsPanel /></Boundary>
|
|
59
|
+
|
|
60
|
+
// fullscreen — for top-level layouts
|
|
61
|
+
<Boundary variant="fullscreen"><AppShell /></Boundary>
|
|
62
|
+
|
|
63
|
+
// custom fallback + auto-reset on route change
|
|
64
|
+
<Boundary
|
|
65
|
+
resetKeys={[pathname]}
|
|
66
|
+
fallback={({ error, reset }) => <MyErrorCard error={error} onRetry={reset} />}
|
|
67
|
+
>
|
|
68
|
+
<Page />
|
|
69
|
+
</Boundary>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
For automatic reporting to your backend, use `MonitorBoundary` from `@djangocfg/layouts` (it wraps `Boundary` and pushes events through `@djangocfg/monitor/client`).
|
|
73
|
+
|
|
43
74
|
> 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
75
|
|
|
45
76
|
## Hooks
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.381",
|
|
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.381",
|
|
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.381",
|
|
170
170
|
"@djangocfg/playground": "workspace:*",
|
|
171
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
171
|
+
"@djangocfg/typescript-config": "^2.1.381",
|
|
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,194 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { AlertTriangle, RotateCcw } from 'lucide-react';
|
|
4
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import { isDev } from '../../lib/env';
|
|
7
|
+
import { cn } from '../../lib/utils';
|
|
8
|
+
import { Button } from '../forms/button';
|
|
9
|
+
|
|
10
|
+
export type BoundaryVariant = 'silent' | 'inline' | 'card' | 'fullscreen';
|
|
11
|
+
|
|
12
|
+
export interface BoundaryRenderProps {
|
|
13
|
+
error: Error;
|
|
14
|
+
reset: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BoundaryProps {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
/**
|
|
20
|
+
* Visual style of the fallback.
|
|
21
|
+
* - silent: render nothing (good for non-critical widgets like a chat launcher)
|
|
22
|
+
* - inline: compact one-line warning (good for inline blocks inside a page)
|
|
23
|
+
* - card: bordered card with retry button (good for panels/features)
|
|
24
|
+
* - fullscreen: centered fullscreen fallback (good for top-level layout)
|
|
25
|
+
* @default 'card'
|
|
26
|
+
*/
|
|
27
|
+
variant?: BoundaryVariant;
|
|
28
|
+
/**
|
|
29
|
+
* Custom fallback. Receives the caught error and a reset() function.
|
|
30
|
+
* Overrides `variant` rendering when provided.
|
|
31
|
+
*/
|
|
32
|
+
fallback?: ReactNode | ((props: BoundaryRenderProps) => ReactNode);
|
|
33
|
+
/**
|
|
34
|
+
* Auto-reset the boundary when any of these values change.
|
|
35
|
+
* Common use: pass the current pathname or a feature id.
|
|
36
|
+
*/
|
|
37
|
+
resetKeys?: ReadonlyArray<unknown>;
|
|
38
|
+
/**
|
|
39
|
+
* Called when an error is caught. Hook up Sentry / logging here.
|
|
40
|
+
*/
|
|
41
|
+
onError?: (error: Error, info: ErrorInfo) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Optional label shown in dev console logs to help locate the source.
|
|
44
|
+
*/
|
|
45
|
+
name?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Extra className for the fallback wrapper (variant: inline / card).
|
|
48
|
+
*/
|
|
49
|
+
className?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface BoundaryState {
|
|
53
|
+
error: Error | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function arraysShallowEqual(a: ReadonlyArray<unknown>, b: ReadonlyArray<unknown>): boolean {
|
|
57
|
+
if (a.length !== b.length) return false;
|
|
58
|
+
for (let i = 0; i < a.length; i++) {
|
|
59
|
+
if (!Object.is(a[i], b[i])) return false;
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class Boundary extends Component<BoundaryProps, BoundaryState> {
|
|
65
|
+
state: BoundaryState = { error: null };
|
|
66
|
+
|
|
67
|
+
static getDerivedStateFromError(error: Error): BoundaryState {
|
|
68
|
+
return { error };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
72
|
+
this.props.onError?.(error, info);
|
|
73
|
+
if (isDev) {
|
|
74
|
+
const tag = this.props.name ? `[Boundary:${this.props.name}]` : '[Boundary]';
|
|
75
|
+
console.error(tag, error, info);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
componentDidUpdate(prevProps: BoundaryProps) {
|
|
80
|
+
if (!this.state.error) return;
|
|
81
|
+
const prevKeys = prevProps.resetKeys;
|
|
82
|
+
const nextKeys = this.props.resetKeys;
|
|
83
|
+
if (prevKeys && nextKeys && !arraysShallowEqual(prevKeys, nextKeys)) {
|
|
84
|
+
this.reset();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
reset = () => {
|
|
89
|
+
this.setState({ error: null });
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
render() {
|
|
93
|
+
const { error } = this.state;
|
|
94
|
+
if (!error) return this.props.children;
|
|
95
|
+
|
|
96
|
+
const { fallback, variant = 'card', className } = this.props;
|
|
97
|
+
|
|
98
|
+
if (typeof fallback === 'function') {
|
|
99
|
+
return fallback({ error, reset: this.reset });
|
|
100
|
+
}
|
|
101
|
+
if (fallback !== undefined) {
|
|
102
|
+
return fallback;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return renderVariant(variant, error, this.reset, className);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderVariant(
|
|
110
|
+
variant: BoundaryVariant,
|
|
111
|
+
error: Error,
|
|
112
|
+
reset: () => void,
|
|
113
|
+
className?: string,
|
|
114
|
+
): ReactNode {
|
|
115
|
+
if (variant === 'silent') return null;
|
|
116
|
+
|
|
117
|
+
const message = isDev ? error.message : null;
|
|
118
|
+
|
|
119
|
+
if (variant === 'inline') {
|
|
120
|
+
return (
|
|
121
|
+
<div
|
|
122
|
+
role="alert"
|
|
123
|
+
className={cn(
|
|
124
|
+
'flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive',
|
|
125
|
+
className,
|
|
126
|
+
)}
|
|
127
|
+
>
|
|
128
|
+
<AlertTriangle className="h-4 w-4 shrink-0" />
|
|
129
|
+
<span className="flex-1 truncate">
|
|
130
|
+
{message ?? 'Something went wrong.'}
|
|
131
|
+
</span>
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
onClick={reset}
|
|
135
|
+
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium underline-offset-2 hover:underline"
|
|
136
|
+
>
|
|
137
|
+
<RotateCcw className="h-3 w-3" />
|
|
138
|
+
Retry
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (variant === 'fullscreen') {
|
|
145
|
+
return (
|
|
146
|
+
<div className={cn('flex min-h-screen items-center justify-center bg-background p-4', className)}>
|
|
147
|
+
<div className="max-w-md w-full space-y-4 text-center">
|
|
148
|
+
<AlertTriangle className="mx-auto h-10 w-10 text-destructive" />
|
|
149
|
+
<h1 className="text-2xl font-bold text-foreground">Something went wrong</h1>
|
|
150
|
+
<p className="text-muted-foreground">
|
|
151
|
+
We're sorry, but something unexpected happened. Please try refreshing the page.
|
|
152
|
+
</p>
|
|
153
|
+
{message && (
|
|
154
|
+
<pre className="text-left text-xs text-muted-foreground bg-muted rounded p-2 overflow-auto max-h-40">
|
|
155
|
+
{message}
|
|
156
|
+
</pre>
|
|
157
|
+
)}
|
|
158
|
+
<div className="flex justify-center gap-2">
|
|
159
|
+
<Button variant="outline" onClick={reset}>
|
|
160
|
+
<RotateCcw className="mr-2 h-4 w-4" />
|
|
161
|
+
Try again
|
|
162
|
+
</Button>
|
|
163
|
+
<Button onClick={() => window.location.reload()}>Refresh page</Button>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// card (default)
|
|
171
|
+
return (
|
|
172
|
+
<div
|
|
173
|
+
role="alert"
|
|
174
|
+
className={cn(
|
|
175
|
+
'rounded-[var(--radius)] border border-destructive/40 bg-destructive/5 p-4 text-sm',
|
|
176
|
+
className,
|
|
177
|
+
)}
|
|
178
|
+
>
|
|
179
|
+
<div className="flex items-start gap-3">
|
|
180
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
|
181
|
+
<div className="flex-1 space-y-2">
|
|
182
|
+
<p className="font-medium text-destructive">Something went wrong</p>
|
|
183
|
+
{message && (
|
|
184
|
+
<p className="text-xs text-muted-foreground break-words">{message}</p>
|
|
185
|
+
)}
|
|
186
|
+
<Button size="sm" variant="outline" onClick={reset}>
|
|
187
|
+
<RotateCcw className="mr-2 h-3 w-3" />
|
|
188
|
+
Try again
|
|
189
|
+
</Button>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { defineStory, useSelect } from '@djangocfg/playground';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
|
|
4
|
+
import { Button } from '../forms/button';
|
|
5
|
+
import { Boundary } from '.';
|
|
6
|
+
import type { BoundaryVariant } from '.';
|
|
7
|
+
|
|
8
|
+
export default defineStory({
|
|
9
|
+
title: 'Core/Boundary',
|
|
10
|
+
component: Boundary,
|
|
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.',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function BoomButton({ label = 'Throw error' }: { label?: string }) {
|
|
16
|
+
const [boom, setBoom] = useState(false);
|
|
17
|
+
if (boom) throw new Error('Demo crash from BoomButton');
|
|
18
|
+
return (
|
|
19
|
+
<Button variant="outline" size="sm" onClick={() => setBoom(true)}>
|
|
20
|
+
{label}
|
|
21
|
+
</Button>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const Interactive = () => {
|
|
26
|
+
const [variant] = useSelect('variant', {
|
|
27
|
+
options: ['silent', 'inline', 'card', 'fullscreen'] as const,
|
|
28
|
+
defaultValue: 'card',
|
|
29
|
+
label: 'Variant',
|
|
30
|
+
description: 'Fallback visual style',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="max-w-lg space-y-3">
|
|
35
|
+
<p className="text-sm text-muted-foreground">
|
|
36
|
+
Click the button to throw — the surrounding page stays alive, only this block swaps to the fallback.
|
|
37
|
+
</p>
|
|
38
|
+
<Boundary variant={variant as BoundaryVariant} name="story">
|
|
39
|
+
<div className="rounded-md border p-4">
|
|
40
|
+
<BoomButton />
|
|
41
|
+
</div>
|
|
42
|
+
</Boundary>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const Silent = () => (
|
|
48
|
+
<div className="max-w-lg space-y-2">
|
|
49
|
+
<p className="text-sm text-muted-foreground">
|
|
50
|
+
variant="silent" renders nothing on error. Use for non-critical widgets (chat launcher, embeds).
|
|
51
|
+
</p>
|
|
52
|
+
<Boundary variant="silent" name="silent-demo">
|
|
53
|
+
<BoomButton label="Crash silently" />
|
|
54
|
+
</Boundary>
|
|
55
|
+
<p className="text-xs text-muted-foreground">↑ button disappears after click. Page is fine.</p>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
export const Inline = () => (
|
|
60
|
+
<div className="max-w-lg">
|
|
61
|
+
<Boundary variant="inline" name="inline-demo">
|
|
62
|
+
<BoomButton />
|
|
63
|
+
</Boundary>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
export const Card = () => (
|
|
68
|
+
<div className="max-w-lg">
|
|
69
|
+
<Boundary variant="card" name="card-demo">
|
|
70
|
+
<BoomButton />
|
|
71
|
+
</Boundary>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
export const CustomFallback = () => (
|
|
76
|
+
<div className="max-w-lg">
|
|
77
|
+
<Boundary
|
|
78
|
+
fallback={({ error, reset }) => (
|
|
79
|
+
<div className="rounded-md border-2 border-dashed border-amber-500 bg-amber-500/5 p-4">
|
|
80
|
+
<p className="text-sm font-semibold text-amber-700">Custom fallback</p>
|
|
81
|
+
<p className="mt-1 text-xs text-amber-700/80">{error.message}</p>
|
|
82
|
+
<Button size="sm" variant="outline" className="mt-2" onClick={reset}>
|
|
83
|
+
Reset boundary
|
|
84
|
+
</Button>
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
>
|
|
88
|
+
<BoomButton />
|
|
89
|
+
</Boundary>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
export const ResetKeys = () => {
|
|
94
|
+
const [key, setKey] = useState(0);
|
|
95
|
+
return (
|
|
96
|
+
<div className="max-w-lg space-y-3">
|
|
97
|
+
<p className="text-sm text-muted-foreground">
|
|
98
|
+
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.
|
|
100
|
+
</p>
|
|
101
|
+
<Button size="sm" onClick={() => setKey((k) => k + 1)}>
|
|
102
|
+
Bump resetKey ({key})
|
|
103
|
+
</Button>
|
|
104
|
+
<Boundary variant="card" resetKeys={[key]} name="resetkeys-demo">
|
|
105
|
+
<BoomButton />
|
|
106
|
+
</Boundary>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
};
|
package/src/components/index.ts
CHANGED
|
@@ -162,6 +162,12 @@ export { Preloader, PreloaderSkeleton } from './feedback/preloader';
|
|
|
162
162
|
export type { PreloaderProps, PreloaderSkeletonProps } from './feedback/preloader';
|
|
163
163
|
export { Toaster } from './feedback/sonner';
|
|
164
164
|
|
|
165
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
166
|
+
// Boundary
|
|
167
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
168
|
+
export { Boundary } from './boundary';
|
|
169
|
+
export type { BoundaryProps, BoundaryVariant, BoundaryRenderProps } from './boundary';
|
|
170
|
+
|
|
165
171
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
166
172
|
// Specialized
|
|
167
173
|
// ─────────────────────────────────────────────────────────────────────────────
|