@akinon/pz-theme 2.0.95-rc.0 → 2.0.95
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 +8 -33
- package/package.json +4 -4
- package/src/__tests__/auth-captcha.test.tsx +261 -0
- package/src/__tests__/auth-commerce-events.test.tsx +5 -1
- package/src/__tests__/auth-destination.test.tsx +348 -0
- package/src/__tests__/auth-error-reading.test.ts +232 -0
- package/src/__tests__/auth-flow-scope.test.tsx +162 -0
- package/src/__tests__/auth-form-error-block.test.tsx +2 -0
- package/src/__tests__/auth-form-success-block.test.tsx +429 -0
- package/src/__tests__/auth-form-validation.test.tsx +2 -0
- package/src/__tests__/auth-slot-coverage.test.ts +9 -1
- package/src/__tests__/auth-step-title.test.tsx +2 -0
- package/src/__tests__/block-action-audience.test.ts +154 -0
- package/src/__tests__/detached-submit-state.test.tsx +80 -2
- package/src/__tests__/forgot-password-composed.test.tsx +2 -0
- package/src/__tests__/form-state-composition.test.tsx +484 -0
- package/src/__tests__/modal-login-close.test.tsx +366 -0
- package/src/__tests__/otp-form-composed.test.tsx +2 -0
- package/src/__tests__/password-reset-step.test.tsx +2 -0
- package/src/__tests__/register-form-composed.test.tsx +253 -4
- package/src/__tests__/registry-parity.test.ts +12 -0
- package/src/__tests__/reset-password-composed.test.tsx +385 -0
- package/src/auth/auth-captcha.tsx +125 -0
- package/src/auth/auth-destination.ts +168 -0
- package/src/auth/auth-field-errors.tsx +31 -0
- package/src/auth/form-state-context.tsx +170 -0
- package/src/auth/form-success-context.tsx +56 -0
- package/src/auth/read-auth-error.ts +332 -0
- package/src/blocks/__tests__/slider-block.test.tsx +265 -1
- package/src/blocks/account-button-block.tsx +25 -0
- package/src/blocks/add-to-cart-block.tsx +15 -0
- package/src/blocks/auth-field-block.tsx +13 -3
- package/src/blocks/auth-flow-block.tsx +45 -3
- package/src/blocks/auth-form-block.tsx +337 -54
- package/src/blocks/auth-success-block.tsx +85 -0
- package/src/blocks/block-renderer-registry.tsx +8 -0
- package/src/blocks/button-block.tsx +43 -13
- package/src/blocks/captcha-block.tsx +61 -0
- package/src/blocks/change-password-form-block.tsx +15 -0
- package/src/blocks/forgot-password-block.tsx +132 -9
- package/src/blocks/form-state-block.tsx +84 -0
- package/src/blocks/newsletter-form-block.tsx +16 -0
- package/src/blocks/otp-form-block.tsx +161 -67
- package/src/blocks/profile-form-block.tsx +13 -0
- package/src/blocks/register-form-block.tsx +362 -110
- package/src/blocks/reset-password-block.tsx +717 -0
- package/src/blocks/slider-block.tsx +170 -4
- package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
- package/src/chrome/placeholder-slugs.ts +3 -0
- package/src/chrome/route-slug.ts +11 -0
- package/src/index.ts +10 -0
- package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
- package/src/overlays/__tests__/modal-registry.test.ts +5 -1
- package/src/overlays/modal-chrome-css.ts +8 -1
- package/src/overlays/modal-host-context.tsx +66 -0
- package/src/overlays/modal-preview-host.tsx +7 -2
- package/src/overlays/modal-registry.ts +38 -1
- package/src/overlays/overlay-loading.tsx +144 -0
- package/src/overlays/overlay-surface.tsx +23 -0
- package/src/overlays/overlay-tokens.ts +12 -0
- package/src/overlays/theme-modal-mount.tsx +85 -6
- package/src/utils/visibility-rules.ts +114 -0
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
OverlayCloseButton,
|
|
11
11
|
OverlayHandleProps
|
|
12
12
|
} from './overlay-surface';
|
|
13
|
+
import { ModalHostIdProvider } from './modal-host-context';
|
|
13
14
|
import ThemeModalMount from './theme-modal-mount';
|
|
14
15
|
import { useEditorModals } from './use-editor-modals';
|
|
15
16
|
import { useHeldModal } from './use-modal-preview';
|
|
@@ -167,7 +168,9 @@ export const ModalPreviewHost = ({
|
|
|
167
168
|
chromeCss={chromeCss}
|
|
168
169
|
header={header}
|
|
169
170
|
>
|
|
170
|
-
{
|
|
171
|
+
<ModalHostIdProvider value={modal.id}>
|
|
172
|
+
{composition(modal.slug)}
|
|
173
|
+
</ModalHostIdProvider>
|
|
171
174
|
</CentredModalSurface>
|
|
172
175
|
) : (
|
|
173
176
|
<LayeredOverlaySurface
|
|
@@ -193,7 +196,9 @@ export const ModalPreviewHost = ({
|
|
|
193
196
|
chromeCss={chromeCss}
|
|
194
197
|
header={header}
|
|
195
198
|
>
|
|
196
|
-
{
|
|
199
|
+
<ModalHostIdProvider value={modal.id}>
|
|
200
|
+
{composition(modal.slug)}
|
|
201
|
+
</ModalHostIdProvider>
|
|
197
202
|
</LayeredOverlaySurface>
|
|
198
203
|
);
|
|
199
204
|
|
|
@@ -7,6 +7,7 @@ import type { VisibilityRules } from '../utils/visibility-rules';
|
|
|
7
7
|
import {
|
|
8
8
|
OVERLAY_BACKDROP_COLOR,
|
|
9
9
|
OVERLAY_BACKDROP_OPACITY,
|
|
10
|
+
OVERLAY_LOADING_DELAY_MAX,
|
|
10
11
|
OVERLAY_WIDTH_UNITS,
|
|
11
12
|
SHEET_HEIGHT_FALLBACK,
|
|
12
13
|
isSheetHeightLength,
|
|
@@ -152,6 +153,30 @@ export interface ModalChrome {
|
|
|
152
153
|
*/
|
|
153
154
|
closeText?: string;
|
|
154
155
|
closeTextSide?: 'before' | 'after';
|
|
156
|
+
/**
|
|
157
|
+
* Whether the panel shows a loading mark while its composition is still
|
|
158
|
+
* committing. Absent reads as ON, which cannot change how a published modal
|
|
159
|
+
* looks: the layer is invisible for its first `loadingDelay` ms, and a
|
|
160
|
+
* composition that commits in the same frame — every small one — has
|
|
161
|
+
* revealed itself long before that. What it buys is the large ones, where
|
|
162
|
+
* the panel's white surface is on screen for a beat with nothing in it.
|
|
163
|
+
*
|
|
164
|
+
* Off restores the single commit: the composition renders with the panel and
|
|
165
|
+
* nothing is drawn over it.
|
|
166
|
+
*/
|
|
167
|
+
showLoading?: boolean;
|
|
168
|
+
/**
|
|
169
|
+
* How long the panel may be empty before the mark fades in, in ms. Absent
|
|
170
|
+
* means the historical-safe 150 — see `OVERLAY_LOADING_DELAY`.
|
|
171
|
+
*/
|
|
172
|
+
loadingDelay?: number;
|
|
173
|
+
/**
|
|
174
|
+
* Words beside the mark. Absence-preserving for the same reason as
|
|
175
|
+
* `closeText`, and for one more: this package has no dictionary, so a
|
|
176
|
+
* default would be an English string on every storefront. Authored text is
|
|
177
|
+
* the only text.
|
|
178
|
+
*/
|
|
179
|
+
loadingLabel?: string;
|
|
155
180
|
}
|
|
156
181
|
|
|
157
182
|
/**
|
|
@@ -602,7 +627,19 @@ const readChrome = (value: unknown, name: string): ModalChrome => {
|
|
|
602
627
|
// a close text must not come out of here claiming an empty one, and the
|
|
603
628
|
// side only means something once the text exists.
|
|
604
629
|
closeText: asString(raw.closeText) || undefined,
|
|
605
|
-
closeTextSide: raw.closeTextSide === 'before' ? 'before' : undefined
|
|
630
|
+
closeTextSide: raw.closeTextSide === 'before' ? 'before' : undefined,
|
|
631
|
+
// Absent reads as on: the layer is invisible until the delay has passed,
|
|
632
|
+
// so turning it on for every published modal changes nothing a shopper
|
|
633
|
+
// sees except on the modals that were already keeping them waiting.
|
|
634
|
+
showLoading: asToggle(raw.showLoading, true),
|
|
635
|
+
// Read, not defaulted, on the same grounds as `padding`: 0 is a real
|
|
636
|
+
// answer ("show it at once"), so absence has to stay distinguishable.
|
|
637
|
+
loadingDelay: asOptionalClamped(
|
|
638
|
+
raw.loadingDelay,
|
|
639
|
+
0,
|
|
640
|
+
OVERLAY_LOADING_DELAY_MAX
|
|
641
|
+
),
|
|
642
|
+
loadingLabel: asString(raw.loadingLabel) || undefined
|
|
606
643
|
};
|
|
607
644
|
};
|
|
608
645
|
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { partAttrs } from '../utils';
|
|
5
|
+
import { OVERLAY_LOADING_DELAY } from './overlay-tokens';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a modal shows while its composition is still arriving.
|
|
9
|
+
*
|
|
10
|
+
* The gap this exists for is not a fetch — a composed modal's content ships
|
|
11
|
+
* with the page, in the layout's payload — it is the COMMIT. Opening a drawer
|
|
12
|
+
* mounts the whole composition at once, and a large one (ninety blocks, a
|
|
13
|
+
* text block carrying half a megabyte of pasted markup) takes long enough
|
|
14
|
+
* that the panel's own white surface is on screen well before anything is in
|
|
15
|
+
* it. A shopper reads that as a broken drawer, which is why the host reveals
|
|
16
|
+
* the composition in a transition and puts this underneath in the meantime:
|
|
17
|
+
* the panel and this layer are the cheap commit, and they paint immediately.
|
|
18
|
+
*
|
|
19
|
+
* Two consequences follow from "the main thread is busy" and both shape the
|
|
20
|
+
* implementation:
|
|
21
|
+
*
|
|
22
|
+
* - The reveal is a CSS animation, not a timer. A `setTimeout` scheduled to
|
|
23
|
+
* show a spinner cannot fire while the thread it lives on is committing
|
|
24
|
+
* the very tree it is waiting for.
|
|
25
|
+
* - The layer carries no shopper-facing copy of its own. pz-theme has no
|
|
26
|
+
* dictionary — it renders authored text — so an invented "Loading…" would
|
|
27
|
+
* be an untranslatable English string on every storefront. The dialog
|
|
28
|
+
* carries `aria-busy` instead, and a designer who wants words authors them.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Re-exported from `overlay-tokens.ts`, which owns them so the server-side
|
|
33
|
+
* registry can clamp the authored delay without importing this client module.
|
|
34
|
+
*
|
|
35
|
+
* `OVERLAY_LOADING_DELAY` is long enough that a composition which commits in
|
|
36
|
+
* the same frame — the common case, and the one this must not disturb — never
|
|
37
|
+
* flashes a spinner, and short enough that it is not itself the pause it
|
|
38
|
+
* exists to explain.
|
|
39
|
+
*/
|
|
40
|
+
export {
|
|
41
|
+
OVERLAY_LOADING_DELAY,
|
|
42
|
+
OVERLAY_LOADING_DELAY_MAX
|
|
43
|
+
} from './overlay-tokens';
|
|
44
|
+
|
|
45
|
+
/** How long the layer takes to fade in once the delay has passed. */
|
|
46
|
+
const OVERLAY_LOADING_FADE = 120;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The reveal and the spin. Keyframes cannot be written inline, and the
|
|
50
|
+
* `prefers-reduced-motion` answer cannot either — a shopper who asked for
|
|
51
|
+
* less motion still gets the affordance, just still.
|
|
52
|
+
*
|
|
53
|
+
* Scoped through the layer's own part attribute rather than emitted bare, so
|
|
54
|
+
* a page with two overlays up does not have two authors for one selector.
|
|
55
|
+
*/
|
|
56
|
+
const OVERLAY_LOADING_CSS = [
|
|
57
|
+
'@keyframes pz-overlay-loading-in{from{opacity:0}to{opacity:1}}',
|
|
58
|
+
'@keyframes pz-overlay-loading-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}',
|
|
59
|
+
'@media (prefers-reduced-motion: reduce){' +
|
|
60
|
+
'[data-part="loading"] [data-part="loading-mark"]{animation:none}}'
|
|
61
|
+
].join('');
|
|
62
|
+
|
|
63
|
+
export interface OverlayLoadingLayerProps {
|
|
64
|
+
/**
|
|
65
|
+
* Visible text beside the mark, if the designer authored one. Absent means
|
|
66
|
+
* the mark alone — see the note on copy above.
|
|
67
|
+
*/
|
|
68
|
+
label?: string;
|
|
69
|
+
/** ms before the layer fades in. See `OVERLAY_LOADING_DELAY`. */
|
|
70
|
+
delay?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The layer itself: a mark centred in the panel, invisible for its first
|
|
75
|
+
* `delay` ms.
|
|
76
|
+
*
|
|
77
|
+
* In FLOW, not absolutely positioned, and that is deliberate: the panel is
|
|
78
|
+
* `overflow: auto`, so `inset: 0` would anchor to the scrollable content box
|
|
79
|
+
* and scroll away rather than stay centred — and a stretched panel is a flex
|
|
80
|
+
* column, where an out-of-flow child leaves the column with nothing to size.
|
|
81
|
+
* In flow, `flex: 1 1 auto` fills a stretched drawer and `min-height` gives a
|
|
82
|
+
* centred dialog something to be while it is empty. Nothing is drawn over the
|
|
83
|
+
* composition either way: the host renders one or the other, never both.
|
|
84
|
+
*
|
|
85
|
+
* Inert regardless, because the panel underneath is already listening for
|
|
86
|
+
* clicks it must keep receiving.
|
|
87
|
+
*/
|
|
88
|
+
export const OverlayLoadingLayer = ({
|
|
89
|
+
label,
|
|
90
|
+
delay = OVERLAY_LOADING_DELAY
|
|
91
|
+
}: OverlayLoadingLayerProps) => (
|
|
92
|
+
<div
|
|
93
|
+
{...partAttrs('loading')}
|
|
94
|
+
/**
|
|
95
|
+
* Hidden from assistive tech when it is a bare spinner: the dialog's own
|
|
96
|
+
* `aria-busy` is the fact a screen reader needs, and a wordless status
|
|
97
|
+
* region announced the instant a drawer opens talks over the dialog's
|
|
98
|
+
* name. Authored text is real content and stays readable.
|
|
99
|
+
*/
|
|
100
|
+
aria-hidden={label ? undefined : true}
|
|
101
|
+
style={{
|
|
102
|
+
display: 'flex',
|
|
103
|
+
// Fills a stretched panel, and is ignored by one that is not a flex
|
|
104
|
+
// column — where the min-height is what keeps the mark off the top edge.
|
|
105
|
+
flex: '1 1 auto',
|
|
106
|
+
minHeight: '160px',
|
|
107
|
+
flexDirection: 'column',
|
|
108
|
+
alignItems: 'center',
|
|
109
|
+
justifyContent: 'center',
|
|
110
|
+
gap: '12px',
|
|
111
|
+
pointerEvents: 'none',
|
|
112
|
+
opacity: 0,
|
|
113
|
+
animation:
|
|
114
|
+
`pz-overlay-loading-in ${OVERLAY_LOADING_FADE}ms linear ` +
|
|
115
|
+
`${delay}ms forwards`
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
<style dangerouslySetInnerHTML={{ __html: OVERLAY_LOADING_CSS }} />
|
|
119
|
+
<span
|
|
120
|
+
{...partAttrs('loading-mark')}
|
|
121
|
+
style={{
|
|
122
|
+
width: '24px',
|
|
123
|
+
height: '24px',
|
|
124
|
+
borderRadius: '999px',
|
|
125
|
+
// `currentColor` so the mark inherits the panel's text colour: a
|
|
126
|
+
// designer who darkened the panel does not also have to restyle this.
|
|
127
|
+
border: '2px solid currentColor',
|
|
128
|
+
borderTopColor: 'transparent',
|
|
129
|
+
opacity: 0.45,
|
|
130
|
+
animation: 'pz-overlay-loading-spin 700ms linear infinite'
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
{label ? (
|
|
134
|
+
<span
|
|
135
|
+
{...partAttrs('loading-text')}
|
|
136
|
+
style={{ fontSize: '13px', opacity: 0.7 }}
|
|
137
|
+
>
|
|
138
|
+
{label}
|
|
139
|
+
</span>
|
|
140
|
+
) : null}
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
export default OverlayLoadingLayer;
|
|
@@ -478,6 +478,21 @@ export interface OverlaySurfaceProps extends OverlayGeometryInput {
|
|
|
478
478
|
chromeCss?: string;
|
|
479
479
|
/** Rendered inside the panel, above the authored content. */
|
|
480
480
|
header?: React.ReactNode;
|
|
481
|
+
/**
|
|
482
|
+
* Drawn over the panel while the composition is still committing — see
|
|
483
|
+
* `overlay-loading.tsx` for why a large one needs it.
|
|
484
|
+
*
|
|
485
|
+
* A node rather than a flag because only the composed modal host has one:
|
|
486
|
+
* the panel block passes nothing, so its markup — pinned byte for byte by
|
|
487
|
+
* `panel-legacy-golden.json` — cannot move.
|
|
488
|
+
*/
|
|
489
|
+
loading?: React.ReactNode;
|
|
490
|
+
/**
|
|
491
|
+
* Marks the dialog as still filling. The attribute is the accessible half of
|
|
492
|
+
* `loading`: a wordless spinner tells a screen reader nothing, `aria-busy`
|
|
493
|
+
* tells it exactly what is happening. Absent leaves no attribute at all.
|
|
494
|
+
*/
|
|
495
|
+
panelBusy?: boolean;
|
|
481
496
|
children: React.ReactNode;
|
|
482
497
|
/**
|
|
483
498
|
* Legacy marker attributes per node. Order matters: they are spread BEFORE
|
|
@@ -530,6 +545,8 @@ export const CentredModalSurface = ({
|
|
|
530
545
|
topInset,
|
|
531
546
|
chromeCss,
|
|
532
547
|
header,
|
|
548
|
+
loading,
|
|
549
|
+
panelBusy,
|
|
533
550
|
children,
|
|
534
551
|
markers
|
|
535
552
|
}: Omit<
|
|
@@ -565,6 +582,7 @@ export const CentredModalSurface = ({
|
|
|
565
582
|
role="dialog"
|
|
566
583
|
{...overlayProps}
|
|
567
584
|
aria-label={ariaLabel}
|
|
585
|
+
{...(panelBusy ? { 'aria-busy': true as const } : null)}
|
|
568
586
|
// The backdrop closes the modal; clicks on the panel itself must not
|
|
569
587
|
// bubble up into that handler.
|
|
570
588
|
onClick={(event) => event.stopPropagation()}
|
|
@@ -593,6 +611,7 @@ export const CentredModalSurface = ({
|
|
|
593
611
|
<style dangerouslySetInnerHTML={{ __html: chromeCss }} />
|
|
594
612
|
) : null}
|
|
595
613
|
{header}
|
|
614
|
+
{loading}
|
|
596
615
|
{children}
|
|
597
616
|
</div>
|
|
598
617
|
</div>
|
|
@@ -626,6 +645,8 @@ export const LayeredOverlaySurface = ({
|
|
|
626
645
|
topInset,
|
|
627
646
|
chromeCss,
|
|
628
647
|
header,
|
|
648
|
+
loading,
|
|
649
|
+
panelBusy,
|
|
629
650
|
children,
|
|
630
651
|
markers
|
|
631
652
|
}: OverlaySurfaceProps) => {
|
|
@@ -652,6 +673,7 @@ export const LayeredOverlaySurface = ({
|
|
|
652
673
|
role="dialog"
|
|
653
674
|
{...overlayProps}
|
|
654
675
|
aria-label={ariaLabel}
|
|
676
|
+
{...(panelBusy ? { 'aria-busy': true as const } : null)}
|
|
655
677
|
// Focus target of last resort, for a panel whose every control the
|
|
656
678
|
// designer turned off.
|
|
657
679
|
tabIndex={-1}
|
|
@@ -685,6 +707,7 @@ export const LayeredOverlaySurface = ({
|
|
|
685
707
|
<OverlaySheetHandle markerAttrs={markers?.handle} />
|
|
686
708
|
)}
|
|
687
709
|
{header}
|
|
710
|
+
{loading}
|
|
688
711
|
{children}
|
|
689
712
|
</div>
|
|
690
713
|
);
|
|
@@ -33,6 +33,18 @@ export const OVERLAY_WIDTH_UNITS: OverlayWidthUnit[] = ['px', '%', 'vw'];
|
|
|
33
33
|
export const OVERLAY_BACKDROP_COLOR = '#000000';
|
|
34
34
|
export const OVERLAY_BACKDROP_OPACITY = 50;
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* How long a panel may be empty before its loading mark fades in, in ms, and
|
|
38
|
+
* the longest delay a designer may ask for.
|
|
39
|
+
*
|
|
40
|
+
* Here rather than beside the layer that animates it for the reason at the top
|
|
41
|
+
* of this file: the REGISTRY clamps the authored value, and the registry is
|
|
42
|
+
* parsed on the server. `overlay-loading.tsx` re-exports both so client
|
|
43
|
+
* callers keep one import path.
|
|
44
|
+
*/
|
|
45
|
+
export const OVERLAY_LOADING_DELAY = 150;
|
|
46
|
+
export const OVERLAY_LOADING_DELAY_MAX = 2000;
|
|
47
|
+
|
|
36
48
|
/**
|
|
37
49
|
* How tall a sheet is, and what a designer is allowed to write for it.
|
|
38
50
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useEffect, useRef } from 'react';
|
|
3
|
+
import React, { startTransition, useEffect, useRef, useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import { useOverlay } from '../hooks/use-overlay';
|
|
6
6
|
import { useVisibilityContext } from '../hooks/use-visibility-context';
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
LayeredOverlaySurface,
|
|
19
19
|
OverlayCloseButton
|
|
20
20
|
} from './overlay-surface';
|
|
21
|
+
import { OverlayLoadingLayer } from './overlay-loading';
|
|
22
|
+
import { ModalHostIdProvider } from './modal-host-context';
|
|
21
23
|
import { useEditorKnowsModal } from './use-editor-modals';
|
|
22
24
|
import { useModalPreview } from './use-modal-preview';
|
|
23
25
|
import { useOverlayBreakpoint } from './use-overlay-breakpoint';
|
|
@@ -134,6 +136,53 @@ const ThemeModalMount = ({
|
|
|
134
136
|
isDesigner ? breakpoint : undefined
|
|
135
137
|
);
|
|
136
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Whether the composition is revealed in its own commit, with a loading
|
|
141
|
+
* layer under it until it lands.
|
|
142
|
+
*
|
|
143
|
+
* Never in the canvas: a designer composing a modal must see their blocks
|
|
144
|
+
* the instant they select it, and the editor's own render is not the commit
|
|
145
|
+
* this is about.
|
|
146
|
+
*/
|
|
147
|
+
const showsLoading = chrome.showLoading !== false && !isEditing;
|
|
148
|
+
const [revealed, setRevealed] = useState(!showsLoading);
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A TRANSITION, not a timer.
|
|
152
|
+
*
|
|
153
|
+
* The pause this covers is the commit itself: a composed modal's content
|
|
154
|
+
* ships with the page, so opening a large one — ninety blocks, a text block
|
|
155
|
+
* carrying half a megabyte of pasted markup — is one long synchronous render
|
|
156
|
+
* during which nothing at all can be scheduled, a `setTimeout` that would
|
|
157
|
+
* show a spinner least of all. Rendering the composition at transition
|
|
158
|
+
* priority splits it off the urgent commit that draws the panel, so the
|
|
159
|
+
* panel and the loading layer paint first and the browser is free in
|
|
160
|
+
* between.
|
|
161
|
+
*
|
|
162
|
+
* It costs the fast path one extra commit and nothing visible: the layer is
|
|
163
|
+
* invisible for its first `loadingDelay` ms, which a small composition has
|
|
164
|
+
* long since outrun. And because `act()` flushes transitions, a test that
|
|
165
|
+
* opens a modal still sees its content in the same tick.
|
|
166
|
+
*
|
|
167
|
+
* Reset on close so a reopen starts from the cheap commit again — the bus is
|
|
168
|
+
* module state, so a mount lives far longer than one open.
|
|
169
|
+
*/
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (!showsLoading) {
|
|
172
|
+
setRevealed(true);
|
|
173
|
+
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!opened) {
|
|
178
|
+
setRevealed(false);
|
|
179
|
+
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
startTransition(() => setRevealed(true));
|
|
184
|
+
}, [opened, showsLoading]);
|
|
185
|
+
|
|
137
186
|
/**
|
|
138
187
|
* In the canvas the overlay is fully inert: it never steals focus into the
|
|
139
188
|
* page the designer is styling, never freezes it, never claims `aria-modal`
|
|
@@ -148,8 +197,17 @@ const ThemeModalMount = ({
|
|
|
148
197
|
// be a lie.
|
|
149
198
|
modal: isCentred ? true : chrome.showBackdrop,
|
|
150
199
|
lockScroll: chrome.lockScroll && (isCentred || chrome.showBackdrop),
|
|
151
|
-
closeOnEscape: chrome.closeOnEscape
|
|
152
|
-
|
|
200
|
+
closeOnEscape: chrome.closeOnEscape
|
|
201
|
+
/**
|
|
202
|
+
* No `fallbackFocusRef` on purpose. It used to point at `rootRef`, which
|
|
203
|
+
* cannot take focus (the div carries no `tabIndex={-1}`) and is detached
|
|
204
|
+
* by the time the hand-back runs anyway — `if (!opened) return null`
|
|
205
|
+
* removes it in the same commit that closes the modal. `useOverlay`
|
|
206
|
+
* verifies that focus actually landed, so the branch was dead; naming a
|
|
207
|
+
* target that cannot work only hides the real gap, which is a modal
|
|
208
|
+
* nobody clicked open (an auto-triggered popup) having no opener to hand
|
|
209
|
+
* focus back to.
|
|
210
|
+
*/
|
|
153
211
|
});
|
|
154
212
|
|
|
155
213
|
if (!opened) return null;
|
|
@@ -171,6 +229,20 @@ const ThemeModalMount = ({
|
|
|
171
229
|
|
|
172
230
|
const ariaLabel = chrome.ariaLabel || modal.name;
|
|
173
231
|
|
|
232
|
+
/**
|
|
233
|
+
* The layer, and the composition it stands in for. Both are absent once the
|
|
234
|
+
* composition has landed, so nothing is drawn over it and no attribute is
|
|
235
|
+
* left claiming the dialog is still filling.
|
|
236
|
+
*/
|
|
237
|
+
const loading =
|
|
238
|
+
showsLoading && !revealed ? (
|
|
239
|
+
<OverlayLoadingLayer
|
|
240
|
+
label={chrome.loadingLabel}
|
|
241
|
+
delay={chrome.loadingDelay}
|
|
242
|
+
/>
|
|
243
|
+
) : null;
|
|
244
|
+
const body = revealed ? children : null;
|
|
245
|
+
|
|
174
246
|
const surface = isCentred ? (
|
|
175
247
|
<CentredModalSurface
|
|
176
248
|
overlayProps={overlay.overlayProps}
|
|
@@ -186,8 +258,10 @@ const ThemeModalMount = ({
|
|
|
186
258
|
topInset={chrome.topInset}
|
|
187
259
|
chromeCss={chromeCss}
|
|
188
260
|
header={header}
|
|
261
|
+
loading={loading}
|
|
262
|
+
panelBusy={Boolean(loading)}
|
|
189
263
|
>
|
|
190
|
-
{
|
|
264
|
+
{body}
|
|
191
265
|
</CentredModalSurface>
|
|
192
266
|
) : (
|
|
193
267
|
<LayeredOverlaySurface
|
|
@@ -209,8 +283,10 @@ const ThemeModalMount = ({
|
|
|
209
283
|
panelStretch={chrome.stretchContent}
|
|
210
284
|
chromeCss={chromeCss}
|
|
211
285
|
header={header}
|
|
286
|
+
loading={loading}
|
|
287
|
+
panelBusy={Boolean(loading)}
|
|
212
288
|
>
|
|
213
|
-
{
|
|
289
|
+
{body}
|
|
214
290
|
</LayeredOverlaySurface>
|
|
215
291
|
);
|
|
216
292
|
|
|
@@ -224,7 +300,10 @@ const ThemeModalMount = ({
|
|
|
224
300
|
data-theme-modal={modal.id}
|
|
225
301
|
data-theme-modal-state="open"
|
|
226
302
|
>
|
|
227
|
-
{
|
|
303
|
+
{/* Tells the composition which modal it is inside, so a form that
|
|
304
|
+
finishes can close THIS one — see `modal-host-context.tsx`. Renders
|
|
305
|
+
no element, so the published markup is untouched. */}
|
|
306
|
+
<ModalHostIdProvider value={modal.id}>{surface}</ModalHostIdProvider>
|
|
228
307
|
</div>
|
|
229
308
|
);
|
|
230
309
|
};
|
|
@@ -532,6 +532,109 @@ export const evaluateVisibilityRules = (
|
|
|
532
532
|
const isVisibilityRuleExempt = (node: Block | Section): boolean =>
|
|
533
533
|
containsCheckoutOutlet(node);
|
|
534
534
|
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* A per-breakpoint value, read the way the renderers read one.
|
|
538
|
+
*
|
|
539
|
+
* A local reader rather than `getResponsiveValue` from `../utils`: this module
|
|
540
|
+
* is pure and is walked on the server read path, and that barrel pulls in the
|
|
541
|
+
* React helpers.
|
|
542
|
+
*/
|
|
543
|
+
const readResponsive = (value: unknown, breakpoint?: string): unknown => {
|
|
544
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
545
|
+
return value;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const record = value as Record<string, unknown>;
|
|
549
|
+
const chain = [
|
|
550
|
+
breakpoint,
|
|
551
|
+
...(breakpoint ? BREAKPOINT_FALLBACKS[breakpoint] ?? [] : []),
|
|
552
|
+
'desktop'
|
|
553
|
+
];
|
|
554
|
+
|
|
555
|
+
for (const key of chain) {
|
|
556
|
+
if (key && record[key] !== undefined) return record[key];
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return undefined;
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Who a block's ACTION is for.
|
|
564
|
+
*
|
|
565
|
+
* Visibility already answers "who is this block for"; this answers the same
|
|
566
|
+
* question about the action wired onto it, which is not the same thing. The
|
|
567
|
+
* account button is the case that forced it: it renders its own link — to the
|
|
568
|
+
* account for a member, to the login page for a guest — while a designer wires
|
|
569
|
+
* "open the login drawer" onto the block itself. Both then fire on one click,
|
|
570
|
+
* so a signed-in shopper landed on their account page with the login drawer
|
|
571
|
+
* open over it.
|
|
572
|
+
*
|
|
573
|
+
* Resolved HERE, in the pass that already knows who the shopper is, rather
|
|
574
|
+
* than in `use-block-action`: that hook runs for every block on the page, and
|
|
575
|
+
* making it read the session would make a SessionProvider mandatory for the
|
|
576
|
+
* whole theme rather than for the placeholder wrapper that already has one.
|
|
577
|
+
*/
|
|
578
|
+
export const BLOCK_ACTION_AUDIENCES = ['all', 'guest', 'authenticated'] as const;
|
|
579
|
+
|
|
580
|
+
export type BlockActionAudience = (typeof BLOCK_ACTION_AUDIENCES)[number];
|
|
581
|
+
|
|
582
|
+
const MODAL_ACTION_TYPES = new Set([
|
|
583
|
+
'open-modal',
|
|
584
|
+
'close-modal',
|
|
585
|
+
'toggle-modal'
|
|
586
|
+
]);
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* The audience a block's action is scoped to.
|
|
590
|
+
*
|
|
591
|
+
* Absent means `all`, EXCEPT for an account button opening a modal: a member
|
|
592
|
+
* clicking their own account button never wants the login drawer, so the
|
|
593
|
+
* default there is `guest`. That is a behaviour change on published
|
|
594
|
+
* compositions, and a deliberate one — the alternative is the drawer covering
|
|
595
|
+
* the account page it just navigated to. A brand that really wants it for
|
|
596
|
+
* everyone says `all`.
|
|
597
|
+
*/
|
|
598
|
+
const resolveActionAudience = (
|
|
599
|
+
block: Block,
|
|
600
|
+
currentBreakpoint?: string
|
|
601
|
+
): BlockActionAudience => {
|
|
602
|
+
const authored = String(
|
|
603
|
+
readResponsive(block.properties?.actionAudience, currentBreakpoint) ?? ''
|
|
604
|
+
).trim();
|
|
605
|
+
|
|
606
|
+
if ((BLOCK_ACTION_AUDIENCES as readonly string[]).includes(authored)) {
|
|
607
|
+
return authored as BlockActionAudience;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const actionType = String(
|
|
611
|
+
readResponsive(block.properties?.actionType, currentBreakpoint) ?? ''
|
|
612
|
+
).trim();
|
|
613
|
+
|
|
614
|
+
if (block.type === 'account-button' && MODAL_ACTION_TYPES.has(actionType)) {
|
|
615
|
+
return 'guest';
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return 'all';
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Whether the action runs for this shopper.
|
|
623
|
+
*
|
|
624
|
+
* `loading` counts as a guest: the session is unknown for the first client
|
|
625
|
+
* render, and withholding the login drawer through it would break the common
|
|
626
|
+
* case to fix the rare one.
|
|
627
|
+
*/
|
|
628
|
+
const actionRunsFor = (
|
|
629
|
+
audience: BlockActionAudience,
|
|
630
|
+
authState: unknown
|
|
631
|
+
): boolean => {
|
|
632
|
+
if (audience === 'all') return true;
|
|
633
|
+
if (audience === 'authenticated') return authState === 'authenticated';
|
|
634
|
+
|
|
635
|
+
return authState !== 'authenticated';
|
|
636
|
+
};
|
|
637
|
+
|
|
535
638
|
const applyBlockVisibility = (
|
|
536
639
|
block: Block,
|
|
537
640
|
context: VisibilityRuleContext
|
|
@@ -541,8 +644,19 @@ const applyBlockVisibility = (
|
|
|
541
644
|
isVisibilityRuleExempt(block) ||
|
|
542
645
|
evaluateVisibilityRules(block.properties?.visibilityRules, context);
|
|
543
646
|
|
|
647
|
+
/**
|
|
648
|
+
* An action this shopper is not the audience for is REMOVED rather than
|
|
649
|
+
* ignored at click time, so the wrapper never claims `aria-haspopup="dialog"`
|
|
650
|
+
* for a dialog it will not open either.
|
|
651
|
+
*/
|
|
652
|
+
const audience = resolveActionAudience(block, context.breakpoint);
|
|
653
|
+
const properties = actionRunsFor(audience, context.authState)
|
|
654
|
+
? block.properties
|
|
655
|
+
: { ...(block.properties || {}), actionType: 'none' };
|
|
656
|
+
|
|
544
657
|
return {
|
|
545
658
|
...block,
|
|
659
|
+
properties,
|
|
546
660
|
hidden: Boolean(block.hidden) || !isVisibleByRules,
|
|
547
661
|
blocks
|
|
548
662
|
};
|