@akinon/pz-theme 2.0.95-beta.0 → 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.
Files changed (60) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-captcha.test.tsx +261 -0
  4. package/src/__tests__/auth-commerce-events.test.tsx +5 -1
  5. package/src/__tests__/auth-destination.test.tsx +348 -0
  6. package/src/__tests__/auth-error-reading.test.ts +232 -0
  7. package/src/__tests__/auth-flow-scope.test.tsx +162 -0
  8. package/src/__tests__/auth-form-error-block.test.tsx +2 -0
  9. package/src/__tests__/auth-form-success-block.test.tsx +429 -0
  10. package/src/__tests__/auth-form-validation.test.tsx +2 -0
  11. package/src/__tests__/auth-slot-coverage.test.ts +9 -1
  12. package/src/__tests__/auth-step-title.test.tsx +2 -0
  13. package/src/__tests__/block-action-audience.test.ts +154 -0
  14. package/src/__tests__/detached-submit-state.test.tsx +80 -2
  15. package/src/__tests__/forgot-password-composed.test.tsx +2 -0
  16. package/src/__tests__/form-state-composition.test.tsx +484 -0
  17. package/src/__tests__/modal-login-close.test.tsx +366 -0
  18. package/src/__tests__/otp-form-composed.test.tsx +2 -0
  19. package/src/__tests__/password-reset-step.test.tsx +2 -0
  20. package/src/__tests__/register-form-composed.test.tsx +253 -4
  21. package/src/__tests__/registry-parity.test.ts +12 -0
  22. package/src/__tests__/reset-password-composed.test.tsx +385 -0
  23. package/src/auth/auth-captcha.tsx +125 -0
  24. package/src/auth/auth-destination.ts +168 -0
  25. package/src/auth/auth-field-errors.tsx +31 -0
  26. package/src/auth/form-state-context.tsx +170 -0
  27. package/src/auth/form-success-context.tsx +56 -0
  28. package/src/auth/read-auth-error.ts +332 -0
  29. package/src/blocks/account-button-block.tsx +25 -0
  30. package/src/blocks/add-to-cart-block.tsx +15 -0
  31. package/src/blocks/auth-field-block.tsx +13 -3
  32. package/src/blocks/auth-flow-block.tsx +45 -3
  33. package/src/blocks/auth-form-block.tsx +337 -54
  34. package/src/blocks/auth-success-block.tsx +85 -0
  35. package/src/blocks/block-renderer-registry.tsx +8 -0
  36. package/src/blocks/button-block.tsx +43 -13
  37. package/src/blocks/captcha-block.tsx +61 -0
  38. package/src/blocks/change-password-form-block.tsx +15 -0
  39. package/src/blocks/forgot-password-block.tsx +132 -9
  40. package/src/blocks/form-state-block.tsx +84 -0
  41. package/src/blocks/newsletter-form-block.tsx +16 -0
  42. package/src/blocks/otp-form-block.tsx +161 -67
  43. package/src/blocks/profile-form-block.tsx +13 -0
  44. package/src/blocks/register-form-block.tsx +362 -110
  45. package/src/blocks/reset-password-block.tsx +717 -0
  46. package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
  47. package/src/chrome/placeholder-slugs.ts +3 -0
  48. package/src/chrome/route-slug.ts +11 -0
  49. package/src/index.ts +10 -0
  50. package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
  51. package/src/overlays/__tests__/modal-registry.test.ts +5 -1
  52. package/src/overlays/modal-chrome-css.ts +8 -1
  53. package/src/overlays/modal-host-context.tsx +66 -0
  54. package/src/overlays/modal-preview-host.tsx +7 -2
  55. package/src/overlays/modal-registry.ts +38 -1
  56. package/src/overlays/overlay-loading.tsx +144 -0
  57. package/src/overlays/overlay-surface.tsx +23 -0
  58. package/src/overlays/overlay-tokens.ts +12 -0
  59. package/src/overlays/theme-modal-mount.tsx +85 -6
  60. 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
- fallbackFocusRef: rootRef
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
- {children}
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
- {children}
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
- {surface}
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
  };