@akinon/pz-theme 2.0.37-rc.0 → 2.0.38-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 (78) hide show
  1. package/CHANGELOG.md +15 -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,702 @@
1
+ import React from 'react';
2
+ import { act, fireEvent, render, screen } from '@testing-library/react';
3
+
4
+ // The item-template module pulls in theme-block -> the whole renderer registry
5
+ // (next-auth, RTK, …). These tests are about the strip's own card path, so the
6
+ // custom-layout branch is stubbed off rather than exercised here.
7
+ let itemTemplatePresent = false;
8
+ jest.mock('../item-template', () => ({
9
+ __esModule: true,
10
+ hasItemTemplate: () => itemTemplatePresent,
11
+ ItemTemplateList: () => <div data-testid="item-template-list" />
12
+ }));
13
+
14
+ /** The collection payload behind the strip; undefined means "no collection". */
15
+ let mockWidget: Record<string, unknown> | undefined;
16
+ /** What `/product/{pk}/` answers the overlay with. */
17
+ let mockDetail: Record<string, unknown> | undefined;
18
+ /** Every payload that reached the basket endpoint, in order. */
19
+ const mockAdds: Record<string, unknown>[] = [];
20
+
21
+ /**
22
+ * Every `@akinon/next/data/client/*` subpath is folded onto one mock module by
23
+ * the jest moduleNameMapper, so one factory has to serve the widget query, the
24
+ * overlay's product query and the add mutation. A hoisted function declaration
25
+ * rather than a const: ts-jest lifts `jest.mock` above the imports, and the
26
+ * factory runs while the block is being required.
27
+ */
28
+ function mockDataClient() {
29
+ return {
30
+ __esModule: true,
31
+ useGetWidgetQuery: (_id: unknown, options?: { skip?: boolean }) => ({
32
+ data: options?.skip ? undefined : mockWidget,
33
+ isFetching: false
34
+ }),
35
+ useGetProductByPkQuery: (_pk: unknown, options?: { skip?: boolean }) => ({
36
+ data: options?.skip ? undefined : mockDetail,
37
+ isFetching: false,
38
+ isError: false,
39
+ refetch: () => undefined
40
+ }),
41
+ useAddProductMutation: () => [
42
+ (payload: Record<string, unknown>) => {
43
+ mockAdds.push(payload);
44
+ return { unwrap: () => Promise.resolve({}) };
45
+ },
46
+ { isLoading: false }
47
+ ]
48
+ };
49
+ }
50
+
51
+ jest.mock('@akinon/next/data/client/misc', mockDataClient);
52
+ jest.mock('@akinon/next/data/client/product', mockDataClient);
53
+
54
+ import ProductRecommendationsBlock from '../product-recommendations-block';
55
+ import { ThemePageContextProvider } from '../../theme-page-context';
56
+ import {
57
+ DEFAULT_RECENTLY_VIEWED_KEY,
58
+ readRecentlyViewed,
59
+ recordRecentlyViewed
60
+ } from '../recently-viewed-store';
61
+
62
+ const props = (properties: Record<string, unknown> = {}, over: Record<string, unknown> = {}) =>
63
+ ({
64
+ block: { id: 'rec1', type: 'product-recommendations', label: 'rec', properties },
65
+ placeholderId: 'p',
66
+ sectionId: 's',
67
+ isDesigner: false,
68
+ currentBreakpoint: 'desktop',
69
+ ...over
70
+ } as any);
71
+
72
+ const PRODUCT = {
73
+ pk: 41,
74
+ title: 'Viewed Shirt',
75
+ url: '/viewed-shirt',
76
+ image: 'https://cdn/viewed.jpg',
77
+ price: '199.90',
78
+ currency: 'TRY'
79
+ };
80
+
81
+ const entry = (over: Record<string, unknown> = {}) => ({
82
+ pk: '7',
83
+ name: 'Stored Coat',
84
+ url: '/stored-coat',
85
+ image: 'https://cdn/coat.jpg',
86
+ price: '1299.00',
87
+ currency: 'USD',
88
+ viewedAt: Date.now(),
89
+ ...over
90
+ });
91
+
92
+ const renderBlock = (rendered: React.ReactElement, product: unknown = PRODUCT) =>
93
+ render(
94
+ <ThemePageContextProvider value={{ product } as Record<string, unknown>}>
95
+ {rendered}
96
+ </ThemePageContextProvider>
97
+ );
98
+
99
+ beforeEach(() => {
100
+ // jsdom has no requestIdleCallback, so the deferred write lands on the
101
+ // setTimeout fallback — fake timers are what let a test observe it.
102
+ jest.useFakeTimers();
103
+ window.localStorage.clear();
104
+ jest.restoreAllMocks();
105
+ itemTemplatePresent = false;
106
+ });
107
+
108
+ afterEach(() => {
109
+ jest.useRealTimers();
110
+ });
111
+
112
+ describe('default source is unchanged', () => {
113
+ it('renders the collection empty state and touches no storage', () => {
114
+ const setItem = jest.spyOn(Storage.prototype, 'setItem');
115
+ const { container } = renderBlock(
116
+ <ProductRecommendationsBlock {...props({}, { isDesigner: true })} />
117
+ );
118
+
119
+ expect(container.textContent).toContain('set a collection id');
120
+ expect(setItem).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it('renders nothing on the storefront when the collection is empty', () => {
124
+ const { container } = renderBlock(
125
+ <ProductRecommendationsBlock {...props()} />
126
+ );
127
+
128
+ expect(container).toBeEmptyDOMElement();
129
+ });
130
+
131
+ it('does not record a view when the source was never switched', () => {
132
+ renderBlock(<ProductRecommendationsBlock {...props()} />);
133
+
134
+ expect(window.localStorage.getItem(DEFAULT_RECENTLY_VIEWED_KEY)).toBeNull();
135
+ });
136
+ });
137
+
138
+ describe('recently viewed source', () => {
139
+ it('renders nothing on a first visit', () => {
140
+ const { container } = renderBlock(
141
+ <ProductRecommendationsBlock {...props({ source: 'recently-viewed' })} />
142
+ );
143
+
144
+ expect(container).toBeEmptyDOMElement();
145
+ });
146
+
147
+ it('records the ambient product, deduped and newest first', () => {
148
+ window.localStorage.setItem(
149
+ DEFAULT_RECENTLY_VIEWED_KEY,
150
+ JSON.stringify([entry({ pk: '41', name: 'Old Name' }), entry({ pk: '9' })])
151
+ );
152
+
153
+ renderBlock(
154
+ <ProductRecommendationsBlock {...props({ source: 'recently-viewed' })} />
155
+ );
156
+ act(() => {
157
+ jest.advanceTimersByTime(0);
158
+ });
159
+
160
+ const stored = readRecentlyViewed(DEFAULT_RECENTLY_VIEWED_KEY);
161
+ expect(stored.map((item) => item.pk)).toEqual(['41', '9']);
162
+ expect(stored[0].name).toBe('Viewed Shirt');
163
+ expect(stored[0].price).toBe('199.90');
164
+ });
165
+
166
+ it('caps the stored history at the authored capacity', () => {
167
+ window.localStorage.setItem(
168
+ DEFAULT_RECENTLY_VIEWED_KEY,
169
+ JSON.stringify([entry({ pk: '1' }), entry({ pk: '2' }), entry({ pk: '3' })])
170
+ );
171
+
172
+ renderBlock(
173
+ <ProductRecommendationsBlock
174
+ {...props({ source: 'recently-viewed', recentlyViewedCapacity: 2 })}
175
+ />
176
+ );
177
+ act(() => {
178
+ jest.advanceTimersByTime(0);
179
+ });
180
+
181
+ expect(
182
+ readRecentlyViewed(DEFAULT_RECENTLY_VIEWED_KEY).map((item) => item.pk)
183
+ ).toEqual(['41', '1']);
184
+ });
185
+
186
+ it('shows the stored products and excludes the one being viewed', () => {
187
+ window.localStorage.setItem(
188
+ DEFAULT_RECENTLY_VIEWED_KEY,
189
+ JSON.stringify([
190
+ entry({ pk: '41', name: 'Viewed Shirt' }),
191
+ entry({ pk: '7', name: 'Stored Coat' })
192
+ ])
193
+ );
194
+
195
+ renderBlock(
196
+ <ProductRecommendationsBlock {...props({ source: 'recently-viewed' })} />
197
+ );
198
+
199
+ expect(screen.getByText('Stored Coat')).toBeInTheDocument();
200
+ expect(screen.queryByText('Viewed Shirt')).not.toBeInTheDocument();
201
+ });
202
+
203
+ it('formats the stored amount at read time instead of storing a string', () => {
204
+ window.localStorage.setItem(
205
+ DEFAULT_RECENTLY_VIEWED_KEY,
206
+ JSON.stringify([entry({ pk: '7', price: '1299.00', currency: 'USD' })])
207
+ );
208
+
209
+ renderBlock(
210
+ <ProductRecommendationsBlock {...props({ source: 'recently-viewed' })} />
211
+ );
212
+
213
+ const price = document.querySelector('[data-product-card-price]');
214
+ expect(price?.textContent).toMatch(/1,299\.00/);
215
+ expect(price?.textContent).not.toBe('1299.00');
216
+ });
217
+
218
+ it('drops entries older than the authored window', () => {
219
+ const old = Date.now() - 40 * 24 * 60 * 60 * 1000;
220
+ window.localStorage.setItem(
221
+ DEFAULT_RECENTLY_VIEWED_KEY,
222
+ JSON.stringify([
223
+ entry({ pk: '7', name: 'Stale Coat', viewedAt: old }),
224
+ entry({ pk: '8', name: 'Fresh Coat' })
225
+ ])
226
+ );
227
+
228
+ renderBlock(
229
+ <ProductRecommendationsBlock
230
+ {...props({ source: 'recently-viewed', recentlyViewedMaxAge: 30 })}
231
+ />
232
+ );
233
+
234
+ expect(screen.getByText('Fresh Coat')).toBeInTheDocument();
235
+ expect(screen.queryByText('Stale Coat')).not.toBeInTheDocument();
236
+ });
237
+
238
+ it('honours a custom storage key', () => {
239
+ window.localStorage.setItem(
240
+ 'brand-b',
241
+ JSON.stringify([entry({ pk: '7', name: 'Brand B Coat' })])
242
+ );
243
+
244
+ renderBlock(
245
+ <ProductRecommendationsBlock
246
+ {...props({ source: 'recently-viewed', recentlyViewedKey: 'brand-b' })}
247
+ />
248
+ );
249
+
250
+ expect(screen.getByText('Brand B Coat')).toBeInTheDocument();
251
+ });
252
+ });
253
+
254
+ describe('designer', () => {
255
+ it('draws placeholder rows instead of collapsing', () => {
256
+ const { container } = renderBlock(
257
+ <ProductRecommendationsBlock
258
+ {...props({ source: 'recently-viewed' }, { isDesigner: true })}
259
+ />
260
+ );
261
+
262
+ expect(
263
+ container.querySelectorAll('[data-product-card]').length
264
+ ).toBeGreaterThan(0);
265
+ expect(screen.getAllByText('Recently viewed').length).toBeGreaterThan(0);
266
+ });
267
+
268
+ it('uses the authored placeholder text', () => {
269
+ renderBlock(
270
+ <ProductRecommendationsBlock
271
+ {...props(
272
+ {
273
+ source: 'recently-viewed',
274
+ recentlyViewedEmptyLabel: 'Son gezdiklerin'
275
+ },
276
+ { isDesigner: true }
277
+ )}
278
+ />
279
+ );
280
+
281
+ expect(screen.getAllByText('Son gezdiklerin').length).toBeGreaterThan(0);
282
+ });
283
+
284
+ it('records nothing and keeps links inert', () => {
285
+ const setItem = jest.spyOn(Storage.prototype, 'setItem');
286
+ const { container } = renderBlock(
287
+ <ProductRecommendationsBlock
288
+ {...props({ source: 'recently-viewed' }, { isDesigner: true })}
289
+ />
290
+ );
291
+ act(() => {
292
+ jest.advanceTimersByTime(0);
293
+ });
294
+
295
+ expect(setItem).not.toHaveBeenCalled();
296
+ container.querySelectorAll('a[data-product-card]').forEach((card) => {
297
+ expect(card.getAttribute('href')).toBe('#');
298
+ });
299
+ });
300
+ });
301
+
302
+ describe('storage failures never reach the shopper', () => {
303
+ it('survives a private window where localStorage throws on read', () => {
304
+ jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
305
+ throw new Error('SecurityError');
306
+ });
307
+
308
+ const { container } = renderBlock(
309
+ <ProductRecommendationsBlock {...props({ source: 'recently-viewed' })} />
310
+ );
311
+
312
+ expect(container).toBeEmptyDOMElement();
313
+ });
314
+
315
+ it('survives a full quota on write', () => {
316
+ jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
317
+ throw new Error('QuotaExceededError');
318
+ });
319
+
320
+ expect(() =>
321
+ recordRecentlyViewed(DEFAULT_RECENTLY_VIEWED_KEY, entry(), 20)
322
+ ).not.toThrow();
323
+ });
324
+
325
+ it('treats another app’s payload under the same key as no history', () => {
326
+ window.localStorage.setItem(DEFAULT_RECENTLY_VIEWED_KEY, '{not json');
327
+
328
+ expect(readRecentlyViewed(DEFAULT_RECENTLY_VIEWED_KEY)).toEqual([]);
329
+ });
330
+
331
+ it('drops entries with no pk, which cannot be deduped or excluded', () => {
332
+ window.localStorage.setItem(
333
+ DEFAULT_RECENTLY_VIEWED_KEY,
334
+ JSON.stringify([{ name: 'No Pk' }, entry({ pk: '7' })])
335
+ );
336
+
337
+ expect(
338
+ readRecentlyViewed(DEFAULT_RECENTLY_VIEWED_KEY).map((item) => item.pk)
339
+ ).toEqual(['7']);
340
+ });
341
+ });
342
+
343
+ /**
344
+ * A recommendation card carries ONE pk and a summary, while the overlay sells
345
+ * whatever LEAF the shopper picks. The two are different products the moment
346
+ * the card points at a size, which is the everyday shape of a group product.
347
+ */
348
+ const CARD = {
349
+ pk: 57,
350
+ name: 'Runner',
351
+ absolute_url: '/runner',
352
+ price: '100.00',
353
+ currency_type: 'TRY'
354
+ };
355
+
356
+ const detail = (
357
+ inStock: boolean,
358
+ options: Record<string, unknown>[] | null
359
+ ) => ({
360
+ product: { pk: 57, name: 'Runner', absolute_url: '/runner', price: '100.00' },
361
+ in_stock: inStock,
362
+ selected_variant: null,
363
+ variants: options
364
+ ? [{ attribute_key: 'size', attribute_name: 'Beden', options }]
365
+ : []
366
+ });
367
+
368
+ const QUICK_VIEW = { collectionId: 'runners', showQuickView: true };
369
+
370
+ const trigger = (container: HTMLElement) =>
371
+ container.querySelector('[data-product-card-quick-view]') as HTMLButtonElement;
372
+
373
+ const size = (container: HTMLElement, label: string) =>
374
+ Array.from(
375
+ container.querySelectorAll<HTMLButtonElement>(
376
+ '[data-product-card-quick-view-size]'
377
+ )
378
+ ).find((node) => node.textContent === label) as HTMLButtonElement;
379
+
380
+ const addButton = (container: HTMLElement) =>
381
+ container.querySelector(
382
+ '[data-product-card-quick-view-add]'
383
+ ) as HTMLButtonElement;
384
+
385
+ describe('quick view stock follows the size the shopper picked', () => {
386
+ beforeEach(() => {
387
+ mockWidget = { products: [CARD] };
388
+ mockDetail = undefined;
389
+ mockAdds.length = 0;
390
+ });
391
+
392
+ afterEach(() => {
393
+ mockWidget = undefined;
394
+ mockDetail = undefined;
395
+ });
396
+
397
+ /**
398
+ * The card points at S because S is what the merchandiser put in the
399
+ * collection; S has since sold through while M and L are still sellable.
400
+ */
401
+ const CARD_SIZE_SOLD_OUT = [
402
+ { label: 'S', value: 's', in_stock: false, is_selectable: false, product: { pk: 57, stock: 0 } },
403
+ { label: 'M', value: 'm', in_stock: true, is_selectable: true, product: { pk: 58, stock: 4 } },
404
+ { label: 'L', value: 'l', in_stock: true, is_selectable: true, product: { pk: 59, stock: 2 } }
405
+ ];
406
+
407
+ it('offers the sizes that are left instead of declaring the product dead', () => {
408
+ mockDetail = detail(false, CARD_SIZE_SOLD_OUT);
409
+
410
+ const { container } = renderBlock(
411
+ <ProductRecommendationsBlock {...props(QUICK_VIEW)} />
412
+ );
413
+ fireEvent.click(trigger(container));
414
+
415
+ expect(size(container, 'M')).not.toBeDisabled();
416
+ // Nothing is picked yet and two sizes are sellable, so "Out of stock" is a
417
+ // claim about a product the shop can still sell.
418
+ expect(addButton(container).textContent).toBe('Select a size');
419
+ });
420
+
421
+ it('enables the add button once a sellable size is picked', () => {
422
+ mockDetail = detail(false, CARD_SIZE_SOLD_OUT);
423
+
424
+ const { container } = renderBlock(
425
+ <ProductRecommendationsBlock {...props(QUICK_VIEW)} />
426
+ );
427
+ fireEvent.click(trigger(container));
428
+ fireEvent.click(size(container, 'M'));
429
+
430
+ const add = addButton(container);
431
+ expect(add.textContent).toBe('Add to basket');
432
+ expect(add).not.toBeDisabled();
433
+ expect(add.getAttribute('data-product-card-quick-view-add-state')).toBe(
434
+ 'idle'
435
+ );
436
+ });
437
+
438
+ it('posts the picked leaf, not the pk the card carried', async () => {
439
+ mockDetail = detail(false, CARD_SIZE_SOLD_OUT);
440
+
441
+ const { container } = renderBlock(
442
+ <ProductRecommendationsBlock {...props(QUICK_VIEW)} />
443
+ );
444
+ fireEvent.click(trigger(container));
445
+ fireEvent.click(size(container, 'M'));
446
+ await act(async () => {
447
+ fireEvent.click(addButton(container));
448
+ });
449
+
450
+ expect(mockAdds).toEqual([{ product: 58, quantity: 1, attributes: {} }]);
451
+ });
452
+
453
+ /**
454
+ * The other direction: the parent is in stock, so the overlay used to hand a
455
+ * sold-out leaf to the basket endpoint with everything enabled.
456
+ */
457
+ const LEAF_SOLD_OUT = [
458
+ { label: 'M', value: 'm', in_stock: true, is_selectable: true, product: { pk: 58, stock: 4 } },
459
+ { label: 'L', value: 'l', in_stock: false, is_selectable: true, product: { pk: 57, stock: 0 } }
460
+ ];
461
+
462
+ it('refuses a sold-out leaf commerce did not mark pickable', () => {
463
+ mockDetail = detail(true, LEAF_SOLD_OUT);
464
+
465
+ const { container } = renderBlock(
466
+ <ProductRecommendationsBlock {...props(QUICK_VIEW)} />
467
+ );
468
+ fireEvent.click(trigger(container));
469
+
470
+ const sold = size(container, 'L');
471
+ expect(sold).toBeDisabled();
472
+ expect(sold.getAttribute('data-state')).toContain('unavailable');
473
+ });
474
+
475
+ it('lets a sold-out leaf be picked when commerce says it may, and still refuses to add it', async () => {
476
+ mockDetail = detail(true, [
477
+ LEAF_SOLD_OUT[0],
478
+ { ...LEAF_SOLD_OUT[1], is_selectable_without_stock: true }
479
+ ]);
480
+
481
+ const { container } = renderBlock(
482
+ <ProductRecommendationsBlock {...props(QUICK_VIEW)} />
483
+ );
484
+ fireEvent.click(trigger(container));
485
+
486
+ const sold = size(container, 'L');
487
+ expect(sold).not.toBeDisabled();
488
+ fireEvent.click(sold);
489
+
490
+ const add = addButton(container);
491
+ expect(add.textContent).toBe('Out of stock');
492
+ expect(add).toBeDisabled();
493
+
494
+ await act(async () => {
495
+ fireEvent.click(add);
496
+ });
497
+ expect(mockAdds).toEqual([]);
498
+ });
499
+
500
+ it('still reads the product itself when it has no sizes', () => {
501
+ mockDetail = detail(false, null);
502
+
503
+ const { container } = renderBlock(
504
+ <ProductRecommendationsBlock {...props(QUICK_VIEW)} />
505
+ );
506
+ fireEvent.click(trigger(container));
507
+
508
+ const add = addButton(container);
509
+ expect(add.textContent).toBe('Out of stock');
510
+ expect(add).toBeDisabled();
511
+ });
512
+
513
+ it('adds a product with no sizes at the pk the card carried', async () => {
514
+ mockDetail = detail(true, null);
515
+
516
+ const { container } = renderBlock(
517
+ <ProductRecommendationsBlock {...props(QUICK_VIEW)} />
518
+ );
519
+ fireEvent.click(trigger(container));
520
+ await act(async () => {
521
+ fireEvent.click(addButton(container));
522
+ });
523
+
524
+ expect(mockAdds).toEqual([{ product: 57, quantity: 1, attributes: {} }]);
525
+ });
526
+ });
527
+
528
+ describe('quick view overlay', () => {
529
+ beforeEach(() => {
530
+ mockWidget = { products: [CARD] };
531
+ mockDetail = detail(true, null);
532
+ mockAdds.length = 0;
533
+ });
534
+
535
+ afterEach(() => {
536
+ mockWidget = undefined;
537
+ mockDetail = undefined;
538
+ });
539
+
540
+ it('moves focus into the panel and hands it back to the trigger on Escape', () => {
541
+ const { container } = renderBlock(
542
+ <ProductRecommendationsBlock {...props(QUICK_VIEW)} />
543
+ );
544
+ const opener = trigger(container);
545
+ fireEvent.click(opener);
546
+
547
+ const panel = container.querySelector(
548
+ '[data-product-card-quick-view-panel]'
549
+ ) as HTMLElement;
550
+ expect(panel.contains(document.activeElement)).toBe(true);
551
+ expect(panel.getAttribute('aria-modal')).toBe('true');
552
+
553
+ fireEvent.keyDown(document, { key: 'Escape' });
554
+
555
+ expect(
556
+ container.querySelector('[data-product-card-quick-view-panel]')
557
+ ).toBeNull();
558
+ expect(document.activeElement).toBe(opener);
559
+ });
560
+
561
+ it('gives the page its own overflow back when the overlay closes', () => {
562
+ const { container } = renderBlock(
563
+ <ProductRecommendationsBlock {...props(QUICK_VIEW)} />
564
+ );
565
+ fireEvent.click(trigger(container));
566
+ expect(document.body.style.overflow).toBe('hidden');
567
+
568
+ fireEvent.click(
569
+ container.querySelector(
570
+ '[data-product-card-quick-view-close]'
571
+ ) as HTMLElement
572
+ );
573
+ expect(document.body.style.overflow).toBe('');
574
+ });
575
+
576
+ it('stays inert on the designer canvas', () => {
577
+ const { container } = renderBlock(
578
+ <ProductRecommendationsBlock
579
+ {...props(QUICK_VIEW, { isDesigner: true })}
580
+ />
581
+ );
582
+ fireEvent.click(trigger(container));
583
+
584
+ const panel = container.querySelector(
585
+ '[data-product-card-quick-view-panel]'
586
+ ) as HTMLElement;
587
+ expect(panel).not.toBeNull();
588
+ // No focus stolen into the canvas, no frozen page, no `aria-modal` claim.
589
+ expect(panel.contains(document.activeElement)).toBe(false);
590
+ expect(document.body.style.overflow).toBe('');
591
+ expect(panel.getAttribute('aria-modal')).toBeNull();
592
+
593
+ // An author styling a held-open panel must not have it collapse on Escape.
594
+ fireEvent.keyDown(document, { key: 'Escape' });
595
+ expect(
596
+ container.querySelector('[data-product-card-quick-view-panel]')
597
+ ).not.toBeNull();
598
+ });
599
+ });
600
+
601
+ /**
602
+ * A leaf the payload gave no `value` to.
603
+ *
604
+ * `chosenOption` starts '' and `toText(undefined)` is also '', so an unguarded
605
+ * lookup matched such an option before the shopper had touched anything: the
606
+ * strip reported a size as chosen that no button ever showed as pressed, and
607
+ * the add button posted it.
608
+ */
609
+ describe('quick view will not sell a size nobody picked', () => {
610
+ const NO_VALUE = [
611
+ { label: 'M', product: { pk: 58, stock: 4 } },
612
+ { label: 'L', value: 'l', product: { pk: 59, stock: 2 } }
613
+ ];
614
+
615
+ beforeEach(() => {
616
+ mockWidget = { products: [CARD] };
617
+ mockDetail = detail(true, NO_VALUE);
618
+ mockAdds.length = 0;
619
+ });
620
+
621
+ afterEach(() => {
622
+ mockWidget = undefined;
623
+ mockDetail = undefined;
624
+ });
625
+
626
+ const open = () => {
627
+ const { container } = renderBlock(
628
+ <ProductRecommendationsBlock {...props(QUICK_VIEW)} />
629
+ );
630
+ fireEvent.click(trigger(container));
631
+ return container;
632
+ };
633
+
634
+ it('still asks for a size when the first option carries no value', () => {
635
+ expect(addButton(open()).textContent).toBe('Select a size');
636
+ });
637
+
638
+ it('posts nothing while no size has actually been picked', () => {
639
+ const container = open();
640
+
641
+ act(() => {
642
+ fireEvent.click(addButton(container));
643
+ });
644
+
645
+ expect(mockAdds).toEqual([]);
646
+ });
647
+
648
+ it('reports no size as pressed, matching what the row draws', () => {
649
+ const pressed = Array.from(
650
+ open().querySelectorAll('[data-product-card-quick-view-size]')
651
+ ).filter((node) => node.getAttribute('aria-pressed') === 'true');
652
+
653
+ expect(pressed).toHaveLength(0);
654
+ });
655
+
656
+ it('does not offer a leaf it has no way to name', () => {
657
+ const container = open();
658
+ // Clicking it could only ever set `chosenOption` back to '', so it would
659
+ // be a control whose click does nothing.
660
+ expect(size(container, 'M')).toBeDisabled();
661
+ expect(size(container, 'L')).not.toBeDisabled();
662
+ });
663
+ });
664
+
665
+ describe('the strip root is a real focus target only where it is needed', () => {
666
+ beforeEach(() => {
667
+ mockWidget = { products: [CARD] };
668
+ mockDetail = detail(true, null);
669
+ });
670
+
671
+ afterEach(() => {
672
+ mockWidget = undefined;
673
+ mockDetail = undefined;
674
+ });
675
+
676
+ it('adds no attribute to a strip without quick view', () => {
677
+ const { container } = renderBlock(
678
+ <ProductRecommendationsBlock {...props({ collectionId: 'runners' })} />
679
+ );
680
+
681
+ expect(
682
+ container
683
+ .querySelector('[data-product-recommendations]')
684
+ ?.getAttribute('tabindex')
685
+ ).toBeNull();
686
+ });
687
+
688
+ it('is focusable, out of the tab order, once the overlay exists', () => {
689
+ const { container } = renderBlock(
690
+ <ProductRecommendationsBlock {...props(QUICK_VIEW)} />
691
+ );
692
+ const root = container.querySelector(
693
+ '[data-product-recommendations]'
694
+ ) as HTMLElement;
695
+
696
+ // Without this the overlay's last-resort focus target silently refused
697
+ // focus and a shopper whose trigger had gone was dropped on <body>.
698
+ expect(root.getAttribute('tabindex')).toBe('-1');
699
+ root.focus();
700
+ expect(document.activeElement).toBe(root);
701
+ });
702
+ });