@akinon/pz-theme 2.0.83-rc.0 → 2.0.84-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +39 -33
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-flow-block.test.tsx +211 -0
  4. package/src/__tests__/auth-flow-bus.test.ts +290 -0
  5. package/src/__tests__/auth-form-error-block.test.tsx +326 -0
  6. package/src/__tests__/auth-form-validation.test.tsx +204 -0
  7. package/src/__tests__/auth-step-title.test.tsx +202 -0
  8. package/src/__tests__/detached-submit-state.test.tsx +216 -0
  9. package/src/__tests__/forgot-password-composed.test.tsx +313 -0
  10. package/src/__tests__/otp-form-composed.test.tsx +487 -0
  11. package/src/__tests__/password-reset-step.test.tsx +269 -0
  12. package/src/__tests__/register-form-composed.test.tsx +739 -0
  13. package/src/__tests__/registry-parity.test.ts +9 -0
  14. package/src/auth/auth-field-errors.tsx +151 -0
  15. package/src/auth/auth-flow-bus.ts +415 -0
  16. package/src/auth/form-error-context.tsx +55 -0
  17. package/src/auth/form-submit-bus.ts +93 -0
  18. package/src/auth/otp-form-context.tsx +80 -0
  19. package/src/blocks/__tests__/button-block.test.tsx +55 -0
  20. package/src/blocks/__tests__/link-block.test.tsx +48 -30
  21. package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
  22. package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
  23. package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
  24. package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
  25. package/src/blocks/__tests__/text-block.test.tsx +37 -3
  26. package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
  27. package/src/blocks/auth-consent-block.tsx +214 -0
  28. package/src/blocks/auth-error-block.tsx +82 -0
  29. package/src/blocks/auth-field-block.tsx +270 -0
  30. package/src/blocks/auth-flow-block.tsx +251 -0
  31. package/src/blocks/auth-form-block.tsx +391 -47
  32. package/src/blocks/auth-step-block.tsx +81 -0
  33. package/src/blocks/auth-step-title-block.tsx +81 -0
  34. package/src/blocks/block-renderer-registry.tsx +18 -0
  35. package/src/blocks/button-block.tsx +91 -13
  36. package/src/blocks/forgot-password-block.tsx +318 -24
  37. package/src/blocks/link-block.tsx +22 -84
  38. package/src/blocks/otp-code-input-block.tsx +180 -0
  39. package/src/blocks/otp-form-block.tsx +1092 -0
  40. package/src/blocks/otp-phase-block.tsx +71 -0
  41. package/src/blocks/register-form-block.tsx +541 -74
  42. package/src/blocks/text-block.tsx +17 -40
  43. package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
  44. package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
  45. package/src/hooks/use-block-action.ts +87 -4
  46. package/src/hooks/use-overlay.ts +60 -9
  47. package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
  48. package/src/overlays/__tests__/modal-registry.test.ts +6 -1
  49. package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
  50. package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
  51. package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
  52. package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
  53. package/src/overlays/modal-preview-host.tsx +39 -2
  54. package/src/overlays/modal-registry.ts +344 -4
  55. package/src/overlays/overlay-surface.tsx +249 -19
  56. package/src/overlays/overlay-tokens.ts +94 -0
  57. package/src/overlays/theme-modal-mount.tsx +34 -4
  58. package/src/theme-block.tsx +6 -0
  59. package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
  60. package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
  61. package/src/utils/index.ts +107 -6
  62. package/src/utils/iterator-utils.ts +39 -2
  63. package/src/utils/part-styles.ts +4 -0
@@ -0,0 +1,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
- expect(document.body.style.overflow).toBe('hidden');
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