@djangocfg/ui-core 2.1.607 → 2.1.608

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.607",
3
+ "version": "2.1.608",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -133,8 +133,8 @@
133
133
  "check:docs": "node scripts/check-docs.mjs"
134
134
  },
135
135
  "peerDependencies": {
136
- "@djangocfg/api": "^2.1.607",
137
- "@djangocfg/i18n": "^2.1.607",
136
+ "@djangocfg/api": "^2.1.608",
137
+ "@djangocfg/i18n": "^2.1.608",
138
138
  "consola": "^3.4.2",
139
139
  "lucide-react": "^0.545.0",
140
140
  "moment": "^2.30.1",
@@ -210,10 +210,10 @@
210
210
  "vaul": "1.1.2"
211
211
  },
212
212
  "devDependencies": {
213
- "@djangocfg/api": "^2.1.607",
214
- "@djangocfg/eslint-config": "^2.1.607",
215
- "@djangocfg/i18n": "^2.1.607",
216
- "@djangocfg/typescript-config": "^2.1.607",
213
+ "@djangocfg/api": "^2.1.608",
214
+ "@djangocfg/eslint-config": "^2.1.608",
215
+ "@djangocfg/i18n": "^2.1.608",
216
+ "@djangocfg/typescript-config": "^2.1.608",
217
217
  "@storybook/react-vite": "^10.5.0",
218
218
  "@types/node": "^24.13.3",
219
219
  "@types/react": "19.2.15",
@@ -10,6 +10,7 @@ import {
10
10
  type ReactNode,
11
11
  } from 'react';
12
12
 
13
+ import { canAutoReloadForChunk, isChunkLoadError, reloadForChunkError } from '../../lib/chunk-reload';
13
14
  import { isDev } from '../../lib/env';
14
15
  import { cn } from '../../lib/utils';
15
16
  import { Button } from '../forms/button';
@@ -155,12 +156,17 @@ export class Boundary extends Component<BoundaryProps, BoundaryState> {
155
156
  /** Prevents `onError` from firing twice for the same error instance. */
156
157
  private lastReportedError: Error | null = null;
157
158
 
159
+ /** True once a stale-chunk auto-reload was triggered; the fallback stays hidden meanwhile. */
160
+ private chunkReloading = false;
161
+
158
162
  static getDerivedStateFromError(error: unknown): BoundaryState {
159
163
  return { error: toError(error), errorInfo: null };
160
164
  }
161
165
 
162
166
  override componentDidCatch(error: Error, info: ErrorInfo) {
163
167
  const normalized = toError(error);
168
+ // Stale chunk after a deploy: one guarded hard reload instead of the crash screen.
169
+ if (isChunkLoadError(normalized) && reloadForChunkError()) this.chunkReloading = true;
164
170
  this.setState({ errorInfo: info });
165
171
 
166
172
  if (this.lastReportedError === normalized) return;
@@ -224,6 +230,7 @@ export class Boundary extends Component<BoundaryProps, BoundaryState> {
224
230
  private safeRenderFallback(): ReactNode {
225
231
  const { error, errorInfo } = this.state;
226
232
  if (!error) return null;
233
+ if (isChunkLoadError(error) && (this.chunkReloading || canAutoReloadForChunk())) return null;
227
234
 
228
235
  const { fallback, FallbackComponent, variant = 'card', className } = this.props;
229
236
 
@@ -314,7 +321,7 @@ function renderVariant(
314
321
  <div
315
322
  role="alert"
316
323
  aria-live="assertive"
317
- className={cn('flex min-h-screen items-center justify-center bg-background p-4', className)}
324
+ className={cn('flex min-h-[var(--app-height,100dvh)] items-center justify-center bg-background p-4', className)}
318
325
  >
319
326
  <div className="max-w-md w-full space-y-4 text-center">
320
327
  <AlertTriangle className="mx-auto h-10 w-10 text-destructive" />
@@ -12,6 +12,9 @@ const Toaster = ({ ...props }: ToasterProps) => {
12
12
  <Sonner
13
13
  theme={theme as ToasterProps["theme"]}
14
14
  className="toaster group"
15
+ // Keep toasts clear of the notch / home indicator (tokens are 0 without viewport-fit=cover).
16
+ offset={{ top: 'max(24px, var(--safe-top, 0px))', bottom: 'max(24px, var(--safe-bottom, 0px))', left: 'max(24px, var(--safe-left, 0px))', right: 'max(24px, var(--safe-right, 0px))' }}
17
+ mobileOffset={{ top: 'max(16px, var(--safe-top, 0px))', bottom: 'max(16px, var(--safe-bottom, 0px))', left: 'max(16px, var(--safe-left, 0px))', right: 'max(16px, var(--safe-right, 0px))' }}
15
18
  richColors
16
19
  toastOptions={{
17
20
  classNames: {
@@ -179,6 +179,7 @@ export {
179
179
  SidebarRail,
180
180
  SidebarSeparator,
181
181
  SidebarTrigger,
182
+ SIDEBAR_KEYBOARD_SHORTCUT,
182
183
  useSidebar,
183
184
  } from './navigation/sidebar';
184
185
  export { Stepper, StepperContent, StepperDescription, StepperIndicator, StepperItem, StepperList, StepperNext, StepperPrev, StepperSeparator, StepperTitle, StepperTrigger, useStepper } from './navigation/stepper';
@@ -22,5 +22,6 @@ export {
22
22
  SidebarRail,
23
23
  SidebarSeparator,
24
24
  SidebarTrigger,
25
+ SIDEBAR_KEYBOARD_SHORTCUT,
25
26
  useSidebar,
26
27
  } from './sidebar';
@@ -21,7 +21,8 @@ const SIDEBAR_COOKIE_NAME = "sidebar_state"
21
21
  const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
22
22
  const SIDEBAR_WIDTH = "16rem"
23
23
  const SIDEBAR_WIDTH_ICON = "3rem"
24
- const SIDEBAR_KEYBOARD_SHORTCUT = "b"
24
+ /** Key of the sidebar toggle (with Cmd or Ctrl). Exported so labels cannot diverge from the binding. */
25
+ export const SIDEBAR_KEYBOARD_SHORTCUT = "b"
25
26
 
26
27
  type SidebarContextProps = {
27
28
  state: "expanded" | "collapsed"
@@ -195,8 +196,12 @@ const Sidebar = React.forwardRef<
195
196
  direction={drawerSide}
196
197
  data-sidebar="sidebar"
197
198
  data-mobile="true"
199
+ bare
198
200
  className={cn(
199
- "flex h-[100dvh] max-h-[100dvh] min-h-0 !w-[min(88vw,22rem)] max-w-[min(88vw,22rem)] flex-col gap-0 border-sidebar-border/60 p-0 text-sidebar-foreground shadow-xl",
201
+ // Height pinned to the top and bottom edges of the fixed viewport (`inset-y-0`, `h-auto`) rather
202
+ // than `h-[100dvh]`, so it equals the real visible area in a browser tab AND in an iOS standalone
203
+ // PWA (where 100dvh/100vh can disagree). Width stays 88vw (max 22rem): the dimmed strip closes it.
204
+ "flex !inset-y-0 !h-auto max-h-none min-h-0 overflow-hidden !w-[min(88vw,22rem)] max-w-[min(88vw,22rem)] flex-col gap-0 border-sidebar-border/60 p-0 text-sidebar-foreground shadow-xl",
200
205
  drawerSide === "left" ? "border-r" : "border-l",
201
206
  )}
202
207
  style={
@@ -93,7 +93,7 @@ const DialogContent = React.forwardRef<
93
93
  // A child sizing itself with `h-full` then measures that overgrown
94
94
  // row, its own `min-h-0`/`overflow-y-auto` never receive a bound, and
95
95
  // a long list (e.g. the locale picker) runs off-screen unscrollable.
96
- ? "left-0 top-0 grid grid-rows-[minmax(0,1fr)] h-[100dvh] w-screen max-w-none gap-0 border-0 p-0 shadow-none data-[state=closed]:zoom-out-[0.98] data-[state=open]:zoom-in-[0.98]"
96
+ ? "left-0 top-0 grid grid-rows-[minmax(0,1fr)] h-[var(--app-height,100dvh)] w-screen max-w-none gap-0 border-0 p-0 pt-[var(--safe-top,0px)] pb-[var(--safe-bottom,0px)] shadow-none data-[state=closed]:zoom-out-[0.98] data-[state=open]:zoom-in-[0.98]"
97
97
  // Rounded on ALL sizes — the radius used to be `sm:`-gated, which left
98
98
  // phone dialogs with hard square corners. Centered, soft-cornered card.
99
99
  // Width by MEASUREMENT, not breakpoint: `min()` keeps the card off
@@ -123,7 +123,7 @@ const DialogContent = React.forwardRef<
123
123
  // scrolls. The cap is only a ceiling — a card that must scroll adds
124
124
  // `overflow-y-auto`, and one laying out its own regions overrides
125
125
  // `max-h` as before.
126
- : "left-1/2 top-1/2 grid max-h-[calc(100dvh-2rem)] grid-cols-[minmax(0,1fr)] [aspect-ratio:var(--aspect-dialog,auto)] w-[min(var(--width-dialog),calc(100vw-2rem),calc((100dvh-2rem)*var(--aspect-dialog,1000)))] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-[var(--radius-dialog)] border p-6 shadow-lg data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
126
+ : "left-1/2 top-1/2 grid max-h-[calc(var(--app-height,100dvh)-2rem-var(--safe-top,0px)-var(--safe-bottom,0px))] grid-cols-[minmax(0,1fr)] [aspect-ratio:var(--aspect-dialog,auto)] w-[min(var(--width-dialog),calc(100vw-2rem),calc((100dvh-2rem)*var(--aspect-dialog,1000)))] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-[var(--radius-dialog)] border p-6 shadow-lg data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
127
127
  className
128
128
  )}
129
129
  {...props}
@@ -105,11 +105,11 @@ const horizontalSizePresets: Record<DrawerSize, string> = {
105
105
  };
106
106
 
107
107
  const verticalSizePresets: Record<DrawerSize, string> = {
108
- sm: 'min(100vh, 240px)',
109
- md: 'min(100vh, 360px)',
110
- lg: 'min(100vh, 480px)',
111
- xl: 'min(100vh, 640px)',
112
- full: '100vh',
108
+ sm: 'min(var(--app-height, 100dvh), 240px)',
109
+ md: 'min(var(--app-height, 100dvh), 360px)',
110
+ lg: 'min(var(--app-height, 100dvh), 480px)',
111
+ xl: 'min(var(--app-height, 100dvh), 640px)',
112
+ full: 'var(--app-height, 100dvh)',
113
113
  };
114
114
 
115
115
  // Numeric fallbacks used as initial size when resize starts and the
@@ -137,10 +137,10 @@ const directionStyles = {
137
137
  // a tall panel would otherwise run off the top of a phone. A caller's own
138
138
  // `max-h` cannot do this job — as a flex column the panel FILLS it, so the
139
139
  // cap becomes a fixed height around short content.
140
- bottom: "inset-x-0 bottom-0 mt-24 max-h-[85dvh] rounded-t-lg border-t",
140
+ bottom: "inset-x-0 bottom-0 mt-24 max-h-[min(85dvh,calc(var(--app-height,100dvh)-var(--safe-top,0px)-1rem))] rounded-t-lg border-t",
141
141
  top: "inset-x-0 top-0 mb-24 rounded-b-lg border-b",
142
- right: "inset-y-0 right-0 h-full border-l",
143
- left: "inset-y-0 left-0 h-full border-r",
142
+ right: "inset-y-0 right-0 h-auto border-l",
143
+ left: "inset-y-0 left-0 h-auto border-r",
144
144
  } as const;
145
145
 
146
146
  // The snap-point variant of the above. A snapped panel is FULL height and vaul
@@ -182,6 +182,11 @@ export interface DrawerContentProps
182
182
  width?: string | number;
183
183
  /** CSS length for height — applies to top/bottom drawers. Overrides `size`. */
184
184
  height?: string | number;
185
+ /**
186
+ * Render children directly, with no grab handle and no padded scroll wrapper. For app-shell surfaces
187
+ * (the sidebar drawer) that lay out their own header / scrolling body / footer inside the panel.
188
+ */
189
+ bare?: boolean;
185
190
  /** Allow user to resize the drawer by dragging its inner edge. */
186
191
  resizable?: boolean;
187
192
  /** Disable resize on mobile viewports (< 768px). Default `true`. */
@@ -208,6 +213,7 @@ const DrawerContent = React.forwardRef<
208
213
  children,
209
214
  direction: directionProp,
210
215
  size = 'md',
216
+ bare = false,
211
217
  width,
212
218
  height,
213
219
  style,
@@ -311,7 +317,7 @@ const DrawerContent = React.forwardRef<
311
317
  ? (currentPx != null ? `${currentPx}px` : (toCssLength(height) ?? verticalSizePresets[size]))
312
318
  : undefined;
313
319
  const resolvedWidth = rawWidth ? `min(100vw, ${rawWidth})` : undefined;
314
- const resolvedHeight = rawHeight ? `min(100vh, ${rawHeight})` : undefined;
320
+ const resolvedHeight = rawHeight ? `min(var(--app-height, 100dvh), ${rawHeight})` : undefined;
315
321
 
316
322
  const handlePosition: Record<typeof direction, string> = {
317
323
  right: 'left-0 top-0 h-full w-1.5 -translate-x-1/2 cursor-ew-resize',
@@ -382,7 +388,7 @@ const DrawerContent = React.forwardRef<
382
388
  affordance, not an element of the design.
383
389
  `aria-hidden` because the drag it hints at is not a keyboard
384
390
  affordance — Escape and the close button are. */}
385
- {isVertical && (
391
+ {isVertical && !bare && (
386
392
  <div
387
393
  aria-hidden
388
394
  className="mx-auto mt-3 h-1.5 w-10 shrink-0 rounded-full bg-border"
@@ -409,14 +415,16 @@ const DrawerContent = React.forwardRef<
409
415
  leave the rest of the sheet empty and unscrollable — there `flex-1`
410
416
  is what makes the body fill the snapped height.
411
417
  */}
418
+ {bare ? children : (
412
419
  <div
413
420
  className={cn(
414
- "min-h-0 overflow-y-auto overscroll-contain px-5 pb-[max(1.25rem,env(safe-area-inset-bottom))]",
421
+ "min-h-0 overflow-y-auto overscroll-contain px-5 pb-[max(1.25rem,var(--safe-bottom,env(safe-area-inset-bottom,0px)))]",
415
422
  hasSnapPoints && "flex-1"
416
423
  )}
417
424
  >
418
425
  {children}
419
426
  </div>
427
+ )}
420
428
  </div>
421
429
  </DrawerPrimitive.Content>
422
430
  </DrawerPortal>
@@ -37,12 +37,12 @@ const sheetVariants = cva(
37
37
  {
38
38
  variants: {
39
39
  side: {
40
- top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
40
+ top: "inset-x-0 top-0 pt-[max(1.5rem,var(--safe-top,0px))] border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
41
41
  bottom:
42
- "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
43
- left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
42
+ "inset-x-0 bottom-0 pb-[max(1.5rem,var(--safe-bottom,0px))] border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
43
+ left: "inset-y-0 left-0 h-auto w-3/4 pt-[max(1.5rem,var(--safe-top,0px))] pb-[max(1.5rem,var(--safe-bottom,0px))] border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
44
44
  right:
45
- "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
45
+ "inset-y-0 right-0 h-auto w-3/4 pt-[max(1.5rem,var(--safe-top,0px))] pb-[max(1.5rem,var(--safe-bottom,0px))] border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
46
46
  },
47
47
  },
48
48
  defaultVariants: {
@@ -67,7 +67,7 @@ const SheetContent = React.forwardRef<
67
67
  className={cn(sheetVariants({ side }), className)}
68
68
  {...props}
69
69
  >
70
- <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none data-[state=open]:bg-secondary">
70
+ <SheetPrimitive.Close className="absolute right-4 top-[max(1rem,var(--safe-top,0px))] rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none data-[state=open]:bg-secondary">
71
71
  <Cross2Icon className="h-4 w-4" />
72
72
  <span className="sr-only">Close</span>
73
73
  </SheetPrimitive.Close>
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Display mode of the page: running in a browser tab or as an installed app (PWA).
3
+ *
4
+ * One module-level store, so any number of components share ONE set of media-query listeners.
5
+ * Detection covers the standard `display-mode` media query (standalone, fullscreen, minimal-ui,
6
+ * window-controls-overlay), legacy iOS `navigator.standalone` ("Add to Home Screen" does not always
7
+ * match the media query) and the Android TWA referrer.
8
+ */
9
+
10
+ export type DisplayMode = 'browser' | 'standalone' | 'fullscreen' | 'minimal-ui' | 'window-controls-overlay';
11
+
12
+ const MODES = ['window-controls-overlay', 'fullscreen', 'standalone', 'minimal-ui'] as const;
13
+
14
+ /** Server and hydration value. */
15
+ export const SERVER_DISPLAY_MODE: DisplayMode = 'browser';
16
+
17
+ export function detectDisplayMode(): DisplayMode {
18
+ if (typeof window === 'undefined') return SERVER_DISPLAY_MODE;
19
+ if (window.matchMedia) {
20
+ for (const mode of MODES) {
21
+ if (window.matchMedia(`(display-mode: ${mode})`).matches) return mode;
22
+ }
23
+ }
24
+ if ((window.navigator as Navigator & { standalone?: boolean }).standalone === true) return 'standalone';
25
+ if (typeof document !== 'undefined' && document.referrer.startsWith('android-app://')) return 'standalone';
26
+ return 'browser';
27
+ }
28
+
29
+ /** True for every mode where the browser UI is gone (installed app). */
30
+ export function isInstalledMode(mode: DisplayMode): boolean {
31
+ return mode !== 'browser';
32
+ }
33
+
34
+ export function subscribeDisplayMode(onChange: () => void): () => void {
35
+ if (typeof window === 'undefined' || !window.matchMedia) return () => {};
36
+ const lists = MODES.map((m) => window.matchMedia(`(display-mode: ${m})`));
37
+ lists.forEach((l) => l.addEventListener('change', onChange));
38
+ return () => lists.forEach((l) => l.removeEventListener('change', onChange));
39
+ }
40
+
41
+ /** Sets `<html data-display-mode="browser|standalone|…">` so CSS can react without JS. Returns a cleanup. */
42
+ export function syncDisplayModeAttribute(): () => void {
43
+ if (typeof document === 'undefined') return () => {};
44
+ const apply = () => document.documentElement.setAttribute('data-display-mode', detectDisplayMode());
45
+ apply();
46
+ return subscribeDisplayMode(apply);
47
+ }
@@ -4,3 +4,7 @@ export { useMediaQuery, BREAKPOINTS as MEDIA_BREAKPOINTS } from './useMediaQuery
4
4
  export { useIsMobile, useIsPhone, useIsTabletOrBelow, BREAKPOINTS } from './useMobile';
5
5
  export type { Breakpoint } from './useMobile';
6
6
  export { useIsTouch } from './useIsTouch';
7
+ export { useDisplayMode, useIsPwa } from './useDisplayMode';
8
+ export { detectDisplayMode, syncDisplayModeAttribute } from './displayMode';
9
+ export type { DisplayMode } from './displayMode';
10
+ export { useBlockGestureZoom } from './useBlockGestureZoom';
@@ -0,0 +1,26 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+
5
+ import { detectDisplayMode, isInstalledMode } from './displayMode';
6
+
7
+ /**
8
+ * OPT-IN, off by default: blocks iOS pinch/gesture zoom while running as an installed app.
9
+ *
10
+ * Trade-off: it removes the only zoom some people can use (WCAG 1.4.4). The default recipe already disables
11
+ * double-tap zoom with `touch-action: manipulation` and leaves pinch-zoom alone; use this only for an app whose UI is
12
+ * a fixed canvas and say so in its accessibility notes. Only acts in an installed display mode, never in a browser tab.
13
+ */
14
+ export function useBlockGestureZoom(enabled = true): void {
15
+ useEffect(() => {
16
+ if (!enabled || typeof document === 'undefined' || !isInstalledMode(detectDisplayMode())) return;
17
+ const block = (event: Event) => event.preventDefault();
18
+ // `gesturestart` is a WebKit-only event; other engines never fire it.
19
+ document.addEventListener('gesturestart', block);
20
+ document.addEventListener('gesturechange', block);
21
+ return () => {
22
+ document.removeEventListener('gesturestart', block);
23
+ document.removeEventListener('gesturechange', block);
24
+ };
25
+ }, [enabled]);
26
+ }
@@ -0,0 +1,25 @@
1
+ 'use client';
2
+
3
+ import { useSyncExternalStore } from 'react';
4
+
5
+ import {
6
+ detectDisplayMode,
7
+ isInstalledMode,
8
+ SERVER_DISPLAY_MODE,
9
+ subscribeDisplayMode,
10
+ type DisplayMode,
11
+ } from './displayMode';
12
+
13
+ /**
14
+ * Current display mode. The server and the hydration render report `'browser'`, the real value
15
+ * arrives right after (same contract as `useMediaQuery`); let CSS decide where a flash matters:
16
+ * `:root[data-display-mode='standalone'] { ... }`.
17
+ */
18
+ export function useDisplayMode(): DisplayMode {
19
+ return useSyncExternalStore(subscribeDisplayMode, detectDisplayMode, () => SERVER_DISPLAY_MODE);
20
+ }
21
+
22
+ /** True when the app runs installed (PWA: standalone, fullscreen, minimal-ui, window-controls-overlay). */
23
+ export function useIsPwa(): boolean {
24
+ return isInstalledMode(useDisplayMode());
25
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Stale-chunk recovery.
3
+ *
4
+ * After a deploy the old tab still points at hashed `/_next/static/...` assets
5
+ * that no longer exist. The next lazy import / route navigation fails with a
6
+ * ChunkLoadError (or a cancelled fetch), which is a transient condition, not an
7
+ * app bug. The right response is ONE hard reload, not the crash screen.
8
+ *
9
+ * Single implementation for the whole stack: `Boundary` (render errors),
10
+ * `UiProviders` (window `error` / `unhandledrejection`) and `ErrorLayout`
11
+ * (`global-error.tsx`) all call into this module. Loop guard: a timestamp in
12
+ * `sessionStorage`; a second chunk failure inside the window shows the real
13
+ * fallback. Without a usable `sessionStorage` we never auto-reload (a reload
14
+ * would lose any in-memory guard and could loop).
15
+ */
16
+
17
+ export const CHUNK_RELOAD_STORAGE_KEY = 'djangocfg:chunk-reload-at';
18
+ /** Minimum gap between two automatic reloads in one tab session. */
19
+ export const CHUNK_RELOAD_WINDOW_MS = 30_000;
20
+
21
+ const CHUNK_MESSAGE_PATTERNS: RegExp[] = [
22
+ /loading chunk [\w./-]+ failed/i, // webpack
23
+ /loading css chunk [\w./-]+ failed/i, // webpack CSS chunk
24
+ /failed to fetch dynamically imported module/i, // Chrome / Turbopack
25
+ /error loading dynamically imported module/i, // Firefox
26
+ /importing a module script failed/i, // Safari
27
+ /unable to preload css/i, // Vite
28
+ ];
29
+
30
+ const STATIC_ASSET_RE = /\/_next\/static\//;
31
+ const FETCH_FAILURE_RE = /failed to fetch|load failed|networkerror|aborted|canceled|cancelled|signal is aborted/i;
32
+
33
+ function asText(value: unknown): { name: string; message: string; stack: string } {
34
+ if (value && typeof value === 'object') {
35
+ const v = value as { name?: unknown; message?: unknown; stack?: unknown };
36
+ return {
37
+ name: typeof v.name === 'string' ? v.name : '',
38
+ message: typeof v.message === 'string' ? v.message : '',
39
+ stack: typeof v.stack === 'string' ? v.stack : '',
40
+ };
41
+ }
42
+ return { name: '', message: typeof value === 'string' ? value : '', stack: '' };
43
+ }
44
+
45
+ /**
46
+ * True when `error` is the signature of a missing/cancelled build asset:
47
+ * ChunkLoadError, "Loading chunk N failed", "Loading CSS chunk", dynamic import
48
+ * failures, or a failed/aborted fetch that names a `/_next/static/` URL.
49
+ */
50
+ export function isChunkLoadError(error: unknown): boolean {
51
+ const { name, message, stack } = asText(error);
52
+ if (name === 'ChunkLoadError') return true;
53
+ if (CHUNK_MESSAGE_PATTERNS.some((re) => re.test(message))) return true;
54
+ // Generic network-ish failure only counts when it concerns a build asset.
55
+ if (FETCH_FAILURE_RE.test(`${name} ${message}`) && STATIC_ASSET_RE.test(`${message} ${stack}`)) {
56
+ return true;
57
+ }
58
+ return false;
59
+ }
60
+
61
+ /** Resource-load `error` event from a `<script>`/`<link>` pointing at `/_next/static/`. */
62
+ export function isStaticAssetLoadEvent(event: Event): boolean {
63
+ const target = event.target as { tagName?: string; src?: string; href?: string } | null;
64
+ if (!target || typeof target.tagName !== 'string') return false;
65
+ const tag = target.tagName.toUpperCase();
66
+ if (tag !== 'SCRIPT' && tag !== 'LINK') return false;
67
+ return STATIC_ASSET_RE.test(target.src || target.href || '');
68
+ }
69
+
70
+ function readStamp(): number | null | 'unavailable' {
71
+ try {
72
+ const raw = window.sessionStorage.getItem(CHUNK_RELOAD_STORAGE_KEY);
73
+ if (!raw) return null;
74
+ const n = Number(raw);
75
+ return Number.isFinite(n) ? n : null;
76
+ } catch {
77
+ return 'unavailable';
78
+ }
79
+ }
80
+
81
+ /** Read-only check: would an automatic reload be allowed right now? */
82
+ export function canAutoReloadForChunk(now: number = Date.now()): boolean {
83
+ if (typeof window === 'undefined') return false;
84
+ const stamp = readStamp();
85
+ if (stamp === 'unavailable') return false;
86
+ return stamp === null || now - stamp > CHUNK_RELOAD_WINDOW_MS || now < stamp;
87
+ }
88
+
89
+ /**
90
+ * Reload once if the guard allows. Returns true when a reload was triggered
91
+ * (callers should render nothing meanwhile), false when the guard blocked it
92
+ * (callers show their normal error UI).
93
+ */
94
+ export function reloadForChunkError(now: number = Date.now()): boolean {
95
+ if (!canAutoReloadForChunk(now)) return false;
96
+ try {
97
+ window.sessionStorage.setItem(CHUNK_RELOAD_STORAGE_KEY, String(now));
98
+ } catch {
99
+ return false;
100
+ }
101
+ window.location.reload();
102
+ return true;
103
+ }
104
+
105
+ /**
106
+ * Install window-level listeners that catch chunk failures outside React
107
+ * (route-change imports, failed `<script>`/`<link>` tags, rejected promises).
108
+ * Idempotent; returns a cleanup function.
109
+ */
110
+ export function installChunkReloadListeners(): () => void {
111
+ if (typeof window === 'undefined') return () => {};
112
+ const w = window as unknown as { __djangocfgChunkReload?: boolean };
113
+ if (w.__djangocfgChunkReload) return () => {};
114
+ w.__djangocfgChunkReload = true;
115
+
116
+ const onError = (event: Event) => {
117
+ if (event instanceof ErrorEvent ? isChunkLoadError(event.error ?? event.message) : isStaticAssetLoadEvent(event)) {
118
+ reloadForChunkError();
119
+ }
120
+ };
121
+ const onRejection = (event: PromiseRejectionEvent) => {
122
+ if (isChunkLoadError(event.reason)) reloadForChunkError();
123
+ };
124
+ // capture=true: resource errors do not bubble.
125
+ window.addEventListener('error', onError, true);
126
+ window.addEventListener('unhandledrejection', onRejection);
127
+ return () => {
128
+ window.removeEventListener('error', onError, true);
129
+ window.removeEventListener('unhandledrejection', onRejection);
130
+ w.__djangocfgChunkReload = false;
131
+ };
132
+ }
package/src/lib/index.ts CHANGED
@@ -18,3 +18,4 @@ export * from "./compose-event-handlers";
18
18
  export { copyText } from "./clipboard";
19
19
  export { createContext } from "./create-context";
20
20
  export { getIntensity } from "./intensity";
21
+ export * from "./chunk-reload";
@@ -6,6 +6,8 @@ import type { ErrorInfo } from 'react';
6
6
  import { TooltipProvider } from '../components/overlay/tooltip';
7
7
  import { Toaster } from '../components/feedback/sonner';
8
8
  import { Boundary, type BoundaryProps } from '../components/boundary';
9
+ import { syncDisplayModeAttribute } from '../hooks/media/displayMode';
10
+ import { installChunkReloadListeners } from '../lib/chunk-reload';
9
11
  import { DialogProvider } from '../lib/dialog-service/DialogProvider';
10
12
 
11
13
  export interface UiProvidersProps {
@@ -82,6 +84,10 @@ export function UiProviders({
82
84
  errorBoundary,
83
85
  errorFallback,
84
86
  }: UiProvidersProps) {
87
+ // Stale tab after a deploy: reload once on chunk-load failures outside React.
88
+ React.useEffect(() => installChunkReloadListeners(), []);
89
+ // `<html data-display-mode="browser|standalone|...">` for CSS (see styles/css/base.css tokens).
90
+ React.useEffect(() => syncDisplayModeAttribute(), []);
85
91
  // No SSR-skip on purpose: any nested library component that renders
86
92
  // `<Tooltip>` on its first paint expects to find `<TooltipProvider>`
87
93
  // already in the tree. Skipping the provider during SSR caused
@@ -6,6 +6,17 @@
6
6
 
7
7
  /* Radius scale — theme-independent, shared by light and dark */
8
8
  :root {
9
+ /* Safe-area and viewport tokens. env() is 0 unless the host sets viewport-fit=cover, so these are
10
+ * safe in every mode. UiProviders also sets <html data-display-mode="browser|standalone|...">:
11
+ * use it for rules that differ in an installed app, e.g.
12
+ * :root[data-display-mode='standalone'] .my-header { padding-top: var(--safe-top); }
13
+ * --app-safe-* are optional overrides (Storybook's safe-area toolbar sets them). --app-height is the dynamic viewport; prefer `inset-0` on fixed full-screen surfaces. */
14
+ --safe-top: var(--app-safe-top, env(safe-area-inset-top, 0px));
15
+ --safe-right: var(--app-safe-right, env(safe-area-inset-right, 0px));
16
+ --safe-bottom: var(--app-safe-bottom, env(safe-area-inset-bottom, 0px));
17
+ --safe-left: var(--app-safe-left, env(safe-area-inset-left, 0px));
18
+ --app-height: 100dvh;
19
+
9
20
  --radius: 0.625rem;
10
21
  --radius-sm: 0.375rem;
11
22
  /* Control corner — shared by interactive controls (nav items, inputs,
@@ -102,6 +113,72 @@ html {
102
113
  font-weight: 400;
103
114
  }
104
115
 
116
+ /* ---------------------------------------------------------------------------
117
+ * App-like touch behaviour (PWA-ready by default)
118
+ *
119
+ * - `touch-action: manipulation` turns off double-tap-to-zoom (and its ~300ms click delay) but keeps
120
+ * pinch-zoom, so WCAG 1.4.4 (resize text) still holds. This is the default recipe; viewport
121
+ * `user-scalable=no` is NOT used (iOS ignores it, and it blocks accessibility zoom).
122
+ * - `text-size-adjust: 100%` stops iOS from inflating text after rotation.
123
+ * - Tap highlight flash is removed on controls.
124
+ * ------------------------------------------------------------------------- */
125
+ html {
126
+ touch-action: manipulation;
127
+ -webkit-text-size-adjust: 100%;
128
+ text-size-adjust: 100%;
129
+ }
130
+
131
+ :where(a, button, [role="button"], [role="tab"], [role="menuitem"], summary, label, select, input) {
132
+ -webkit-tap-highlight-color: transparent;
133
+ }
134
+
135
+ /* iOS Safari zooms into any focused field whose font-size is below 16px. On touch devices, floor text
136
+ * fields at 16px; desktop density is untouched. `!important` because component size classes
137
+ * (`text-sm`, `text-xs`) would otherwise win; add `.input-compact` to opt a field out. */
138
+ @media (pointer: coarse) {
139
+ :where(
140
+ input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"], [type="color"], [type="button"], [type="submit"], [type="reset"], [type="image"], .input-compact),
141
+ textarea:not(.input-compact),
142
+ select:not(.input-compact)
143
+ ) {
144
+ font-size: max(16px, 1em) !important;
145
+ }
146
+ }
147
+
148
+ /* Installed app (any display mode the display-mode hook treats as a PWA): behave like a native shell.
149
+ * `data-display-mode` is set on <html> by UiProviders. */
150
+ :root:is([data-display-mode="standalone"], [data-display-mode="fullscreen"], [data-display-mode="minimal-ui"], [data-display-mode="window-controls-overlay"]),
151
+ :root:is([data-display-mode="standalone"], [data-display-mode="fullscreen"], [data-display-mode="minimal-ui"], [data-display-mode="window-controls-overlay"]) body {
152
+ /* No rubber-band and no pull-to-refresh at the shell level; inner scrollers keep their own behaviour. */
153
+ overscroll-behavior: none;
154
+ }
155
+
156
+ /* Private app shell (PrivateLayout sets `data-private-shell` on <html> while mounted): the shell is exactly one
157
+ * viewport tall and owns its scrolling, so in an installed app lock the document itself. Public pages never carry the
158
+ * attribute and stay scrollable. */
159
+ :root[data-private-shell]:is([data-display-mode="standalone"], [data-display-mode="fullscreen"], [data-display-mode="minimal-ui"], [data-display-mode="window-controls-overlay"]),
160
+ :root[data-private-shell]:is([data-display-mode="standalone"], [data-display-mode="fullscreen"], [data-display-mode="minimal-ui"], [data-display-mode="window-controls-overlay"]) body {
161
+ height: 100%;
162
+ overflow: hidden;
163
+ }
164
+
165
+ /* Chrome UI is not selectable and has no long-press callout. Content stays selectable. */
166
+ :root:is([data-display-mode="standalone"], [data-display-mode="fullscreen"], [data-display-mode="minimal-ui"], [data-display-mode="window-controls-overlay"]) :where(button, [role="button"], [role="tab"], [role="menuitem"], nav, header, [data-sidebar], .app-chrome) {
167
+ -webkit-touch-callout: none;
168
+ -webkit-user-select: none;
169
+ user-select: none;
170
+ }
171
+ :root:is([data-display-mode="standalone"], [data-display-mode="fullscreen"], [data-display-mode="minimal-ui"], [data-display-mode="window-controls-overlay"]) :where(button, [role="button"], nav, header, [data-sidebar], .app-chrome) :where(img, svg) {
172
+ -webkit-user-drag: none;
173
+ }
174
+
175
+ /* Opt back in: form fields, code, tables and anything marked `.selectable`. */
176
+ :root:is([data-display-mode="standalone"], [data-display-mode="fullscreen"], [data-display-mode="minimal-ui"], [data-display-mode="window-controls-overlay"]) :where(.selectable, input, textarea, [contenteditable], [contenteditable] *, pre, code, table, td, th) {
177
+ -webkit-touch-callout: default;
178
+ -webkit-user-select: text;
179
+ user-select: text;
180
+ }
181
+
105
182
  body {
106
183
  background-color: var(--background);
107
184
  color: var(--foreground);
@@ -164,3 +164,34 @@
164
164
  min-width: 44px;
165
165
  }
166
166
  }
167
+
168
+ /* Private layout phone bar actions: compact (e.g. 36px) controls with air above and below inside the 48px row.
169
+ * The visual box is not inflated by the rule above; the 44px hit area comes from an invisible ::after. */
170
+ @media (max-width: 768px) {
171
+ [data-sidebar="topbar-actions"] :where(button, [role="button"]) {
172
+ min-height: 0;
173
+ min-width: 0;
174
+ position: relative;
175
+ }
176
+ [data-sidebar="topbar-actions"] :where(button, [role="button"])::after {
177
+ content: "";
178
+ position: absolute;
179
+ inset: -4px;
180
+ }
181
+ }
182
+
183
+ /* Phone drawer nav rows (PrivateLayout): 40px rows; the 44px hit area is an invisible ::after, like the bar actions. */
184
+ @media (max-width: 768px) {
185
+ [data-sidebar="content"] :where(button, [role="button"], a) {
186
+ min-height: 0;
187
+ min-width: 0;
188
+ }
189
+ [data-sidebar="content"] [data-sidebar="menu-button"] {
190
+ position: relative;
191
+ }
192
+ [data-sidebar="content"] [data-sidebar="menu-button"]::after {
193
+ content: "";
194
+ position: absolute;
195
+ inset: -2px 0;
196
+ }
197
+ }