@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.
- package/CHANGELOG.md +39 -33
- 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
|
@@ -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 {
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
{...(
|
|
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 {
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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,
|
package/src/hooks/use-overlay.ts
CHANGED
|
@@ -141,20 +141,72 @@ const popOverlay = (id: string) => {
|
|
|
141
141
|
const noOverlayOnServer = () => false;
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
document.
|
|
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.
|
|
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
|
+
});
|