@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 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`, `HotkeysProvider`, `useHotkeysContext` |
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.380",
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.380",
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.380",
169
+ "@djangocfg/i18n": "^2.1.382",
170
170
  "@djangocfg/playground": "workspace:*",
171
- "@djangocfg/typescript-config": "^2.1.380",
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&apos;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
+ }