@akinon/pz-theme 2.0.94 → 2.0.95-beta.1
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 +16 -0
- 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/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/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
|
@@ -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
|
};
|