@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 CHANGED
@@ -43,33 +43,19 @@ Organized in `components/` by category — everything re-exported from the root
43
43
 
44
44
  ### Boundary
45
45
 
46
- Wrap untrusted subtrees so a single render error does not crash the whole page.
47
-
48
46
  ```tsx
49
- import { Boundary } from '@djangocfg/ui-core';
47
+ import { Boundary, useBoundary } from '@djangocfg/ui-core';
50
48
 
51
- // non-critical widget — disappears on error, page stays alive
52
49
  <Boundary variant="silent"><ChatLauncher /></Boundary>
50
+ <Boundary variant="card" resetKeys={[pathname]}><Panel /></Boundary>
51
+ ```
53
52
 
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>
53
+ Variants: `silent` / `inline` / `card` / `fullscreen`. Plus `useBoundary()` hook for async errors, `resetKeys`, `onReset`, custom `fallback` / `FallbackComponent`, and safe re-render guarantees.
59
54
 
60
- // fullscreen for top-level layouts
61
- <Boundary variant="fullscreen"><AppShell /></Boundary>
55
+ Full docs and patterns: [`src/components/boundary/README.md`](./src/components/boundary/README.md).
62
56
 
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
- ```
57
+ For automatic backend reporting use `MonitorBoundary` from `@djangocfg/layouts`.
71
58
 
72
- For automatic reporting to your backend, use `MonitorBoundary` from `@djangocfg/layouts` (it wraps `Boundary` and pushes events through `@djangocfg/monitor/client`).
73
59
 
74
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.
75
61
 
@@ -88,7 +74,8 @@ import { useNavigate, useLocation, useQueryParams, useRouter, useIsActive } from
88
74
  | **State** | `useDebounce`, `useDebouncedCallback`, `useCountdown`, `useImageLoader`, `useMounted` |
89
75
  | **DOM** | `useEventListener` |
90
76
  | **Theme** | `useThemeColor`, `useThemePalette` (palette-aware hex colors for Canvas/SVG) |
91
- | **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 |
92
79
  | **Feedback** | `useToast`, `toast` (Sonner) |
93
80
  | **Debug** | `useDebugTools` |
94
81
 
@@ -106,6 +93,83 @@ import NextLink from 'next/link';
106
93
 
107
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.
108
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
+
109
173
  ## Schema-driven configurators
110
174
 
111
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.381",
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.381",
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.381",
169
+ "@djangocfg/i18n": "^2.1.382",
170
170
  "@djangocfg/playground": "workspace:*",
171
- "@djangocfg/typescript-config": "^2.1.381",
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",
@@ -1,7 +1,14 @@
1
1
  'use client';
2
2
 
3
3
  import { AlertTriangle, RotateCcw } from 'lucide-react';
4
- import { Component, type ErrorInfo, type ReactNode } from 'react';
4
+ import {
5
+ Component,
6
+ createContext,
7
+ useContext,
8
+ type ComponentType,
9
+ type ErrorInfo,
10
+ type ReactNode,
11
+ } from 'react';
5
12
 
6
13
  import { isDev } from '../../lib/env';
7
14
  import { cn } from '../../lib/utils';
@@ -11,16 +18,29 @@ export type BoundaryVariant = 'silent' | 'inline' | 'card' | 'fullscreen';
11
18
 
12
19
  export interface BoundaryRenderProps {
13
20
  error: Error;
21
+ errorInfo: ErrorInfo | null;
14
22
  reset: () => void;
15
23
  }
16
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
+
17
37
  export interface BoundaryProps {
18
38
  children: ReactNode;
19
39
  /**
20
40
  * Visual style of the fallback.
21
41
  * - silent: render nothing (good for non-critical widgets like a chat launcher)
22
42
  * - inline: compact one-line warning (good for inline blocks inside a page)
23
- * - card: bordered card with retry button (good for panels/features)
43
+ * - card: bordered card with retry button (default; good for panels/features)
24
44
  * - fullscreen: centered fullscreen fallback (good for top-level layout)
25
45
  * @default 'card'
26
46
  */
@@ -28,29 +48,76 @@ export interface BoundaryProps {
28
48
  /**
29
49
  * Custom fallback. Receives the caught error and a reset() function.
30
50
  * Overrides `variant` rendering when provided.
51
+ * Takes precedence over `FallbackComponent`.
31
52
  */
32
53
  fallback?: ReactNode | ((props: BoundaryRenderProps) => ReactNode);
33
54
  /**
34
- * Auto-reset the boundary when any of these values change.
35
- * Common use: pass the current pathname or a feature id.
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.
36
63
  */
37
64
  resetKeys?: ReadonlyArray<unknown>;
38
65
  /**
39
- * Called when an error is caught. Hook up Sentry / logging here.
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.
40
69
  */
41
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;
42
76
  /**
43
77
  * Optional label shown in dev console logs to help locate the source.
44
78
  */
45
79
  name?: string;
46
80
  /**
47
- * Extra className for the fallback wrapper (variant: inline / card).
81
+ * Extra className for the fallback wrapper (variant: inline / card / fullscreen).
48
82
  */
49
83
  className?: string;
84
+ /**
85
+ * Replace default dev console logger. Defaults to `console.error` in development, no-op in production.
86
+ */
87
+ logger?: BoundaryLogger;
50
88
  }
51
89
 
52
90
  interface BoundaryState {
53
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;
54
121
  }
55
122
 
56
123
  function arraysShallowEqual(a: ReadonlyArray<unknown>, b: ReadonlyArray<unknown>): boolean {
@@ -61,19 +128,57 @@ function arraysShallowEqual(a: ReadonlyArray<unknown>, b: ReadonlyArray<unknown>
61
128
  return true;
62
129
  }
63
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
+
64
146
  export class Boundary extends Component<BoundaryProps, BoundaryState> {
65
- state: BoundaryState = { error: null };
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;
66
157
 
67
- static getDerivedStateFromError(error: Error): BoundaryState {
68
- return { error };
158
+ static getDerivedStateFromError(error: unknown): BoundaryState {
159
+ return { error: toError(error), errorInfo: null };
69
160
  }
70
161
 
71
162
  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);
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
+ );
76
178
  }
179
+
180
+ const tag = this.props.name ? `[Boundary:${this.props.name}]` : '[Boundary]';
181
+ (this.props.logger ?? defaultLogger)(`${tag} caught error`, normalized, info);
77
182
  }
78
183
 
79
184
  componentDidUpdate(prevProps: BoundaryProps) {
@@ -81,28 +186,92 @@ export class Boundary extends Component<BoundaryProps, BoundaryState> {
81
186
  const prevKeys = prevProps.resetKeys;
82
187
  const nextKeys = this.props.resetKeys;
83
188
  if (prevKeys && nextKeys && !arraysShallowEqual(prevKeys, nextKeys)) {
84
- this.reset();
189
+ this.reset('keys', prevKeys, nextKeys);
85
190
  }
86
191
  }
87
192
 
88
- reset = () => {
89
- this.setState({ error: null });
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 });
90
197
  };
91
198
 
92
- render() {
93
- const { error } = this.state;
94
- if (!error) return this.props.children;
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 });
95
208
 
96
- const { fallback, variant = 'card', className } = this.props;
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
+ };
97
222
 
98
- if (typeof fallback === 'function') {
99
- return fallback({ error, reset: this.reset });
100
- }
101
- if (fallback !== undefined) {
102
- return fallback;
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
+ );
103
263
  }
264
+ }
104
265
 
105
- return renderVariant(variant, error, this.reset, className);
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
+ );
106
275
  }
107
276
  }
108
277
 
@@ -120,15 +289,14 @@ function renderVariant(
120
289
  return (
121
290
  <div
122
291
  role="alert"
292
+ aria-live="assertive"
123
293
  className={cn(
124
294
  'flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive',
125
295
  className,
126
296
  )}
127
297
  >
128
298
  <AlertTriangle className="h-4 w-4 shrink-0" />
129
- <span className="flex-1 truncate">
130
- {message ?? 'Something went wrong.'}
131
- </span>
299
+ <span className="flex-1 truncate">{message ?? 'Something went wrong.'}</span>
132
300
  <button
133
301
  type="button"
134
302
  onClick={reset}
@@ -143,7 +311,11 @@ function renderVariant(
143
311
 
144
312
  if (variant === 'fullscreen') {
145
313
  return (
146
- <div className={cn('flex min-h-screen items-center justify-center bg-background p-4', className)}>
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
+ >
147
319
  <div className="max-w-md w-full space-y-4 text-center">
148
320
  <AlertTriangle className="mx-auto h-10 w-10 text-destructive" />
149
321
  <h1 className="text-2xl font-bold text-foreground">Something went wrong</h1>
@@ -171,6 +343,7 @@ function renderVariant(
171
343
  return (
172
344
  <div
173
345
  role="alert"
346
+ aria-live="assertive"
174
347
  className={cn(
175
348
  'rounded-[var(--radius)] border border-destructive/40 bg-destructive/5 p-4 text-sm',
176
349
  className,
@@ -180,9 +353,7 @@ function renderVariant(
180
353
  <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
181
354
  <div className="flex-1 space-y-2">
182
355
  <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
- )}
356
+ {message && <p className="text-xs text-muted-foreground break-words">{message}</p>}
186
357
  <Button size="sm" variant="outline" onClick={reset}>
187
358
  <RotateCcw className="mr-2 h-3 w-3" />
188
359
  Try again