@akinon/pz-theme 2.0.83-rc.0 → 2.0.84-beta.0
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 +39 -33
- 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
|
@@ -4,7 +4,10 @@ import React from 'react';
|
|
|
4
4
|
import { partAttrs } from '../utils';
|
|
5
5
|
import {
|
|
6
6
|
OVERLAY_BACKDROP_COLOR,
|
|
7
|
-
OVERLAY_BACKDROP_OPACITY
|
|
7
|
+
OVERLAY_BACKDROP_OPACITY,
|
|
8
|
+
SHEET_HEIGHT_FALLBACK,
|
|
9
|
+
isSheetHeightLength,
|
|
10
|
+
resolveOverlayTopInset
|
|
8
11
|
} from './overlay-tokens';
|
|
9
12
|
import type { OverlayPresentation, OverlayWidthUnit } from './overlay-tokens';
|
|
10
13
|
|
|
@@ -101,6 +104,57 @@ export const resolveOverlayBackdrop = (
|
|
|
101
104
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
102
105
|
};
|
|
103
106
|
|
|
107
|
+
/**
|
|
108
|
+
* How a stretched panel hands its height down to the composition.
|
|
109
|
+
*
|
|
110
|
+
* The markup between the panel and the designer's blocks is fixed and ours:
|
|
111
|
+
* `<div data-placeholder>` then `<section data-section-id>` (whose content
|
|
112
|
+
* box is `display: contents`, so the section lays the block wrappers out
|
|
113
|
+
* directly). These rules make that fixed chain fill the panel; what happens
|
|
114
|
+
* INSIDE the section stays the designer's (a section set to Flex column with
|
|
115
|
+
* "space between" pins its last block to the drawer's bottom edge). The auth
|
|
116
|
+
* journey wrappers are carried along via `:has` so a flow's active step can
|
|
117
|
+
* fill the drawer without the designer hand-writing the chain.
|
|
118
|
+
*
|
|
119
|
+
* The chain has to be UNBROKEN, which is the whole difficulty: height is not
|
|
120
|
+
* inherited, it is passed down link by link, and one wrapper left at `auto`
|
|
121
|
+
* strands everything below it. That is why the composed forms are in the list
|
|
122
|
+
* too — an OTP form sits between the step and its phase, so a phase can only
|
|
123
|
+
* fill the drawer if the form does. A block that should NOT grow opts out with
|
|
124
|
+
* its own Flex control (`flex: 0 0 auto`), which outranks these rules.
|
|
125
|
+
*/
|
|
126
|
+
const AUTH_JOURNEY_PARTS = [
|
|
127
|
+
'data-auth-flow',
|
|
128
|
+
'data-auth-step',
|
|
129
|
+
'data-otp-phase',
|
|
130
|
+
'data-auth-form',
|
|
131
|
+
'data-register-form',
|
|
132
|
+
'data-otp-form',
|
|
133
|
+
'data-forgot-password'
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
const strandsOf = (attribute: string) => ({
|
|
137
|
+
/** The designer wrapper the generated stylesheet targets. */
|
|
138
|
+
wrapper: `[data-modal-stretch] [data-block-id]:has(>[${attribute}])`,
|
|
139
|
+
/** Our own element inside it. */
|
|
140
|
+
element: `[data-modal-stretch] [${attribute}]`
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const PANEL_STRETCH_CSS = [
|
|
144
|
+
'[data-modal-stretch]>[data-placeholder]{flex:1 1 auto;min-height:0;display:flex;flex-direction:column}',
|
|
145
|
+
'[data-modal-stretch]>[data-placeholder]>section{flex:1 1 auto;min-height:0}',
|
|
146
|
+
`${AUTH_JOURNEY_PARTS.map((attribute) => strandsOf(attribute).wrapper).join(
|
|
147
|
+
','
|
|
148
|
+
)}{flex:1 1 auto;min-height:0;display:flex;flex-direction:column}`,
|
|
149
|
+
`${AUTH_JOURNEY_PARTS.map((attribute) => strandsOf(attribute).element).join(
|
|
150
|
+
','
|
|
151
|
+
)}{flex:1 1 auto;min-height:0}`
|
|
152
|
+
].join('\n');
|
|
153
|
+
|
|
154
|
+
export const PanelStretchStyles = () => (
|
|
155
|
+
<style dangerouslySetInnerHTML={{ __html: PANEL_STRETCH_CSS }} />
|
|
156
|
+
);
|
|
157
|
+
|
|
104
158
|
/** The panel's own box, before geometry places it. */
|
|
105
159
|
export const overlayPanelSurface: React.CSSProperties = {
|
|
106
160
|
overflow: 'auto',
|
|
@@ -118,12 +172,47 @@ export interface OverlayGeometryInput {
|
|
|
118
172
|
/** Unit of `width`; px unless the composed modal's chrome says otherwise. */
|
|
119
173
|
widthUnit?: OverlayWidthUnit;
|
|
120
174
|
panelSide?: string;
|
|
121
|
-
/**
|
|
122
|
-
|
|
175
|
+
/**
|
|
176
|
+
* Sheet height. A number is vh (the historical form); a string is any CSS
|
|
177
|
+
* length, so `calc(100dvh - 172px)` can say "everything below the header".
|
|
178
|
+
*/
|
|
179
|
+
sheetHeight?: number | string;
|
|
123
180
|
anchorAlign?: 'start' | 'center' | 'end';
|
|
124
181
|
anchorOffset?: number;
|
|
182
|
+
/**
|
|
183
|
+
* Whether the panel hands its height to the composition. Only the sheet
|
|
184
|
+
* reads it: a drawer is already full height and a centred dialog sizes
|
|
185
|
+
* itself in the surface rather than here.
|
|
186
|
+
*/
|
|
187
|
+
panelStretch?: boolean;
|
|
125
188
|
}
|
|
126
189
|
|
|
190
|
+
/**
|
|
191
|
+
* A sheet's height, as a CSS length.
|
|
192
|
+
*
|
|
193
|
+
* A bare number is the historical form and means vh, which is what every
|
|
194
|
+
* published sheet carries. A string is taken as written, so a designer can say
|
|
195
|
+
* what a percentage cannot: `calc(100dvh - 172px)` is "everything below the
|
|
196
|
+
* header", and `fit-content` is "as tall as the composition needs".
|
|
197
|
+
*
|
|
198
|
+
* Rejected values fall back to the historical 60vh rather than reaching the
|
|
199
|
+
* browser: a declaration the browser drops would leave the panel with no height
|
|
200
|
+
* at all, and one carrying a brace or a semicolon would not be a length.
|
|
201
|
+
*/
|
|
202
|
+
export const resolveSheetHeight = (
|
|
203
|
+
value: number | string | undefined
|
|
204
|
+
): string => {
|
|
205
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
206
|
+
return `${value}vh`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (typeof value === 'string' && isSheetHeightLength(value)) {
|
|
210
|
+
return value.trim();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return `${SHEET_HEIGHT_FALLBACK}vh`;
|
|
214
|
+
};
|
|
215
|
+
|
|
127
216
|
/**
|
|
128
217
|
* Where the panel sits for a given presentation.
|
|
129
218
|
*
|
|
@@ -136,9 +225,10 @@ export const resolveOverlayPanelGeometry = ({
|
|
|
136
225
|
width,
|
|
137
226
|
widthUnit = 'px',
|
|
138
227
|
panelSide = 'right',
|
|
139
|
-
sheetHeight = 60,
|
|
228
|
+
sheetHeight = 60 as number | string,
|
|
140
229
|
anchorAlign = 'start',
|
|
141
|
-
anchorOffset = 8
|
|
230
|
+
anchorOffset = 8,
|
|
231
|
+
panelStretch = false
|
|
142
232
|
}: OverlayGeometryInput): React.CSSProperties => {
|
|
143
233
|
if (presentation === 'drawer') {
|
|
144
234
|
return {
|
|
@@ -158,7 +248,17 @@ export const resolveOverlayPanelGeometry = ({
|
|
|
158
248
|
right: 0,
|
|
159
249
|
bottom: 0,
|
|
160
250
|
width: '100%',
|
|
161
|
-
|
|
251
|
+
/**
|
|
252
|
+
* A stretched sheet is TOLD its height rather than merely capped at it.
|
|
253
|
+
* `maxHeight` alone leaves the panel as tall as its content, so the flex
|
|
254
|
+
* column stretch turns on with no free space to hand out and a CTA given
|
|
255
|
+
* `margin-top: auto` does not move — which reads as the setting doing
|
|
256
|
+
* nothing. Unstretched, the cap stays the whole rule: a short sheet
|
|
257
|
+
* should still hug its content.
|
|
258
|
+
*/
|
|
259
|
+
...(panelStretch
|
|
260
|
+
? { height: resolveSheetHeight(sheetHeight) }
|
|
261
|
+
: { maxHeight: resolveSheetHeight(sheetHeight) }),
|
|
162
262
|
boxShadow: '0 -12px 32px rgba(0, 0, 0, 0.18)'
|
|
163
263
|
};
|
|
164
264
|
}
|
|
@@ -167,8 +267,8 @@ export const resolveOverlayPanelGeometry = ({
|
|
|
167
267
|
anchorAlign === 'center'
|
|
168
268
|
? { left: '50%', transform: 'translateX(-50%)' }
|
|
169
269
|
: anchorAlign === 'end'
|
|
170
|
-
|
|
171
|
-
|
|
270
|
+
? { right: 0 }
|
|
271
|
+
: { left: 0 };
|
|
172
272
|
|
|
173
273
|
return {
|
|
174
274
|
position: 'absolute',
|
|
@@ -186,13 +286,56 @@ export const resolveOverlayPanelGeometry = ({
|
|
|
186
286
|
export interface OverlayCloseButtonProps {
|
|
187
287
|
label: string;
|
|
188
288
|
onClick: () => void;
|
|
289
|
+
/**
|
|
290
|
+
* Where the button sits inside the panel, and how big its glyph is.
|
|
291
|
+
*
|
|
292
|
+
* The button is positioned against the PANEL while a title row authored in
|
|
293
|
+
* the composition flows with its own padding, so the two never line up by
|
|
294
|
+
* accident. Absent keeps the historical 12 / 12 / 18 every published modal
|
|
295
|
+
* renders with.
|
|
296
|
+
*/
|
|
297
|
+
top?: number | string;
|
|
298
|
+
right?: number | string;
|
|
299
|
+
size?: number | string;
|
|
300
|
+
/**
|
|
301
|
+
* Lift the button above the panel's authored content.
|
|
302
|
+
*
|
|
303
|
+
* A composed modal fills its panel with the designer's blocks, and a
|
|
304
|
+
* full-width one — a title row, a banner — paints OVER this button: it comes
|
|
305
|
+
* later in the DOM and the button, positioned but unlayered, loses. The click
|
|
306
|
+
* then lands on the heading and the ✕ does nothing at all, which is how this
|
|
307
|
+
* was found.
|
|
308
|
+
*
|
|
309
|
+
* A flag rather than always-on because the legacy size-guide panel renders
|
|
310
|
+
* the same button and its markup is pinned byte for byte by
|
|
311
|
+
* `panel-legacy-golden.json` — live storefronts style it. Only the composed
|
|
312
|
+
* hosts ask.
|
|
313
|
+
*/
|
|
314
|
+
elevate?: boolean;
|
|
189
315
|
/** Legacy marker attribute, e.g. `{ 'data-size-guide-close': '' }`. */
|
|
190
316
|
markerAttrs?: Record<string, string>;
|
|
191
317
|
}
|
|
192
318
|
|
|
319
|
+
/** px unless the author wrote a length. */
|
|
320
|
+
const asCssLength = (
|
|
321
|
+
value: number | string | undefined,
|
|
322
|
+
fallback: string
|
|
323
|
+
): string => {
|
|
324
|
+
if (typeof value === 'number' && Number.isFinite(value)) return `${value}px`;
|
|
325
|
+
if (typeof value === 'string' && isSheetHeightLength(value)) {
|
|
326
|
+
return value.trim();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return fallback;
|
|
330
|
+
};
|
|
331
|
+
|
|
193
332
|
export const OverlayCloseButton = ({
|
|
194
333
|
label,
|
|
195
334
|
onClick,
|
|
335
|
+
top,
|
|
336
|
+
right,
|
|
337
|
+
size,
|
|
338
|
+
elevate,
|
|
196
339
|
markerAttrs
|
|
197
340
|
}: OverlayCloseButtonProps) => (
|
|
198
341
|
<button
|
|
@@ -203,18 +346,31 @@ export const OverlayCloseButton = ({
|
|
|
203
346
|
onClick={onClick}
|
|
204
347
|
style={{
|
|
205
348
|
position: 'absolute',
|
|
206
|
-
top: '12px',
|
|
207
|
-
right: '12px',
|
|
349
|
+
top: asCssLength(top, '12px'),
|
|
350
|
+
right: asCssLength(right, '12px'),
|
|
208
351
|
border: 'none',
|
|
209
352
|
background: 'transparent',
|
|
210
353
|
color: 'inherit',
|
|
211
354
|
cursor: 'pointer',
|
|
212
|
-
|
|
355
|
+
/**
|
|
356
|
+
* Untouched unless a size is asked for, and that is not fussiness: this
|
|
357
|
+
* button is the legacy size-guide panel's too, whose markup is pinned
|
|
358
|
+
* byte for byte by `panel-legacy-golden.json` because live storefronts
|
|
359
|
+
* style it. A hit area is worth having — the padding is what a thumb aims
|
|
360
|
+
* at, and the negative margin keeps the glyph where the offsets put it —
|
|
361
|
+
* but only a modal that asked for a size gets them.
|
|
362
|
+
*/
|
|
363
|
+
...(size === undefined
|
|
364
|
+
? { padding: 0 }
|
|
365
|
+
: { padding: '8px', margin: '-8px', lineHeight: 0 }),
|
|
366
|
+
// Only has to beat static content, which is what the composition's
|
|
367
|
+
// blocks are; the panel's own layer sits at 1000 above the page.
|
|
368
|
+
...(elevate ? { zIndex: 2 } : null)
|
|
213
369
|
}}
|
|
214
370
|
>
|
|
215
371
|
<svg
|
|
216
|
-
width=
|
|
217
|
-
height=
|
|
372
|
+
width={size === undefined ? '18' : asCssLength(size, '18px')}
|
|
373
|
+
height={size === undefined ? '18' : asCssLength(size, '18px')}
|
|
218
374
|
viewBox="0 0 24 24"
|
|
219
375
|
fill="none"
|
|
220
376
|
stroke="currentColor"
|
|
@@ -261,6 +417,29 @@ export interface OverlaySurfaceProps extends OverlayGeometryInput {
|
|
|
261
417
|
backdropOpacity?: number;
|
|
262
418
|
closeOnOverlay: boolean;
|
|
263
419
|
onDismiss: () => void;
|
|
420
|
+
/**
|
|
421
|
+
* The panel's inner padding in px; absent means the historical 24. Owned by
|
|
422
|
+
* the composed modal's chrome — the panel block keeps its fixed surface.
|
|
423
|
+
*/
|
|
424
|
+
panelPadding?: number;
|
|
425
|
+
/**
|
|
426
|
+
* Stretch the composed content to the panel's full height (see
|
|
427
|
+
* `ModalChrome.stretchContent`). The panel becomes a flex column and the
|
|
428
|
+
* scoped rules below walk the stretch through the placeholder markup.
|
|
429
|
+
*/
|
|
430
|
+
panelStretch?: boolean;
|
|
431
|
+
/**
|
|
432
|
+
* The sheet's grab bar. Absent reads as shown, which is what every published
|
|
433
|
+
* sheet has — a composition with its own header turns it off so the bar does
|
|
434
|
+
* not read as a stray line above the title.
|
|
435
|
+
*/
|
|
436
|
+
showSheetHandle?: boolean;
|
|
437
|
+
/**
|
|
438
|
+
* How far down the whole overlay layer starts, so a sticky site header is
|
|
439
|
+
* neither covered nor dimmed. Absent means the layer covers the viewport,
|
|
440
|
+
* which is what every published modal does.
|
|
441
|
+
*/
|
|
442
|
+
topInset?: number | string;
|
|
264
443
|
/** Rendered inside the panel, above the authored content. */
|
|
265
444
|
header?: React.ReactNode;
|
|
266
445
|
children: React.ReactNode;
|
|
@@ -278,6 +457,18 @@ export interface OverlaySurfaceProps extends OverlayGeometryInput {
|
|
|
278
457
|
};
|
|
279
458
|
}
|
|
280
459
|
|
|
460
|
+
/**
|
|
461
|
+
* The `top` a layer takes when the design keeps the site header clear, as a
|
|
462
|
+
* style object so both surfaces spread it the same way.
|
|
463
|
+
*/
|
|
464
|
+
const topInsetStyle = (
|
|
465
|
+
value: number | string | undefined
|
|
466
|
+
): React.CSSProperties | null => {
|
|
467
|
+
const resolved = resolveOverlayTopInset(value);
|
|
468
|
+
|
|
469
|
+
return resolved ? { top: resolved } : null;
|
|
470
|
+
};
|
|
471
|
+
|
|
281
472
|
/**
|
|
282
473
|
* The centred modal, kept as ONE node that is both the fixed root and the
|
|
283
474
|
* backdrop.
|
|
@@ -298,6 +489,9 @@ export const CentredModalSurface = ({
|
|
|
298
489
|
widthUnit,
|
|
299
490
|
backdropColor,
|
|
300
491
|
backdropOpacity,
|
|
492
|
+
panelPadding,
|
|
493
|
+
panelStretch,
|
|
494
|
+
topInset,
|
|
301
495
|
header,
|
|
302
496
|
children,
|
|
303
497
|
markers
|
|
@@ -314,6 +508,11 @@ export const CentredModalSurface = ({
|
|
|
314
508
|
style={{
|
|
315
509
|
position: 'fixed',
|
|
316
510
|
inset: 0,
|
|
511
|
+
// Same rule as the layered surface: this node IS the backdrop, so the
|
|
512
|
+
// inset is what keeps a sticky header visible and clickable. The dialog
|
|
513
|
+
// then centres in what is left, which is where a designer who asked for
|
|
514
|
+
// the header to stay clear expects it.
|
|
515
|
+
...topInsetStyle(topInset),
|
|
317
516
|
zIndex: 1000,
|
|
318
517
|
display: 'flex',
|
|
319
518
|
alignItems: 'center',
|
|
@@ -325,6 +524,7 @@ export const CentredModalSurface = ({
|
|
|
325
524
|
<div
|
|
326
525
|
{...markers?.panel}
|
|
327
526
|
{...partAttrs('modal', { open: true })}
|
|
527
|
+
{...(panelStretch ? { 'data-modal-stretch': '' } : null)}
|
|
328
528
|
role="dialog"
|
|
329
529
|
{...overlayProps}
|
|
330
530
|
aria-label={ariaLabel}
|
|
@@ -337,13 +537,21 @@ export const CentredModalSurface = ({
|
|
|
337
537
|
maxWidth: resolveOverlayWidth(width, widthUnit),
|
|
338
538
|
maxHeight: '80vh',
|
|
339
539
|
overflow: 'auto',
|
|
340
|
-
padding:
|
|
540
|
+
padding: `${panelPadding ?? 24}px`,
|
|
341
541
|
borderRadius: '4px',
|
|
342
542
|
background: '#ffffff',
|
|
343
543
|
color: '#111827',
|
|
344
|
-
textAlign: 'left'
|
|
544
|
+
textAlign: 'left',
|
|
545
|
+
...(panelStretch
|
|
546
|
+
? {
|
|
547
|
+
display: 'flex',
|
|
548
|
+
flexDirection: 'column' as const,
|
|
549
|
+
height: '80vh'
|
|
550
|
+
}
|
|
551
|
+
: null)
|
|
345
552
|
}}
|
|
346
553
|
>
|
|
554
|
+
{panelStretch && <PanelStretchStyles />}
|
|
347
555
|
{header}
|
|
348
556
|
{children}
|
|
349
557
|
</div>
|
|
@@ -372,6 +580,10 @@ export const LayeredOverlaySurface = ({
|
|
|
372
580
|
sheetHeight,
|
|
373
581
|
anchorAlign,
|
|
374
582
|
anchorOffset,
|
|
583
|
+
panelPadding,
|
|
584
|
+
panelStretch,
|
|
585
|
+
showSheetHandle,
|
|
586
|
+
topInset,
|
|
375
587
|
header,
|
|
376
588
|
children,
|
|
377
589
|
markers
|
|
@@ -395,6 +607,7 @@ export const LayeredOverlaySurface = ({
|
|
|
395
607
|
<div
|
|
396
608
|
{...markers?.panel}
|
|
397
609
|
{...partAttrs('modal', { open: true })}
|
|
610
|
+
{...(panelStretch ? { 'data-modal-stretch': '' } : null)}
|
|
398
611
|
role="dialog"
|
|
399
612
|
{...overlayProps}
|
|
400
613
|
aria-label={ariaLabel}
|
|
@@ -404,6 +617,9 @@ export const LayeredOverlaySurface = ({
|
|
|
404
617
|
onClick={(event) => event.stopPropagation()}
|
|
405
618
|
style={{
|
|
406
619
|
...overlayPanelSurface,
|
|
620
|
+
...(panelPadding !== undefined
|
|
621
|
+
? { padding: `${panelPadding}px` }
|
|
622
|
+
: null),
|
|
407
623
|
...resolveOverlayPanelGeometry({
|
|
408
624
|
presentation,
|
|
409
625
|
width,
|
|
@@ -411,12 +627,17 @@ export const LayeredOverlaySurface = ({
|
|
|
411
627
|
panelSide,
|
|
412
628
|
sheetHeight,
|
|
413
629
|
anchorAlign,
|
|
414
|
-
anchorOffset
|
|
630
|
+
anchorOffset,
|
|
631
|
+
panelStretch
|
|
415
632
|
}),
|
|
416
|
-
pointerEvents: showBackdrop ? undefined : 'auto'
|
|
633
|
+
pointerEvents: showBackdrop ? undefined : 'auto',
|
|
634
|
+
...(panelStretch
|
|
635
|
+
? { display: 'flex', flexDirection: 'column' as const }
|
|
636
|
+
: null)
|
|
417
637
|
}}
|
|
418
638
|
>
|
|
419
|
-
{
|
|
639
|
+
{panelStretch && <PanelStretchStyles />}
|
|
640
|
+
{presentation === 'sheet' && showSheetHandle !== false && (
|
|
420
641
|
<OverlaySheetHandle markerAttrs={markers?.handle} />
|
|
421
642
|
)}
|
|
422
643
|
{header}
|
|
@@ -430,7 +651,12 @@ export const LayeredOverlaySurface = ({
|
|
|
430
651
|
{showBackdrop && (
|
|
431
652
|
<div
|
|
432
653
|
{...markers?.backdropRoot}
|
|
433
|
-
style={{
|
|
654
|
+
style={{
|
|
655
|
+
position: 'fixed',
|
|
656
|
+
inset: 0,
|
|
657
|
+
...topInsetStyle(topInset),
|
|
658
|
+
zIndex: 999
|
|
659
|
+
}}
|
|
434
660
|
>
|
|
435
661
|
{backdrop}
|
|
436
662
|
</div>
|
|
@@ -446,6 +672,10 @@ export const LayeredOverlaySurface = ({
|
|
|
446
672
|
style={{
|
|
447
673
|
position: 'fixed',
|
|
448
674
|
inset: 0,
|
|
675
|
+
// The inset is on the LAYER, not the panel: the backdrop lives here
|
|
676
|
+
// too, and a panel-only offset would leave the header dimmed and
|
|
677
|
+
// unclickable — which is exactly what the design does not do.
|
|
678
|
+
...topInsetStyle(topInset),
|
|
449
679
|
zIndex: 1000,
|
|
450
680
|
pointerEvents: showBackdrop ? undefined : 'none'
|
|
451
681
|
}}
|
|
@@ -32,3 +32,97 @@ export const OVERLAY_WIDTH_UNITS: OverlayWidthUnit[] = ['px', '%', 'vw'];
|
|
|
32
32
|
/** Backdrop paint defaults — what every published surface shipped with. */
|
|
33
33
|
export const OVERLAY_BACKDROP_COLOR = '#000000';
|
|
34
34
|
export const OVERLAY_BACKDROP_OPACITY = 50;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How tall a sheet is, and what a designer is allowed to write for it.
|
|
38
|
+
*
|
|
39
|
+
* A number is a percentage of the viewport — the historical form every
|
|
40
|
+
* published sheet carries. A string is a CSS length, which is the only way to
|
|
41
|
+
* say what a percentage cannot: `calc(100dvh - 172px)` is "everything below the
|
|
42
|
+
* header", `fit-content` is "as tall as the composition needs".
|
|
43
|
+
*
|
|
44
|
+
* Lives here rather than beside the surface that applies it because the
|
|
45
|
+
* REGISTRY validates it, and the registry is parsed on the server — see the
|
|
46
|
+
* note at the top of this file for what importing a client module there costs.
|
|
47
|
+
*/
|
|
48
|
+
const SHEET_HEIGHT_KEYWORDS = new Set([
|
|
49
|
+
'auto',
|
|
50
|
+
'fit-content',
|
|
51
|
+
'min-content',
|
|
52
|
+
'max-content',
|
|
53
|
+
'stretch'
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/** Units a viewport-sized panel is plausibly measured in. */
|
|
57
|
+
const SHEET_HEIGHT_UNIT =
|
|
58
|
+
'(px|%|vh|dvh|svh|lvh|vw|dvw|svw|lvw|rem|em|ch|ex|cm|mm|in|pt|pc|q)';
|
|
59
|
+
|
|
60
|
+
const SHEET_HEIGHT_LENGTH = new RegExp(
|
|
61
|
+
`^-?\\d*\\.?\\d+${SHEET_HEIGHT_UNIT}$`,
|
|
62
|
+
'i'
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const SHEET_HEIGHT_FUNCTION = /^(calc|min|max|clamp)\(.+\)$/i;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether a string is a height the browser will accept.
|
|
69
|
+
*
|
|
70
|
+
* Strict on purpose: an earlier, looser check let `tall` through as a "length",
|
|
71
|
+
* and a declaration the browser drops leaves the panel with NO height — a sheet
|
|
72
|
+
* collapsed to its content with the composition spilling out of it. A function
|
|
73
|
+
* is checked for length-ish contents rather than parsed: nothing here may carry
|
|
74
|
+
* a brace, a semicolon, a `url()` or a `var()`, which are how a value stops
|
|
75
|
+
* being a measurement.
|
|
76
|
+
*/
|
|
77
|
+
export const isSheetHeightLength = (value: string): boolean => {
|
|
78
|
+
const trimmed = value.trim();
|
|
79
|
+
|
|
80
|
+
if (!trimmed || trimmed.length > 120) return false;
|
|
81
|
+
if (/[;{}]|url|expression/i.test(trimmed)) return false;
|
|
82
|
+
if (SHEET_HEIGHT_KEYWORDS.has(trimmed.toLowerCase())) return true;
|
|
83
|
+
if (SHEET_HEIGHT_LENGTH.test(trimmed)) return true;
|
|
84
|
+
// `var(--header-height)` and `env(safe-area-inset-top)`: a designer who
|
|
85
|
+
// knows the measure only as a variable has no other way to say it, and a
|
|
86
|
+
// custom property cannot break out of a declaration.
|
|
87
|
+
if (/^(var|env)\(--?[a-z0-9-]+(,[^;{}]*)?\)$/i.test(trimmed)) return true;
|
|
88
|
+
|
|
89
|
+
if (SHEET_HEIGHT_FUNCTION.test(trimmed)) {
|
|
90
|
+
// Inside a function only arithmetic, numbers, units, variables and nesting.
|
|
91
|
+
const body = trimmed.replace(/^(calc|min|max|clamp)\(/i, '').slice(0, -1);
|
|
92
|
+
return (
|
|
93
|
+
/^[0-9a-z\s().,%+*/_-]+$/i.test(body) &&
|
|
94
|
+
// A number or a variable has to appear, or this is words in parentheses.
|
|
95
|
+
(/\d/.test(body) || /var\(|env\(/i.test(body))
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return false;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* How far down the overlay layer starts.
|
|
104
|
+
*
|
|
105
|
+
* A bottom sheet in a design does not ride over the site header: the header
|
|
106
|
+
* stays visible AND clickable, and the dimming begins below it. That cannot be
|
|
107
|
+
* expressed as a panel height — the backdrop covers the header either way — so
|
|
108
|
+
* it is an inset on the layer itself, which the backdrop and the panel share.
|
|
109
|
+
*
|
|
110
|
+
* Same vocabulary as a sheet height: a number is px (the unit a header is
|
|
111
|
+
* measured in), a string is any length this module accepts.
|
|
112
|
+
*/
|
|
113
|
+
export const resolveOverlayTopInset = (
|
|
114
|
+
value: number | string | undefined
|
|
115
|
+
): string | undefined => {
|
|
116
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
117
|
+
return `${value}px`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (typeof value === 'string' && isSheetHeightLength(value)) {
|
|
121
|
+
return value.trim();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return undefined;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** The default every sheet had before its height could be written out. */
|
|
128
|
+
export const SHEET_HEIGHT_FALLBACK = 60;
|
|
@@ -7,7 +7,11 @@ import { useVisibilityContext } from '../hooks/use-visibility-context';
|
|
|
7
7
|
import { useBrowseMode } from '../browse-mode';
|
|
8
8
|
import { evaluateVisibilityRules } from '../utils/visibility-rules';
|
|
9
9
|
import { closeModal, useModalOpen } from './overlay-bus';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
ThemeModal,
|
|
12
|
+
resolveModalChrome,
|
|
13
|
+
resolveModalPresentation
|
|
14
|
+
} from './modal-registry';
|
|
11
15
|
import {
|
|
12
16
|
CentredModalSurface,
|
|
13
17
|
LayeredOverlaySurface,
|
|
@@ -37,7 +41,8 @@ interface ThemeModalMountProps {
|
|
|
37
41
|
|
|
38
42
|
const isDesignerWindow = () =>
|
|
39
43
|
typeof window !== 'undefined' &&
|
|
40
|
-
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner ===
|
|
44
|
+
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner ===
|
|
45
|
+
true;
|
|
41
46
|
|
|
42
47
|
/**
|
|
43
48
|
* One composed modal: the bus decides whether it is open, `useOverlay` decides
|
|
@@ -102,7 +107,18 @@ const ThemeModalMount = ({
|
|
|
102
107
|
const supersededByEditor = origin === 'saved' && editorOwnsModal;
|
|
103
108
|
|
|
104
109
|
const opened = !isPreviewing && !supersededByEditor && isOpen && existsHere;
|
|
105
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Resolved, not read: a sheet's height and the weight of its backdrop are
|
|
112
|
+
* answered per device, so the values have to be settled for THIS breakpoint
|
|
113
|
+
* before any of them reaches a style.
|
|
114
|
+
*/
|
|
115
|
+
const chrome = resolveModalChrome(modal, breakpoint);
|
|
116
|
+
/**
|
|
117
|
+
* Resolved, not read: a modal is often a centred dialog on a desktop and a
|
|
118
|
+
* bottom sheet on a phone, and the shape decides the geometry, the backdrop
|
|
119
|
+
* and the sheet handle — so it has to be settled before any of them.
|
|
120
|
+
*/
|
|
121
|
+
const presentation = resolveModalPresentation(modal, breakpoint);
|
|
106
122
|
const isCentred = presentation === 'modal';
|
|
107
123
|
|
|
108
124
|
/**
|
|
@@ -128,7 +144,14 @@ const ThemeModalMount = ({
|
|
|
128
144
|
const dismiss = () => closeModal(modal.id);
|
|
129
145
|
|
|
130
146
|
const header = chrome.showClose ? (
|
|
131
|
-
<OverlayCloseButton
|
|
147
|
+
<OverlayCloseButton
|
|
148
|
+
label={chrome.closeLabel}
|
|
149
|
+
onClick={dismiss}
|
|
150
|
+
top={chrome.closeTop}
|
|
151
|
+
right={chrome.closeRight}
|
|
152
|
+
size={chrome.closeSize}
|
|
153
|
+
elevate
|
|
154
|
+
/>
|
|
132
155
|
) : null;
|
|
133
156
|
|
|
134
157
|
const ariaLabel = chrome.ariaLabel || modal.name;
|
|
@@ -143,6 +166,9 @@ const ThemeModalMount = ({
|
|
|
143
166
|
widthUnit={chrome.widthUnit}
|
|
144
167
|
backdropColor={chrome.backdropColor}
|
|
145
168
|
backdropOpacity={chrome.backdropOpacity}
|
|
169
|
+
panelPadding={chrome.padding}
|
|
170
|
+
panelStretch={chrome.stretchContent}
|
|
171
|
+
topInset={chrome.topInset}
|
|
146
172
|
header={header}
|
|
147
173
|
>
|
|
148
174
|
{children}
|
|
@@ -161,6 +187,10 @@ const ThemeModalMount = ({
|
|
|
161
187
|
backdropOpacity={chrome.backdropOpacity}
|
|
162
188
|
panelSide={chrome.panelSide}
|
|
163
189
|
sheetHeight={chrome.sheetHeight}
|
|
190
|
+
showSheetHandle={chrome.showSheetHandle}
|
|
191
|
+
topInset={chrome.topInset}
|
|
192
|
+
panelPadding={chrome.padding}
|
|
193
|
+
panelStretch={chrome.stretchContent}
|
|
164
194
|
header={header}
|
|
165
195
|
>
|
|
166
196
|
{children}
|
package/src/theme-block.tsx
CHANGED
|
@@ -46,6 +46,12 @@ export interface Block {
|
|
|
46
46
|
blocks?: Block[]; // Optional - only group blocks have nested blocks
|
|
47
47
|
isIterator?: boolean;
|
|
48
48
|
iteratorDataPath?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Properties this clone resolved for itself from its row. One generated rule
|
|
51
|
+
* serves every clone, so these are the values that have to be inlined on the
|
|
52
|
+
* element — see `collectRowBoundStyleKeys` in utils/iterator-utils.ts.
|
|
53
|
+
*/
|
|
54
|
+
rowStyleKeys?: string[];
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
interface ThemeBlockProps {
|