@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
@@ -1,39 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  import { useLocalization } from '@akinon/next/hooks';
4
- import React from 'react';
4
+ import React, { useMemo } from 'react';
5
5
  import { BlockRendererProps } from './block-renderer-registry';
6
- import { getCSSStyles } from '../utils';
6
+ import { getInnerRootInlineStyles, partAttrs } from '../utils';
7
7
  import { useThemeSettingsContext } from '../theme-settings-context';
8
8
 
9
- /**
10
- * The block's box geometry, which this div must not wear.
11
- *
12
- * Everything else in a text block's style map has to be applied here, and not
13
- * because the stylesheet forgot it: a bound value (`{{item.badgeColor}}`) is
14
- * deliberately stripped from the shared `[data-block-id]` rule, because every
15
- * row of an iterator shares that id and a rule cannot hold a different colour
16
- * per row. Inline is the only place a per-row value can land.
17
- *
18
- * Margin and padding are the exception. They describe the block's box, the
19
- * stylesheet already puts them on the `[data-block-id]` wrapper this div renders
20
- * inside, and a box inside a box means they compound — a designer setting 29px
21
- * under a heading got 29 on the wrapper and 29 again here, with nothing in the
22
- * inspector to explain the second one. Measured on a live page: nine blocks
23
- * carried the same margin at both levels.
24
- *
25
- * A BOUND margin is the one thing this gives up, and it is not a thing: row data
26
- * carries prices and colours, not the spacing of the box it is drawn in.
27
- */
28
- const BOX_GEOMETRY_PREFIXES = ['margin', 'padding'];
29
-
30
- const isBoxGeometryKey = (key: string): boolean => {
31
- const normalized = key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
32
- return BOX_GEOMETRY_PREFIXES.some(
33
- (prefix) => normalized === prefix || normalized.startsWith(`${prefix}-`)
34
- );
35
- };
36
-
37
9
  const TextBlock = ({
38
10
  block,
39
11
  currentBreakpoint = 'desktop'
@@ -68,20 +40,25 @@ const TextBlock = ({
68
40
 
69
41
  const content = getLocalizedContent();
70
42
 
71
- const wrapperStyles = React.useMemo(() => {
72
- const styles = block.styles || {};
73
- const withoutBoxGeometry = Object.fromEntries(
74
- Object.entries(styles).filter(([key]) => !isBoxGeometryKey(key))
43
+ /**
44
+ * Only the values the generated stylesheet cannot produce. Everything else
45
+ * now arrives as a rule against this element (`data-part="root"`), so a
46
+ * padding is applied once instead of landing on the designer wrapper AND
47
+ * being inlined here — which is what doubled every spacing set on a text.
48
+ */
49
+ const rootStyles = useMemo(() => {
50
+ return getInnerRootInlineStyles(
51
+ block.styles || {},
52
+ themeSettings,
53
+ currentBreakpoint,
54
+ block.rowStyleKeys
75
55
  );
76
-
77
- return getCSSStyles(withoutBoxGeometry, themeSettings, currentBreakpoint);
78
- }, [block.styles, themeSettings, currentBreakpoint]);
79
-
80
- const hasStyles = Object.keys(wrapperStyles).length > 0;
56
+ }, [block.styles, block.rowStyleKeys, themeSettings, currentBreakpoint]);
81
57
 
82
58
  return (
83
59
  <div
84
- {...(hasStyles ? { style: wrapperStyles } : null)}
60
+ {...partAttrs('root')}
61
+ style={rootStyles}
85
62
  dangerouslySetInnerHTML={{ __html: content }}
86
63
  />
87
64
  );
@@ -90,7 +90,10 @@ describe('readThemeConfig', () => {
90
90
  widthUnit: 'px',
91
91
  panelSide: 'right',
92
92
  sheetHeight: 60,
93
- lockScroll: true
93
+ lockScroll: true,
94
+ padding: undefined,
95
+ showSheetHandle: true,
96
+ stretchContent: false
94
97
  },
95
98
  autoTrigger: undefined,
96
99
  visibilityRules: undefined
@@ -5,7 +5,11 @@ import { fireEvent, render } from '@testing-library/react';
5
5
  // does not have.
6
6
  import { renderToStaticMarkup } from 'react-dom/server.node';
7
7
 
8
- import { useOverlay } from '../use-overlay';
8
+ import {
9
+ isScrollLocked,
10
+ SCROLL_LOCK_ATTRIBUTE,
11
+ useOverlay
12
+ } from '../use-overlay';
9
13
  import type { OverlayPointerBook } from '../use-overlay';
10
14
 
11
15
  /**
@@ -158,6 +162,15 @@ describe('useOverlay', () => {
158
162
  afterEach(() => {
159
163
  document.body.style.overflow = '';
160
164
  document.body.removeAttribute('data-overlay-state');
165
+ // The lock is an attribute on the root plus a stylesheet injected once and
166
+ // left in place, and neither belongs to the case that put it there. The
167
+ // render cleanup already unmounts every panel, so today it is the sheet
168
+ // that matters: a case asserting the rule was injected exactly once would
169
+ // otherwise be reading a leftover from a case before it. The attribute goes
170
+ // with it so that a case which ends mid-lock — one that stops asserting
171
+ // before the cleanup runs, say — cannot hand the next one a frozen page.
172
+ document.documentElement.removeAttribute(SCROLL_LOCK_ATTRIBUTE);
173
+ document.getElementById('pz-overlay-scroll-lock')?.remove();
161
174
  });
162
175
 
163
176
  it('reproduces the loop the per-block traps have: two document-level traps pull focus at each other forever', () => {
@@ -378,34 +391,111 @@ describe('useOverlay', () => {
378
391
  getByTestId('a-first').focus();
379
392
  bookRef.current?.track({ pointerId: 7, clientX: 10, clientY: 10 });
380
393
  expect(bookRef.current?.size()).toBe(1);
381
- expect(document.body.style.overflow).toBe('hidden');
394
+ expect(isScrollLocked()).toBe(true);
395
+ // The lock is a rule of its own, so the page's inline slot still reads back
396
+ // exactly what the page put there while the panel is up.
397
+ expect(document.body.style.overflow).toBe('auto');
382
398
 
383
399
  unmount();
384
400
 
401
+ expect(isScrollLocked()).toBe(false);
385
402
  expect(document.body.style.overflow).toBe('auto');
386
403
  expect(onDeactivate).toHaveBeenCalledTimes(1);
387
404
  expect(bookRef.current?.size()).toBe(0);
388
405
  });
389
406
 
390
- it('counts every scroll-lock holder and restores the page’s own overflow only for the last', () => {
407
+ it('counts every scroll-lock holder and lets the page go only for the last', () => {
391
408
  document.body.style.overflow = 'auto';
392
409
 
393
410
  const { rerender } = render(<Page a={{ open: true }} b={{ open: true }} />);
394
- expect(document.body.style.overflow).toBe('hidden');
411
+ expect(isScrollLocked()).toBe(true);
412
+ // Asserted at every step and not only at the end: two holders on one lock
413
+ // still leave the inline slot reading back exactly what the page wrote.
414
+ expect(document.body.style.overflow).toBe('auto');
395
415
 
396
416
  rerender(<Page a={{ open: false }} b={{ open: true }} />);
397
417
  // One holder left: the page must stay frozen behind the panel still up.
398
- expect(document.body.style.overflow).toBe('hidden');
418
+ expect(isScrollLocked()).toBe(true);
419
+ expect(document.body.style.overflow).toBe('auto');
399
420
 
400
421
  rerender(<Page a={{ open: false }} b={{ open: false }} />);
422
+ expect(isScrollLocked()).toBe(false);
423
+ // Letting go is dropping the attribute, not restoring a value: two holders
424
+ // came and went without either of them ever taking the page's own.
401
425
  expect(document.body.style.overflow).toBe('auto');
402
426
  });
403
427
 
428
+ it('keeps the lock when something else writes the page’s inline overflow', () => {
429
+ render(<Page a={{ open: true }} />);
430
+
431
+ expect(isScrollLocked()).toBe(true);
432
+
433
+ // The write that broke the old lock, in the shape it arrives in: the
434
+ // commerce `Modal` a login step renders inside an open drawer sets
435
+ // `body.style.overflow` to `auto` from an effect that runs on MOUNT,
436
+ // whether that modal is open or not. It is simply the last writer of a slot
437
+ // the whole page shares, and it handed the shopper a scrollbar with the
438
+ // drawer still covering the page.
439
+ document.body.style.overflow = 'auto';
440
+
441
+ expect(isScrollLocked()).toBe(true);
442
+ expect(document.documentElement.hasAttribute(SCROLL_LOCK_ATTRIBUTE)).toBe(
443
+ true
444
+ );
445
+ // The attribute surviving an inline write proves nothing on its own — an
446
+ // inline declaration could not remove an attribute if it tried. What has to
447
+ // hold is the CASCADE: with `auto` sitting in the page's own inline slot,
448
+ // the axis the shopper scrolls still resolves to `hidden`, on the root and
449
+ // on the body alike.
450
+ expect(getComputedStyle(document.body).overflowY).toBe('hidden');
451
+ expect(getComputedStyle(document.documentElement).overflowY).toBe('hidden');
452
+ });
453
+
454
+ it('locks with one author rule that outranks any inline declaration', () => {
455
+ render(<Page a={{ open: true }} b={{ open: true }} />);
456
+
457
+ // The rule does nothing without the attribute, so it is injected once and
458
+ // left alone: a second overlay opening must not add a second copy of it.
459
+ const sheets = document.querySelectorAll('style#pz-overlay-scroll-lock');
460
+ expect(sheets).toHaveLength(1);
461
+
462
+ const rule = sheets[0].textContent ?? '';
463
+ // Only an `!important` author declaration outranks an inline one, and
464
+ // outranking inline writers is the entire point of doing it this way.
465
+ expect(rule).toContain('!important');
466
+ // The root is locked outright as well as the body, because `body`'s
467
+ // overflow reaches the viewport only by propagation and a brand that sets
468
+ // an `overflow-y` of its own on `html` stops that propagation dead. Both
469
+ // selectors are pinned as the LIST they form: `html[…]` on its own is a
470
+ // substring of `html[…]>body`, so asserting it alone would go on passing
471
+ // for a rule that had quietly dropped the root.
472
+ expect(rule).toContain(
473
+ `html[${SCROLL_LOCK_ATTRIBUTE}],html[${SCROLL_LOCK_ATTRIBUTE}]>body{`
474
+ );
475
+ });
476
+
477
+ it('leaves the page’s own inline overflow untouched across a whole cycle', () => {
478
+ // A distinctive value the page could plausibly have set for itself: the
479
+ // lock has no business reading it, replacing it, or putting it back.
480
+ document.body.style.overflow = 'clip';
481
+
482
+ const { rerender } = render(<Page a={{ open: true }} />);
483
+
484
+ expect(isScrollLocked()).toBe(true);
485
+ expect(document.body.style.overflow).toBe('clip');
486
+
487
+ rerender(<Page a={{ open: false }} />);
488
+
489
+ expect(isScrollLocked()).toBe(false);
490
+ expect(document.body.style.overflow).toBe('clip');
491
+ });
492
+
404
493
  it('leaves the page alone for an overlay that does not ask for the lock', () => {
405
494
  document.body.style.overflow = 'auto';
406
495
 
407
496
  render(<Page a={{ open: true, lockScroll: false }} />);
408
497
 
498
+ expect(isScrollLocked()).toBe(false);
409
499
  expect(document.body.style.overflow).toBe('auto');
410
500
  });
411
501
 
@@ -481,6 +571,7 @@ describe('useOverlay', () => {
481
571
 
482
572
  // The canvas is the author's workspace: nothing may reach into it.
483
573
  expect(document.activeElement).toBe(outside);
574
+ expect(isScrollLocked()).toBe(false);
484
575
  expect(document.body.style.overflow).toBe('auto');
485
576
  expect(getByTestId('a-panel')).not.toHaveAttribute('aria-modal');
486
577
 
@@ -521,6 +612,7 @@ describe('useOverlay', () => {
521
612
 
522
613
  expect(markup).toContain('data-testid="a-panel"');
523
614
  expect(markup).not.toContain('aria-modal');
615
+ expect(isScrollLocked()).toBe(false);
524
616
  expect(document.body.style.overflow).toBe('auto');
525
617
  expect(addEventListener).not.toHaveBeenCalled();
526
618
 
@@ -10,6 +10,14 @@ import {
10
10
  toggleModal,
11
11
  useModalOpen
12
12
  } from '../overlays/overlay-bus';
13
+ import {
14
+ AuthFlowIdContext,
15
+ DEFAULT_AUTH_FLOW_ID,
16
+ goBackAuthStep,
17
+ resolveAuthStepTarget,
18
+ setAuthStep
19
+ } from '../auth/auth-flow-bus';
20
+ import { useOtpForm } from '../auth/otp-form-context';
13
21
 
14
22
  /**
15
23
  * What a block does when it is clicked.
@@ -32,7 +40,11 @@ export type BlockActionType =
32
40
  | 'close-modal'
33
41
  | 'toggle-modal'
34
42
  | 'close-top-modal'
35
- | 'scroll-to';
43
+ | 'scroll-to'
44
+ | 'auth-step'
45
+ | 'auth-back'
46
+ | 'otp-resend'
47
+ | 'otp-change-number';
36
48
 
37
49
  export const BLOCK_ACTION_TYPES: BlockActionType[] = [
38
50
  'none',
@@ -41,7 +53,11 @@ export const BLOCK_ACTION_TYPES: BlockActionType[] = [
41
53
  'close-modal',
42
54
  'toggle-modal',
43
55
  'close-top-modal',
44
- 'scroll-to'
56
+ 'scroll-to',
57
+ 'auth-step',
58
+ 'auth-back',
59
+ 'otp-resend',
60
+ 'otp-change-number'
45
61
  ];
46
62
 
47
63
  const MODAL_ACTIONS: BlockActionType[] = [
@@ -112,6 +128,7 @@ export interface UseBlockActionResult {
112
128
  'aria-haspopup'?: 'dialog';
113
129
  'aria-expanded'?: boolean;
114
130
  'aria-controls'?: string;
131
+ 'aria-disabled'?: boolean;
115
132
  };
116
133
  }
117
134
 
@@ -149,6 +166,19 @@ export const useBlockAction = (
149
166
  MODAL_ACTIONS.includes(type) ? target : ''
150
167
  );
151
168
 
169
+ // The flow a trigger inside an `auth-flow` block belongs to, so its target
170
+ // can be a bare step key. Empty everywhere else; the auth actions then fall
171
+ // back to the default flow id, which is what a lone flow on the page uses.
172
+ const contextFlowId = React.useContext(AuthFlowIdContext);
173
+
174
+ /**
175
+ * The OTP form this block stands inside, for the two controls a composed
176
+ * verification needs and no bus can carry: asking for a fresh code and going
177
+ * back to the number. Both belong to ONE form's state, so unlike the modal
178
+ * and auth-step actions they are reached through a context rather than by id.
179
+ */
180
+ const otpForm = useOtpForm();
181
+
152
182
  const handleClick = useCallback(
153
183
  (event: React.MouseEvent<HTMLElement>) => {
154
184
  if (isDesigner) return;
@@ -175,14 +205,47 @@ export const useBlockAction = (
175
205
  event.preventDefault();
176
206
  scrollToAnchor(target);
177
207
  break;
208
+ case 'auth-step': {
209
+ // A trigger that also navigates would race the step it opens.
210
+ event.preventDefault();
211
+ const resolved = resolveAuthStepTarget(target, contextFlowId);
212
+ if (resolved) setAuthStep(resolved.flowId, resolved.stepKey);
213
+ break;
214
+ }
215
+ case 'otp-resend':
216
+ event.preventDefault();
217
+ // No-ops while the countdown runs — the form owns that rule, so a
218
+ // resend placed anywhere obeys the same one.
219
+ otpForm?.resend();
220
+ break;
221
+ case 'otp-change-number':
222
+ event.preventDefault();
223
+ otpForm?.changeNumber();
224
+ break;
225
+ case 'auth-back':
226
+ event.preventDefault();
227
+ // The optional target names a flow; without one the nearest (or
228
+ // default) flow is walked back.
229
+ goBackAuthStep(target || contextFlowId || DEFAULT_AUTH_FLOW_ID);
230
+ break;
178
231
  default:
179
232
  break;
180
233
  }
181
234
  },
182
- [isDesigner, type, target]
235
+ [isDesigner, type, target, contextFlowId, otpForm]
183
236
  );
184
237
 
185
- const isActive = isInteractiveAction(type) && (type === 'close-top-modal' || Boolean(target));
238
+ /** Actions that act on what is in scope, so they need no target typed. */
239
+ const TARGETLESS_ACTIONS: BlockActionType[] = [
240
+ 'close-top-modal',
241
+ 'auth-back',
242
+ 'otp-resend',
243
+ 'otp-change-number'
244
+ ];
245
+
246
+ const isActive =
247
+ isInteractiveAction(type) &&
248
+ (TARGETLESS_ACTIONS.includes(type) || Boolean(target));
186
249
 
187
250
  if (!isActive) {
188
251
  return { isActive: false, ariaProps: {} };
@@ -190,6 +253,26 @@ export const useBlockAction = (
190
253
 
191
254
  const opensDialog = type === 'open-modal' || type === 'toggle-modal';
192
255
 
256
+ // A resend during the cooldown is a real control that is momentarily not
257
+ // available. `aria-disabled` announces that and gives the brand a selector
258
+ // to dim it, without taking the element out of the tab order the way
259
+ // `disabled` on a styled div cannot anyway.
260
+ // Same for "use a different number" while a REGISTRATION is being verified:
261
+ // the parked payload is the only copy of the sign-up, so collecting a new
262
+ // number would throw it away. The fixed layout hides the control outright;
263
+ // a composed one is a Button the author placed, so it says it is unavailable
264
+ // rather than disappearing out from under the design.
265
+ if (
266
+ (type === 'otp-resend' && otpForm && !otpForm.canResend) ||
267
+ (type === 'otp-change-number' && otpForm && !otpForm.canChangeNumber)
268
+ ) {
269
+ return {
270
+ isActive: true,
271
+ onClick: handleClick,
272
+ ariaProps: { 'aria-disabled': true }
273
+ };
274
+ }
275
+
193
276
  return {
194
277
  isActive: true,
195
278
  onClick: handleClick,
@@ -141,20 +141,72 @@ const popOverlay = (id: string) => {
141
141
  const noOverlayOnServer = () => false;
142
142
 
143
143
  /**
144
- * The page's own overflow, held by whoever locked first and written back only
145
- * when the last holder leaves. Each block used to keep its own counter, so the
146
- * second block to open captured `hidden` as "the page's own value" and the
147
- * page stayed frozen after every panel had closed.
144
+ * How many overlays are holding the page still. The lock itself is a rule (see
145
+ * below), so the count is the only state there is: the first holder switches it
146
+ * on, the last one out switches it off, and nothing has to be remembered in
147
+ * between. Each block used to keep its own counter AND its own copy of the
148
+ * page's overflow, so the second block to open captured `hidden` as "the
149
+ * page's own value" and the page stayed frozen after every panel had closed.
148
150
  */
149
151
  let scrollLockHolders = 0;
150
- let scrollLockPreviousOverflow = '';
152
+
153
+ /**
154
+ * The lock is an author rule flipped by an attribute, NOT an inline style.
155
+ *
156
+ * `body.style.overflow` is a single slot the whole page shares, and brand code
157
+ * writes it directly: the commerce `Modal` sets it to `auto` from an effect
158
+ * that runs on MOUNT — open or closed — and again on unmount. Anything of that
159
+ * shape appearing while an overlay is up (a consent dialog rendered by a login
160
+ * step, a quick-view mounted behind a drawer) was the last writer, so it handed
161
+ * the page its scrollbar back with the overlay still covering it. An
162
+ * `!important` author declaration outranks every inline one, so the lock now
163
+ * survives writers it does not control, and releasing it is dropping an
164
+ * attribute rather than restoring a value someone else may have replaced
165
+ * meanwhile.
166
+ *
167
+ * Both `html` and `body` are named. `body`'s overflow reaches the viewport only
168
+ * by propagation, which stops the moment a brand sets an overflow of its own on
169
+ * `html` (`overflow-y: scroll`, to keep the scrollbar's width from shifting the
170
+ * layout, is the common one) — so the root is locked outright and the body is
171
+ * locked with it.
172
+ *
173
+ * Only the block axis: `overflow-x` belongs to the page (the app template
174
+ * clips it, and `StickySlotOverflow` keeps that clip a `clip` rather than a
175
+ * `hidden` precisely so a pinned slot stays pinned), and claiming it here would
176
+ * unpin every sticky slot for as long as a modal is open.
177
+ */
178
+ export const SCROLL_LOCK_ATTRIBUTE = 'data-pz-scroll-locked';
179
+
180
+ const SCROLL_LOCK_STYLE_ID = 'pz-overlay-scroll-lock';
181
+
182
+ const SCROLL_LOCK_RULE =
183
+ `html[${SCROLL_LOCK_ATTRIBUTE}],` +
184
+ `html[${SCROLL_LOCK_ATTRIBUTE}]>body{overflow-y:hidden!important}`;
185
+
186
+ /**
187
+ * Injected once and left in place: the rule does nothing without the
188
+ * attribute, and a page that has opened one overlay will open another.
189
+ */
190
+ const ensureScrollLockRule = () => {
191
+ if (document.getElementById(SCROLL_LOCK_STYLE_ID)) return;
192
+
193
+ const style = document.createElement('style');
194
+ style.id = SCROLL_LOCK_STYLE_ID;
195
+ style.textContent = SCROLL_LOCK_RULE;
196
+ (document.head || document.documentElement).appendChild(style);
197
+ };
198
+
199
+ /** Whether an overlay is currently holding the page still. */
200
+ export const isScrollLocked = (): boolean =>
201
+ typeof document !== 'undefined' &&
202
+ document.documentElement.hasAttribute(SCROLL_LOCK_ATTRIBUTE);
151
203
 
152
204
  const acquireScrollLock = () => {
153
205
  if (typeof document === 'undefined') return;
154
206
 
155
207
  if (scrollLockHolders === 0) {
156
- scrollLockPreviousOverflow = document.body.style.overflow;
157
- document.body.style.overflow = 'hidden';
208
+ ensureScrollLockRule();
209
+ document.documentElement.setAttribute(SCROLL_LOCK_ATTRIBUTE, '');
158
210
  }
159
211
 
160
212
  scrollLockHolders += 1;
@@ -166,8 +218,7 @@ const releaseScrollLock = () => {
166
218
  scrollLockHolders -= 1;
167
219
 
168
220
  if (scrollLockHolders === 0) {
169
- document.body.style.overflow = scrollLockPreviousOverflow;
170
- scrollLockPreviousOverflow = '';
221
+ document.documentElement.removeAttribute(SCROLL_LOCK_ATTRIBUTE);
171
222
  }
172
223
  };
173
224
 
@@ -0,0 +1,119 @@
1
+ import { act, render } 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 ThemeModalMount from '../theme-modal-mount';
18
+ import {
19
+ __resetOverlayBusForTests,
20
+ openModal,
21
+ registerModalHost
22
+ } from '../overlay-bus';
23
+ import { __resetModalPreviewForTests } from '../use-modal-preview';
24
+ import { clearAutoOpenRecord } from '../modal-frequency';
25
+ import { readThemeModals, ThemeModal } from '../modal-registry';
26
+
27
+ /**
28
+ * The whole path of two chrome settings, from saved config to the DOM.
29
+ *
30
+ * Both were lost in the middle of it, twice over, and neither loss could show
31
+ * up in a unit test of either end: `readChrome` builds the chrome object field
32
+ * by field and simply never read them, so the panel kept its historical 24px
33
+ * however the designer set the control; and the mount then handed the surface
34
+ * a `stretchContent` prop when the surface declares `panelStretch`, which a
35
+ * package with no tsconfig never flagged. A designer typing 0 saw 24, and the
36
+ * bottom-bar CTA had nothing to stretch against.
37
+ *
38
+ * So these assertions deliberately start at the raw config and end at the
39
+ * rendered element — the two ends a designer actually experiences.
40
+ */
41
+
42
+ const mkModal = (chrome: Record<string, unknown> = {}): ThemeModal =>
43
+ readThemeModals([
44
+ {
45
+ id: 'drawer',
46
+ name: 'Drawer',
47
+ chrome: { presentation: 'panel', ...chrome }
48
+ }
49
+ ])[0];
50
+
51
+ const openWith = (chrome: Record<string, unknown> = {}) => {
52
+ render(
53
+ <ThemeModalMount modal={mkModal(chrome)}>
54
+ <p>composed content</p>
55
+ </ThemeModalMount>
56
+ );
57
+
58
+ act(() => {
59
+ openModal('drawer');
60
+ });
61
+
62
+ return document.querySelector('[role="dialog"]') as HTMLElement;
63
+ };
64
+
65
+ beforeEach(() => {
66
+ __resetOverlayBusForTests();
67
+ __resetModalPreviewForTests();
68
+ registerModalHost();
69
+ clearAutoOpenRecord('drawer');
70
+ document.body.style.overflow = '';
71
+ });
72
+
73
+ describe('the padding a designer sets on a modal', () => {
74
+ it('survives the config parser', () => {
75
+ expect(mkModal({ padding: 40 }).chrome.padding).toBe(40);
76
+ });
77
+
78
+ it('reaches the panel', () => {
79
+ expect(openWith({ padding: 40 }).style.padding).toBe('40px');
80
+ });
81
+
82
+ it('can be zero, which is a real answer and not an absence', () => {
83
+ expect(mkModal({ padding: 0 }).chrome.padding).toBe(0);
84
+ expect(openWith({ padding: 0 }).style.padding).toBe('0px');
85
+ });
86
+
87
+ it('is absent when never authored, so the panel keeps its historical 24', () => {
88
+ // Every modal saved before the control existed must render as it did.
89
+ expect(mkModal().chrome.padding).toBeUndefined();
90
+ expect(openWith().style.padding).toBe('24px');
91
+ });
92
+
93
+ it('ignores junk rather than handing the browser a broken declaration', () => {
94
+ expect(mkModal({ padding: 'wide' }).chrome.padding).toBeUndefined();
95
+ });
96
+
97
+ it('clamps a typo instead of rendering a 2400px panel', () => {
98
+ expect(mkModal({ padding: 2400 }).chrome.padding).toBe(200);
99
+ });
100
+ });
101
+
102
+ describe('stretch content to full height', () => {
103
+ it('survives the config parser', () => {
104
+ expect(mkModal({ stretchContent: true }).chrome.stretchContent).toBe(true);
105
+ });
106
+
107
+ it('marks the panel, which is what the stretch rules select on', () => {
108
+ // The rules in PANEL_STRETCH_CSS all hang off this attribute; without it
109
+ // nothing in the chain applies and the CTA cannot be pinned.
110
+ expect(openWith({ stretchContent: true })).toHaveAttribute(
111
+ 'data-modal-stretch'
112
+ );
113
+ });
114
+
115
+ it('is off unless asked for', () => {
116
+ expect(mkModal().chrome.stretchContent).toBe(false);
117
+ expect(openWith()).not.toHaveAttribute('data-modal-stretch');
118
+ });
119
+ });
@@ -58,7 +58,12 @@ describe('readThemeModals', () => {
58
58
  widthUnit: 'px',
59
59
  panelSide: 'right',
60
60
  sheetHeight: 60,
61
- lockScroll: true
61
+ lockScroll: true,
62
+ // Absent, not defaulted: the panel's historical 24px lives in the
63
+ // surface, so an unauthored padding must stay unauthored here.
64
+ padding: undefined,
65
+ showSheetHandle: true,
66
+ stretchContent: false
62
67
  },
63
68
  autoTrigger: undefined,
64
69
  visibilityRules: undefined
@@ -0,0 +1,60 @@
1
+ import { render } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ import { PanelStretchStyles } from '../overlay-surface';
5
+
6
+ /**
7
+ * "Stretch Content to Full Height" is what lets a drawer pin its CTA to the
8
+ * bottom edge, and it works by handing the panel's height DOWN a chain of
9
+ * wrappers. Height is not inherited — it is passed link by link — so one
10
+ * wrapper left at `auto` strands everything below it, and the designer sees a
11
+ * button that will not move however many times they type 100%.
12
+ *
13
+ * The chain is our own markup, so it is testable as a list: every structural
14
+ * element of an auth journey has to be in it. The composed forms included — an
15
+ * OTP form sits between the step and its phase.
16
+ */
17
+ const cssOf = (): string => {
18
+ const { container } = render(<PanelStretchStyles />);
19
+ return container.querySelector('style')?.innerHTML ?? '';
20
+ };
21
+
22
+ const JOURNEY_PARTS = [
23
+ 'data-auth-flow',
24
+ 'data-auth-step',
25
+ 'data-otp-phase',
26
+ 'data-auth-form',
27
+ 'data-register-form',
28
+ 'data-otp-form',
29
+ 'data-forgot-password'
30
+ ];
31
+
32
+ describe('the panel stretch chain', () => {
33
+ it('starts at the placeholder and its section', () => {
34
+ const css = cssOf();
35
+
36
+ expect(css).toContain('[data-modal-stretch]>[data-placeholder]{');
37
+ expect(css).toContain('[data-modal-stretch]>[data-placeholder]>section{');
38
+ });
39
+
40
+ it.each(JOURNEY_PARTS)('stretches the wrapper around [%s]', (attribute) => {
41
+ expect(cssOf()).toContain(
42
+ `[data-modal-stretch] [data-block-id]:has(>[${attribute}])`
43
+ );
44
+ });
45
+
46
+ it.each(JOURNEY_PARTS)('stretches [%s] itself', (attribute) => {
47
+ expect(cssOf()).toContain(`[data-modal-stretch] [${attribute}]`);
48
+ });
49
+
50
+ it('leaves the free space to the composition, not to itself', () => {
51
+ const css = cssOf();
52
+
53
+ // `min-height:0` on every link, or a long code phase would refuse to
54
+ // shrink and the bottom bar would be pushed off the panel.
55
+ expect(css.match(/min-height:0/g)?.length).toBe(4);
56
+ // The designer's own blocks are never named here: what happens inside the
57
+ // section stays theirs.
58
+ expect(css).not.toContain('data-part');
59
+ });
60
+ });