@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.
- package/CHANGELOG.md +34 -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
|
@@ -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
|
package/src/chrome/route-slug.ts
CHANGED
|
@@ -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
|
-
{
|
|
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;
|