@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.
- package/CHANGELOG.md +33 -15
- package/package.json +4 -4
- package/src/__tests__/auth-commerce-events.test.tsx +1 -5
- package/src/__tests__/auth-form-error-block.test.tsx +0 -2
- package/src/__tests__/auth-form-validation.test.tsx +0 -2
- package/src/__tests__/auth-slot-coverage.test.ts +1 -9
- package/src/__tests__/auth-step-title.test.tsx +0 -2
- package/src/__tests__/detached-submit-state.test.tsx +2 -80
- package/src/__tests__/forgot-password-composed.test.tsx +0 -2
- package/src/__tests__/otp-form-composed.test.tsx +0 -2
- package/src/__tests__/password-reset-step.test.tsx +0 -2
- package/src/__tests__/register-form-composed.test.tsx +4 -253
- package/src/__tests__/registry-parity.test.ts +0 -12
- package/src/auth/auth-field-errors.tsx +0 -31
- package/src/blocks/account-button-block.tsx +0 -25
- package/src/blocks/add-to-cart-block.tsx +0 -15
- package/src/blocks/auth-field-block.tsx +3 -13
- package/src/blocks/auth-flow-block.tsx +3 -45
- package/src/blocks/auth-form-block.tsx +54 -337
- package/src/blocks/block-renderer-registry.tsx +0 -8
- package/src/blocks/button-block.tsx +13 -43
- package/src/blocks/change-password-form-block.tsx +0 -15
- package/src/blocks/forgot-password-block.tsx +9 -132
- package/src/blocks/newsletter-form-block.tsx +0 -16
- package/src/blocks/otp-form-block.tsx +67 -161
- package/src/blocks/profile-form-block.tsx +0 -13
- package/src/blocks/register-form-block.tsx +110 -362
- package/src/chrome/__tests__/theme-config-source.test.ts +1 -2
- package/src/chrome/placeholder-slugs.ts +0 -3
- package/src/chrome/route-slug.ts +0 -11
- package/src/index.ts +0 -10
- package/src/overlays/__tests__/modal-registry.test.ts +1 -5
- package/src/overlays/modal-chrome-css.ts +1 -8
- package/src/overlays/modal-preview-host.tsx +2 -7
- package/src/overlays/modal-registry.ts +1 -38
- package/src/overlays/overlay-surface.tsx +0 -23
- package/src/overlays/overlay-tokens.ts +0 -12
- package/src/overlays/theme-modal-mount.tsx +6 -85
- package/src/utils/visibility-rules.ts +0 -114
- package/src/__tests__/auth-captcha.test.tsx +0 -261
- package/src/__tests__/auth-destination.test.tsx +0 -348
- package/src/__tests__/auth-error-reading.test.ts +0 -232
- package/src/__tests__/auth-flow-scope.test.tsx +0 -162
- package/src/__tests__/auth-form-success-block.test.tsx +0 -429
- package/src/__tests__/block-action-audience.test.ts +0 -154
- package/src/__tests__/form-state-composition.test.tsx +0 -484
- package/src/__tests__/modal-login-close.test.tsx +0 -366
- package/src/__tests__/reset-password-composed.test.tsx +0 -385
- package/src/auth/auth-captcha.tsx +0 -125
- package/src/auth/auth-destination.ts +0 -168
- package/src/auth/form-state-context.tsx +0 -170
- package/src/auth/form-success-context.tsx +0 -56
- package/src/auth/read-auth-error.ts +0 -332
- package/src/blocks/auth-success-block.tsx +0 -85
- package/src/blocks/captcha-block.tsx +0 -61
- package/src/blocks/form-state-block.tsx +0 -84
- package/src/blocks/reset-password-block.tsx +0 -717
- package/src/overlays/__tests__/modal-loading.test.tsx +0 -235
- package/src/overlays/modal-host-context.tsx +0 -66
- 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;
|