@djangocfg/ui-core 2.1.607 → 2.1.609
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 +7 -7
- package/src/components/boundary/Boundary.tsx +8 -1
- package/src/components/feedback/sonner/index.tsx +3 -0
- package/src/components/index.ts +1 -0
- package/src/components/navigation/sidebar/index.ts +1 -0
- package/src/components/navigation/sidebar/sidebar.tsx +9 -4
- package/src/components/overlay/dialog/index.tsx +2 -2
- package/src/components/overlay/drawer/index.tsx +19 -11
- package/src/components/overlay/sheet/index.tsx +5 -5
- package/src/hooks/media/appHeight.ts +69 -0
- package/src/hooks/media/displayMode.ts +47 -0
- package/src/hooks/media/index.ts +5 -0
- package/src/hooks/media/useBlockGestureZoom.ts +26 -0
- package/src/hooks/media/useDisplayMode.ts +25 -0
- package/src/lib/chunk-reload.ts +132 -0
- package/src/lib/index.ts +1 -0
- package/src/providers/UiProviders.tsx +9 -0
- package/src/styles/css/base.css +87 -0
- package/src/styles/css/utilities/touch-safe.css +31 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.609",
|
|
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.
|
|
137
|
-
"@djangocfg/i18n": "^2.1.
|
|
136
|
+
"@djangocfg/api": "^2.1.609",
|
|
137
|
+
"@djangocfg/i18n": "^2.1.609",
|
|
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.
|
|
214
|
-
"@djangocfg/eslint-config": "^2.1.
|
|
215
|
-
"@djangocfg/i18n": "^2.1.
|
|
216
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
213
|
+
"@djangocfg/api": "^2.1.609",
|
|
214
|
+
"@djangocfg/eslint-config": "^2.1.609",
|
|
215
|
+
"@djangocfg/i18n": "^2.1.609",
|
|
216
|
+
"@djangocfg/typescript-config": "^2.1.609",
|
|
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-
|
|
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: {
|
package/src/components/index.ts
CHANGED
|
@@ -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';
|
|
@@ -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
|
-
|
|
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"
|
|
@@ -130,7 +131,7 @@ const SidebarProvider = React.forwardRef<
|
|
|
130
131
|
} as React.CSSProperties
|
|
131
132
|
}
|
|
132
133
|
className={cn(
|
|
133
|
-
"group/sidebar-wrapper flex min-h-
|
|
134
|
+
"group/sidebar-wrapper flex min-h-[var(--app-height,100svh)] w-full has-[&_[data-variant=inset]]:bg-sidebar",
|
|
134
135
|
className
|
|
135
136
|
)}
|
|
136
137
|
ref={ref}
|
|
@@ -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
|
-
|
|
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={
|
|
@@ -257,7 +262,7 @@ const Sidebar = React.forwardRef<
|
|
|
257
262
|
/>
|
|
258
263
|
<div
|
|
259
264
|
className={cn(
|
|
260
|
-
"fixed inset-y-0 z-10 hidden h-
|
|
265
|
+
"fixed inset-y-0 z-10 hidden h-[var(--app-height,100svh)] transition-[left,right,width] duration-200 ease-linear md:flex",
|
|
261
266
|
side === "left"
|
|
262
267
|
? state === "collapsed" && collapsible === "offcanvas"
|
|
263
268
|
? "left-[calc(var(--sidebar-width)*-1)]"
|
|
@@ -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(
|
|
109
|
-
md: 'min(
|
|
110
|
-
lg: 'min(
|
|
111
|
-
xl: 'min(
|
|
112
|
-
full: '
|
|
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-
|
|
143
|
-
left: "inset-y-0 left-0 h-
|
|
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(
|
|
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-
|
|
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-
|
|
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-
|
|
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,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--app-height`: the real height of the app viewport, kept on `<html>`.
|
|
3
|
+
*
|
|
4
|
+
* Why it is measured: in an installed iOS app with `viewport-fit=cover` + a translucent status bar, WebKit reports
|
|
5
|
+
* `100%`, `100svh` and `100dvh` as the screen height MINUS the top inset, so a shell sized from them stops short and
|
|
6
|
+
* leaves a dead band at the bottom (and `overflow: hidden` clips the rest). `100lvh` and `window.innerHeight` are not
|
|
7
|
+
* always right either, across iOS versions. So in an installed mode we take the largest of the candidates, capped at
|
|
8
|
+
* the physical screen, and publish it as `--app-height`. In a browser tab the property is removed and the CSS default
|
|
9
|
+
* (`100dvh`, see base.css) applies. The shell paints under the status bar and pads by `--safe-top` itself.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { detectDisplayMode, isInstalledMode, subscribeDisplayMode } from './displayMode';
|
|
13
|
+
|
|
14
|
+
const PROP = '--app-height';
|
|
15
|
+
|
|
16
|
+
function probeHeight(unit: 'lvh' | 'dvh'): number {
|
|
17
|
+
if (typeof CSS !== 'undefined' && CSS.supports && !CSS.supports('height', `100${unit}`)) return 0;
|
|
18
|
+
const el = document.createElement('div');
|
|
19
|
+
el.style.cssText = `position:fixed;top:0;left:0;width:0;height:100${unit};visibility:hidden;pointer-events:none`;
|
|
20
|
+
document.body.appendChild(el);
|
|
21
|
+
const h = el.getBoundingClientRect().height;
|
|
22
|
+
el.remove();
|
|
23
|
+
return h;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Largest plausible app height in px, or 0 when nothing could be measured. */
|
|
27
|
+
export function measureAppHeight(): number {
|
|
28
|
+
if (typeof document === 'undefined' || !document.body) return 0;
|
|
29
|
+
const screenMax = Math.max(window.screen?.height || 0, window.screen?.width || 0);
|
|
30
|
+
const candidates = [probeHeight('lvh'), probeHeight('dvh'), window.innerHeight].filter((n) => n > 0);
|
|
31
|
+
if (!candidates.length) return 0;
|
|
32
|
+
const best = Math.max(...candidates);
|
|
33
|
+
return screenMax > 0 ? Math.min(best, screenMax) : best;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Keeps `--app-height` in step with the installed viewport. Returns a cleanup. */
|
|
37
|
+
export function syncAppHeight(): () => void {
|
|
38
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return () => {};
|
|
39
|
+
const root = document.documentElement;
|
|
40
|
+
const timers: number[] = [];
|
|
41
|
+
|
|
42
|
+
const apply = () => {
|
|
43
|
+
if (!isInstalledMode(detectDisplayMode())) {
|
|
44
|
+
root.style.removeProperty(PROP);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const h = measureAppHeight();
|
|
48
|
+
if (h > 0) root.style.setProperty(PROP, `${Math.round(h)}px`);
|
|
49
|
+
};
|
|
50
|
+
// iOS reports the new size a beat after `orientationchange`; re-measure once it settled.
|
|
51
|
+
const applyLater = () => {
|
|
52
|
+
apply();
|
|
53
|
+
timers.push(window.setTimeout(apply, 120), window.setTimeout(apply, 400));
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
apply();
|
|
57
|
+
window.addEventListener('resize', apply);
|
|
58
|
+
window.addEventListener('orientationchange', applyLater);
|
|
59
|
+
window.addEventListener('pageshow', apply);
|
|
60
|
+
const unsubscribe = subscribeDisplayMode(apply);
|
|
61
|
+
return () => {
|
|
62
|
+
window.removeEventListener('resize', apply);
|
|
63
|
+
window.removeEventListener('orientationchange', applyLater);
|
|
64
|
+
window.removeEventListener('pageshow', apply);
|
|
65
|
+
unsubscribe();
|
|
66
|
+
timers.forEach((t) => window.clearTimeout(t));
|
|
67
|
+
root.style.removeProperty(PROP);
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -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
|
+
}
|
package/src/hooks/media/index.ts
CHANGED
|
@@ -4,3 +4,8 @@ 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 { syncAppHeight, measureAppHeight } from './appHeight';
|
|
11
|
+
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
|
@@ -6,6 +6,9 @@ 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 { syncAppHeight } from '../hooks/media/appHeight';
|
|
11
|
+
import { installChunkReloadListeners } from '../lib/chunk-reload';
|
|
9
12
|
import { DialogProvider } from '../lib/dialog-service/DialogProvider';
|
|
10
13
|
|
|
11
14
|
export interface UiProvidersProps {
|
|
@@ -82,6 +85,12 @@ export function UiProviders({
|
|
|
82
85
|
errorBoundary,
|
|
83
86
|
errorFallback,
|
|
84
87
|
}: UiProvidersProps) {
|
|
88
|
+
// Stale tab after a deploy: reload once on chunk-load failures outside React.
|
|
89
|
+
React.useEffect(() => installChunkReloadListeners(), []);
|
|
90
|
+
// `<html data-display-mode="browser|standalone|...">` for CSS (see styles/css/base.css tokens).
|
|
91
|
+
React.useEffect(() => syncDisplayModeAttribute(), []);
|
|
92
|
+
// `--app-height` measured in an installed app (iOS standalone reports dvh/100% short; see hooks/media/appHeight).
|
|
93
|
+
React.useEffect(() => syncAppHeight(), []);
|
|
85
94
|
// No SSR-skip on purpose: any nested library component that renders
|
|
86
95
|
// `<Tooltip>` on its first paint expects to find `<TooltipProvider>`
|
|
87
96
|
// already in the tree. Skipping the provider during SSR caused
|
package/src/styles/css/base.css
CHANGED
|
@@ -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,82 @@ 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, first paint: iOS reports 100dvh short by the top inset with viewport-fit=cover, 100lvh is the full
|
|
149
|
+
* screen. `syncAppHeight()` (UiProviders) then replaces this with the measured px value on <html>. */
|
|
150
|
+
@supports (height: 100lvh) {
|
|
151
|
+
@media (display-mode: standalone), (display-mode: fullscreen) {
|
|
152
|
+
:root { --app-height: 100lvh; }
|
|
153
|
+
}
|
|
154
|
+
:root:is([data-display-mode="standalone"], [data-display-mode="fullscreen"]) { --app-height: 100lvh; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* Installed app (any display mode the display-mode hook treats as a PWA): behave like a native shell.
|
|
158
|
+
* `data-display-mode` is set on <html> by UiProviders. */
|
|
159
|
+
:root:is([data-display-mode="standalone"], [data-display-mode="fullscreen"], [data-display-mode="minimal-ui"], [data-display-mode="window-controls-overlay"]),
|
|
160
|
+
:root:is([data-display-mode="standalone"], [data-display-mode="fullscreen"], [data-display-mode="minimal-ui"], [data-display-mode="window-controls-overlay"]) body {
|
|
161
|
+
/* No rubber-band and no pull-to-refresh at the shell level; inner scrollers keep their own behaviour. */
|
|
162
|
+
overscroll-behavior: none;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* Private app shell (PrivateLayout sets `data-private-shell` on <html> while mounted): the shell is exactly one
|
|
166
|
+
* viewport tall and owns its scrolling, so in an installed app lock the document itself. Public pages never carry the
|
|
167
|
+
* attribute and stay scrollable. */
|
|
168
|
+
:root[data-private-shell]:is([data-display-mode="standalone"], [data-display-mode="fullscreen"], [data-display-mode="minimal-ui"], [data-display-mode="window-controls-overlay"]),
|
|
169
|
+
: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 {
|
|
170
|
+
/* Not `100%`: in an installed iOS app with viewport-fit=cover it resolves short by the top inset. */
|
|
171
|
+
height: var(--app-height, 100dvh);
|
|
172
|
+
overflow: hidden;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* Chrome UI is not selectable and has no long-press callout. Content stays selectable. */
|
|
176
|
+
: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) {
|
|
177
|
+
-webkit-touch-callout: none;
|
|
178
|
+
-webkit-user-select: none;
|
|
179
|
+
user-select: none;
|
|
180
|
+
}
|
|
181
|
+
: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) {
|
|
182
|
+
-webkit-user-drag: none;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* Opt back in: form fields, code, tables and anything marked `.selectable`. */
|
|
186
|
+
: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) {
|
|
187
|
+
-webkit-touch-callout: default;
|
|
188
|
+
-webkit-user-select: text;
|
|
189
|
+
user-select: text;
|
|
190
|
+
}
|
|
191
|
+
|
|
105
192
|
body {
|
|
106
193
|
background-color: var(--background);
|
|
107
194
|
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
|
+
}
|