@akinon/pz-theme 2.0.83-beta.0 → 2.0.83
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/CHANGELOG.md +31 -7
- package/package.json +4 -4
- package/src/__tests__/auth-flow-block.test.tsx +211 -0
- package/src/__tests__/auth-flow-bus.test.ts +290 -0
- package/src/__tests__/auth-form-error-block.test.tsx +326 -0
- package/src/__tests__/auth-form-validation.test.tsx +204 -0
- package/src/__tests__/auth-step-title.test.tsx +202 -0
- package/src/__tests__/detached-submit-state.test.tsx +216 -0
- package/src/__tests__/forgot-password-composed.test.tsx +313 -0
- package/src/__tests__/otp-form-composed.test.tsx +487 -0
- package/src/__tests__/password-reset-step.test.tsx +269 -0
- package/src/__tests__/register-form-composed.test.tsx +739 -0
- package/src/__tests__/registry-parity.test.ts +9 -0
- package/src/auth/auth-field-errors.tsx +151 -0
- package/src/auth/auth-flow-bus.ts +415 -0
- package/src/auth/form-error-context.tsx +55 -0
- package/src/auth/form-submit-bus.ts +93 -0
- package/src/auth/otp-form-context.tsx +80 -0
- package/src/blocks/__tests__/button-block.test.tsx +55 -0
- package/src/blocks/__tests__/link-block.test.tsx +48 -30
- package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
- package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
- package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
- package/src/blocks/__tests__/text-block.test.tsx +37 -3
- package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
- package/src/blocks/auth-consent-block.tsx +214 -0
- package/src/blocks/auth-error-block.tsx +82 -0
- package/src/blocks/auth-field-block.tsx +270 -0
- package/src/blocks/auth-flow-block.tsx +251 -0
- package/src/blocks/auth-form-block.tsx +391 -47
- package/src/blocks/auth-step-block.tsx +81 -0
- package/src/blocks/auth-step-title-block.tsx +81 -0
- package/src/blocks/block-renderer-registry.tsx +18 -0
- package/src/blocks/button-block.tsx +91 -13
- package/src/blocks/forgot-password-block.tsx +318 -24
- package/src/blocks/link-block.tsx +22 -84
- package/src/blocks/otp-code-input-block.tsx +180 -0
- package/src/blocks/otp-form-block.tsx +1092 -0
- package/src/blocks/otp-phase-block.tsx +71 -0
- package/src/blocks/register-form-block.tsx +541 -74
- package/src/blocks/text-block.tsx +17 -40
- package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
- package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
- package/src/hooks/use-block-action.ts +87 -4
- package/src/hooks/use-overlay.ts +60 -9
- package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
- package/src/overlays/__tests__/modal-registry.test.ts +6 -1
- package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
- package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
- package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
- package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
- package/src/overlays/modal-preview-host.tsx +39 -2
- package/src/overlays/modal-registry.ts +344 -4
- package/src/overlays/overlay-surface.tsx +249 -19
- package/src/overlays/overlay-tokens.ts +94 -0
- package/src/overlays/theme-modal-mount.tsx +34 -4
- package/src/theme-block.tsx +6 -0
- package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
- package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
- package/src/utils/index.ts +107 -6
- package/src/utils/iterator-utils.ts +39 -2
- package/src/utils/part-styles.ts +4 -0
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
import ThemeModalMount from './theme-modal-mount';
|
|
13
13
|
import { useEditorModals } from './use-editor-modals';
|
|
14
14
|
import { useHeldModal } from './use-modal-preview';
|
|
15
|
+
import { resolveModalChrome, resolveModalPresentation } from './modal-registry';
|
|
16
|
+
import { useOverlayBreakpoint } from './use-overlay-breakpoint';
|
|
15
17
|
|
|
16
18
|
interface ModalPreviewHostProps {
|
|
17
19
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -63,6 +65,9 @@ export const ModalPreviewHost = ({
|
|
|
63
65
|
}: ModalPreviewHostProps) => {
|
|
64
66
|
const modal = useHeldModal();
|
|
65
67
|
const editorModals = useEditorModals();
|
|
68
|
+
// Above the early return below: a hook that runs on some renders and not
|
|
69
|
+
// others is the one thing React cannot survive.
|
|
70
|
+
const breakpoint = useOverlayBreakpoint();
|
|
66
71
|
|
|
67
72
|
const composition = (slug: string) => (
|
|
68
73
|
<ThemePlaceholderWrapper
|
|
@@ -101,14 +106,33 @@ export const ModalPreviewHost = ({
|
|
|
101
106
|
|
|
102
107
|
if (!modal) return triggerable;
|
|
103
108
|
|
|
104
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Resolved, not read: a sheet's height and the weight of its backdrop are
|
|
111
|
+
* answered per device, so the values have to be settled for THIS breakpoint
|
|
112
|
+
* before any of them reaches a style.
|
|
113
|
+
*/
|
|
114
|
+
const chrome = resolveModalChrome(modal, breakpoint);
|
|
115
|
+
/**
|
|
116
|
+
* The canvas has to show the shape the chosen device will get: a designer who
|
|
117
|
+
* switches the editor to mobile and still sees a centred dialog has no way to
|
|
118
|
+
* lay out the sheet they just asked for. The hook reads the editor's forced
|
|
119
|
+
* breakpoint inside the iframe, so this follows the viewport buttons.
|
|
120
|
+
*/
|
|
121
|
+
const presentation = resolveModalPresentation(modal, breakpoint);
|
|
105
122
|
|
|
106
123
|
// Every dismissal is inert: the designer has to be able to click the close
|
|
107
124
|
// button and the backdrop they are colouring without the surface vanishing.
|
|
108
125
|
const noop = () => undefined;
|
|
109
126
|
|
|
110
127
|
const header = chrome.showClose ? (
|
|
111
|
-
<OverlayCloseButton
|
|
128
|
+
<OverlayCloseButton
|
|
129
|
+
label={chrome.closeLabel}
|
|
130
|
+
onClick={noop}
|
|
131
|
+
top={chrome.closeTop}
|
|
132
|
+
right={chrome.closeRight}
|
|
133
|
+
size={chrome.closeSize}
|
|
134
|
+
elevate
|
|
135
|
+
/>
|
|
112
136
|
) : null;
|
|
113
137
|
|
|
114
138
|
const ariaLabel = chrome.ariaLabel || modal.name;
|
|
@@ -124,6 +148,12 @@ export const ModalPreviewHost = ({
|
|
|
124
148
|
widthUnit={chrome.widthUnit}
|
|
125
149
|
backdropColor={chrome.backdropColor}
|
|
126
150
|
backdropOpacity={chrome.backdropOpacity}
|
|
151
|
+
// The canvas preview has to show what the storefront will: this host is
|
|
152
|
+
// where "I set the padding to 0 and nothing happened" came from the
|
|
153
|
+
// first time, because only the real mount was passing them through.
|
|
154
|
+
panelPadding={chrome.padding}
|
|
155
|
+
panelStretch={chrome.stretchContent}
|
|
156
|
+
topInset={chrome.topInset}
|
|
127
157
|
header={header}
|
|
128
158
|
>
|
|
129
159
|
{composition(modal.slug)}
|
|
@@ -140,8 +170,15 @@ export const ModalPreviewHost = ({
|
|
|
140
170
|
widthUnit={chrome.widthUnit}
|
|
141
171
|
backdropColor={chrome.backdropColor}
|
|
142
172
|
backdropOpacity={chrome.backdropOpacity}
|
|
173
|
+
// The canvas preview has to show what the storefront will: this host is
|
|
174
|
+
// where "I set the padding to 0 and nothing happened" came from the
|
|
175
|
+
// first time, because only the real mount was passing them through.
|
|
176
|
+
panelPadding={chrome.padding}
|
|
177
|
+
panelStretch={chrome.stretchContent}
|
|
143
178
|
panelSide={chrome.panelSide}
|
|
144
179
|
sheetHeight={chrome.sheetHeight}
|
|
180
|
+
showSheetHandle={chrome.showSheetHandle}
|
|
181
|
+
topInset={chrome.topInset}
|
|
145
182
|
header={header}
|
|
146
183
|
>
|
|
147
184
|
{composition(modal.slug)}
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
OVERLAY_BACKDROP_COLOR,
|
|
9
9
|
OVERLAY_BACKDROP_OPACITY,
|
|
10
10
|
OVERLAY_WIDTH_UNITS,
|
|
11
|
+
SHEET_HEIGHT_FALLBACK,
|
|
12
|
+
isSheetHeightLength,
|
|
11
13
|
type OverlayWidthUnit
|
|
12
14
|
} from './overlay-tokens';
|
|
13
15
|
|
|
@@ -88,9 +90,72 @@ export interface ModalChrome {
|
|
|
88
90
|
*/
|
|
89
91
|
widthUnit: OverlayWidthUnit;
|
|
90
92
|
panelSide: 'left' | 'right';
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Sheet height. A number is a percentage of the viewport (the historical
|
|
95
|
+
* form); a string is any CSS length, which is the only way to say what a
|
|
96
|
+
* percentage cannot — `calc(100dvh - 172px)` is "everything below the
|
|
97
|
+
* header", `fit-content` is "as tall as the composition needs".
|
|
98
|
+
*/
|
|
99
|
+
sheetHeight: number | string;
|
|
93
100
|
lockScroll: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* The panel's inner padding in px. Optional because every modal saved before
|
|
103
|
+
* this existed has no value, and those must keep rendering with the 24 the
|
|
104
|
+
* surfaces have always applied.
|
|
105
|
+
*/
|
|
106
|
+
padding?: number;
|
|
107
|
+
/**
|
|
108
|
+
* How far down the overlay starts, so a sticky site header is neither covered
|
|
109
|
+
* nor dimmed — the design's bottom sheet does not ride over the header. A
|
|
110
|
+
* number is px (the unit a header is measured in); a string is any length,
|
|
111
|
+
* including `var(--header-height)`. Absent means the overlay covers the
|
|
112
|
+
* viewport, which is what every modal published so far does.
|
|
113
|
+
*/
|
|
114
|
+
topInset?: number | string;
|
|
115
|
+
/**
|
|
116
|
+
* Where the built-in close button sits inside the panel, and how big it is.
|
|
117
|
+
*
|
|
118
|
+
* The button is chrome: it is positioned against the PANEL, while a title row
|
|
119
|
+
* authored in the composition flows with its own padding. The two are in
|
|
120
|
+
* different coordinate systems, so they never line up by accident — and part
|
|
121
|
+
* styles cannot reach chrome (a part rule hangs off a `[data-block-id]`,
|
|
122
|
+
* which chrome has none of). These are the way to align them. Absent keeps
|
|
123
|
+
* the historical 12px / 12px / 18px every published modal renders with.
|
|
124
|
+
*/
|
|
125
|
+
closeTop?: number | string;
|
|
126
|
+
closeRight?: number | string;
|
|
127
|
+
closeSize?: number | string;
|
|
128
|
+
/**
|
|
129
|
+
* The grab bar a sheet draws at its top centre. Absent reads as ON, because
|
|
130
|
+
* every sheet published so far has one and removing it silently would move
|
|
131
|
+
* their markup. A composition with its own header does not want it: the bar
|
|
132
|
+
* then reads as a stray line above the title.
|
|
133
|
+
*/
|
|
134
|
+
showSheetHandle?: boolean;
|
|
135
|
+
/**
|
|
136
|
+
* Hands the panel's full height down to the composition, so a drawer can pin
|
|
137
|
+
* its CTA to the bottom edge instead of leaving it under the last field. Off
|
|
138
|
+
* by default and optional for the same backward-compatibility reason as
|
|
139
|
+
* `padding` — see `PANEL_STRETCH_CSS` in overlay-surface.tsx for the chain it
|
|
140
|
+
* turns on.
|
|
141
|
+
*/
|
|
142
|
+
stretchContent?: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The chrome fields that may be answered again for a narrower breakpoint.
|
|
147
|
+
* Absent means "whatever the wider breakpoint said" — never a copied value, so
|
|
148
|
+
* a later change to the desktop answer still reaches the phone.
|
|
149
|
+
*/
|
|
150
|
+
export interface ModalChromeOverrides {
|
|
151
|
+
sheetHeight?: number | string;
|
|
152
|
+
topInset?: number | string;
|
|
153
|
+
/** A title row is padded differently per device, so these follow it. */
|
|
154
|
+
closeTop?: number | string;
|
|
155
|
+
closeRight?: number | string;
|
|
156
|
+
closeSize?: number | string;
|
|
157
|
+
backdropColor?: string;
|
|
158
|
+
backdropOpacity?: number;
|
|
94
159
|
}
|
|
95
160
|
|
|
96
161
|
export interface ThemeModal {
|
|
@@ -101,7 +166,34 @@ export interface ThemeModal {
|
|
|
101
166
|
/** `modal-<id>`, the widget slug the composition is saved under. */
|
|
102
167
|
slug: string;
|
|
103
168
|
presentation: ModalPresentation;
|
|
169
|
+
/**
|
|
170
|
+
* The presentation to use at a narrower breakpoint, when it differs.
|
|
171
|
+
*
|
|
172
|
+
* A centred modal is the wrong shape on a phone: the pattern every storefront
|
|
173
|
+
* reaches for there is a bottom sheet that fills the screen, with the CTA on
|
|
174
|
+
* its bottom edge. That is not a variant of the desktop dialog — it is a
|
|
175
|
+
* different presentation — and one scalar could not express both.
|
|
176
|
+
*
|
|
177
|
+
* A breakpoint left out follows the desktop choice, so every modal saved
|
|
178
|
+
* before this existed presents exactly as it did.
|
|
179
|
+
*/
|
|
180
|
+
presentationByBreakpoint?: Partial<
|
|
181
|
+
Record<'tablet' | 'mobile', ModalPresentation>
|
|
182
|
+
>;
|
|
104
183
|
chrome: ModalChrome;
|
|
184
|
+
/**
|
|
185
|
+
* Chrome values that differ at a narrower breakpoint.
|
|
186
|
+
*
|
|
187
|
+
* Only the settings whose right answer genuinely changes with the device: a
|
|
188
|
+
* sheet that should cover 92% of a phone is not the same measure as the 60%
|
|
189
|
+
* it gets on a desktop, and a backdrop dark enough behind a small sheet is
|
|
190
|
+
* too heavy behind a centred dialog. Everything else stays single-valued,
|
|
191
|
+
* because a control that can differ per device is a control a designer has
|
|
192
|
+
* to check three times.
|
|
193
|
+
*/
|
|
194
|
+
chromeByBreakpoint?: Partial<
|
|
195
|
+
Record<'tablet' | 'mobile', ModalChromeOverrides>
|
|
196
|
+
>;
|
|
105
197
|
autoTrigger?: ModalAutoTrigger;
|
|
106
198
|
/**
|
|
107
199
|
* Where this modal EXISTS — the same rule object sections and blocks already
|
|
@@ -191,6 +283,238 @@ const asClamped = (
|
|
|
191
283
|
return Math.min(max, Math.max(min, numeric));
|
|
192
284
|
};
|
|
193
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Like `asClamped`, but absence survives.
|
|
288
|
+
*
|
|
289
|
+
* A setting whose default lives in the renderer (the panel's historical 24px
|
|
290
|
+
* padding) must come out of here as `undefined` when it was never authored —
|
|
291
|
+
* defaulting it in the parser would make every modal saved before the control
|
|
292
|
+
* existed claim a value it never had.
|
|
293
|
+
*/
|
|
294
|
+
const asOptionalClamped = (
|
|
295
|
+
value: unknown,
|
|
296
|
+
min: number,
|
|
297
|
+
max: number
|
|
298
|
+
): number | undefined => {
|
|
299
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
300
|
+
|
|
301
|
+
const numeric = Math.round(Number(value));
|
|
302
|
+
if (!Number.isFinite(numeric)) return undefined;
|
|
303
|
+
|
|
304
|
+
return Math.min(max, Math.max(min, numeric));
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Reads the per-breakpoint presentation overrides.
|
|
309
|
+
*
|
|
310
|
+
* Absence is preserved rather than filled with the desktop value: an override
|
|
311
|
+
* that merely repeats the desktop choice would be indistinguishable from one a
|
|
312
|
+
* designer set deliberately, and the resolver below is the single place that
|
|
313
|
+
* decides what an absent breakpoint means.
|
|
314
|
+
*/
|
|
315
|
+
const readPresentationByBreakpoint = (
|
|
316
|
+
value: unknown
|
|
317
|
+
): ThemeModal['presentationByBreakpoint'] => {
|
|
318
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const raw = value as Record<string, unknown>;
|
|
323
|
+
const overrides: Partial<Record<'tablet' | 'mobile', ModalPresentation>> = {};
|
|
324
|
+
|
|
325
|
+
(['tablet', 'mobile'] as const).forEach((breakpoint) => {
|
|
326
|
+
const candidate = raw[breakpoint];
|
|
327
|
+
if (
|
|
328
|
+
typeof candidate === 'string' &&
|
|
329
|
+
MODAL_PRESENTATIONS.includes(candidate as ModalPresentation)
|
|
330
|
+
) {
|
|
331
|
+
overrides[breakpoint] = candidate as ModalPresentation;
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
return Object.keys(overrides).length > 0 ? overrides : undefined;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Reads the per-breakpoint chrome overrides.
|
|
340
|
+
*
|
|
341
|
+
* Each field goes through the same validation as its base counterpart — a junk
|
|
342
|
+
* colour or an out-of-range height must not become an override the base value
|
|
343
|
+
* could never have been. A breakpoint whose every field was rejected comes back
|
|
344
|
+
* absent rather than as an empty object nobody chose.
|
|
345
|
+
*/
|
|
346
|
+
const readChromeByBreakpoint = (
|
|
347
|
+
value: unknown
|
|
348
|
+
): ThemeModal['chromeByBreakpoint'] => {
|
|
349
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const raw = value as Record<string, unknown>;
|
|
354
|
+
const overrides: Partial<Record<'tablet' | 'mobile', ModalChromeOverrides>> =
|
|
355
|
+
{};
|
|
356
|
+
|
|
357
|
+
(['tablet', 'mobile'] as const).forEach((breakpoint) => {
|
|
358
|
+
const entry = raw[breakpoint];
|
|
359
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return;
|
|
360
|
+
|
|
361
|
+
const fields = entry as Record<string, unknown>;
|
|
362
|
+
const next: ModalChromeOverrides = {};
|
|
363
|
+
|
|
364
|
+
if (
|
|
365
|
+
fields.sheetHeight !== undefined &&
|
|
366
|
+
fields.sheetHeight !== null &&
|
|
367
|
+
fields.sheetHeight !== ''
|
|
368
|
+
) {
|
|
369
|
+
// Sentinel-free: an override that fails validation is dropped rather
|
|
370
|
+
// than falling back to 60, which would be an answer nobody gave.
|
|
371
|
+
const sheetHeight = asSheetHeight(fields.sheetHeight, '');
|
|
372
|
+
if (sheetHeight !== '') next.sheetHeight = sheetHeight;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (
|
|
376
|
+
typeof fields.backdropColor === 'string' &&
|
|
377
|
+
HEX_COLOR_PATTERN.test(fields.backdropColor.trim())
|
|
378
|
+
) {
|
|
379
|
+
next.backdropColor = fields.backdropColor.trim();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const backdropOpacity = asOptionalClamped(fields.backdropOpacity, 0, 100);
|
|
383
|
+
if (backdropOpacity !== undefined) next.backdropOpacity = backdropOpacity;
|
|
384
|
+
|
|
385
|
+
// A header is a different height on a phone, so this belongs per breakpoint.
|
|
386
|
+
const topInset = asChromeLength(fields.topInset);
|
|
387
|
+
if (topInset !== undefined) next.topInset = topInset;
|
|
388
|
+
|
|
389
|
+
(
|
|
390
|
+
[
|
|
391
|
+
['closeTop', 200],
|
|
392
|
+
['closeRight', 200],
|
|
393
|
+
['closeSize', 64]
|
|
394
|
+
] as const
|
|
395
|
+
).forEach(([field, max]) => {
|
|
396
|
+
const measure = asChromeLength(fields[field], max);
|
|
397
|
+
if (measure !== undefined) next[field] = measure;
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
if (Object.keys(next).length > 0) overrides[breakpoint] = next;
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
return Object.keys(overrides).length > 0 ? overrides : undefined;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The chrome to render at one breakpoint.
|
|
408
|
+
*
|
|
409
|
+
* Overrides fall UP the chain exactly as the presentation does: a phone takes
|
|
410
|
+
* its own answer, then the tablet's, then the desktop's. Returns the SAME
|
|
411
|
+
* object when nothing overrides, so a modal without overrides is not merely
|
|
412
|
+
* equal but identical — no new object per render.
|
|
413
|
+
*/
|
|
414
|
+
export const resolveModalChrome = (
|
|
415
|
+
modal: Pick<ThemeModal, 'chrome' | 'chromeByBreakpoint'>,
|
|
416
|
+
breakpoint?: string
|
|
417
|
+
): ModalChrome => {
|
|
418
|
+
const overrides = modal.chromeByBreakpoint;
|
|
419
|
+
if (!overrides) return modal.chrome;
|
|
420
|
+
|
|
421
|
+
// Widest first: each layer overwrites what the wider one said.
|
|
422
|
+
const chain: (ModalChromeOverrides | undefined)[] = [];
|
|
423
|
+
if (breakpoint === 'tablet' || breakpoint === 'mobile') {
|
|
424
|
+
chain.push(overrides.tablet);
|
|
425
|
+
}
|
|
426
|
+
if (breakpoint === 'mobile') {
|
|
427
|
+
chain.push(overrides.mobile);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const applied = chain.filter(Boolean) as ModalChromeOverrides[];
|
|
431
|
+
if (applied.length === 0) return modal.chrome;
|
|
432
|
+
|
|
433
|
+
return applied.reduce<ModalChrome>(
|
|
434
|
+
(chrome, layer) => ({ ...chrome, ...layer }),
|
|
435
|
+
modal.chrome
|
|
436
|
+
);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The presentation to render at one breakpoint.
|
|
441
|
+
*
|
|
442
|
+
* Narrower breakpoints fall UP the chain rather than to the desktop value: a
|
|
443
|
+
* modal told to be a sheet on tablet should stay a sheet on a phone, which is
|
|
444
|
+
* the narrower device, unless the phone was given its own answer. Anything
|
|
445
|
+
* else would have a designer set two controls to say one thing.
|
|
446
|
+
*/
|
|
447
|
+
export const resolveModalPresentation = (
|
|
448
|
+
modal: Pick<ThemeModal, 'presentation' | 'presentationByBreakpoint'>,
|
|
449
|
+
breakpoint?: string
|
|
450
|
+
): ModalPresentation => {
|
|
451
|
+
const overrides = modal.presentationByBreakpoint;
|
|
452
|
+
if (!overrides) return modal.presentation;
|
|
453
|
+
|
|
454
|
+
if (breakpoint === 'mobile') {
|
|
455
|
+
return overrides.mobile ?? overrides.tablet ?? modal.presentation;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (breakpoint === 'tablet') {
|
|
459
|
+
return overrides.tablet ?? modal.presentation;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return modal.presentation;
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* A sheet height, as a number of viewport percent or as a CSS length.
|
|
467
|
+
*
|
|
468
|
+
* The number keeps its historical clamp — 4000 is not a height. A string is
|
|
469
|
+
* kept only if it reads as a length: the surface resolves it, and a value
|
|
470
|
+
* carrying a brace or a semicolon would not be one. Junk falls back rather
|
|
471
|
+
* than passing through, so the panel is never left with no height at all.
|
|
472
|
+
*/
|
|
473
|
+
const asSheetHeight = (
|
|
474
|
+
value: unknown,
|
|
475
|
+
fallback: number | string
|
|
476
|
+
): number | string => {
|
|
477
|
+
if (typeof value === 'string') {
|
|
478
|
+
const trimmed = value.trim();
|
|
479
|
+
// A plain numeric string is the number, not a length: an editor input hands
|
|
480
|
+
// back "80", and `80` alone is not valid CSS.
|
|
481
|
+
if (/^\d+(\.\d+)?$/.test(trimmed)) {
|
|
482
|
+
return asClamped(trimmed, SHEET_HEIGHT_FALLBACK, 20, 100);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return isSheetHeightLength(trimmed) ? trimmed : fallback;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const numeric = Math.round(Number(value));
|
|
489
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
490
|
+
|
|
491
|
+
return Math.min(100, Math.max(20, numeric));
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* A measurement in px or as a written length. Absent stays absent: a value of
|
|
496
|
+
* `0` and no value at all render differently — only the second says "the
|
|
497
|
+
* designer never asked for this", which is what lets the renderer keep its own
|
|
498
|
+
* default.
|
|
499
|
+
*/
|
|
500
|
+
const asChromeLength = (
|
|
501
|
+
value: unknown,
|
|
502
|
+
max = 600
|
|
503
|
+
): number | string | undefined => {
|
|
504
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
505
|
+
|
|
506
|
+
if (typeof value === 'string') {
|
|
507
|
+
const trimmed = value.trim();
|
|
508
|
+
if (/^\d+(\.\d+)?$/.test(trimmed)) {
|
|
509
|
+
return asOptionalClamped(trimmed, 0, max);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return isSheetHeightLength(trimmed) ? trimmed : undefined;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return asOptionalClamped(value, 0, max);
|
|
516
|
+
};
|
|
517
|
+
|
|
194
518
|
const readChrome = (value: unknown, name: string): ModalChrome => {
|
|
195
519
|
const raw =
|
|
196
520
|
value && typeof value === 'object' && !Array.isArray(value)
|
|
@@ -235,8 +559,20 @@ const readChrome = (value: unknown, name: string): ModalChrome => {
|
|
|
235
559
|
: asClamped(raw.width, 80, 10, 100),
|
|
236
560
|
widthUnit,
|
|
237
561
|
panelSide: raw.panelSide === 'left' ? 'left' : 'right',
|
|
238
|
-
sheetHeight:
|
|
239
|
-
lockScroll: asToggle(raw.lockScroll, true)
|
|
562
|
+
sheetHeight: asSheetHeight(raw.sheetHeight, 60),
|
|
563
|
+
lockScroll: asToggle(raw.lockScroll, true),
|
|
564
|
+
// Read, not defaulted: 0 is a real answer ("no padding at all"), which is
|
|
565
|
+
// why absence has to stay distinguishable from it. The upper bound is a
|
|
566
|
+
// typo guard — 2400 is not a padding.
|
|
567
|
+
padding: asOptionalClamped(raw.padding, 0, 200),
|
|
568
|
+
// Absent reads as on: every sheet published before this toggle has a bar.
|
|
569
|
+
showSheetHandle: asToggle(raw.showSheetHandle, true),
|
|
570
|
+
topInset: asChromeLength(raw.topInset),
|
|
571
|
+
closeTop: asChromeLength(raw.closeTop, 200),
|
|
572
|
+
closeRight: asChromeLength(raw.closeRight, 200),
|
|
573
|
+
// A tap target below ~24px is hard to hit and above ~64px is not an icon.
|
|
574
|
+
closeSize: asChromeLength(raw.closeSize, 64),
|
|
575
|
+
stretchContent: asToggle(raw.stretchContent, false)
|
|
240
576
|
};
|
|
241
577
|
};
|
|
242
578
|
|
|
@@ -275,6 +611,10 @@ export const readThemeModal = (value: unknown): ThemeModal | null => {
|
|
|
275
611
|
name,
|
|
276
612
|
slug,
|
|
277
613
|
presentation: asOption(raw.presentation, MODAL_PRESENTATIONS, 'modal'),
|
|
614
|
+
presentationByBreakpoint: readPresentationByBreakpoint(
|
|
615
|
+
raw.presentationByBreakpoint
|
|
616
|
+
),
|
|
617
|
+
chromeByBreakpoint: readChromeByBreakpoint(raw.chromeByBreakpoint),
|
|
278
618
|
chrome: readChrome(raw.chrome, name),
|
|
279
619
|
autoTrigger: readAutoTrigger(raw.autoTrigger),
|
|
280
620
|
visibilityRules:
|