@akinon/pz-theme 2.0.37-rc.0 → 2.0.37

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 (78) hide show
  1. package/CHANGELOG.md +7 -31
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
@@ -0,0 +1,817 @@
1
+ import React from 'react';
2
+ import { render, fireEvent, act } from '@testing-library/react';
3
+ import VariantSelectorBlock from '../variant-selector-block';
4
+ import { ThemePageContextProvider } from '../../theme-page-context';
5
+ import GOLDEN from './variant-selector-golden.json';
6
+
7
+ jest.mock('next/navigation', () => ({
8
+ useRouter: () => ({ push: jest.fn() }),
9
+ usePathname: () => '/p/blue',
10
+ useSearchParams: () => new URLSearchParams('')
11
+ }));
12
+
13
+ let sessionStatus: 'authenticated' | 'unauthenticated' = 'unauthenticated';
14
+ let sessionEmail = '';
15
+
16
+ jest.mock('next-auth/react', () => ({
17
+ useSession: () => ({
18
+ status: sessionStatus,
19
+ data:
20
+ sessionStatus === 'authenticated'
21
+ ? { user: { email: sessionEmail } }
22
+ : null
23
+ })
24
+ }));
25
+
26
+ const addStockAlert = jest.fn(() => ({ unwrap: () => Promise.resolve({}) }));
27
+
28
+ jest.mock('@akinon/next/data/client/wishlist', () => ({
29
+ __esModule: true,
30
+ useAddStockAlertMutation: () => [addStockAlert, { isLoading: false }]
31
+ }));
32
+
33
+ /**
34
+ * `L` is the sold-out size the whole feature is about. `XL` is sold out with no
35
+ * url — still alertable, because commerce sent a product id for it. `NOPK` is
36
+ * sold out with nothing to register against, and `NOURL` is IN STOCK but
37
+ * unpickable: neither may ever be offered an alert.
38
+ */
39
+ const PRODUCT = {
40
+ pk: 10,
41
+ title: 'Oxford Shirt',
42
+ variants: [
43
+ {
44
+ attribute_key: 'color',
45
+ attribute_name: 'Color',
46
+ options: [
47
+ {
48
+ label: 'Blue',
49
+ value: 'blue',
50
+ is_selected: true,
51
+ is_selectable: true,
52
+ product: { pk: 11, absolute_url: '/p/blue', stock: 9 },
53
+ attributes: { color: '#0000ff' }
54
+ },
55
+ {
56
+ label: 'Red',
57
+ value: 'red',
58
+ is_selectable: true,
59
+ product: { pk: 12, absolute_url: '/p/red', stock: 2 }
60
+ }
61
+ ]
62
+ },
63
+ {
64
+ attribute_key: 'size',
65
+ attribute_name: 'Size',
66
+ options: [
67
+ {
68
+ label: 'M',
69
+ value: 'm',
70
+ is_selected: true,
71
+ is_selectable: true,
72
+ product: { pk: 22, absolute_url: '/p/m', stock: 1 }
73
+ },
74
+ {
75
+ label: 'L',
76
+ value: 'l',
77
+ is_selectable: false,
78
+ in_stock: false,
79
+ product: { pk: 23, absolute_url: '/p/l', stock: 0 }
80
+ },
81
+ {
82
+ label: 'XL',
83
+ value: 'xl',
84
+ is_selectable: false,
85
+ in_stock: false,
86
+ product: { pk: 24, stock: 0 }
87
+ },
88
+ {
89
+ label: 'NOPK',
90
+ value: 'nopk',
91
+ is_selectable: false,
92
+ in_stock: false,
93
+ product: { absolute_url: '/p/nopk', stock: 0 }
94
+ },
95
+ {
96
+ // In stock, but commerce sent no url — disabled, never sold out.
97
+ label: 'NOURL',
98
+ value: 'nourl',
99
+ is_selectable: true,
100
+ in_stock: true,
101
+ product: { pk: 26, stock: 7 }
102
+ }
103
+ ]
104
+ }
105
+ ]
106
+ };
107
+
108
+ const renderBlock = (
109
+ properties: Record<string, unknown>,
110
+ isDesigner = false
111
+ ) =>
112
+ render(
113
+ <ThemePageContextProvider value={{ product: PRODUCT } as never}>
114
+ <VariantSelectorBlock
115
+ {...({
116
+ block: {
117
+ id: 'vs',
118
+ type: 'variant-selector',
119
+ styles: {},
120
+ properties,
121
+ order: 0,
122
+ hidden: false
123
+ },
124
+ placeholderId: 'p',
125
+ sectionId: 's',
126
+ isDesigner,
127
+ currentBreakpoint: 'desktop'
128
+ } as never)}
129
+ />
130
+ </ThemePageContextProvider>
131
+ );
132
+
133
+ const ON = { stockAlert: true };
134
+
135
+ /** The trigger for one sold-out label. */
136
+ const trigger = (container: HTMLElement, label: string) =>
137
+ Array.from(container.querySelectorAll('button[data-variant-option]')).find(
138
+ (node) => node.textContent === label
139
+ ) as HTMLButtonElement | undefined;
140
+
141
+ beforeEach(() => {
142
+ addStockAlert.mockClear();
143
+ sessionStatus = 'unauthenticated';
144
+ sessionEmail = '';
145
+ document.body.style.overflow = '';
146
+ });
147
+
148
+ /* ------------------------------------------------------------------ */
149
+ /* The default render, locked to a golden captured from HEAD. */
150
+ /* ------------------------------------------------------------------ */
151
+
152
+ describe('default render is untouched', () => {
153
+ /**
154
+ * The golden was captured by rendering the block AT HEAD, before the stock
155
+ * alert existed. Every case must still come back byte for byte, which is the
156
+ * only real proof that a composition nobody edited did not move.
157
+ */
158
+ const GOLDEN_PRODUCT = {
159
+ pk: 10,
160
+ variants: [
161
+ {
162
+ attribute_key: 'color',
163
+ attribute_name: 'Color',
164
+ options: [
165
+ {
166
+ label: 'Blue',
167
+ value: 'blue',
168
+ is_selected: true,
169
+ is_selectable: true,
170
+ product: { pk: 11, absolute_url: '/p/blue', stock: 9 },
171
+ attributes: { color: '#0000ff' }
172
+ },
173
+ {
174
+ label: 'Red',
175
+ value: 'red',
176
+ is_selectable: true,
177
+ product: { pk: 12, absolute_url: '/p/red', stock: 2 }
178
+ }
179
+ ]
180
+ },
181
+ {
182
+ attribute_key: 'size',
183
+ attribute_name: 'Size',
184
+ options: [
185
+ {
186
+ label: 'S',
187
+ value: 's',
188
+ is_selectable: true,
189
+ product: { pk: 21, absolute_url: '/p/s', stock: 5 }
190
+ },
191
+ {
192
+ label: 'M',
193
+ value: 'm',
194
+ is_selected: true,
195
+ is_selectable: true,
196
+ product: { pk: 22, absolute_url: '/p/m', stock: 1 }
197
+ },
198
+ {
199
+ label: 'L',
200
+ value: 'l',
201
+ is_selectable: false,
202
+ in_stock: false,
203
+ product: { pk: 23, absolute_url: '/p/l', stock: 0 }
204
+ },
205
+ {
206
+ label: 'XL',
207
+ value: 'xl',
208
+ is_selectable: false,
209
+ in_stock: false,
210
+ product: { pk: 24, stock: 0 }
211
+ }
212
+ ]
213
+ }
214
+ ]
215
+ };
216
+
217
+ const renderGolden = (
218
+ properties: Record<string, unknown>,
219
+ isDesigner = false
220
+ ) =>
221
+ render(
222
+ <ThemePageContextProvider value={{ product: GOLDEN_PRODUCT } as never}>
223
+ <VariantSelectorBlock
224
+ {...({
225
+ block: {
226
+ id: 'vs',
227
+ type: 'variant-selector',
228
+ styles: {},
229
+ properties,
230
+ order: 0,
231
+ hidden: false
232
+ },
233
+ placeholderId: 'p',
234
+ sectionId: 's',
235
+ isDesigner,
236
+ currentBreakpoint: 'desktop'
237
+ } as never)}
238
+ />
239
+ </ThemePageContextProvider>
240
+ );
241
+
242
+ const CASES: Array<[string, Record<string, unknown>, boolean]> = [
243
+ ['defaults', {}, false],
244
+ ['defaults-designer', {}, true],
245
+ ['dropdown', { layout: 'dropdown' }, false],
246
+ ['diagonal', { disabledStyle: 'diagonal' }, false],
247
+ ['diagonal-w3', { disabledStyle: 'diagonal', strikeWidth: 3 }, false],
248
+ ['nomark', { disabledStyle: 'none' }, false],
249
+ ['lowstock', { lowStockThreshold: 3 }, false],
250
+ [
251
+ 'lowstock-label',
252
+ { lowStockThreshold: 3, lowStockLabel: 'Few left' },
253
+ false
254
+ ],
255
+ ['chip', { groupLayouts: 'color:chip' }, false],
256
+ [
257
+ 'chip-image',
258
+ { groupLayouts: 'color:chip', swatchContent: 'image' },
259
+ false
260
+ ],
261
+ ['text-layout', { groupLayouts: 'size:text' }, false],
262
+ ['group-dropdown', { groupLayouts: 'size:dropdown' }, false],
263
+ ['selected-value', { showSelectedValue: true }, false],
264
+ [
265
+ 'selected-sep',
266
+ { showSelectedValue: true, selectedValueSeparator: ' - ' },
267
+ false
268
+ ],
269
+ ['no-label', { showGroupLabel: false }, false],
270
+ ['gaps', { gap: 4, optionGap: 20 }, false],
271
+ ['swatchsize', { groupLayouts: 'color:chip', swatchSize: 48 }, false],
272
+ [
273
+ 'combo',
274
+ {
275
+ layout: 'dropdown',
276
+ groupLayouts: 'size:box',
277
+ lowStockThreshold: 2,
278
+ disabledStyle: 'diagonal'
279
+ },
280
+ false
281
+ ]
282
+ ];
283
+
284
+ it.each(CASES)('%s matches the HEAD golden', (name, props, designer) => {
285
+ const { container } = renderGolden(
286
+ props as Record<string, unknown>,
287
+ designer as boolean
288
+ );
289
+ expect(container.innerHTML).toBe(
290
+ (GOLDEN as Record<string, string>)[name as string]
291
+ );
292
+ });
293
+
294
+ it('covers every captured case', () => {
295
+ expect(CASES.map(([n]) => n).sort()).toEqual(Object.keys(GOLDEN).sort());
296
+ });
297
+
298
+ it('renders no alert markup at all while the switch is off', () => {
299
+ const { container } = renderBlock({});
300
+ expect(container.querySelector('[data-variant-alert]')).toBeNull();
301
+ expect(container.querySelectorAll('button[data-variant-option]')).toHaveLength(
302
+ 0
303
+ );
304
+ });
305
+ });
306
+
307
+ /* ------------------------------------------------------------------ */
308
+ /* Which options become actionable. */
309
+ /* ------------------------------------------------------------------ */
310
+
311
+ describe('only a sold-out option with an id becomes actionable', () => {
312
+ it('turns the sold-out sizes into real controls', () => {
313
+ const { container } = renderBlock(ON);
314
+
315
+ expect(trigger(container, 'L')).toBeTruthy();
316
+ // Sold out with no url still has a product id, so it can still be watched.
317
+ expect(trigger(container, 'XL')).toBeTruthy();
318
+ });
319
+
320
+ it('leaves a sold-out option commerce sent no id for inert', () => {
321
+ const { container } = renderBlock(ON);
322
+
323
+ expect(trigger(container, 'NOPK')).toBeUndefined();
324
+ const inert = Array.from(
325
+ container.querySelectorAll('span[data-variant-option="disabled"]')
326
+ ).find((node) => node.textContent === 'NOPK');
327
+ expect(inert).toBeTruthy();
328
+ expect(inert?.getAttribute('aria-disabled')).toBe('true');
329
+ });
330
+
331
+ it('never offers an alert on an in-stock option that is merely unpickable', () => {
332
+ const { container } = renderBlock(ON);
333
+
334
+ expect(trigger(container, 'NOURL')).toBeUndefined();
335
+ });
336
+
337
+ it('keeps the disabled part state so existing swatch styling still lands', () => {
338
+ const { container } = renderBlock(ON);
339
+ const button = trigger(container, 'L');
340
+
341
+ expect(button?.getAttribute('data-part')).toBe('option');
342
+ expect(button?.getAttribute('data-state')).toBe('disabled');
343
+ expect(button?.getAttribute('data-variant-option')).toBe('disabled');
344
+ // A control that does something must not announce itself as disabled.
345
+ expect(button?.hasAttribute('aria-disabled')).toBe(false);
346
+ });
347
+ });
348
+
349
+ /* ------------------------------------------------------------------ */
350
+ /* The form is bound to the option, not the product. */
351
+ /* ------------------------------------------------------------------ */
352
+
353
+ describe('the form belongs to the option that was clicked', () => {
354
+ it('names that option in the description and sends that option id', async () => {
355
+ sessionStatus = 'authenticated';
356
+ sessionEmail = 'shopper@example.com';
357
+
358
+ const { container } = renderBlock({
359
+ ...ON,
360
+ stockAlertDescription: 'Tell me when {attribute} {option} of {product} returns'
361
+ });
362
+
363
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
364
+
365
+ expect(
366
+ container.querySelector('[data-variant-alert-description]')?.textContent
367
+ ).toBe('Tell me when Size L of Oxford Shirt returns');
368
+
369
+ await act(async () => {
370
+ fireEvent.submit(
371
+ container.querySelector('[data-variant-alert-form]') as HTMLFormElement
372
+ );
373
+ });
374
+
375
+ // 23 is L's own product id — not 10, the product's.
376
+ expect(addStockAlert).toHaveBeenCalledWith({
377
+ productPk: 23,
378
+ email: 'shopper@example.com'
379
+ });
380
+ });
381
+
382
+ it('opens against the second sold-out option independently', () => {
383
+ sessionStatus = 'authenticated';
384
+ sessionEmail = 'shopper@example.com';
385
+
386
+ const { container } = renderBlock({
387
+ ...ON,
388
+ stockAlertDescription: '{option}'
389
+ });
390
+
391
+ fireEvent.click(trigger(container, 'XL') as HTMLButtonElement);
392
+ expect(
393
+ container.querySelector('[data-variant-alert-description]')?.textContent
394
+ ).toBe('XL');
395
+ expect(container.querySelectorAll('[data-variant-alert]')).toHaveLength(1);
396
+ });
397
+
398
+ it('clears a previous answer when another option is opened', async () => {
399
+ sessionStatus = 'authenticated';
400
+ sessionEmail = 'shopper@example.com';
401
+
402
+ const { container } = renderBlock(ON);
403
+
404
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
405
+ await act(async () => {
406
+ fireEvent.submit(
407
+ container.querySelector('[data-variant-alert-form]') as HTMLFormElement
408
+ );
409
+ });
410
+ expect(container.querySelector('[data-variant-alert-success]')).toBeTruthy();
411
+
412
+ fireEvent.click(trigger(container, 'XL') as HTMLButtonElement);
413
+ // A success from L must never read as XL's answer.
414
+ expect(container.querySelector('[data-variant-alert-success]')).toBeNull();
415
+ expect(container.querySelector('[data-variant-alert-form]')).toBeTruthy();
416
+ });
417
+
418
+ it('closes when the same option is clicked again', () => {
419
+ sessionStatus = 'authenticated';
420
+ const { container } = renderBlock(ON);
421
+
422
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
423
+ expect(container.querySelector('[data-variant-alert]')).toBeTruthy();
424
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
425
+ expect(container.querySelector('[data-variant-alert]')).toBeNull();
426
+ });
427
+ });
428
+
429
+ /* ------------------------------------------------------------------ */
430
+ /* Honesty: a guest is told the truth, never given a fake waiting list.*/
431
+ /* ------------------------------------------------------------------ */
432
+
433
+ describe('a signed-out shopper is not pretended at', () => {
434
+ it('shows the authorable explanation and link instead of a form', () => {
435
+ const { container } = renderBlock({
436
+ ...ON,
437
+ stockAlertSignedOutText: 'Sign in first',
438
+ stockAlertSignInLabel: 'Go to sign in',
439
+ stockAlertSignInUrl: '/users/login'
440
+ });
441
+
442
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
443
+
444
+ expect(container.querySelector('[data-variant-alert-form]')).toBeNull();
445
+ expect(
446
+ container.querySelector('[data-variant-alert-signed-out]')?.textContent
447
+ ).toBe('Sign in first');
448
+
449
+ const link = container.querySelector('[data-variant-alert-signin]');
450
+ expect(link?.getAttribute('href')).toBe('/users/login');
451
+ expect(link?.textContent).toBe('Go to sign in');
452
+ });
453
+
454
+ it('never reaches the alert endpoint', () => {
455
+ const { container } = renderBlock(ON);
456
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
457
+ expect(addStockAlert).not.toHaveBeenCalled();
458
+ });
459
+
460
+ it('drops a script url rather than rendering it', () => {
461
+ const { container } = renderBlock({
462
+ ...ON,
463
+ // eslint-disable-next-line no-script-url
464
+ stockAlertSignInUrl: 'java\tscript:alert(1)'
465
+ });
466
+
467
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
468
+ expect(container.querySelector('[data-variant-alert-signin]')).toBeNull();
469
+ });
470
+ });
471
+
472
+ /* ------------------------------------------------------------------ */
473
+ /* It validates before it submits, and says what happened either way. */
474
+ /* ------------------------------------------------------------------ */
475
+
476
+ describe('validation and outcome', () => {
477
+ beforeEach(() => {
478
+ sessionStatus = 'authenticated';
479
+ sessionEmail = 'shopper@example.com';
480
+ });
481
+
482
+ it('refuses an invalid address without calling commerce', async () => {
483
+ const { container } = renderBlock({
484
+ ...ON,
485
+ stockAlertInvalidEmailLabel: 'Bad address'
486
+ });
487
+
488
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
489
+ fireEvent.change(
490
+ container.querySelector('[data-variant-alert-input]') as HTMLInputElement,
491
+ { target: { value: 'not-an-email' } }
492
+ );
493
+ await act(async () => {
494
+ fireEvent.submit(
495
+ container.querySelector('[data-variant-alert-form]') as HTMLFormElement
496
+ );
497
+ });
498
+
499
+ expect(addStockAlert).not.toHaveBeenCalled();
500
+ const error = container.querySelector('[data-variant-alert-error]');
501
+ expect(error?.textContent).toBe('Bad address');
502
+ expect(error?.getAttribute('role')).toBe('alert');
503
+ });
504
+
505
+ it('refuses to submit until required consent is given', async () => {
506
+ const { container } = renderBlock({
507
+ ...ON,
508
+ stockAlertConsentText: 'I agree to be emailed',
509
+ stockAlertConsentRequired: true,
510
+ stockAlertConsentErrorLabel: 'Consent needed'
511
+ });
512
+
513
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
514
+ await act(async () => {
515
+ fireEvent.submit(
516
+ container.querySelector('[data-variant-alert-form]') as HTMLFormElement
517
+ );
518
+ });
519
+
520
+ expect(addStockAlert).not.toHaveBeenCalled();
521
+ expect(
522
+ container.querySelector('[data-variant-alert-error]')?.textContent
523
+ ).toBe('Consent needed');
524
+
525
+ fireEvent.click(
526
+ container.querySelector(
527
+ '[data-variant-alert-consent] input[type="checkbox"]'
528
+ ) as HTMLInputElement
529
+ );
530
+ await act(async () => {
531
+ fireEvent.submit(
532
+ container.querySelector('[data-variant-alert-form]') as HTMLFormElement
533
+ );
534
+ });
535
+ expect(addStockAlert).toHaveBeenCalledTimes(1);
536
+ });
537
+
538
+ it('shows consent copy without a checkbox when it is not required', () => {
539
+ const { container } = renderBlock({
540
+ ...ON,
541
+ stockAlertConsentText: 'We only email you once.'
542
+ });
543
+
544
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
545
+ const consent = container.querySelector('[data-variant-alert-consent]');
546
+ expect(consent?.textContent).toBe('We only email you once.');
547
+ expect(consent?.querySelector('input')).toBeNull();
548
+ });
549
+
550
+ it('says so when commerce rejects the alert', async () => {
551
+ addStockAlert.mockImplementationOnce(() => ({
552
+ unwrap: () => Promise.reject(new Error('401'))
553
+ }));
554
+
555
+ const { container } = renderBlock({
556
+ ...ON,
557
+ stockAlertErrorLabel: 'Could not register'
558
+ });
559
+
560
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
561
+ await act(async () => {
562
+ fireEvent.submit(
563
+ container.querySelector('[data-variant-alert-form]') as HTMLFormElement
564
+ );
565
+ });
566
+
567
+ expect(container.querySelector('[data-variant-alert-success]')).toBeNull();
568
+ expect(
569
+ container.querySelector('[data-variant-alert-error]')?.textContent
570
+ ).toBe('Could not register');
571
+ });
572
+
573
+ it('confirms in the brand words when it worked', async () => {
574
+ const { container } = renderBlock({
575
+ ...ON,
576
+ stockAlertSuccessLabel: 'You are on the list for {option}'
577
+ });
578
+
579
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
580
+ await act(async () => {
581
+ fireEvent.submit(
582
+ container.querySelector('[data-variant-alert-form]') as HTMLFormElement
583
+ );
584
+ });
585
+
586
+ const success = container.querySelector('[data-variant-alert-success]');
587
+ expect(success?.textContent).toBe('You are on the list for L');
588
+ expect(success?.getAttribute('role')).toBe('status');
589
+ });
590
+ });
591
+
592
+ /* ------------------------------------------------------------------ */
593
+ /* The designer is not a storefront. */
594
+ /* ------------------------------------------------------------------ */
595
+
596
+ describe('designer safety', () => {
597
+ it('never posts an alert from the canvas', async () => {
598
+ sessionStatus = 'authenticated';
599
+ sessionEmail = 'shopper@example.com';
600
+
601
+ const { container } = renderBlock(ON, true);
602
+
603
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
604
+ await act(async () => {
605
+ fireEvent.submit(
606
+ container.querySelector('[data-variant-alert-form]') as HTMLFormElement
607
+ );
608
+ });
609
+
610
+ expect(addStockAlert).not.toHaveBeenCalled();
611
+ });
612
+
613
+ it('shows the author the form even though nobody is signed in', () => {
614
+ const { container } = renderBlock(ON, true);
615
+
616
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
617
+ expect(container.querySelector('[data-variant-alert-form]')).toBeTruthy();
618
+ });
619
+
620
+ it('leaves the sign-in link inert', () => {
621
+ const { container } = renderBlock(
622
+ { ...ON, stockAlertSignInUrl: '/users/login' },
623
+ true
624
+ );
625
+
626
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
627
+ // The designer branch shows the form, so no navigation is offered at all.
628
+ expect(container.querySelector('[data-variant-alert-signin]')).toBeNull();
629
+ });
630
+
631
+ it('locks no scroll and claims no dialog on the canvas', () => {
632
+ const { container } = renderBlock(
633
+ { ...ON, stockAlertPresentation: 'modal' },
634
+ true
635
+ );
636
+
637
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
638
+
639
+ expect(document.body.style.overflow).toBe('');
640
+ expect(
641
+ container.querySelector('[data-variant-alert]')?.hasAttribute('aria-modal')
642
+ ).toBe(false);
643
+ });
644
+ });
645
+
646
+ /* ------------------------------------------------------------------ */
647
+ /* The modal owes the full contract. */
648
+ /* ------------------------------------------------------------------ */
649
+
650
+ describe('modal contract', () => {
651
+ const MODAL = { ...ON, stockAlertPresentation: 'modal' };
652
+
653
+ beforeEach(() => {
654
+ sessionStatus = 'authenticated';
655
+ sessionEmail = 'shopper@example.com';
656
+ });
657
+
658
+ it('claims aria-modal, moves focus in and returns it on close', () => {
659
+ const { container } = renderBlock(MODAL);
660
+ const button = trigger(container, 'L') as HTMLButtonElement;
661
+
662
+ fireEvent.click(button);
663
+
664
+ const panel = container.querySelector('[data-variant-alert]');
665
+ expect(panel?.getAttribute('aria-modal')).toBe('true');
666
+ expect(panel?.getAttribute('role')).toBe('dialog');
667
+ expect(panel?.contains(document.activeElement)).toBe(true);
668
+
669
+ fireEvent.keyDown(document, { key: 'Escape' });
670
+
671
+ expect(container.querySelector('[data-variant-alert]')).toBeNull();
672
+ expect(document.activeElement).toBe(button);
673
+ });
674
+
675
+ it('reference-counts the scroll lock across two open pickers', () => {
676
+ document.body.style.overflow = 'scroll';
677
+
678
+ const first = renderBlock(MODAL);
679
+ const second = renderBlock(MODAL);
680
+
681
+ fireEvent.click(trigger(first.container, 'L') as HTMLButtonElement);
682
+ expect(document.body.style.overflow).toBe('hidden');
683
+
684
+ fireEvent.click(trigger(second.container, 'L') as HTMLButtonElement);
685
+ expect(document.body.style.overflow).toBe('hidden');
686
+
687
+ // Closed one at a time: Escape is on the document, so it would dismiss
688
+ // both at once and never exercise the counting.
689
+ fireEvent.click(
690
+ first.container.querySelector(
691
+ '[data-variant-alert-close]'
692
+ ) as HTMLButtonElement
693
+ );
694
+ // The first letting go must not unlock the page under the second.
695
+ expect(document.body.style.overflow).toBe('hidden');
696
+
697
+ fireEvent.click(
698
+ second.container.querySelector(
699
+ '[data-variant-alert-close]'
700
+ ) as HTMLButtonElement
701
+ );
702
+ // Only the last holder restores what the page had before either opened.
703
+ expect(document.body.style.overflow).toBe('scroll');
704
+ });
705
+
706
+ it('closes on the backdrop but not on a click inside the dialog', () => {
707
+ const { container } = renderBlock(MODAL);
708
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
709
+
710
+ fireEvent.click(
711
+ container.querySelector('[data-variant-alert-input]') as HTMLElement
712
+ );
713
+ expect(container.querySelector('[data-variant-alert]')).toBeTruthy();
714
+
715
+ fireEvent.click(
716
+ container.querySelector('[data-variant-alert-backdrop]') as HTMLElement
717
+ );
718
+ expect(container.querySelector('[data-variant-alert]')).toBeNull();
719
+ });
720
+
721
+ it('keeps the backdrop out of the dialog subtree', () => {
722
+ const { container } = renderBlock(MODAL);
723
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
724
+
725
+ const backdrop = container.querySelector('[data-variant-alert-backdrop]');
726
+ const panel = container.querySelector('[data-variant-alert]');
727
+ // aria-hidden on an ancestor would hide the dialog it sits behind.
728
+ expect(backdrop?.contains(panel as Node)).toBe(false);
729
+ });
730
+
731
+ it('claims no aria-modal for the inline panel', () => {
732
+ const { container } = renderBlock(ON);
733
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
734
+
735
+ const panel = container.querySelector('[data-variant-alert]');
736
+ expect(panel?.hasAttribute('aria-modal')).toBe(false);
737
+ expect(panel?.getAttribute('role')).toBe('group');
738
+ expect(document.body.style.overflow).toBe('');
739
+ });
740
+
741
+ it('draws the inline panel inside the group that owns the option', () => {
742
+ const { container } = renderBlock(ON);
743
+ fireEvent.click(trigger(container, 'L') as HTMLButtonElement);
744
+
745
+ const group = container.querySelector('[data-variant-group="size"]');
746
+ expect(group?.querySelector('[data-variant-alert]')).toBeTruthy();
747
+ expect(
748
+ container
749
+ .querySelector('[data-variant-group="color"]')
750
+ ?.querySelector('[data-variant-alert]')
751
+ ).toBeNull();
752
+ });
753
+ });
754
+
755
+ /* ------------------------------------------------------------------ */
756
+ /* Two dialogs on one PDP is an ordinary composition — a desktop */
757
+ /* picker and a mobile one — and it must not take the tab with it. */
758
+ /* ------------------------------------------------------------------ */
759
+
760
+ describe('two dialogs open at once', () => {
761
+ const MODAL = { ...ON, stockAlertPresentation: 'modal' };
762
+
763
+ beforeEach(() => {
764
+ sessionStatus = 'authenticated';
765
+ sessionEmail = 'shopper@example.com';
766
+ });
767
+
768
+ const openBoth = () => {
769
+ const first = renderBlock(MODAL);
770
+ const second = renderBlock(MODAL);
771
+
772
+ fireEvent.click(trigger(first.container, 'L') as HTMLButtonElement);
773
+ fireEvent.click(trigger(second.container, 'L') as HTMLButtonElement);
774
+
775
+ return {
776
+ firstPanel: first.container.querySelector(
777
+ '[data-variant-alert]'
778
+ ) as HTMLElement,
779
+ secondPanel: second.container.querySelector(
780
+ '[data-variant-alert]'
781
+ ) as HTMLElement
782
+ };
783
+ };
784
+
785
+ it('does not fight itself to death when focus lands outside both', () => {
786
+ const { secondPanel } = openBoth();
787
+
788
+ const outside = document.createElement('button');
789
+ document.body.appendChild(outside);
790
+
791
+ // Each panel used to read the other's focus as "outside" and pull it back
792
+ // into itself, forever: RangeError: Maximum call stack size exceeded.
793
+ let moves = 0;
794
+ const countMoves = () => {
795
+ moves += 1;
796
+ };
797
+ document.addEventListener('focusin', countMoves);
798
+
799
+ try {
800
+ expect(() => outside.focus()).not.toThrow();
801
+ } finally {
802
+ document.removeEventListener('focusin', countMoves);
803
+ outside.remove();
804
+ }
805
+
806
+ // The move itself, plus the one correction from the dialog on top.
807
+ expect(moves).toBeLessThan(5);
808
+ expect(secondPanel.contains(document.activeElement)).toBe(true);
809
+ });
810
+
811
+ it('lets only the dialog on top claim the rest of the page is inert', () => {
812
+ const { firstPanel, secondPanel } = openBoth();
813
+
814
+ expect(firstPanel.hasAttribute('aria-modal')).toBe(false);
815
+ expect(secondPanel.getAttribute('aria-modal')).toBe('true');
816
+ });
817
+ });