@akinon/pz-theme 2.0.95-rc.0 → 2.0.95-rc.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 +34 -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
@@ -93,7 +93,8 @@ describe('readThemeConfig', () => {
93
93
  lockScroll: true,
94
94
  padding: undefined,
95
95
  showSheetHandle: true,
96
- stretchContent: false
96
+ stretchContent: false,
97
+ showLoading: true
97
98
  },
98
99
  autoTrigger: undefined,
99
100
  visibilityRules: undefined
@@ -63,6 +63,9 @@ export const PLACEHOLDER_SLUGS = {
63
63
  // Auth
64
64
  register: 'register-page-body',
65
65
  forgotPassword: 'forgot-password-body',
66
+ // The screen the reset MAIL lands on — a different page from the one that
67
+ // ASKS for the mail. `reset-password-block` is what fills it.
68
+ resetPassword: 'reset-password-body',
66
69
  guestLogin: 'guest-login-body',
67
70
 
68
71
  // Content
@@ -71,6 +71,13 @@ const DEFAULT_RULES: RouteSlugRule[] = [
71
71
  PLACEHOLDER_SLUGS.forgotPassword
72
72
  ],
73
73
  [/^\/users\/password\/reset(\/|$)/, PLACEHOLDER_SLUGS.forgotPassword],
74
+ // The screen the reset MAIL lands on, which is a different page from the one
75
+ // above: that one ASKS for the mail, this one is where the link goes. The
76
+ // token rides in the path as a catch-all — and may itself contain slashes —
77
+ // so the rule stops at the first boundary and lets the block read the rest.
78
+ // Ordering is safe either way: `/users/password/reset` and `/users/reset`
79
+ // cannot both match a path.
80
+ [/^\/users\/reset(\/|$)/, PLACEHOLDER_SLUGS.resetPassword],
74
81
  [/^\/(users\/)?auth\/guest(\/|$)/, PLACEHOLDER_SLUGS.guestLogin],
75
82
  [/^\/(users\/)?auth\/?$/, PLACEHOLDER_SLUGS.auth],
76
83
 
@@ -143,6 +150,10 @@ const PAGE_MOUNTED_SLUGS = new Set<string>([
143
150
  PLACEHOLDER_SLUGS.auth,
144
151
  PLACEHOLDER_SLUGS.register,
145
152
  PLACEHOLDER_SLUGS.forgotPassword,
153
+ // Mounted by its own layout beside the catch-all page that reads the token,
154
+ // for the same reason the two above are: the page is a client component, so
155
+ // the handover cannot live in it.
156
+ PLACEHOLDER_SLUGS.resetPassword,
146
157
  PLACEHOLDER_SLUGS.guestLogin,
147
158
  PLACEHOLDER_SLUGS.flatPage,
148
159
  // Mounted by the not-found boundary itself, which renders on any unmatched
package/src/index.ts CHANGED
@@ -99,6 +99,16 @@ export {
99
99
  useOpenModalIds
100
100
  } from './overlays/overlay-bus';
101
101
 
102
+ /**
103
+ * Which modal the composition is inside, for a brand's OWN auth surface: a
104
+ * hand-written form composed into a drawer can now close the drawer the way
105
+ * the built-in ones do. See `overlays/modal-host-context.tsx`.
106
+ */
107
+ export {
108
+ useModalHostId,
109
+ useHostModalCloser
110
+ } from './overlays/modal-host-context';
111
+
102
112
  export {
103
113
  readThemeModals,
104
114
  MODAL_PRESENTATIONS
@@ -0,0 +1,235 @@
1
+ import { act, render, screen } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ jest.mock('next/navigation', () => ({
5
+ usePathname: () => '/',
6
+ useSearchParams: () => new URLSearchParams()
7
+ }));
8
+
9
+ jest.mock('next-auth/react', () => ({
10
+ useSession: () => ({ status: 'unauthenticated' })
11
+ }));
12
+
13
+ jest.mock('@akinon/next/hooks/use-localization', () => ({
14
+ useLocalization: () => ({ locale: 'en', currency: 'USD' })
15
+ }));
16
+
17
+ import { clearAutoOpenRecord } from '../modal-frequency';
18
+ import { buildModalChromeCss } from '../modal-chrome-css';
19
+ import { readThemeModals, ThemeModal } from '../modal-registry';
20
+ import {
21
+ __resetOverlayBusForTests,
22
+ openModal,
23
+ closeModal,
24
+ registerModalHost
25
+ } from '../overlay-bus';
26
+ import { OverlayLoadingLayer } from '../overlay-loading';
27
+ import { LayeredOverlaySurface } from '../overlay-surface';
28
+ import ThemeModalMount from '../theme-modal-mount';
29
+ import { __resetModalPreviewForTests } from '../use-modal-preview';
30
+
31
+ /**
32
+ * What a modal shows while its composition is still committing.
33
+ *
34
+ * The pause is the commit, not a fetch: a composed modal's content ships with
35
+ * the page, and a large one mounts ninety blocks at once. So the host draws
36
+ * the panel and a loading layer in an urgent commit and reveals the
37
+ * composition in a transition — which is also why nothing here has to fight
38
+ * the clock: `act()` flushes transitions, so the tests see the settled state
39
+ * unless they render a surface directly.
40
+ */
41
+
42
+ const mkModal = (chrome: Record<string, unknown> = {}): ThemeModal =>
43
+ readThemeModals([{ id: 'login', name: 'Login', chrome }])[0];
44
+
45
+ const openWith = (chrome: Record<string, unknown> = {}, isDesigner = false) => {
46
+ if (isDesigner) {
47
+ (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
48
+ true;
49
+ }
50
+
51
+ render(
52
+ <ThemeModalMount modal={mkModal(chrome)}>
53
+ <p>composed content</p>
54
+ </ThemeModalMount>
55
+ );
56
+
57
+ act(() => {
58
+ openModal('login');
59
+ });
60
+ };
61
+
62
+ beforeEach(() => {
63
+ __resetOverlayBusForTests();
64
+ __resetModalPreviewForTests();
65
+ registerModalHost();
66
+ clearAutoOpenRecord('login');
67
+ delete (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner;
68
+ });
69
+
70
+ describe('the loading settings', () => {
71
+ it('is on for a modal that never mentioned it, and off when asked', () => {
72
+ expect(mkModal().chrome.showLoading).toBe(true);
73
+ expect(mkModal({ showLoading: false }).chrome.showLoading).toBe(false);
74
+ });
75
+
76
+ it('keeps an unauthored delay absent, so the surface default stands', () => {
77
+ expect(mkModal().chrome.loadingDelay).toBeUndefined();
78
+ // Zero is a real answer — "show it at once" — and must survive as one.
79
+ expect(mkModal({ loadingDelay: 0 }).chrome.loadingDelay).toBe(0);
80
+ expect(mkModal({ loadingDelay: 400 }).chrome.loadingDelay).toBe(400);
81
+ // A delay longer than the wait it explains is a typo.
82
+ expect(mkModal({ loadingDelay: 90000 }).chrome.loadingDelay).toBe(2000);
83
+ expect(mkModal({ loadingDelay: -5 }).chrome.loadingDelay).toBe(0);
84
+ expect(
85
+ mkModal({ loadingDelay: 'soon' }).chrome.loadingDelay
86
+ ).toBeUndefined();
87
+ });
88
+
89
+ it('keeps the label absence-preserving, since the package has no copy', () => {
90
+ expect(mkModal().chrome.loadingLabel).toBeUndefined();
91
+ expect(
92
+ mkModal({ loadingLabel: ' ' }).chrome.loadingLabel
93
+ ).toBeUndefined();
94
+ expect(mkModal({ loadingLabel: 'Yükleniyor' }).chrome.loadingLabel).toBe(
95
+ 'Yükleniyor'
96
+ );
97
+ });
98
+ });
99
+
100
+ describe('the layer itself', () => {
101
+ const renderLayer = (props: { label?: string; delay?: number } = {}) => {
102
+ render(
103
+ <LayeredOverlaySurface
104
+ presentation="drawer"
105
+ overlayProps={{ ref: undefined, 'aria-modal': 'true' }}
106
+ ariaLabel="Login"
107
+ showBackdrop
108
+ closeOnOverlay={false}
109
+ onDismiss={() => undefined}
110
+ width={480}
111
+ widthUnit="px"
112
+ loading={<OverlayLoadingLayer {...props} />}
113
+ panelBusy
114
+ >
115
+ <p>composed content</p>
116
+ </LayeredOverlaySurface>
117
+ );
118
+
119
+ // The LAST one: a test that renders twice to compare two settings stacks
120
+ // two containers, and the first match would answer for the previous render.
121
+ return Array.from(
122
+ document.querySelectorAll('[data-part="loading"]')
123
+ ).pop() as HTMLElement;
124
+ };
125
+
126
+ it('starts invisible and fades in only after the delay', () => {
127
+ const layer = renderLayer({ delay: 400 });
128
+
129
+ expect(layer.style.opacity).toBe('0');
130
+ expect(layer.style.animation).toContain('400ms forwards');
131
+ });
132
+
133
+ it('falls back to the surface delay when the modal authored none', () => {
134
+ expect(renderLayer().style.animation).toContain('150ms forwards');
135
+ });
136
+
137
+ it('never takes a click meant for the panel underneath', () => {
138
+ expect(renderLayer().style.pointerEvents).toBe('none');
139
+ });
140
+
141
+ it('sizes itself in flow, so a scrolling panel cannot scroll it away', () => {
142
+ const layer = renderLayer();
143
+
144
+ expect(layer.style.position).toBe('');
145
+ // Fills a stretched panel's flex column; the min-height is what a centred
146
+ // dialog with nothing in it has to go on.
147
+ expect(layer.style.flex).toBe('1 1 auto');
148
+ expect(layer.style.minHeight).toBe('160px');
149
+ });
150
+
151
+ it('is silent for assistive tech until a designer gives it words', () => {
152
+ expect(renderLayer().getAttribute('aria-hidden')).toBe('true');
153
+
154
+ const labelled = renderLayer({ label: 'Yükleniyor' });
155
+
156
+ expect(labelled.getAttribute('aria-hidden')).toBeNull();
157
+ expect(
158
+ labelled.querySelector('[data-part="loading-text"]')?.textContent
159
+ ).toBe('Yükleniyor');
160
+ });
161
+
162
+ it('tells assistive tech the dialog is still filling', () => {
163
+ renderLayer();
164
+
165
+ expect(
166
+ document.querySelector('[role="dialog"]')?.getAttribute('aria-busy')
167
+ ).toBe('true');
168
+ });
169
+ });
170
+
171
+ describe('a modal that is holding one', () => {
172
+ it('has the composition and no layer left once the transition lands', () => {
173
+ openWith();
174
+
175
+ expect(screen.getByText('composed content')).toBeInTheDocument();
176
+ // Both halves go together: nothing drawn over the content, and no
177
+ // attribute left claiming the dialog is still filling.
178
+ expect(document.querySelector('[data-part="loading"]')).toBeNull();
179
+ expect(
180
+ document.querySelector('[role="dialog"]')?.getAttribute('aria-busy')
181
+ ).toBeNull();
182
+ });
183
+
184
+ it('draws no layer at all when the setting is off', () => {
185
+ openWith({ showLoading: false });
186
+
187
+ expect(screen.getByText('composed content')).toBeInTheDocument();
188
+ expect(document.querySelector('[data-part="loading"]')).toBeNull();
189
+ });
190
+
191
+ it('shows the composition at once in the canvas, where the wait is not real', () => {
192
+ openWith({}, true);
193
+
194
+ expect(screen.getByText('composed content')).toBeInTheDocument();
195
+ expect(document.querySelector('[data-part="loading"]')).toBeNull();
196
+ });
197
+
198
+ it('starts from the cheap commit again on the next open', () => {
199
+ openWith();
200
+
201
+ act(() => {
202
+ closeModal('login');
203
+ });
204
+
205
+ expect(document.querySelector('[role="dialog"]')).toBeNull();
206
+
207
+ act(() => {
208
+ openModal('login');
209
+ });
210
+
211
+ // The reopen is a fresh mount of the composition, so the reveal has to
212
+ // have been reset — a latched one would leave the second open with no
213
+ // layer to cover it.
214
+ expect(screen.getByText('composed content')).toBeInTheDocument();
215
+ });
216
+ });
217
+
218
+ describe('the designer styles', () => {
219
+ it('reach the layer, its mark and its words', () => {
220
+ const css = buildModalChromeCss({
221
+ id: 'login',
222
+ styles: {
223
+ 'loading/background-color': '#ffffff',
224
+ 'loading-mark/border-color': '#00321b',
225
+ 'loading-text/font-size': '15px'
226
+ }
227
+ });
228
+
229
+ expect(css).toContain('[data-theme-modal="login"] [data-part="loading"]');
230
+ expect(css).toContain('[data-part="loading-mark"]');
231
+ expect(css).toContain('[data-part="loading-text"]');
232
+ // The panel's own inline styles would otherwise outrank every rule here.
233
+ expect(css).toContain('!important');
234
+ });
235
+ });
@@ -63,7 +63,11 @@ describe('readThemeModals', () => {
63
63
  // surface, so an unauthored padding must stay unauthored here.
64
64
  padding: undefined,
65
65
  showSheetHandle: true,
66
- stretchContent: false
66
+ stretchContent: false,
67
+ // On for a modal that never mentioned it, which is safe precisely
68
+ // because the layer waits: an unauthored delay leaves the surface's
69
+ // own 150ms, and nothing a small composition can outrun changes.
70
+ showLoading: true
67
71
  },
68
72
  autoTrigger: undefined,
69
73
  visibilityRules: undefined
@@ -41,7 +41,14 @@ export const MODAL_CHROME_PARTS = [
41
41
  'overlay',
42
42
  'close',
43
43
  'close-text',
44
- 'handle'
44
+ 'handle',
45
+ // The layer shown while a composition is still committing, its mark, and the
46
+ // authored words beside it. Chrome rather than blocks for the usual reason:
47
+ // they are drawn by `overlay-loading.tsx` from inline styles and hang off no
48
+ // `[data-block-id]`, so no block part rule could ever reach them.
49
+ 'loading',
50
+ 'loading-mark',
51
+ 'loading-text'
45
52
  ] as const;
46
53
 
47
54
  export type ModalChromePart = (typeof MODAL_CHROME_PARTS)[number];
@@ -0,0 +1,66 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import { closeModal } from './overlay-bus';
6
+
7
+ /**
8
+ * Which modal the composition being rendered is INSIDE.
9
+ *
10
+ * A composed form had no way to know. The consequence a shopper met: signing
11
+ * in inside the auth drawer left the drawer open over the page, because the
12
+ * only close a form could perform was `closeAllModals()` behind an authored
13
+ * toggle — and that toggle defaults to off, deliberately, since "close every
14
+ * modal on the page" is not something a form on a plain page should ever do
15
+ * unasked. So the safe default and the useful one were in conflict, and the
16
+ * live GANT drawer's SAVED config shows exactly how that ends — measured off
17
+ * the storefront's own payload, not off the shipped template: the OTP form has
18
+ * the toggle on and the login and register forms do not, so signing in with an
19
+ * e-mail left the drawer covering the page while the phone path closed it. A
20
+ * designer had no way to know they had to tick three separate boxes.
21
+ *
22
+ * With the host's id in hand the conflict disappears: a form closes THE MODAL
23
+ * IT IS IN, which is never a surprise, and a form that is not in one closes
24
+ * nothing at all.
25
+ *
26
+ * Same shape and same reason as `AuthFlowIdContext` — a host telling its
27
+ * composed children which instance of itself they belong to, because they are
28
+ * blocks at any depth and the host has no reference to hand them.
29
+ */
30
+ const ModalHostIdContext = React.createContext<string>('');
31
+
32
+ export const ModalHostIdProvider = ModalHostIdContext.Provider;
33
+
34
+ /** The id of the modal this block is inside, or '' when it is not in one. */
35
+ export const useModalHostId = (): string =>
36
+ React.useContext(ModalHostIdContext);
37
+
38
+ /**
39
+ * "My task is finished — close the surface I am on."
40
+ *
41
+ * One helper rather than the same three lines in every block that can finish,
42
+ * because the rule is the same everywhere and the mistakes would not be: a
43
+ * form that closes a modal it is not in, or that closes one while the shopper
44
+ * still has something to read.
45
+ *
46
+ * Which blocks turn it on by DEFAULT is a judgement about their success state,
47
+ * not about the mechanism:
48
+ *
49
+ * - a sign-in leaves nothing to read — the shopper is in — so the auth forms
50
+ * default to closing, and that is the bug this whole family exists to fix;
51
+ * - "check your inbox", "thanks for subscribing", "your password has been
52
+ * changed" are the point of the success state, so those forms default to
53
+ * staying open and let the designer decide otherwise.
54
+ *
55
+ * Returns a stable no-op when the block is not in a modal, so a caller can
56
+ * fire it unconditionally.
57
+ */
58
+ export const useHostModalCloser = (enabled: boolean): (() => void) => {
59
+ const hostModalId = useModalHostId();
60
+
61
+ return React.useCallback(() => {
62
+ if (!enabled || !hostModalId) return;
63
+
64
+ closeModal(hostModalId);
65
+ }, [enabled, hostModalId]);
66
+ };
@@ -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
- {composition(modal.slug)}
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
- {composition(modal.slug)}
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;