@akinon/pz-theme 2.0.83-beta.0 → 2.0.83
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 +31 -7
- package/package.json +4 -4
- package/src/__tests__/auth-flow-block.test.tsx +211 -0
- package/src/__tests__/auth-flow-bus.test.ts +290 -0
- package/src/__tests__/auth-form-error-block.test.tsx +326 -0
- package/src/__tests__/auth-form-validation.test.tsx +204 -0
- package/src/__tests__/auth-step-title.test.tsx +202 -0
- package/src/__tests__/detached-submit-state.test.tsx +216 -0
- package/src/__tests__/forgot-password-composed.test.tsx +313 -0
- package/src/__tests__/otp-form-composed.test.tsx +487 -0
- package/src/__tests__/password-reset-step.test.tsx +269 -0
- package/src/__tests__/register-form-composed.test.tsx +739 -0
- package/src/__tests__/registry-parity.test.ts +9 -0
- package/src/auth/auth-field-errors.tsx +151 -0
- package/src/auth/auth-flow-bus.ts +415 -0
- package/src/auth/form-error-context.tsx +55 -0
- package/src/auth/form-submit-bus.ts +93 -0
- package/src/auth/otp-form-context.tsx +80 -0
- package/src/blocks/__tests__/button-block.test.tsx +55 -0
- package/src/blocks/__tests__/link-block.test.tsx +48 -30
- package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
- package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
- package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
- package/src/blocks/__tests__/text-block.test.tsx +37 -3
- package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
- package/src/blocks/auth-consent-block.tsx +214 -0
- package/src/blocks/auth-error-block.tsx +82 -0
- package/src/blocks/auth-field-block.tsx +270 -0
- package/src/blocks/auth-flow-block.tsx +251 -0
- package/src/blocks/auth-form-block.tsx +391 -47
- package/src/blocks/auth-step-block.tsx +81 -0
- package/src/blocks/auth-step-title-block.tsx +81 -0
- package/src/blocks/block-renderer-registry.tsx +18 -0
- package/src/blocks/button-block.tsx +91 -13
- package/src/blocks/forgot-password-block.tsx +318 -24
- package/src/blocks/link-block.tsx +22 -84
- package/src/blocks/otp-code-input-block.tsx +180 -0
- package/src/blocks/otp-form-block.tsx +1092 -0
- package/src/blocks/otp-phase-block.tsx +71 -0
- package/src/blocks/register-form-block.tsx +541 -74
- package/src/blocks/text-block.tsx +17 -40
- package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
- package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
- package/src/hooks/use-block-action.ts +87 -4
- package/src/hooks/use-overlay.ts +60 -9
- package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
- package/src/overlays/__tests__/modal-registry.test.ts +6 -1
- package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
- package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
- package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
- package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
- package/src/overlays/modal-preview-host.tsx +39 -2
- package/src/overlays/modal-registry.ts +344 -4
- package/src/overlays/overlay-surface.tsx +249 -19
- package/src/overlays/overlay-tokens.ts +94 -0
- package/src/overlays/theme-modal-mount.tsx +34 -4
- package/src/theme-block.tsx +6 -0
- package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
- package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
- package/src/utils/index.ts +107 -6
- package/src/utils/iterator-utils.ts +39 -2
- package/src/utils/part-styles.ts +4 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Which named forms are mid-request, for the CTA that submits them from
|
|
7
|
+
* somewhere else on the page.
|
|
8
|
+
*
|
|
9
|
+
* A form's own submit button knows when to disable itself: the state is right
|
|
10
|
+
* there. A DETACHED CTA — the Button carrying `submitFormId`, which is how a
|
|
11
|
+
* drawer pins "Continue" to its bottom bar — is outside that form, often
|
|
12
|
+
* outside its React subtree, so it had no way to know and stayed bright and
|
|
13
|
+
* clickable while the request ran. Pressing it again did nothing (every submit
|
|
14
|
+
* handler guards on its own busy flag), but "nothing" is exactly what reads as
|
|
15
|
+
* broken.
|
|
16
|
+
*
|
|
17
|
+
* Module state rather than context, for the same reason `overlay-bus` and
|
|
18
|
+
* `auth-flow-bus` use it: the CTA and the form can be in different
|
|
19
|
+
* placeholders, and every placeholder mounts its own React root. The key is the
|
|
20
|
+
* form's HTML id, which is already the contract the two sides share.
|
|
21
|
+
*/
|
|
22
|
+
const busyForms = new Set<string>();
|
|
23
|
+
|
|
24
|
+
const listeners = new Set<() => void>();
|
|
25
|
+
|
|
26
|
+
const notify = (): void => {
|
|
27
|
+
listeners.forEach((listener) => listener());
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const subscribe = (listener: () => void): (() => void) => {
|
|
31
|
+
listeners.add(listener);
|
|
32
|
+
|
|
33
|
+
return () => {
|
|
34
|
+
listeners.delete(listener);
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A form says whether it is working. Called from an effect so the publish
|
|
40
|
+
* happens after render — writing to module state during one would make two
|
|
41
|
+
* components disagree about the same tick.
|
|
42
|
+
*/
|
|
43
|
+
export const publishFormSubmitState = (
|
|
44
|
+
formHtmlId: string,
|
|
45
|
+
isBusy: boolean
|
|
46
|
+
): void => {
|
|
47
|
+
if (!formHtmlId) return;
|
|
48
|
+
|
|
49
|
+
const wasBusy = busyForms.has(formHtmlId);
|
|
50
|
+
if (wasBusy === isBusy) return;
|
|
51
|
+
|
|
52
|
+
if (isBusy) {
|
|
53
|
+
busyForms.add(formHtmlId);
|
|
54
|
+
} else {
|
|
55
|
+
busyForms.delete(formHtmlId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
notify();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** A form that leaves the page cannot be busy — an unmount says so. */
|
|
62
|
+
export const clearFormSubmitState = (formHtmlId: string): void => {
|
|
63
|
+
publishFormSubmitState(formHtmlId, false);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const isFormSubmitting = (formHtmlId: string): boolean =>
|
|
67
|
+
busyForms.has(formHtmlId);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Nothing is in flight during SSR, so the server snapshot must always say so
|
|
71
|
+
* or the markup would differ from the client's first paint and hydration would
|
|
72
|
+
* tear. (Same rule as the other buses.)
|
|
73
|
+
*/
|
|
74
|
+
const falseOnServer = () => false;
|
|
75
|
+
|
|
76
|
+
/** Subscription for a caller with no form to watch — costs no listener. */
|
|
77
|
+
const neverNotifies = () => () => undefined;
|
|
78
|
+
|
|
79
|
+
const alwaysFalse = () => false;
|
|
80
|
+
|
|
81
|
+
/** Pass an empty id for "nothing to watch". */
|
|
82
|
+
export const useFormSubmitState = (formHtmlId: string): boolean =>
|
|
83
|
+
useSyncExternalStore(
|
|
84
|
+
formHtmlId ? subscribe : neverNotifies,
|
|
85
|
+
formHtmlId ? () => isFormSubmitting(formHtmlId) : alwaysFalse,
|
|
86
|
+
falseOnServer
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
/** Test hook: module state outlives a render, so it has to be resettable. */
|
|
90
|
+
export const __resetFormSubmitBusForTests = (): void => {
|
|
91
|
+
busyForms.clear();
|
|
92
|
+
listeners.clear();
|
|
93
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The live state of the OTP form a block is standing inside.
|
|
7
|
+
*
|
|
8
|
+
* The form's pieces became blocks the way an auth form's fields did — a phase
|
|
9
|
+
* wrapper, the code boxes, the resend and change-number controls — but unlike a
|
|
10
|
+
* field they cannot be dumb: a code box has to write into the ONE code the form
|
|
11
|
+
* verifies, and a resend has to know whether the countdown has run out. Those
|
|
12
|
+
* live in the form's own state, and the blocks that need them sit at any depth
|
|
13
|
+
* below it, so a context is the only way down.
|
|
14
|
+
*
|
|
15
|
+
* Outside a form the hook answers null and every block falls back to drawing
|
|
16
|
+
* itself statically, which is what makes them safe to drop on a page.
|
|
17
|
+
*/
|
|
18
|
+
export interface OtpFormApi {
|
|
19
|
+
/** Which half of the journey is on screen. */
|
|
20
|
+
phase: 'phone' | 'code';
|
|
21
|
+
/** How many boxes the code has — the form owns the number. */
|
|
22
|
+
codeLength: number;
|
|
23
|
+
/**
|
|
24
|
+
* What has been typed so far, at most `codeLength` characters. A box the
|
|
25
|
+
* shopper skipped over is a blank, so the digits after it keep the positions
|
|
26
|
+
* they were typed in; a box that draws it renders the blank as empty.
|
|
27
|
+
*/
|
|
28
|
+
code: string;
|
|
29
|
+
/** Appends digits (a paste included), capped at `codeLength`. */
|
|
30
|
+
appendDigits: (digits: string) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Writes ONE box. This is what typing into the row does: a code is a row of
|
|
33
|
+
* separate boxes, so a digit belongs where it was typed rather than at the
|
|
34
|
+
* end — which is what `appendDigits` alone could offer, and why correcting a
|
|
35
|
+
* wrong digit used to be impossible.
|
|
36
|
+
*/
|
|
37
|
+
setDigitAt: (index: number, digit: string) => void;
|
|
38
|
+
/** Empties ONE box — what Backspace means in the box it was pressed in. */
|
|
39
|
+
clearDigitAt: (index: number) => void;
|
|
40
|
+
/** Drops the last digit. Kept for a control that means "undo the last one". */
|
|
41
|
+
removeLastDigit: () => void;
|
|
42
|
+
/**
|
|
43
|
+
* Hands a box to the form so it can keep the caret on the next empty one.
|
|
44
|
+
* Called with null on unmount, the way a ref callback is.
|
|
45
|
+
*/
|
|
46
|
+
registerCodeInput: (index: number, element: HTMLInputElement | null) => void;
|
|
47
|
+
/** Seconds until another code may be asked for. */
|
|
48
|
+
secondsLeft: number;
|
|
49
|
+
/** What the countdown counts down FROM, for drawing a ring. */
|
|
50
|
+
timerSeconds: number;
|
|
51
|
+
canResend: boolean;
|
|
52
|
+
/** No-ops while the countdown is running, a request is in flight, or in the canvas. */
|
|
53
|
+
resend: () => void;
|
|
54
|
+
/** Back to the phone phase, forgetting the pending verification. */
|
|
55
|
+
changeNumber: () => void;
|
|
56
|
+
/**
|
|
57
|
+
* Whether that is something this form may do. False during a REGISTRATION's
|
|
58
|
+
* verification: the parked payload is the only copy of the sign-up, so
|
|
59
|
+
* collecting a new number would throw it away. A control wired to
|
|
60
|
+
* `changeNumber` should hide or disable itself on this.
|
|
61
|
+
*/
|
|
62
|
+
canChangeNumber: boolean;
|
|
63
|
+
/** The number the code went to, for copy that names it. */
|
|
64
|
+
displayPhone: string;
|
|
65
|
+
/** True while a send or a verify is in flight. */
|
|
66
|
+
isBusy: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* The canvas. Controls must not fire there (a resend would text a real
|
|
69
|
+
* shopper), and every phase has to be dressable whatever the state says.
|
|
70
|
+
*/
|
|
71
|
+
isDesigner: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const OtpFormContext = React.createContext<OtpFormApi | null>(null);
|
|
75
|
+
|
|
76
|
+
export const OtpFormProvider = OtpFormContext.Provider;
|
|
77
|
+
|
|
78
|
+
/** The form in scope, or null for a block standing on its own. */
|
|
79
|
+
export const useOtpForm = (): OtpFormApi | null =>
|
|
80
|
+
React.useContext(OtpFormContext);
|
|
@@ -2,6 +2,34 @@ import React from 'react';
|
|
|
2
2
|
import { render, screen } from '@testing-library/react';
|
|
3
3
|
import ButtonBlock from '../button-block';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* The commerce `Modal` this block reaches for, faithful in the one way that
|
|
7
|
+
* matters here: it writes the PAGE's overflow from an effect that runs on
|
|
8
|
+
* mount, open or closed, and draws nothing while closed. The shared mock in
|
|
9
|
+
* `jest/__mocks__` leaves that side effect out, which is exactly what let a
|
|
10
|
+
* closed dialog mounted for every button on the page go unnoticed.
|
|
11
|
+
*/
|
|
12
|
+
jest.mock('@akinon/next/components/modal', () => {
|
|
13
|
+
const ReactModule = require('react');
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
__esModule: true,
|
|
17
|
+
Modal: ({ open, children }: { open?: boolean; children?: unknown }) => {
|
|
18
|
+
ReactModule.useEffect(() => {
|
|
19
|
+
document.body.style.overflow = open ? 'hidden' : 'auto';
|
|
20
|
+
|
|
21
|
+
return () => {
|
|
22
|
+
document.body.style.overflow = 'auto';
|
|
23
|
+
};
|
|
24
|
+
}, [open]);
|
|
25
|
+
|
|
26
|
+
return open
|
|
27
|
+
? ReactModule.createElement(ReactModule.Fragment, null, children)
|
|
28
|
+
: null;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
5
33
|
// useLocalization is globally mocked to return locale: 'en-us'.
|
|
6
34
|
// getResponsiveValue / colorToRgba / resolveThemeCssVariables come from the
|
|
7
35
|
// real ../utils module (not mocked). The Modal mock renders nothing while
|
|
@@ -96,4 +124,31 @@ describe('ButtonBlock', () => {
|
|
|
96
124
|
expect(anchor).toHaveAttribute('rel', 'noopener noreferrer');
|
|
97
125
|
});
|
|
98
126
|
});
|
|
127
|
+
describe('the newsletter feedback dialog', () => {
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
document.body.style.overflow = '';
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('is not mounted while it has nothing to say, so the page keeps its own scrolling', () => {
|
|
133
|
+
// Every button used to carry a closed dialog whose mount effect handed
|
|
134
|
+
// the page its scrolling back. One button inside an open drawer — a step
|
|
135
|
+
// of an auth flow arriving in one — was enough to unfreeze the page
|
|
136
|
+
// behind it.
|
|
137
|
+
renderBlock({ id: 'feedback-1', properties: { text: 'Subscribe' } });
|
|
138
|
+
|
|
139
|
+
expect(document.body.style.overflow).toBe('');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('leaves the page alone however many buttons a composition has', () => {
|
|
143
|
+
renderBlock({ id: 'feedback-2', properties: { text: 'One' } });
|
|
144
|
+
renderBlock({
|
|
145
|
+
id: 'feedback-3',
|
|
146
|
+
properties: { text: 'Two', tag: 'button' }
|
|
147
|
+
});
|
|
148
|
+
renderBlock({ id: 'feedback-4', properties: { text: 'Three' } });
|
|
149
|
+
|
|
150
|
+
expect(document.body.style.overflow).toBe('');
|
|
151
|
+
expect(screen.getByText('Two')).toBeInTheDocument();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
99
154
|
});
|
|
@@ -186,13 +186,18 @@ describe('LinkBlock', () => {
|
|
|
186
186
|
/**
|
|
187
187
|
* The double-drawn card box.
|
|
188
188
|
*
|
|
189
|
-
* `data-block-id` rides the designer wrapper this anchor renders inside
|
|
190
|
-
*
|
|
191
|
-
* the
|
|
192
|
-
*
|
|
193
|
-
*
|
|
189
|
+
* `data-block-id` rides the designer wrapper this anchor renders inside. A card
|
|
190
|
+
* with one border in the inspector showed two on the page — one closing under
|
|
191
|
+
* the label, one at the bottom of the row — because the box was painted on the
|
|
192
|
+
* wrapper by the sheet AND inlined here by the renderer, and the wrapper is the
|
|
193
|
+
* grid item that stretches to its row while the anchor only hugs its content.
|
|
194
|
+
*
|
|
195
|
+
* The sheet now routes the block's rule to the anchor itself instead
|
|
196
|
+
* (`data-part="root"`, see INNER_ROOT_BLOCK_TYPES), so the box is declared in
|
|
197
|
+
* one place. What stays inline is only what a rule cannot express: a theme
|
|
198
|
+
* token the renderer resolves, and a value an iterator row resolved per clone.
|
|
194
199
|
*/
|
|
195
|
-
describe('LinkBlock leaves the block box to the
|
|
200
|
+
describe('LinkBlock leaves the block box to the sheet', () => {
|
|
196
201
|
const boxStyles = {
|
|
197
202
|
border: { desktop: '1px solid #d4d4d4' },
|
|
198
203
|
'border-radius': { desktop: '12px' },
|
|
@@ -221,7 +226,12 @@ describe('LinkBlock leaves the block box to the wrapper', () => {
|
|
|
221
226
|
);
|
|
222
227
|
});
|
|
223
228
|
|
|
224
|
-
|
|
229
|
+
/**
|
|
230
|
+
* The sheet's rule lands on the anchor itself now (`data-part="root"`), so the
|
|
231
|
+
* layout it declares reaches the element that lays out the card without a
|
|
232
|
+
* second inline copy shadowing the media queries emitted beside it.
|
|
233
|
+
*/
|
|
234
|
+
it('leaves the layout keys to the sheet that now targets the anchor', () => {
|
|
225
235
|
const { container } = render(
|
|
226
236
|
<LinkBlock
|
|
227
237
|
{...props({
|
|
@@ -240,32 +250,40 @@ describe('LinkBlock leaves the block box to the wrapper', () => {
|
|
|
240
250
|
/>
|
|
241
251
|
);
|
|
242
252
|
const el = anchor(container);
|
|
243
|
-
expect(el.style.display).toBe('
|
|
244
|
-
expect(el.style.flexDirection).toBe('
|
|
245
|
-
expect(el.style.gap).toBe('
|
|
253
|
+
expect(el.style.display).toBe('');
|
|
254
|
+
expect(el.style.flexDirection).toBe('');
|
|
255
|
+
expect(el.style.gap).toBe('');
|
|
246
256
|
});
|
|
247
257
|
|
|
248
258
|
/**
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
259
|
+
* The one thing the sheet cannot write, and so the only reason an inline copy
|
|
260
|
+
* survives at all: one rule serves every clone of an iterator, so a colour a
|
|
261
|
+
* row resolved for itself has nowhere else to live. A plain background, which
|
|
262
|
+
* the rule CAN hold, stays out of the inline copy.
|
|
252
263
|
*/
|
|
253
|
-
it('
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
264
|
+
it('inlines a background only when a row resolved it', () => {
|
|
265
|
+
const withStyles = (extra: Record<string, unknown>) =>
|
|
266
|
+
render(
|
|
267
|
+
<LinkBlock
|
|
268
|
+
{...props({
|
|
269
|
+
block: {
|
|
270
|
+
id: 'l',
|
|
271
|
+
properties: { href: '/foo' },
|
|
272
|
+
styles: {
|
|
273
|
+
...boxStyles,
|
|
274
|
+
'background-color': { desktop: 'rgb(255, 0, 0)' }
|
|
275
|
+
},
|
|
276
|
+
blocks: [child({ id: 'a', order: 0 })],
|
|
277
|
+
...extra
|
|
278
|
+
}
|
|
279
|
+
})}
|
|
280
|
+
/>
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
expect(anchor(withStyles({}).container).style.backgroundColor).toBe('');
|
|
284
|
+
expect(
|
|
285
|
+
anchor(withStyles({ rowStyleKeys: ['background-color'] }).container).style
|
|
286
|
+
.backgroundColor
|
|
287
|
+
).toBe('rgb(255, 0, 0)');
|
|
270
288
|
});
|
|
271
289
|
});
|
|
@@ -28,7 +28,7 @@ import HamburgerMenuBlock from '../hamburger-menu-block';
|
|
|
28
28
|
import SearchBoxBlock from '../search-box-block';
|
|
29
29
|
import MegaMenuBlock from '../mega-menu-block';
|
|
30
30
|
import SortSelectBlock from '../sort-select-block';
|
|
31
|
-
import { useOverlay } from '../../hooks/use-overlay';
|
|
31
|
+
import { isScrollLocked, useOverlay } from '../../hooks/use-overlay';
|
|
32
32
|
import { ThemePageContextProvider } from '../../theme-page-context';
|
|
33
33
|
|
|
34
34
|
/**
|
|
@@ -240,19 +240,25 @@ describe('the filter drawer next to an overlay already on the primitive', () =>
|
|
|
240
240
|
expect(screen.getByRole('dialog', { name: 'Filters' })).toBeInTheDocument();
|
|
241
241
|
});
|
|
242
242
|
|
|
243
|
-
it('counts its scroll lock with every other overlay and
|
|
243
|
+
it('counts its scroll lock with every other overlay and never takes the inline slot the page owns', () => {
|
|
244
244
|
document.body.style.overflow = 'auto';
|
|
245
245
|
|
|
246
246
|
const { rerender } = render(<Page />);
|
|
247
247
|
openDrawer();
|
|
248
|
-
expect(
|
|
248
|
+
expect(isScrollLocked()).toBe(true);
|
|
249
|
+
// The lock is an author rule keyed off the root, so `body.style.overflow`
|
|
250
|
+
// stays the page's to write: it reads back exactly what the page set even
|
|
251
|
+
// while the drawer has the page frozen.
|
|
252
|
+
expect(document.body.style.overflow).toBe('auto');
|
|
249
253
|
|
|
250
254
|
rerender(<Page migratedOpen />);
|
|
251
255
|
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
|
252
256
|
// One holder left: the page must stay frozen behind the panel still up.
|
|
253
|
-
expect(
|
|
257
|
+
expect(isScrollLocked()).toBe(true);
|
|
258
|
+
expect(document.body.style.overflow).toBe('auto');
|
|
254
259
|
|
|
255
260
|
rerender(<Page migratedOpen={false} />);
|
|
261
|
+
expect(isScrollLocked()).toBe(false);
|
|
256
262
|
expect(document.body.style.overflow).toBe('auto');
|
|
257
263
|
});
|
|
258
264
|
|
|
@@ -281,6 +287,7 @@ describe('the filter drawer next to an overlay already on the primitive', () =>
|
|
|
281
287
|
// The canvas is the author's workspace: nothing may steal focus into it,
|
|
282
288
|
// freeze the page being styled, or claim the page is hidden.
|
|
283
289
|
expect(drawer.contains(document.activeElement)).toBe(false);
|
|
290
|
+
expect(isScrollLocked()).toBe(false);
|
|
284
291
|
expect(document.body.style.overflow).toBe('auto');
|
|
285
292
|
expect(drawer).not.toHaveAttribute('aria-modal');
|
|
286
293
|
|
|
@@ -296,6 +303,7 @@ describe('the filter drawer next to an overlay already on the primitive', () =>
|
|
|
296
303
|
render(<Page facetProperties={{ layout: 'panel', lockScroll: false }} />);
|
|
297
304
|
openDrawer();
|
|
298
305
|
|
|
306
|
+
expect(isScrollLocked()).toBe(false);
|
|
299
307
|
expect(document.body.style.overflow).toBe('auto');
|
|
300
308
|
});
|
|
301
309
|
|
|
@@ -527,6 +535,7 @@ describe('the search panel next to an overlay already on the primitive', () => {
|
|
|
527
535
|
expect(screen.getByRole('dialog', { name: 'Search' })).not.toHaveAttribute(
|
|
528
536
|
'aria-modal'
|
|
529
537
|
);
|
|
538
|
+
expect(isScrollLocked()).toBe(false);
|
|
530
539
|
expect(document.body.style.overflow).toBe('');
|
|
531
540
|
});
|
|
532
541
|
|
|
@@ -565,7 +574,10 @@ describe('the search panel next to an overlay already on the primitive', () => {
|
|
|
565
574
|
/>
|
|
566
575
|
);
|
|
567
576
|
openPanel();
|
|
568
|
-
expect(
|
|
577
|
+
expect(isScrollLocked()).toBe(true);
|
|
578
|
+
// Two holders on one lock, and still not a byte written to the inline slot
|
|
579
|
+
// the page keeps for itself.
|
|
580
|
+
expect(document.body.style.overflow).toBe('auto');
|
|
569
581
|
|
|
570
582
|
// The panel that froze the page first closes while the search panel is up.
|
|
571
583
|
rerender(
|
|
@@ -574,9 +586,10 @@ describe('the search panel next to an overlay already on the primitive', () => {
|
|
|
574
586
|
migratedOpen={false}
|
|
575
587
|
/>
|
|
576
588
|
);
|
|
577
|
-
expect(
|
|
589
|
+
expect(isScrollLocked()).toBe(true);
|
|
578
590
|
|
|
579
591
|
fireEvent.click(document.querySelector('[data-search-close]') as HTMLElement);
|
|
592
|
+
expect(isScrollLocked()).toBe(false);
|
|
580
593
|
expect(document.body.style.overflow).toBe('auto');
|
|
581
594
|
});
|
|
582
595
|
|
|
@@ -691,21 +704,30 @@ describe('the hamburger drawer next to an overlay already on the primitive', ()
|
|
|
691
704
|
* `hidden` as "the page's own value", so the other panel closing thawed the
|
|
692
705
|
* page under a drawer still up — and the drawer closing wrote `hidden` back
|
|
693
706
|
* for good, leaving a storefront no one can scroll.
|
|
707
|
+
*
|
|
708
|
+
* Nothing captures anything now: every drawer on the page is one holder of a
|
|
709
|
+
* single counted lock, and the lock is an author rule on the root rather than
|
|
710
|
+
* a value borrowed out of the inline slot the page shares with brand code.
|
|
711
|
+
* So the two facts below are separate — whether the page is held still, and
|
|
712
|
+
* what the page itself has written — and the drawer decides only the first.
|
|
694
713
|
*/
|
|
695
714
|
it('counts its scroll lock with every other overlay', () => {
|
|
696
715
|
document.body.style.overflow = 'auto';
|
|
697
716
|
|
|
698
717
|
const { rerender } = render(<HamburgerPage migratedOpen />);
|
|
699
718
|
openDrawerMenu();
|
|
700
|
-
expect(
|
|
719
|
+
expect(isScrollLocked()).toBe(true);
|
|
720
|
+
expect(document.body.style.overflow).toBe('auto');
|
|
701
721
|
|
|
702
722
|
// The panel that froze the page first closes while the drawer is still up.
|
|
703
723
|
rerender(<HamburgerPage migratedOpen={false} />);
|
|
704
|
-
expect(
|
|
724
|
+
expect(isScrollLocked()).toBe(true);
|
|
725
|
+
expect(document.body.style.overflow).toBe('auto');
|
|
705
726
|
|
|
706
727
|
fireEvent.click(
|
|
707
728
|
document.querySelector('[data-hamburger-close]') as HTMLElement
|
|
708
729
|
);
|
|
730
|
+
expect(isScrollLocked()).toBe(false);
|
|
709
731
|
expect(document.body.style.overflow).toBe('auto');
|
|
710
732
|
});
|
|
711
733
|
|
|
@@ -714,6 +736,7 @@ describe('the hamburger drawer next to an overlay already on the primitive', ()
|
|
|
714
736
|
render(<HamburgerPage properties={{ lockScroll: false }} />);
|
|
715
737
|
openDrawerMenu();
|
|
716
738
|
|
|
739
|
+
expect(isScrollLocked()).toBe(false);
|
|
717
740
|
expect(document.body.style.overflow).toBe('auto');
|
|
718
741
|
});
|
|
719
742
|
});
|
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import { fireEvent, render } from '@testing-library/react';
|
|
5
5
|
import ProductGalleryBlock from '../product-gallery-block';
|
|
6
|
+
import { isScrollLocked } from '../../hooks/use-overlay';
|
|
6
7
|
import { ThemePageContextProvider } from '../../theme-page-context';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -360,24 +361,30 @@ describe('ProductGalleryBlock — full-screen viewer', () => {
|
|
|
360
361
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
|
361
362
|
});
|
|
362
363
|
|
|
363
|
-
it('
|
|
364
|
-
//
|
|
364
|
+
it('lets the page scroll again after every visit, and never takes its overflow', () => {
|
|
365
|
+
// The overflow a storefront sets is the storefront's, and it stays that
|
|
366
|
+
// way throughout: the lock is an author rule switched on by an attribute,
|
|
367
|
+
// so it never borrows the inline slot and has nothing to put back.
|
|
365
368
|
document.body.style.overflow = 'auto';
|
|
366
369
|
|
|
367
370
|
const { container, unmount } = renderBlock({ enableViewer: true }, PRODUCT);
|
|
368
371
|
|
|
369
372
|
openViewer(container);
|
|
370
|
-
expect(
|
|
373
|
+
expect(isScrollLocked()).toBe(true);
|
|
374
|
+
expect(document.body.style.overflow).toBe('auto');
|
|
371
375
|
|
|
372
376
|
fireEvent.click(container.querySelector('[data-part="viewer-close"]')!);
|
|
377
|
+
expect(isScrollLocked()).toBe(false);
|
|
373
378
|
expect(document.body.style.overflow).toBe('auto');
|
|
374
379
|
|
|
375
380
|
// A lock leaked on the first visit leaves the page frozen from here on.
|
|
376
381
|
openViewer(container);
|
|
377
|
-
expect(
|
|
382
|
+
expect(isScrollLocked()).toBe(true);
|
|
383
|
+
expect(document.body.style.overflow).toBe('auto');
|
|
378
384
|
|
|
379
385
|
// Navigating away with the viewer up must not freeze what comes next.
|
|
380
386
|
unmount();
|
|
387
|
+
expect(isScrollLocked()).toBe(false);
|
|
381
388
|
expect(document.body.style.overflow).toBe('auto');
|
|
382
389
|
|
|
383
390
|
document.body.style.overflow = '';
|
|
@@ -389,6 +396,7 @@ describe('ProductGalleryBlock — full-screen viewer', () => {
|
|
|
389
396
|
});
|
|
390
397
|
openViewer(container);
|
|
391
398
|
|
|
399
|
+
expect(isScrollLocked()).toBe(false);
|
|
392
400
|
expect(document.body.style.overflow).toBe('');
|
|
393
401
|
});
|
|
394
402
|
|
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
readRecentlyViewed,
|
|
65
65
|
recordRecentlyViewed
|
|
66
66
|
} from '../recently-viewed-store';
|
|
67
|
+
import { isScrollLocked } from '../../hooks/use-overlay';
|
|
67
68
|
|
|
68
69
|
const props = (properties: Record<string, unknown> = {}, over: Record<string, unknown> = {}) =>
|
|
69
70
|
({
|
|
@@ -564,18 +565,23 @@ describe('quick view overlay', () => {
|
|
|
564
565
|
expect(document.activeElement).toBe(opener);
|
|
565
566
|
});
|
|
566
567
|
|
|
567
|
-
it('
|
|
568
|
+
it('freezes the page while the overlay is up and lets it scroll again on close', () => {
|
|
568
569
|
const { container } = renderBlock(
|
|
569
570
|
<ProductRecommendationsBlock {...props(QUICK_VIEW)} />
|
|
570
571
|
);
|
|
571
572
|
fireEvent.click(trigger(container));
|
|
572
|
-
expect(
|
|
573
|
+
expect(isScrollLocked()).toBe(true);
|
|
574
|
+
// The freeze is an author rule keyed off an attribute, so the inline
|
|
575
|
+
// overflow slot stays the page's own throughout: nothing was borrowed
|
|
576
|
+
// while the panel was up and nothing has to be given back after it.
|
|
577
|
+
expect(document.body.style.overflow).toBe('');
|
|
573
578
|
|
|
574
579
|
fireEvent.click(
|
|
575
580
|
container.querySelector(
|
|
576
581
|
'[data-product-card-quick-view-close]'
|
|
577
582
|
) as HTMLElement
|
|
578
583
|
);
|
|
584
|
+
expect(isScrollLocked()).toBe(false);
|
|
579
585
|
expect(document.body.style.overflow).toBe('');
|
|
580
586
|
});
|
|
581
587
|
|
|
@@ -593,6 +599,7 @@ describe('quick view overlay', () => {
|
|
|
593
599
|
expect(panel).not.toBeNull();
|
|
594
600
|
// No focus stolen into the canvas, no frozen page, no `aria-modal` claim.
|
|
595
601
|
expect(panel.contains(document.activeElement)).toBe(false);
|
|
602
|
+
expect(isScrollLocked()).toBe(false);
|
|
596
603
|
expect(document.body.style.overflow).toBe('');
|
|
597
604
|
expect(panel.getAttribute('aria-modal')).toBeNull();
|
|
598
605
|
|