@akinon/pz-theme 2.0.95-beta.1 → 2.0.95-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +33 -15
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-commerce-events.test.tsx +1 -5
  4. package/src/__tests__/auth-form-error-block.test.tsx +0 -2
  5. package/src/__tests__/auth-form-validation.test.tsx +0 -2
  6. package/src/__tests__/auth-slot-coverage.test.ts +1 -9
  7. package/src/__tests__/auth-step-title.test.tsx +0 -2
  8. package/src/__tests__/detached-submit-state.test.tsx +2 -80
  9. package/src/__tests__/forgot-password-composed.test.tsx +0 -2
  10. package/src/__tests__/otp-form-composed.test.tsx +0 -2
  11. package/src/__tests__/password-reset-step.test.tsx +0 -2
  12. package/src/__tests__/register-form-composed.test.tsx +4 -253
  13. package/src/__tests__/registry-parity.test.ts +0 -12
  14. package/src/auth/auth-field-errors.tsx +0 -31
  15. package/src/blocks/account-button-block.tsx +0 -25
  16. package/src/blocks/add-to-cart-block.tsx +0 -15
  17. package/src/blocks/auth-field-block.tsx +3 -13
  18. package/src/blocks/auth-flow-block.tsx +3 -45
  19. package/src/blocks/auth-form-block.tsx +54 -337
  20. package/src/blocks/block-renderer-registry.tsx +0 -8
  21. package/src/blocks/button-block.tsx +13 -43
  22. package/src/blocks/change-password-form-block.tsx +0 -15
  23. package/src/blocks/forgot-password-block.tsx +9 -132
  24. package/src/blocks/newsletter-form-block.tsx +0 -16
  25. package/src/blocks/otp-form-block.tsx +67 -161
  26. package/src/blocks/profile-form-block.tsx +0 -13
  27. package/src/blocks/register-form-block.tsx +110 -362
  28. package/src/chrome/__tests__/theme-config-source.test.ts +1 -2
  29. package/src/chrome/placeholder-slugs.ts +0 -3
  30. package/src/chrome/route-slug.ts +0 -11
  31. package/src/index.ts +0 -10
  32. package/src/overlays/__tests__/modal-registry.test.ts +1 -5
  33. package/src/overlays/modal-chrome-css.ts +1 -8
  34. package/src/overlays/modal-preview-host.tsx +2 -7
  35. package/src/overlays/modal-registry.ts +1 -38
  36. package/src/overlays/overlay-surface.tsx +0 -23
  37. package/src/overlays/overlay-tokens.ts +0 -12
  38. package/src/overlays/theme-modal-mount.tsx +6 -85
  39. package/src/utils/visibility-rules.ts +0 -114
  40. package/src/__tests__/auth-captcha.test.tsx +0 -261
  41. package/src/__tests__/auth-destination.test.tsx +0 -348
  42. package/src/__tests__/auth-error-reading.test.ts +0 -232
  43. package/src/__tests__/auth-flow-scope.test.tsx +0 -162
  44. package/src/__tests__/auth-form-success-block.test.tsx +0 -429
  45. package/src/__tests__/block-action-audience.test.ts +0 -154
  46. package/src/__tests__/form-state-composition.test.tsx +0 -484
  47. package/src/__tests__/modal-login-close.test.tsx +0 -366
  48. package/src/__tests__/reset-password-composed.test.tsx +0 -385
  49. package/src/auth/auth-captcha.tsx +0 -125
  50. package/src/auth/auth-destination.ts +0 -168
  51. package/src/auth/form-state-context.tsx +0 -170
  52. package/src/auth/form-success-context.tsx +0 -56
  53. package/src/auth/read-auth-error.ts +0 -332
  54. package/src/blocks/auth-success-block.tsx +0 -85
  55. package/src/blocks/captcha-block.tsx +0 -61
  56. package/src/blocks/form-state-block.tsx +0 -84
  57. package/src/blocks/reset-password-block.tsx +0 -717
  58. package/src/overlays/__tests__/modal-loading.test.tsx +0 -235
  59. package/src/overlays/modal-host-context.tsx +0 -66
  60. package/src/overlays/overlay-loading.tsx +0 -144
@@ -1,235 +0,0 @@
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
- });
@@ -1,66 +0,0 @@
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
- };
@@ -1,144 +0,0 @@
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;