@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
|
@@ -0,0 +1,446 @@
|
|
|
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 {
|
|
26
|
+
readThemeModals,
|
|
27
|
+
resolveModalChrome,
|
|
28
|
+
ThemeModal
|
|
29
|
+
} from '../modal-registry';
|
|
30
|
+
import { resolveSheetHeight } from '../overlay-surface';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Chrome values that differ with the device, and the sheet's grab bar.
|
|
34
|
+
*
|
|
35
|
+
* A sheet covering 92% of a phone is not the 60% a desktop gets, and a backdrop
|
|
36
|
+
* dark enough behind a small sheet is too heavy behind a centred dialog — so
|
|
37
|
+
* these three are answerable per breakpoint. Everything else stays
|
|
38
|
+
* single-valued on purpose: a control that can differ per device is a control a
|
|
39
|
+
* designer has to check three times.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const mkModal = (overrides: Record<string, unknown> = {}): ThemeModal =>
|
|
43
|
+
readThemeModals([
|
|
44
|
+
{
|
|
45
|
+
id: 'sheet',
|
|
46
|
+
name: 'Sheet',
|
|
47
|
+
presentation: 'sheet',
|
|
48
|
+
chrome: { sheetHeight: 60, backdropOpacity: 50 },
|
|
49
|
+
...overrides
|
|
50
|
+
}
|
|
51
|
+
])[0];
|
|
52
|
+
|
|
53
|
+
const atWidth = (width: number) => {
|
|
54
|
+
(window as unknown as { innerWidth: number }).innerWidth = width;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const openAt = (width: number, overrides: Record<string, unknown> = {}) => {
|
|
58
|
+
atWidth(width);
|
|
59
|
+
|
|
60
|
+
render(
|
|
61
|
+
<ThemeModalMount modal={mkModal(overrides)}>
|
|
62
|
+
<p>composed content</p>
|
|
63
|
+
</ThemeModalMount>
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
act(() => {
|
|
67
|
+
openModal('sheet');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return document.querySelector('[role="dialog"]') as HTMLElement;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
__resetOverlayBusForTests();
|
|
75
|
+
__resetModalPreviewForTests();
|
|
76
|
+
registerModalHost();
|
|
77
|
+
clearAutoOpenRecord('sheet');
|
|
78
|
+
document.body.style.overflow = '';
|
|
79
|
+
atWidth(1440);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('resolving chrome for a breakpoint', () => {
|
|
83
|
+
const modal = {
|
|
84
|
+
chrome: { sheetHeight: 60, backdropOpacity: 50 } as ThemeModal['chrome'],
|
|
85
|
+
chromeByBreakpoint: { mobile: { sheetHeight: 92 } }
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
it('returns the very same object when nothing overrides', () => {
|
|
89
|
+
const plain = { chrome: modal.chrome };
|
|
90
|
+
|
|
91
|
+
// Identity, not equality: a new object per render would rerender the
|
|
92
|
+
// surface for every modal on the page.
|
|
93
|
+
expect(resolveModalChrome(plain, 'mobile')).toBe(plain.chrome);
|
|
94
|
+
expect(resolveModalChrome(modal, 'desktop')).toBe(modal.chrome);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('applies only the fields the breakpoint answered', () => {
|
|
98
|
+
const resolved = resolveModalChrome(modal, 'mobile');
|
|
99
|
+
|
|
100
|
+
expect(resolved.sheetHeight).toBe(92);
|
|
101
|
+
// Untouched by an override that never mentioned it.
|
|
102
|
+
expect(resolved.backdropOpacity).toBe(50);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('lets a phone inherit the tablet answer', () => {
|
|
106
|
+
const resolved = resolveModalChrome(
|
|
107
|
+
{
|
|
108
|
+
chrome: modal.chrome,
|
|
109
|
+
chromeByBreakpoint: { tablet: { backdropOpacity: 80 } }
|
|
110
|
+
},
|
|
111
|
+
'mobile'
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(resolved.backdropOpacity).toBe(80);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('keeps the phone answer over the tablet one', () => {
|
|
118
|
+
const resolved = resolveModalChrome(
|
|
119
|
+
{
|
|
120
|
+
chrome: modal.chrome,
|
|
121
|
+
chromeByBreakpoint: {
|
|
122
|
+
tablet: { sheetHeight: 70 },
|
|
123
|
+
mobile: { sheetHeight: 92 }
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
'mobile'
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
expect(resolved.sheetHeight).toBe(92);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('leaves the desktop answer alone', () => {
|
|
133
|
+
expect(resolveModalChrome(modal, 'desktop').sheetHeight).toBe(60);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('the config parser', () => {
|
|
138
|
+
it('carries the overrides through', () => {
|
|
139
|
+
expect(
|
|
140
|
+
mkModal({ chromeByBreakpoint: { mobile: { sheetHeight: 92 } } })
|
|
141
|
+
.chromeByBreakpoint
|
|
142
|
+
).toEqual({ mobile: { sheetHeight: 92 } });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('validates an override exactly as it validates the base value', () => {
|
|
146
|
+
const parsed = mkModal({
|
|
147
|
+
chromeByBreakpoint: {
|
|
148
|
+
mobile: {
|
|
149
|
+
sheetHeight: 4000,
|
|
150
|
+
backdropColor: 'chartreuse',
|
|
151
|
+
backdropOpacity: -20
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Clamped, and the junk colour rejected rather than handed to the browser.
|
|
157
|
+
expect(parsed.chromeByBreakpoint?.mobile).toEqual({
|
|
158
|
+
sheetHeight: 100,
|
|
159
|
+
backdropOpacity: 0
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('drops a breakpoint whose every field was rejected', () => {
|
|
164
|
+
expect(
|
|
165
|
+
mkModal({ chromeByBreakpoint: { mobile: { sheetHeight: 'tall' } } })
|
|
166
|
+
.chromeByBreakpoint
|
|
167
|
+
).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('the rendered sheet', () => {
|
|
172
|
+
it('takes its height from the breakpoint in force', () => {
|
|
173
|
+
const overrides = { chromeByBreakpoint: { mobile: { sheetHeight: 92 } } };
|
|
174
|
+
|
|
175
|
+
expect(openAt(1440, overrides).style.maxHeight).toBe('60vh');
|
|
176
|
+
document.body.innerHTML = '';
|
|
177
|
+
expect(openAt(480, overrides).style.maxHeight).toBe('92vh');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('draws the grab bar by default, as every published sheet has', () => {
|
|
181
|
+
expect(openAt(1440).querySelector('[data-part="handle"]')).not.toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('drops the bar when the composition brings its own header', () => {
|
|
185
|
+
expect(
|
|
186
|
+
openAt(1440, { chrome: { showSheetHandle: false } }).querySelector(
|
|
187
|
+
'[data-part="handle"]'
|
|
188
|
+
)
|
|
189
|
+
).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Writing the height out instead of picking a percentage.
|
|
195
|
+
*
|
|
196
|
+
* A sheet in a design is rarely "80% of the screen": it is "everything below
|
|
197
|
+
* the header", and no percentage can say that — the header is a fixed number of
|
|
198
|
+
* pixels. The measured symptom was a sheet whose composition (599px of banner,
|
|
199
|
+
* copy and CTA) did not fit the 60% it was given, so the logo scrolled out of
|
|
200
|
+
* view with no way to ask for more.
|
|
201
|
+
*/
|
|
202
|
+
describe('the sheet height as a written length', () => {
|
|
203
|
+
it('keeps a number meaning viewport percent, as every published sheet does', () => {
|
|
204
|
+
expect(resolveSheetHeight(80)).toBe('80vh');
|
|
205
|
+
expect(mkModal({ chrome: { sheetHeight: 80 } }).chrome.sheetHeight).toBe(
|
|
206
|
+
80
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('takes a calc() as written, which is the whole point', () => {
|
|
211
|
+
const written = 'calc(100dvh - 172px)';
|
|
212
|
+
|
|
213
|
+
expect(resolveSheetHeight(written)).toBe(written);
|
|
214
|
+
expect(
|
|
215
|
+
mkModal({ chrome: { sheetHeight: written } }).chrome.sheetHeight
|
|
216
|
+
).toBe(written);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it.each([
|
|
220
|
+
'fit-content',
|
|
221
|
+
'92%',
|
|
222
|
+
'640px',
|
|
223
|
+
'min(90dvh, 720px)',
|
|
224
|
+
'clamp(320px, 80vh, 900px)'
|
|
225
|
+
])('accepts %s', (written) => {
|
|
226
|
+
expect(resolveSheetHeight(written)).toBe(written);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('reads a numeric string as the number, not as a length', () => {
|
|
230
|
+
// An editor text input hands back "80", and `80` alone is not valid CSS —
|
|
231
|
+
// applied verbatim it would leave the panel with no height at all.
|
|
232
|
+
expect(mkModal({ chrome: { sheetHeight: '80' } }).chrome.sheetHeight).toBe(
|
|
233
|
+
80
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it.each([
|
|
238
|
+
'tall',
|
|
239
|
+
'80',
|
|
240
|
+
'calc(words)',
|
|
241
|
+
'80vh; position: fixed',
|
|
242
|
+
'url(x.png)',
|
|
243
|
+
'expression(alert(1))',
|
|
244
|
+
'80vh}'
|
|
245
|
+
])('falls back rather than handing the browser %s', (written) => {
|
|
246
|
+
// A declaration the browser drops leaves the sheet with no height — the
|
|
247
|
+
// composition then spills out of a panel collapsed to nothing.
|
|
248
|
+
expect(resolveSheetHeight(written)).toBe('60vh');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('applies the written length to the panel', () => {
|
|
252
|
+
const panel = openAt(480, {
|
|
253
|
+
chrome: { sheetHeight: 'calc(100dvh - 172px)' }
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
expect(panel.style.maxHeight).toBe('calc(100dvh - 172px)');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('can be written for one breakpoint only', () => {
|
|
260
|
+
const parsed = mkModal({
|
|
261
|
+
chromeByBreakpoint: { mobile: { sheetHeight: 'calc(100dvh - 172px)' } }
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(parsed.chromeByBreakpoint?.mobile?.sheetHeight).toBe(
|
|
265
|
+
'calc(100dvh - 172px)'
|
|
266
|
+
);
|
|
267
|
+
// And a junk override is dropped rather than falling back to 60, which
|
|
268
|
+
// would be an answer nobody gave.
|
|
269
|
+
expect(
|
|
270
|
+
mkModal({ chromeByBreakpoint: { mobile: { sheetHeight: 'tall' } } })
|
|
271
|
+
.chromeByBreakpoint
|
|
272
|
+
).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Keeping the site header clear.
|
|
278
|
+
*
|
|
279
|
+
* A bottom sheet in the design does not ride over the header: the header stays
|
|
280
|
+
* visible AND clickable, and the dimming begins below it. No panel height can
|
|
281
|
+
* express that — the backdrop covers the header either way — so the inset is on
|
|
282
|
+
* the overlay LAYER, which the backdrop and the panel share.
|
|
283
|
+
*/
|
|
284
|
+
describe('the overlay top inset', () => {
|
|
285
|
+
it('is absent by default, so a published modal covers the viewport', () => {
|
|
286
|
+
const panel = openAt(1440);
|
|
287
|
+
const layer = panel.closest('[data-part="overlay"]') as HTMLElement | null;
|
|
288
|
+
|
|
289
|
+
expect(mkModal().chrome.topInset).toBeUndefined();
|
|
290
|
+
expect(layer?.style.top || '').toBe('');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('moves the whole layer down, not just the panel', () => {
|
|
294
|
+
openAt(480, { chrome: { topInset: 96 } });
|
|
295
|
+
|
|
296
|
+
// The backdrop is inside this layer: insetting the panel alone would leave
|
|
297
|
+
// the header dimmed and unclickable, which is the actual complaint.
|
|
298
|
+
const layers = [...document.querySelectorAll('div')].filter(
|
|
299
|
+
(node) => node.style.position === 'fixed' && node.style.top === '96px'
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
expect(layers.length).toBeGreaterThan(0);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('takes a number as px, the unit a header is measured in', () => {
|
|
306
|
+
expect(mkModal({ chrome: { topInset: 96 } }).chrome.topInset).toBe(96);
|
|
307
|
+
expect(mkModal({ chrome: { topInset: '96' } }).chrome.topInset).toBe(96);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('takes a written length, including a theme variable', () => {
|
|
311
|
+
expect(
|
|
312
|
+
mkModal({ chrome: { topInset: 'var(--header-height)' } }).chrome.topInset
|
|
313
|
+
).toBe('var(--header-height)');
|
|
314
|
+
expect(
|
|
315
|
+
mkModal({ chrome: { topInset: 'calc(48px + env(safe-area-inset-top))' } })
|
|
316
|
+
.chrome.topInset
|
|
317
|
+
).toBe('calc(48px + env(safe-area-inset-top))');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('drops junk rather than shifting the layer by a broken value', () => {
|
|
321
|
+
expect(mkModal({ chrome: { topInset: 'below' } }).chrome.topInset).toBe(
|
|
322
|
+
undefined
|
|
323
|
+
);
|
|
324
|
+
expect(
|
|
325
|
+
mkModal({ chrome: { topInset: '96px;left:0' } }).chrome.topInset
|
|
326
|
+
).toBe(undefined);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('can be answered per breakpoint, since a header is a different height', () => {
|
|
330
|
+
expect(
|
|
331
|
+
mkModal({ chromeByBreakpoint: { mobile: { topInset: 132 } } })
|
|
332
|
+
.chromeByBreakpoint?.mobile?.topInset
|
|
333
|
+
).toBe(132);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Aligning the built-in close button with an authored title row.
|
|
339
|
+
*
|
|
340
|
+
* The button is chrome: it is positioned against the PANEL, while a title row
|
|
341
|
+
* authored in the composition flows with its own padding. Two coordinate
|
|
342
|
+
* systems, so they never line up by accident — and part styles cannot reach
|
|
343
|
+
* chrome, because a part rule hangs off a `[data-block-id]` and chrome has
|
|
344
|
+
* none. Hence three measures.
|
|
345
|
+
*/
|
|
346
|
+
describe('the close button’s position and size', () => {
|
|
347
|
+
const closeButton = () =>
|
|
348
|
+
document.querySelector('[data-part="close"]') as HTMLElement;
|
|
349
|
+
|
|
350
|
+
it('keeps 12 / 12 / 18 when nothing asks otherwise', () => {
|
|
351
|
+
openAt(1440);
|
|
352
|
+
|
|
353
|
+
// Every modal published before these settings existed renders here.
|
|
354
|
+
expect(closeButton().style.top).toBe('12px');
|
|
355
|
+
expect(closeButton().style.right).toBe('12px');
|
|
356
|
+
// Unitless, exactly as the markup live storefronts style has always been:
|
|
357
|
+
// this button is the legacy size-guide panel's too, and its golden pins it.
|
|
358
|
+
expect(closeButton().querySelector('svg')?.getAttribute('width')).toBe(
|
|
359
|
+
'18'
|
|
360
|
+
);
|
|
361
|
+
expect(closeButton().style.lineHeight).toBe('');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('takes the offsets a design needs', () => {
|
|
365
|
+
openAt(1440, { chrome: { closeTop: 28, closeRight: 20 } });
|
|
366
|
+
|
|
367
|
+
expect(closeButton().style.top).toBe('28px');
|
|
368
|
+
expect(closeButton().style.right).toBe('20px');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('takes a written length as readily as a number', () => {
|
|
372
|
+
openAt(1440, { chrome: { closeTop: 'calc(50% - 9px)' } });
|
|
373
|
+
|
|
374
|
+
expect(closeButton().style.top).toBe('calc(50% - 9px)');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('grows the glyph and gives it a hit area worth aiming at', () => {
|
|
378
|
+
openAt(480, { chrome: { closeSize: 24 } });
|
|
379
|
+
|
|
380
|
+
expect(closeButton().querySelector('svg')?.getAttribute('width')).toBe(
|
|
381
|
+
'24px'
|
|
382
|
+
);
|
|
383
|
+
// The padding is what a thumb hits; the negative margin cancels its effect
|
|
384
|
+
// on where the glyph lands, so aligning the icon stays predictable.
|
|
385
|
+
expect(closeButton().style.padding).toBe('8px');
|
|
386
|
+
expect(closeButton().style.margin).toBe('-8px');
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('clamps a size that stopped being an icon', () => {
|
|
390
|
+
expect(mkModal({ chrome: { closeSize: 400 } }).chrome.closeSize).toBe(64);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('drops junk rather than breaking the declaration', () => {
|
|
394
|
+
expect(
|
|
395
|
+
mkModal({ chrome: { closeTop: 'up a bit' } }).chrome.closeTop
|
|
396
|
+
).toBeUndefined();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('can be answered per breakpoint, as a padded row is', () => {
|
|
400
|
+
const parsed = mkModal({
|
|
401
|
+
chromeByBreakpoint: { mobile: { closeTop: 18, closeSize: 24 } }
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
expect(parsed.chromeByBreakpoint?.mobile).toEqual({
|
|
405
|
+
closeTop: 18,
|
|
406
|
+
closeSize: 24
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('uses the breakpoint’s answer on the panel', () => {
|
|
411
|
+
openAt(480, {
|
|
412
|
+
chrome: { closeTop: 12 },
|
|
413
|
+
chromeByBreakpoint: { mobile: { closeTop: 30 } }
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
expect(closeButton().style.top).toBe('30px');
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The close button has to survive the composition drawn under it.
|
|
422
|
+
*
|
|
423
|
+
* Measured on the storefront: at the ✕'s own centre the element receiving the
|
|
424
|
+
* click was the `h2` of the shared title row. A composed modal fills its panel
|
|
425
|
+
* with authored blocks, a full-width one covers the corner the button sits in,
|
|
426
|
+
* and the button — positioned but unlayered — loses to a later sibling. The ✕
|
|
427
|
+
* looked present and did nothing.
|
|
428
|
+
*/
|
|
429
|
+
describe('the close button’s stacking', () => {
|
|
430
|
+
const closeButton = () =>
|
|
431
|
+
document.querySelector('[data-part="close"]') as HTMLElement;
|
|
432
|
+
|
|
433
|
+
it('is lifted above the panel’s authored content', () => {
|
|
434
|
+
openAt(1440);
|
|
435
|
+
|
|
436
|
+
// Beating static content is all it needs: the composition's blocks are
|
|
437
|
+
// static, so any layer at all puts the button back on top.
|
|
438
|
+
expect(closeButton().style.zIndex).toBe('2');
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('is lifted whatever the presentation', () => {
|
|
442
|
+
openAt(480, { presentationByBreakpoint: { mobile: 'sheet' } });
|
|
443
|
+
|
|
444
|
+
expect(closeButton().style.zIndex).toBe('2');
|
|
445
|
+
});
|
|
446
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
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 {
|
|
26
|
+
readThemeModals,
|
|
27
|
+
resolveModalPresentation,
|
|
28
|
+
ThemeModal
|
|
29
|
+
} from '../modal-registry';
|
|
30
|
+
import { resolveOverlayPanelGeometry } from '../overlay-surface';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A modal that changes shape with the viewport.
|
|
34
|
+
*
|
|
35
|
+
* A centred dialog is simply the wrong object on a phone: the pattern every
|
|
36
|
+
* storefront uses there is a sheet that comes up from the bottom edge, fills
|
|
37
|
+
* the screen and carries its CTA on a bar at the bottom. One `presentation`
|
|
38
|
+
* scalar could not say both, so a designer had no way to ask for it.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const mkModal = (overrides: Record<string, unknown> = {}): ThemeModal =>
|
|
42
|
+
readThemeModals([
|
|
43
|
+
{ id: 'drawer', name: 'Drawer', presentation: 'modal', ...overrides }
|
|
44
|
+
])[0];
|
|
45
|
+
|
|
46
|
+
const atWidth = (width: number) => {
|
|
47
|
+
(window as unknown as { innerWidth: number }).innerWidth = width;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const openAt = (width: number, overrides: Record<string, unknown> = {}) => {
|
|
51
|
+
atWidth(width);
|
|
52
|
+
|
|
53
|
+
render(
|
|
54
|
+
<ThemeModalMount modal={mkModal(overrides)}>
|
|
55
|
+
<p>composed content</p>
|
|
56
|
+
</ThemeModalMount>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
act(() => {
|
|
60
|
+
openModal('drawer');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return document.querySelector('[role="dialog"]') as HTMLElement;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
__resetOverlayBusForTests();
|
|
68
|
+
__resetModalPreviewForTests();
|
|
69
|
+
registerModalHost();
|
|
70
|
+
clearAutoOpenRecord('drawer');
|
|
71
|
+
document.body.style.overflow = '';
|
|
72
|
+
atWidth(1440);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('resolving the presentation for a breakpoint', () => {
|
|
76
|
+
const modal = {
|
|
77
|
+
presentation: 'modal' as const,
|
|
78
|
+
presentationByBreakpoint: { mobile: 'sheet' as const }
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
it('uses the desktop choice where nothing overrides it', () => {
|
|
82
|
+
expect(resolveModalPresentation(modal, 'desktop')).toBe('modal');
|
|
83
|
+
expect(resolveModalPresentation(modal, 'tablet')).toBe('modal');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('uses the override at the breakpoint it names', () => {
|
|
87
|
+
expect(resolveModalPresentation(modal, 'mobile')).toBe('sheet');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('lets a phone inherit the tablet answer rather than the desktop one', () => {
|
|
91
|
+
// Overrides fall UP the chain: a modal told to be a sheet on tablet should
|
|
92
|
+
// still be one on the narrower device, or a designer would set two
|
|
93
|
+
// controls to say a single thing.
|
|
94
|
+
expect(
|
|
95
|
+
resolveModalPresentation(
|
|
96
|
+
{
|
|
97
|
+
presentation: 'modal',
|
|
98
|
+
presentationByBreakpoint: { tablet: 'sheet' }
|
|
99
|
+
},
|
|
100
|
+
'mobile'
|
|
101
|
+
)
|
|
102
|
+
).toBe('sheet');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('keeps a phone-specific answer over the tablet one', () => {
|
|
106
|
+
expect(
|
|
107
|
+
resolveModalPresentation(
|
|
108
|
+
{
|
|
109
|
+
presentation: 'modal',
|
|
110
|
+
presentationByBreakpoint: { tablet: 'drawer', mobile: 'sheet' }
|
|
111
|
+
},
|
|
112
|
+
'mobile'
|
|
113
|
+
)
|
|
114
|
+
).toBe('sheet');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('is the desktop choice when no overrides exist at all', () => {
|
|
118
|
+
expect(resolveModalPresentation({ presentation: 'drawer' }, 'mobile')).toBe(
|
|
119
|
+
'drawer'
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('the config parser', () => {
|
|
125
|
+
it('carries the overrides through', () => {
|
|
126
|
+
expect(
|
|
127
|
+
mkModal({ presentationByBreakpoint: { mobile: 'sheet' } })
|
|
128
|
+
.presentationByBreakpoint
|
|
129
|
+
).toEqual({ mobile: 'sheet' });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('drops a presentation it does not know', () => {
|
|
133
|
+
expect(
|
|
134
|
+
mkModal({ presentationByBreakpoint: { mobile: 'fullscreen' } })
|
|
135
|
+
.presentationByBreakpoint
|
|
136
|
+
).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('leaves absence absent, so an old modal presents as it always did', () => {
|
|
140
|
+
expect(mkModal().presentationByBreakpoint).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('the mounted modal', () => {
|
|
145
|
+
it('is the centred dialog on a desktop viewport', () => {
|
|
146
|
+
const panel = openAt(1440, {
|
|
147
|
+
presentationByBreakpoint: { mobile: 'sheet' }
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// The negatives matter as much as the sheet's positives below: without
|
|
151
|
+
// them a pair of assertions that never discriminate would pass either way.
|
|
152
|
+
expect(panel.style.bottom).not.toBe('0px');
|
|
153
|
+
expect(panel.querySelector('[data-part="handle"]')).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('is a bottom sheet on a phone viewport', () => {
|
|
157
|
+
const panel = openAt(480, {
|
|
158
|
+
presentationByBreakpoint: { mobile: 'sheet' }
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(panel.style.bottom).toBe('0px');
|
|
162
|
+
expect(panel.style.width).toBe('100%');
|
|
163
|
+
// The grab handle only the sheet draws.
|
|
164
|
+
expect(panel.querySelector('[data-part="handle"]')).not.toBeNull();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('a stretched sheet', () => {
|
|
169
|
+
it('is told its height, so the composition has room to hand out', () => {
|
|
170
|
+
// maxHeight alone leaves the panel content-tall: the stretch turns on with
|
|
171
|
+
// no free space and a CTA set to `margin-top: auto` does not move.
|
|
172
|
+
expect(
|
|
173
|
+
resolveOverlayPanelGeometry({
|
|
174
|
+
presentation: 'sheet',
|
|
175
|
+
width: 640,
|
|
176
|
+
sheetHeight: 92,
|
|
177
|
+
panelStretch: true
|
|
178
|
+
})
|
|
179
|
+
).toMatchObject({ height: '92vh' });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('still only caps an unstretched one, which should hug its content', () => {
|
|
183
|
+
const geometry = resolveOverlayPanelGeometry({
|
|
184
|
+
presentation: 'sheet',
|
|
185
|
+
width: 640,
|
|
186
|
+
sheetHeight: 60
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(geometry).toMatchObject({ maxHeight: '60vh' });
|
|
190
|
+
expect(geometry.height).toBeUndefined();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
openModal,
|
|
23
23
|
registerModalHost
|
|
24
24
|
} from '../overlay-bus';
|
|
25
|
+
import { isScrollLocked } from '../../hooks/use-overlay';
|
|
25
26
|
import { __resetModalPreviewForTests } from '../use-modal-preview';
|
|
26
27
|
import { clearAutoOpenRecord } from '../modal-frequency';
|
|
27
28
|
import { readThemeModals, ThemeModal } from '../modal-registry';
|
|
@@ -76,12 +77,21 @@ describe('ThemeModalMount', () => {
|
|
|
76
77
|
});
|
|
77
78
|
|
|
78
79
|
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
|
|
79
|
-
|
|
80
|
+
|
|
81
|
+
// Frozen means the lock attribute is on, not that an inline style was
|
|
82
|
+
// written: this composed mount is the surface where brand code — a
|
|
83
|
+
// commerce dialog rendered inside a step, say — writes
|
|
84
|
+
// `body.style.overflow` of its own accord while the modal is up, and an
|
|
85
|
+
// author rule cannot be overwritten by it. The page's inline slot stays
|
|
86
|
+
// the page's own the whole way through, locked or not.
|
|
87
|
+
expect(isScrollLocked()).toBe(true);
|
|
88
|
+
expect(document.body.style.overflow).toBe('');
|
|
80
89
|
|
|
81
90
|
act(() => {
|
|
82
91
|
closeModal('newsletter');
|
|
83
92
|
});
|
|
84
93
|
|
|
94
|
+
expect(isScrollLocked()).toBe(false);
|
|
85
95
|
expect(document.body.style.overflow).toBe('');
|
|
86
96
|
});
|
|
87
97
|
|
|
@@ -134,6 +144,7 @@ describe('ThemeModalMount', () => {
|
|
|
134
144
|
// With the page behind still usable, `aria-modal` would be a lie and the
|
|
135
145
|
// scroll lock would be a bug.
|
|
136
146
|
expect(screen.getByRole('dialog')).not.toHaveAttribute('aria-modal');
|
|
147
|
+
expect(isScrollLocked()).toBe(false);
|
|
137
148
|
expect(document.body.style.overflow).toBe('');
|
|
138
149
|
});
|
|
139
150
|
|