@akinon/pz-theme 2.0.83-rc.0 → 2.0.84-beta.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 (63) hide show
  1. package/CHANGELOG.md +39 -33
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-flow-block.test.tsx +211 -0
  4. package/src/__tests__/auth-flow-bus.test.ts +290 -0
  5. package/src/__tests__/auth-form-error-block.test.tsx +326 -0
  6. package/src/__tests__/auth-form-validation.test.tsx +204 -0
  7. package/src/__tests__/auth-step-title.test.tsx +202 -0
  8. package/src/__tests__/detached-submit-state.test.tsx +216 -0
  9. package/src/__tests__/forgot-password-composed.test.tsx +313 -0
  10. package/src/__tests__/otp-form-composed.test.tsx +487 -0
  11. package/src/__tests__/password-reset-step.test.tsx +269 -0
  12. package/src/__tests__/register-form-composed.test.tsx +739 -0
  13. package/src/__tests__/registry-parity.test.ts +9 -0
  14. package/src/auth/auth-field-errors.tsx +151 -0
  15. package/src/auth/auth-flow-bus.ts +415 -0
  16. package/src/auth/form-error-context.tsx +55 -0
  17. package/src/auth/form-submit-bus.ts +93 -0
  18. package/src/auth/otp-form-context.tsx +80 -0
  19. package/src/blocks/__tests__/button-block.test.tsx +55 -0
  20. package/src/blocks/__tests__/link-block.test.tsx +48 -30
  21. package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
  22. package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
  23. package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
  24. package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
  25. package/src/blocks/__tests__/text-block.test.tsx +37 -3
  26. package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
  27. package/src/blocks/auth-consent-block.tsx +214 -0
  28. package/src/blocks/auth-error-block.tsx +82 -0
  29. package/src/blocks/auth-field-block.tsx +270 -0
  30. package/src/blocks/auth-flow-block.tsx +251 -0
  31. package/src/blocks/auth-form-block.tsx +391 -47
  32. package/src/blocks/auth-step-block.tsx +81 -0
  33. package/src/blocks/auth-step-title-block.tsx +81 -0
  34. package/src/blocks/block-renderer-registry.tsx +18 -0
  35. package/src/blocks/button-block.tsx +91 -13
  36. package/src/blocks/forgot-password-block.tsx +318 -24
  37. package/src/blocks/link-block.tsx +22 -84
  38. package/src/blocks/otp-code-input-block.tsx +180 -0
  39. package/src/blocks/otp-form-block.tsx +1092 -0
  40. package/src/blocks/otp-phase-block.tsx +71 -0
  41. package/src/blocks/register-form-block.tsx +541 -74
  42. package/src/blocks/text-block.tsx +17 -40
  43. package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
  44. package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
  45. package/src/hooks/use-block-action.ts +87 -4
  46. package/src/hooks/use-overlay.ts +60 -9
  47. package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
  48. package/src/overlays/__tests__/modal-registry.test.ts +6 -1
  49. package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
  50. package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
  51. package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
  52. package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
  53. package/src/overlays/modal-preview-host.tsx +39 -2
  54. package/src/overlays/modal-registry.ts +344 -4
  55. package/src/overlays/overlay-surface.tsx +249 -19
  56. package/src/overlays/overlay-tokens.ts +94 -0
  57. package/src/overlays/theme-modal-mount.tsx +34 -4
  58. package/src/theme-block.tsx +6 -0
  59. package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
  60. package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
  61. package/src/utils/index.ts +107 -6
  62. package/src/utils/iterator-utils.ts +39 -2
  63. 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, and the
190
- * generated stylesheet paints the block's box on that wrapper. The wrapper is
191
- * the grid item, so it stretches to its row; the anchor only hugs its content.
192
- * A card with one border in the inspector therefore showed two on the page —
193
- * one closing under the label, one at the bottom of the row.
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 wrapper', () => {
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
- it('still writes the layout keys inline, since the wrapper has one child', () => {
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('flex');
244
- expect(el.style.flexDirection).toBe('column');
245
- expect(el.style.gap).toBe('8px');
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
- * Painting the same colour on the wrapper and on the anchor leaves no
250
- * artifact, and an iterator resolves a bound colour per row into this inline
251
- * copy — the one shared stylesheet rule cannot hold a value per row.
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('still writes background inline, which duplication cannot spoil', () => {
254
- const { container } = render(
255
- <LinkBlock
256
- {...props({
257
- block: {
258
- id: 'l',
259
- properties: { href: '/foo' },
260
- styles: {
261
- ...boxStyles,
262
- 'background-color': { desktop: 'rgb(255, 0, 0)' }
263
- },
264
- blocks: [child({ id: 'a', order: 0 })]
265
- }
266
- })}
267
- />
268
- );
269
- expect(anchor(container).style.backgroundColor).toBe('rgb(255, 0, 0)');
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 gives the page its own overflow back', () => {
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(document.body.style.overflow).toBe('hidden');
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(document.body.style.overflow).toBe('hidden');
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(document.body.style.overflow).toBe('hidden');
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(document.body.style.overflow).toBe('hidden');
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(document.body.style.overflow).toBe('hidden');
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(document.body.style.overflow).toBe('hidden');
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('gives the page back its own scrolling on every visit', () => {
364
- // A storefront that sets its own overflow has to get THAT value back.
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(document.body.style.overflow).toBe('hidden');
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(document.body.style.overflow).toBe('hidden');
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('gives the page its own overflow back when the overlay closes', () => {
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(document.body.style.overflow).toBe('hidden');
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