@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,974 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import React from 'react';
4
+ import { fireEvent, render } from '@testing-library/react';
5
+ import ProductGalleryBlock from '../product-gallery-block';
6
+ import { ThemePageContextProvider } from '../../theme-page-context';
7
+
8
+ /**
9
+ * jsdom ships no PointerEvent, and testing-library then falls back to a plain
10
+ * Event — which carries no coordinates, so every gesture assertion would pass
11
+ * or fail on NaN rather than on the behaviour. MouseEvent already models the
12
+ * coordinates; only the two pointer fields have to be added.
13
+ */
14
+ if (typeof window.PointerEvent === 'undefined') {
15
+ class PointerEventPolyfill extends MouseEvent {
16
+ public pointerId: number;
17
+ public pointerType: string;
18
+
19
+ constructor(type: string, init: PointerEventInit = {}) {
20
+ super(type, init);
21
+ this.pointerId = init.pointerId ?? 0;
22
+ this.pointerType = init.pointerType ?? '';
23
+ }
24
+ }
25
+
26
+ (window as unknown as Record<string, unknown>).PointerEvent =
27
+ PointerEventPolyfill;
28
+ }
29
+
30
+ /**
31
+ * Zoom, the full-screen viewer and the fallback image are all opt-in, so the
32
+ * first suite here is the one that matters most: a block carrying none of the
33
+ * new properties must render the exact markup it rendered before they existed.
34
+ * The snapshots in this file were captured from the block at HEAD, BEFORE a
35
+ * line of the new code was written — they are a golden, not a recording of the
36
+ * current behaviour.
37
+ */
38
+ const renderBlock = (
39
+ properties: Record<string, unknown>,
40
+ product: Record<string, unknown> | null,
41
+ extra: Record<string, unknown> = {}
42
+ ) =>
43
+ render(
44
+ <ThemePageContextProvider value={product ? { product } : null}>
45
+ <ProductGalleryBlock
46
+ {...({
47
+ block: {
48
+ id: 'gallery',
49
+ type: 'product-gallery',
50
+ styles: {},
51
+ properties,
52
+ order: 0,
53
+ hidden: false
54
+ },
55
+ placeholderId: 'p',
56
+ sectionId: 's',
57
+ isDesigner: false,
58
+ currentBreakpoint: 'desktop',
59
+ ...extra
60
+ } as any)}
61
+ />
62
+ </ThemePageContextProvider>
63
+ );
64
+
65
+ const PRODUCT = {
66
+ pk: 1,
67
+ name: 'Wool Coat',
68
+ images: [
69
+ { url: 'https://cdn.example.com/a.jpg', alt: 'front' },
70
+ { url: 'https://cdn.example.com/b.jpg', alt: 'back' },
71
+ { url: 'https://cdn.example.com/c.jpg' }
72
+ ]
73
+ };
74
+
75
+ describe('ProductGalleryBlock — golden default render', () => {
76
+ it('renders the carousel exactly as it did before zoom existed', () => {
77
+ const { container } = renderBlock({}, PRODUCT);
78
+
79
+ expect(container.innerHTML).toMatchSnapshot();
80
+ });
81
+
82
+ it('renders the grid layout unchanged', () => {
83
+ const { container } = renderBlock({ layout: 'grid' }, PRODUCT);
84
+
85
+ expect(container.innerHTML).toMatchSnapshot();
86
+ });
87
+
88
+ it('renders arrows, dots and the counter unchanged', () => {
89
+ const { container } = renderBlock(
90
+ { showArrows: true, showDots: true, showCounter: true },
91
+ PRODUCT
92
+ );
93
+
94
+ expect(container.innerHTML).toMatchSnapshot();
95
+ });
96
+
97
+ it('renders the side-thumbnail arrangement unchanged', () => {
98
+ const { container } = renderBlock(
99
+ { thumbPosition: 'left', thumbSize: 80, thumbLimit: 2 },
100
+ PRODUCT
101
+ );
102
+
103
+ expect(container.innerHTML).toMatchSnapshot();
104
+ });
105
+
106
+ it('renders the single-image product unchanged', () => {
107
+ const { container } = renderBlock({}, { pk: 2, image: 'https://cdn.example.com/only.jpg' });
108
+
109
+ expect(container.innerHTML).toMatchSnapshot();
110
+ });
111
+
112
+ it('renders nothing on the storefront when the product has no photos', () => {
113
+ const { container } = renderBlock({}, { pk: 3 });
114
+
115
+ expect(container.innerHTML).toBe('');
116
+ });
117
+
118
+ it('renders the designer placeholder when the product has no photos', () => {
119
+ const { container } = renderBlock({}, { pk: 3 }, { isDesigner: true });
120
+
121
+ expect(container.innerHTML).toMatchSnapshot();
122
+ });
123
+
124
+ it('renders the designer carousel unchanged', () => {
125
+ const { container } = renderBlock({}, PRODUCT, { isDesigner: true });
126
+
127
+ expect(container.innerHTML).toMatchSnapshot();
128
+ });
129
+
130
+ it('draws no enlargement affordance of any kind by default', () => {
131
+ const { container } = renderBlock({}, PRODUCT);
132
+
133
+ expect(container.querySelector('[data-part="viewer-trigger"]')).toBeNull();
134
+ expect(container.querySelector('[data-part="zoom-image"]')).toBeNull();
135
+ expect(container.querySelector('[data-part="zoom-hint"]')).toBeNull();
136
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
137
+ });
138
+ });
139
+
140
+ /** jsdom measures nothing, so the frame is given a size to zoom against. */
141
+ const sizeFrame = (element: Element | null) => {
142
+ if (!element) throw new Error('no frame');
143
+ element.getBoundingClientRect = () =>
144
+ ({ left: 0, top: 0, width: 200, height: 400 }) as DOMRect;
145
+ };
146
+
147
+ describe('ProductGalleryBlock — pointer zoom', () => {
148
+ it('magnifies the region under a mouse and drops it on leave', () => {
149
+ const { container } = renderBlock({ enableZoom: true }, PRODUCT);
150
+ const frame = container.querySelector('[data-product-gallery-main]');
151
+ sizeFrame(frame);
152
+
153
+ fireEvent.pointerMove(frame!, {
154
+ pointerType: 'mouse',
155
+ clientX: 50,
156
+ clientY: 100
157
+ });
158
+
159
+ const layer = container.querySelector<HTMLElement>(
160
+ '[data-part="zoom-image"]'
161
+ );
162
+ expect(layer).not.toBeNull();
163
+ expect(layer?.style.transform).toBe('scale(2)');
164
+ expect(layer?.style.transformOrigin).toBe('25% 25%');
165
+ // The frame reports it so a brand can restyle it while it is magnified.
166
+ expect(frame?.getAttribute('data-state')).toBe('open');
167
+
168
+ fireEvent.pointerLeave(frame!);
169
+ expect(container.querySelector('[data-part="zoom-image"]')).toBeNull();
170
+ });
171
+
172
+ it('honours the authored magnification', () => {
173
+ const { container } = renderBlock(
174
+ { enableZoom: true, zoomLevel: 3.5 },
175
+ PRODUCT
176
+ );
177
+ const frame = container.querySelector('[data-product-gallery-main]');
178
+ sizeFrame(frame);
179
+
180
+ fireEvent.pointerMove(frame!, {
181
+ pointerType: 'mouse',
182
+ clientX: 100,
183
+ clientY: 200
184
+ });
185
+
186
+ expect(
187
+ container.querySelector<HTMLElement>('[data-part="zoom-image"]')?.style
188
+ .transform
189
+ ).toBe('scale(3.5)');
190
+ });
191
+
192
+ it('ignores a finger — a touch has no hover to magnify under', () => {
193
+ const { container } = renderBlock({ enableZoom: true }, PRODUCT);
194
+ const frame = container.querySelector('[data-product-gallery-main]');
195
+ sizeFrame(frame);
196
+
197
+ fireEvent.pointerMove(frame!, {
198
+ pointerType: 'touch',
199
+ clientX: 50,
200
+ clientY: 100
201
+ });
202
+
203
+ expect(container.querySelector('[data-part="zoom-image"]')).toBeNull();
204
+ });
205
+
206
+ it('never magnifies under the author pointer in the designer', () => {
207
+ const { container } = renderBlock({ enableZoom: true }, PRODUCT, {
208
+ isDesigner: true
209
+ });
210
+ const frame = container.querySelector('[data-product-gallery-main]');
211
+ sizeFrame(frame);
212
+
213
+ fireEvent.pointerMove(frame!, {
214
+ pointerType: 'mouse',
215
+ clientX: 50,
216
+ clientY: 100
217
+ });
218
+
219
+ expect(container.querySelector('[data-part="zoom-image"]')).toBeNull();
220
+ });
221
+
222
+ it('scales the asset commerce sent until a larger one is named', () => {
223
+ const { container } = renderBlock({ enableZoom: true }, PRODUCT);
224
+ const frame = container.querySelector('[data-product-gallery-main]');
225
+ sizeFrame(frame);
226
+
227
+ fireEvent.pointerMove(frame!, {
228
+ pointerType: 'mouse',
229
+ clientX: 50,
230
+ clientY: 100
231
+ });
232
+
233
+ expect(
234
+ container.querySelector('[data-part="zoom-image"]')?.getAttribute('src')
235
+ ).toBe('https://cdn.example.com/a.jpg');
236
+ });
237
+ });
238
+
239
+ const openViewer = (container: HTMLElement) => {
240
+ const trigger = container.querySelector<HTMLElement>(
241
+ '[data-part="viewer-trigger"]'
242
+ );
243
+ if (!trigger) throw new Error('no viewer trigger');
244
+ fireEvent.click(trigger);
245
+ return trigger;
246
+ };
247
+
248
+ describe('ProductGalleryBlock — full-screen viewer', () => {
249
+ it('opens from the photo and reaches the other photos from inside', () => {
250
+ const { container } = renderBlock(
251
+ { enableViewer: true, viewerShowThumbs: true, viewerShowCounter: true },
252
+ PRODUCT
253
+ );
254
+
255
+ openViewer(container);
256
+
257
+ const dialog = container.querySelector('[role="dialog"]');
258
+ expect(dialog).not.toBeNull();
259
+ expect(dialog?.getAttribute('aria-label')).toBe('Product images');
260
+ expect(
261
+ container.querySelector('[data-part="viewer-image"]')?.getAttribute('src')
262
+ ).toBe('https://cdn.example.com/a.jpg');
263
+ expect(
264
+ container.querySelector('[data-part="viewer-counter"]')?.textContent
265
+ ).toBe('1 / 3');
266
+
267
+ fireEvent.click(
268
+ container.querySelector('[data-product-gallery-viewer-arrow="next"]')!
269
+ );
270
+ expect(
271
+ container.querySelector('[data-part="viewer-image"]')?.getAttribute('src')
272
+ ).toBe('https://cdn.example.com/b.jpg');
273
+
274
+ const thumbs = container.querySelectorAll('[data-part="viewer-thumb"]');
275
+ expect(thumbs).toHaveLength(3);
276
+ fireEvent.click(thumbs[2]);
277
+ expect(
278
+ container.querySelector('[data-part="viewer-image"]')?.getAttribute('src')
279
+ ).toBe('https://cdn.example.com/c.jpg');
280
+ });
281
+
282
+ it('claims aria-modal on the storefront and never in the designer', () => {
283
+ const shop = renderBlock({ enableViewer: true }, PRODUCT);
284
+ openViewer(shop.container);
285
+ expect(
286
+ shop.container.querySelector('[role="dialog"]')?.getAttribute('aria-modal')
287
+ ).toBe('true');
288
+
289
+ const canvas = renderBlock({ enableViewer: true }, PRODUCT, {
290
+ isDesigner: true
291
+ });
292
+ openViewer(canvas.container);
293
+ // The trap is deliberately not armed on the canvas, so the claim would
294
+ // describe a dialog that does not exist.
295
+ expect(
296
+ canvas.container
297
+ .querySelector('[role="dialog"]')
298
+ ?.getAttribute('aria-modal')
299
+ ).toBeNull();
300
+ });
301
+
302
+ it('moves focus in, traps it, and hands it back to the opener', () => {
303
+ const { container } = renderBlock({ enableViewer: true }, PRODUCT);
304
+ const trigger = openViewer(container);
305
+
306
+ const close = container.querySelector<HTMLElement>(
307
+ '[data-part="viewer-close"]'
308
+ );
309
+ expect(document.activeElement).toBe(close);
310
+
311
+ // Focus escaping into the page behind is pulled straight back.
312
+ document.body.focus();
313
+ fireEvent.focusIn(document.body, { target: document.body });
314
+ expect(container.contains(document.activeElement)).toBe(true);
315
+
316
+ fireEvent.keyDown(document, { key: 'Escape' });
317
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
318
+ expect(document.activeElement).toBe(trigger);
319
+ });
320
+
321
+ it('always keeps a pointer way out, whatever else is switched off', () => {
322
+ const { container } = renderBlock(
323
+ { enableViewer: true, viewerCloseOnEscape: false, viewerShowArrows: false },
324
+ PRODUCT
325
+ );
326
+ openViewer(container);
327
+
328
+ fireEvent.keyDown(document, { key: 'Escape' });
329
+ expect(container.querySelector('[role="dialog"]')).not.toBeNull();
330
+
331
+ const close = container.querySelector('[data-part="viewer-close"]');
332
+ expect(close).not.toBeNull();
333
+ fireEvent.click(close!);
334
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
335
+ });
336
+
337
+ /**
338
+ * The backdrop is painted, then the dialog is laid over the whole viewport
339
+ * on top of it — so no tap ever reaches the backdrop. What a shopper can
340
+ * actually hit around the photo is the stage, and that must NOT dismiss the
341
+ * viewer: the first half of a double-tap-to-zoom lands there.
342
+ */
343
+ it('keeps the viewer up under the taps a shopper can actually land', () => {
344
+ const { container } = renderBlock({ enableViewer: true }, PRODUCT);
345
+ openViewer(container);
346
+
347
+ const stage = () => container.querySelector('[data-part="viewer-stage"]')!;
348
+
349
+ fireEvent.click(stage());
350
+ expect(container.querySelector('[role="dialog"]')).not.toBeNull();
351
+
352
+ fireEvent.doubleClick(stage());
353
+ expect(container.querySelector('[role="dialog"]')).not.toBeNull();
354
+ expect(
355
+ container.querySelector<HTMLElement>('[data-part="viewer-image"]')?.style
356
+ .transform
357
+ ).toBe('translate(0px, 0px) scale(3)');
358
+
359
+ fireEvent.click(container.querySelector('[data-part="viewer-close"]')!);
360
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
361
+ });
362
+
363
+ it('gives the page back its own scrolling on every visit', () => {
364
+ // A storefront that sets its own overflow has to get THAT value back.
365
+ document.body.style.overflow = 'auto';
366
+
367
+ const { container, unmount } = renderBlock({ enableViewer: true }, PRODUCT);
368
+
369
+ openViewer(container);
370
+ expect(document.body.style.overflow).toBe('hidden');
371
+
372
+ fireEvent.click(container.querySelector('[data-part="viewer-close"]')!);
373
+ expect(document.body.style.overflow).toBe('auto');
374
+
375
+ // A lock leaked on the first visit leaves the page frozen from here on.
376
+ openViewer(container);
377
+ expect(document.body.style.overflow).toBe('hidden');
378
+
379
+ // Navigating away with the viewer up must not freeze what comes next.
380
+ unmount();
381
+ expect(document.body.style.overflow).toBe('auto');
382
+
383
+ document.body.style.overflow = '';
384
+ });
385
+
386
+ it('leaves the page alone in the designer', () => {
387
+ const { container } = renderBlock({ enableViewer: true }, PRODUCT, {
388
+ isDesigner: true
389
+ });
390
+ openViewer(container);
391
+
392
+ expect(document.body.style.overflow).toBe('');
393
+ });
394
+
395
+ it('opens every grid cell at the photo that was clicked', () => {
396
+ const { container } = renderBlock(
397
+ { layout: 'grid', enableViewer: true },
398
+ PRODUCT
399
+ );
400
+
401
+ const triggers = container.querySelectorAll('[data-part="viewer-trigger"]');
402
+ expect(triggers).toHaveLength(3);
403
+
404
+ fireEvent.click(triggers[1]);
405
+ expect(
406
+ container.querySelector('[data-part="viewer-image"]')?.getAttribute('src')
407
+ ).toBe('https://cdn.example.com/b.jpg');
408
+ });
409
+
410
+ it('swipes to the next photo and back', () => {
411
+ const { container } = renderBlock({ enableViewer: true }, PRODUCT);
412
+ openViewer(container);
413
+
414
+ const stage = container.querySelector('[data-part="viewer-stage"]')!;
415
+ const swipe = (from: number, to: number) => {
416
+ fireEvent.pointerDown(stage, { pointerId: 1, clientX: from, clientY: 40 });
417
+ fireEvent.pointerMove(stage, { pointerId: 1, clientX: to, clientY: 40 });
418
+ fireEvent.pointerUp(stage, { pointerId: 1, clientX: to, clientY: 40 });
419
+ };
420
+
421
+ swipe(300, 100);
422
+ expect(
423
+ container.querySelector('[data-part="viewer-image"]')?.getAttribute('src')
424
+ ).toBe('https://cdn.example.com/b.jpg');
425
+
426
+ swipe(100, 300);
427
+ expect(
428
+ container.querySelector('[data-part="viewer-image"]')?.getAttribute('src')
429
+ ).toBe('https://cdn.example.com/a.jpg');
430
+ });
431
+
432
+ it('does not mistake a tap or a short drag for a swipe', () => {
433
+ const { container } = renderBlock({ enableViewer: true }, PRODUCT);
434
+ openViewer(container);
435
+
436
+ const stage = container.querySelector('[data-part="viewer-stage"]')!;
437
+ fireEvent.pointerDown(stage, { pointerId: 1, clientX: 300, clientY: 40 });
438
+ fireEvent.pointerMove(stage, { pointerId: 1, clientX: 288, clientY: 40 });
439
+ fireEvent.pointerUp(stage, { pointerId: 1, clientX: 288, clientY: 40 });
440
+
441
+ expect(
442
+ container.querySelector('[data-part="viewer-image"]')?.getAttribute('src')
443
+ ).toBe('https://cdn.example.com/a.jpg');
444
+ });
445
+
446
+ it('pinches to zoom, then pans instead of swiping, and double-tap resets', () => {
447
+ const { container } = renderBlock({ enableViewer: true }, PRODUCT);
448
+ openViewer(container);
449
+
450
+ const stage = container.querySelector('[data-part="viewer-stage"]')!;
451
+ const image = container.querySelector<HTMLElement>(
452
+ '[data-part="viewer-image"]'
453
+ )!;
454
+
455
+ fireEvent.pointerDown(stage, { pointerId: 1, clientX: 100, clientY: 100 });
456
+ fireEvent.pointerDown(stage, { pointerId: 2, clientX: 140, clientY: 100 });
457
+ fireEvent.pointerMove(stage, { pointerId: 2, clientX: 220, clientY: 100 });
458
+
459
+ expect(image.style.transform).toBe('translate(0px, 0px) scale(3)');
460
+ expect(image.getAttribute('data-state')).toContain('active');
461
+
462
+ // One finger up, the other keeps dragging: that is a pan, not a swipe.
463
+ fireEvent.pointerUp(stage, { pointerId: 2, clientX: 220, clientY: 100 });
464
+ fireEvent.pointerMove(stage, { pointerId: 1, clientX: 130, clientY: 120 });
465
+ expect(image.style.transform).toBe('translate(30px, 20px) scale(3)');
466
+
467
+ fireEvent.pointerUp(stage, { pointerId: 1, clientX: 130, clientY: 120 });
468
+ expect(
469
+ container.querySelector('[data-part="viewer-image"]')?.getAttribute('src')
470
+ ).toBe('https://cdn.example.com/a.jpg');
471
+
472
+ fireEvent.doubleClick(stage);
473
+ expect(
474
+ container.querySelector<HTMLElement>('[data-part="viewer-image"]')?.style
475
+ .transform
476
+ ).toBe('translate(0px, 0px) scale(1)');
477
+ });
478
+
479
+ /**
480
+ * A finger resting on the photo while the other hand taps Close is never
481
+ * followed by a pointerup. If that id stays booked, every later one-finger
482
+ * drag is counted as the second finger of a pinch and no swipe fires again.
483
+ */
484
+ it('still swipes on later visits after a finger was down when it closed', () => {
485
+ const { container } = renderBlock({ enableViewer: true }, PRODUCT);
486
+ const stage = () => container.querySelector('[data-part="viewer-stage"]')!;
487
+ const shownPhoto = () =>
488
+ container.querySelector('[data-part="viewer-image"]')?.getAttribute('src');
489
+
490
+ openViewer(container);
491
+ fireEvent.pointerDown(stage(), { pointerId: 7, clientX: 120, clientY: 40 });
492
+ fireEvent.click(container.querySelector('[data-part="viewer-close"]')!);
493
+
494
+ const swipeLeft = (pointerId: number) => {
495
+ fireEvent.pointerDown(stage(), { pointerId, clientX: 300, clientY: 40 });
496
+ fireEvent.pointerMove(stage(), { pointerId, clientX: 100, clientY: 40 });
497
+ fireEvent.pointerUp(stage(), { pointerId, clientX: 100, clientY: 40 });
498
+ };
499
+
500
+ openViewer(container);
501
+ swipeLeft(8);
502
+ expect(shownPhoto()).toBe('https://cdn.example.com/b.jpg');
503
+ fireEvent.click(container.querySelector('[data-part="viewer-close"]')!);
504
+
505
+ openViewer(container);
506
+ swipeLeft(9);
507
+ expect(shownPhoto()).toBe('https://cdn.example.com/c.jpg');
508
+ });
509
+
510
+ /**
511
+ * A PDP swaps the photo list under the gallery when the shopper picks
512
+ * another colour, and it can do that while the viewer is up — the cell that
513
+ * opened it is then gone by the time it closes. Handing focus back to a
514
+ * detached node silently drops a keyboard shopper at the top of the
515
+ * document, with the gallery they were reading nowhere near the caret.
516
+ */
517
+ it('lands focus back on the gallery when the trigger it came from is gone', () => {
518
+ const Gallery = ({ images }: { images: unknown[] }) => (
519
+ <ThemePageContextProvider
520
+ value={{ product: { pk: 1, name: 'Wool Coat', images } }}
521
+ >
522
+ <ProductGalleryBlock
523
+ {...({
524
+ block: {
525
+ id: 'gallery',
526
+ type: 'product-gallery',
527
+ styles: {},
528
+ properties: { layout: 'grid', enableViewer: true },
529
+ order: 0,
530
+ hidden: false
531
+ },
532
+ placeholderId: 'p',
533
+ sectionId: 's',
534
+ isDesigner: false,
535
+ currentBreakpoint: 'desktop'
536
+ } as any)}
537
+ />
538
+ </ThemePageContextProvider>
539
+ );
540
+
541
+ const { container, rerender } = render(<Gallery images={PRODUCT.images} />);
542
+
543
+ const opener = container.querySelectorAll<HTMLElement>(
544
+ '[data-part="viewer-trigger"]'
545
+ )[2];
546
+ // A real click focuses the button it lands on; jsdom's does not, and
547
+ // without it the handover is measured from a page where nothing had focus.
548
+ opener.focus();
549
+ fireEvent.click(opener);
550
+
551
+ rerender(<Gallery images={[PRODUCT.images[0]]} />);
552
+ expect(opener.isConnected).toBe(false);
553
+
554
+ fireEvent.click(container.querySelector('[data-part="viewer-close"]')!);
555
+
556
+ expect(document.activeElement).toBe(
557
+ container.querySelector('[data-part="viewer-trigger"]')
558
+ );
559
+ });
560
+
561
+ it('resets the magnification when the photo changes', () => {
562
+ const { container } = renderBlock({ enableViewer: true }, PRODUCT);
563
+ openViewer(container);
564
+
565
+ const stage = container.querySelector('[data-part="viewer-stage"]')!;
566
+ fireEvent.doubleClick(stage);
567
+ expect(
568
+ container.querySelector<HTMLElement>('[data-part="viewer-image"]')?.style
569
+ .transform
570
+ ).toBe('translate(0px, 0px) scale(3)');
571
+
572
+ fireEvent.click(
573
+ container.querySelector('[data-product-gallery-viewer-arrow="next"]')!
574
+ );
575
+ expect(
576
+ container.querySelector<HTMLElement>('[data-part="viewer-image"]')?.style
577
+ .transform
578
+ ).toBe('translate(0px, 0px) scale(1)');
579
+ });
580
+ });
581
+
582
+ describe('ProductGalleryBlock — high-resolution source', () => {
583
+ it('asks the CDN for a larger rendition inside the viewer', () => {
584
+ const { container } = renderBlock(
585
+ { enableViewer: true, highResTemplate: '{url}?width=2000' },
586
+ PRODUCT
587
+ );
588
+ openViewer(container);
589
+
590
+ expect(
591
+ container.querySelector('[data-part="viewer-image"]')?.getAttribute('src')
592
+ ).toBe('https://cdn.example.com/a.jpg?width=2000');
593
+ });
594
+
595
+ it('falls back to the asset commerce sent when the larger one 404s', () => {
596
+ const { container } = renderBlock(
597
+ { enableViewer: true, highResTemplate: '{url}?width=2000' },
598
+ PRODUCT
599
+ );
600
+ openViewer(container);
601
+
602
+ const image = container.querySelector('[data-part="viewer-image"]')!;
603
+ fireEvent.error(image);
604
+
605
+ expect(
606
+ container.querySelector('[data-part="viewer-image"]')?.getAttribute('src')
607
+ ).toBe('https://cdn.example.com/a.jpg');
608
+ });
609
+
610
+ it('ignores a template with no binding rather than pointing every photo at one url', () => {
611
+ const { container } = renderBlock(
612
+ { enableViewer: true, highResTemplate: 'https://cdn.example.com/big.jpg' },
613
+ PRODUCT
614
+ );
615
+ openViewer(container);
616
+
617
+ expect(
618
+ container.querySelector('[data-part="viewer-image"]')?.getAttribute('src')
619
+ ).toBe('https://cdn.example.com/a.jpg');
620
+ });
621
+
622
+ it('refuses a template that resolves to a script url', () => {
623
+ const { container } = renderBlock(
624
+ { enableViewer: true, highResTemplate: 'javascript:alert(1)//{url}' },
625
+ PRODUCT
626
+ );
627
+ openViewer(container);
628
+
629
+ expect(
630
+ container.querySelector('[data-part="viewer-image"]')?.getAttribute('src')
631
+ ).toBe('https://cdn.example.com/a.jpg');
632
+ });
633
+ });
634
+
635
+ describe('ProductGalleryBlock — fallback photo', () => {
636
+ const FALLBACK = 'https://cdn.example.com/placeholder.png';
637
+
638
+ it('still renders nothing when no fallback is authored', () => {
639
+ const { container } = renderBlock({}, { pk: 9, images: [] });
640
+
641
+ expect(container.innerHTML).toBe('');
642
+ });
643
+
644
+ it('stands in for a product with no photos at all', () => {
645
+ const { container } = renderBlock(
646
+ { fallbackImage: FALLBACK, fallbackAlt: 'Photo coming soon' },
647
+ { pk: 9 }
648
+ );
649
+
650
+ const image = container.querySelector('[data-part="image"]');
651
+ expect(image?.getAttribute('src')).toBe(FALLBACK);
652
+ expect(image?.getAttribute('alt')).toBe('Photo coming soon');
653
+ expect(image?.getAttribute('data-state')).toBe('empty');
654
+ expect(
655
+ container
656
+ .querySelector('[data-product-gallery-main]')
657
+ ?.getAttribute('data-state')
658
+ ).toBe('empty');
659
+ });
660
+
661
+ it('stands in when the payload carries only empty urls', () => {
662
+ const { container } = renderBlock(
663
+ { fallbackImage: FALLBACK },
664
+ { pk: 9, images: [{ url: '' }, { image: ' ' }] }
665
+ );
666
+
667
+ expect(
668
+ container.querySelector('[data-part="image"]')?.getAttribute('src')
669
+ ).toBe(FALLBACK);
670
+ });
671
+
672
+ it('takes over the frame and the thumbnail when a photo fails to load', () => {
673
+ const { container } = renderBlock({ fallbackImage: FALLBACK }, PRODUCT);
674
+
675
+ fireEvent.error(container.querySelector('[data-part="image"]')!);
676
+
677
+ const image = container.querySelector('[data-part="image"]');
678
+ expect(image?.getAttribute('src')).toBe(FALLBACK);
679
+ expect(image?.getAttribute('data-state')).toBe('empty');
680
+ // The same dead url in the strip is swapped too.
681
+ const thumb = container.querySelector('[data-part="thumb-image"]');
682
+ expect(thumb?.getAttribute('src')).toBe(FALLBACK);
683
+ // Only the photo that failed; the others are untouched.
684
+ expect(
685
+ container.querySelectorAll('[data-part="thumb-image"]')[1].getAttribute('src')
686
+ ).toBe('https://cdn.example.com/b.jpg');
687
+ });
688
+
689
+ it('does not loop when the fallback itself fails', () => {
690
+ const { container } = renderBlock({ fallbackImage: FALLBACK }, { pk: 9 });
691
+
692
+ const image = container.querySelector('[data-part="image"]')!;
693
+ fireEvent.error(image);
694
+ fireEvent.error(image);
695
+
696
+ expect(
697
+ container.querySelector('[data-part="image"]')?.getAttribute('src')
698
+ ).toBe(FALLBACK);
699
+ });
700
+
701
+ it('refuses a script url as a fallback and keeps the old behaviour', () => {
702
+ const { container } = renderBlock(
703
+ { fallbackImage: 'java\tscript:alert(1)' },
704
+ { pk: 9 }
705
+ );
706
+
707
+ expect(container.innerHTML).toBe('');
708
+ });
709
+
710
+ it('refuses an svg data url, which carries markup', () => {
711
+ const { container } = renderBlock(
712
+ { fallbackImage: 'data:image/svg+xml,<svg onload="alert(1)"/>' },
713
+ { pk: 9 }
714
+ );
715
+
716
+ expect(container.innerHTML).toBe('');
717
+ });
718
+
719
+ /**
720
+ * Grid cells past the first row are lazy, so a photo the shopper never
721
+ * scrolled to has never been requested — the viewer is its first requester,
722
+ * and its failure is the first anything hears of the dead url.
723
+ */
724
+ it('takes over inside the viewer when the photo the viewer asked for is dead', () => {
725
+ const { container } = renderBlock(
726
+ {
727
+ layout: 'grid',
728
+ enableViewer: true,
729
+ fallbackImage: FALLBACK,
730
+ fallbackAlt: 'Photo coming soon'
731
+ },
732
+ PRODUCT
733
+ );
734
+
735
+ const triggers = container.querySelectorAll('[data-part="viewer-trigger"]');
736
+ fireEvent.click(triggers[2]);
737
+
738
+ const image = () => container.querySelector('[data-part="viewer-image"]');
739
+ expect(image()?.getAttribute('src')).toBe('https://cdn.example.com/c.jpg');
740
+
741
+ fireEvent.error(image()!);
742
+
743
+ expect(image()?.getAttribute('src')).toBe(FALLBACK);
744
+ expect(image()?.getAttribute('alt')).toBe('Photo coming soon');
745
+ expect(image()?.getAttribute('data-state')).toContain('empty');
746
+ });
747
+
748
+ it('drops the larger rendition first, then gives up on the url itself', () => {
749
+ const { container } = renderBlock(
750
+ {
751
+ enableViewer: true,
752
+ highResTemplate: '{url}?width=2000',
753
+ fallbackImage: FALLBACK
754
+ },
755
+ PRODUCT
756
+ );
757
+ openViewer(container);
758
+
759
+ const image = () => container.querySelector('[data-part="viewer-image"]');
760
+ expect(image()?.getAttribute('src')).toBe(
761
+ 'https://cdn.example.com/a.jpg?width=2000'
762
+ );
763
+
764
+ fireEvent.error(image()!);
765
+ expect(image()?.getAttribute('src')).toBe('https://cdn.example.com/a.jpg');
766
+ expect(image()?.getAttribute('data-state') ?? '').not.toContain('empty');
767
+
768
+ // Only now is the url itself given up on — and the fallback is asked for
769
+ // at the same larger rendition, since the template is a CDN address rule.
770
+ fireEvent.error(image()!);
771
+ expect(image()?.getAttribute('src')).toBe(`${FALLBACK}?width=2000`);
772
+ expect(image()?.getAttribute('data-state')).toContain('empty');
773
+
774
+ // A fallback that will not load either has to come to rest, not loop.
775
+ fireEvent.error(image()!);
776
+ expect(image()?.getAttribute('src')).toBe(FALLBACK);
777
+
778
+ fireEvent.error(image()!);
779
+ expect(image()?.getAttribute('src')).toBe(FALLBACK);
780
+ });
781
+
782
+ it('shows the fallback inside the viewer too', () => {
783
+ const { container } = renderBlock(
784
+ { enableViewer: true, fallbackImage: FALLBACK, fallbackAlt: 'No photo' },
785
+ { pk: 9 }
786
+ );
787
+ openViewer(container);
788
+
789
+ const image = container.querySelector('[data-part="viewer-image"]');
790
+ expect(image?.getAttribute('src')).toBe(FALLBACK);
791
+ expect(image?.getAttribute('alt')).toBe('No photo');
792
+ expect(image?.getAttribute('data-state')).toContain('empty');
793
+ });
794
+ });
795
+
796
+ describe('ProductGalleryBlock — authored copy', () => {
797
+ it('draws the hint only once an enlargement is offered, and only if authored', () => {
798
+ expect(
799
+ renderBlock({ zoomHint: 'Tap to enlarge' }, PRODUCT).container.querySelector(
800
+ '[data-part="zoom-hint"]'
801
+ )
802
+ ).toBeNull();
803
+
804
+ expect(
805
+ renderBlock(
806
+ { enableViewer: true, zoomHint: 'Tap to enlarge' },
807
+ PRODUCT
808
+ ).container.querySelector('[data-part="zoom-hint"]')?.textContent
809
+ ).toBe('Tap to enlarge');
810
+ });
811
+
812
+ /**
813
+ * Magnify is a hover affordance. With the viewer off there is no enlargement
814
+ * a finger can reach, so a hint left standing over the photo on a phone
815
+ * promises something a tap cannot deliver.
816
+ */
817
+ it('hides a magnify-only hint from a pointer that cannot hover', () => {
818
+ const { container } = renderBlock(
819
+ { enableZoom: true, zoomHint: 'Hover to zoom' },
820
+ PRODUCT
821
+ );
822
+
823
+ expect(
824
+ container.querySelector('[data-part="zoom-hint"]')?.textContent
825
+ ).toBe('Hover to zoom');
826
+
827
+ const rules = Array.from(container.querySelectorAll('style'))
828
+ .map((node) => node.textContent ?? '')
829
+ .join('');
830
+ const gate = /@media \(hover:none\)\{([^{]+)\{display:none;\}\}/.exec(rules);
831
+ expect(gate).not.toBeNull();
832
+
833
+ // The selector is handed back to the DOM rather than matched as a string:
834
+ // a rule scoped to the wrong instance reads identically and hides nothing.
835
+ expect(Array.from(container.querySelectorAll(gate![1].trim()))).toEqual([
836
+ container.querySelector('[data-part="zoom-hint"]')
837
+ ]);
838
+ });
839
+
840
+ it('scopes that gate over every cell of the grid layout', () => {
841
+ const { container } = renderBlock(
842
+ { layout: 'grid', enableZoom: true, zoomHint: 'Hover to zoom' },
843
+ PRODUCT
844
+ );
845
+
846
+ const rules = Array.from(container.querySelectorAll('style'))
847
+ .map((node) => node.textContent ?? '')
848
+ .join('');
849
+ const gate = /@media \(hover:none\)\{([^{]+)\{display:none;\}\}/.exec(rules);
850
+
851
+ expect(container.querySelectorAll('[data-part="zoom-hint"]')).toHaveLength(3);
852
+ expect(container.querySelectorAll(gate![1].trim())).toHaveLength(3);
853
+ });
854
+
855
+ it('keeps the hint for every shopper once the viewer can answer a tap', () => {
856
+ const { container } = renderBlock(
857
+ { enableZoom: true, enableViewer: true, zoomHint: 'Tap to enlarge' },
858
+ PRODUCT
859
+ );
860
+
861
+ expect(
862
+ container.querySelector('[data-part="zoom-hint"]')?.textContent
863
+ ).toBe('Tap to enlarge');
864
+ expect(
865
+ Array.from(container.querySelectorAll('style'))
866
+ .map((node) => node.textContent ?? '')
867
+ .join('')
868
+ ).not.toContain('hover:none');
869
+ });
870
+
871
+ it('lets a brand word every viewer control', () => {
872
+ const { container } = renderBlock(
873
+ {
874
+ enableViewer: true,
875
+ viewerOpenLabel: '{name} fotoğrafını büyüt',
876
+ viewerTitle: 'Ürün fotoğrafları',
877
+ viewerCloseLabel: 'Kapat',
878
+ viewerShowCounter: true,
879
+ viewerCounterLabel: '{index}. / {total}',
880
+ viewerPrevLabel: 'Önceki',
881
+ viewerNextLabel: 'Sonraki'
882
+ },
883
+ PRODUCT
884
+ );
885
+
886
+ expect(
887
+ container
888
+ .querySelector('[data-part="viewer-trigger"]')
889
+ ?.getAttribute('aria-label')
890
+ ).toBe('Wool Coat fotoğrafını büyüt');
891
+
892
+ openViewer(container);
893
+
894
+ expect(
895
+ container.querySelector('[role="dialog"]')?.getAttribute('aria-label')
896
+ ).toBe('Ürün fotoğrafları');
897
+ expect(
898
+ container
899
+ .querySelector('[data-part="viewer-close"]')
900
+ ?.getAttribute('aria-label')
901
+ ).toBe('Kapat');
902
+ expect(
903
+ container.querySelector('[data-part="viewer-counter"]')?.textContent
904
+ ).toBe('1. / 3');
905
+ expect(
906
+ container
907
+ .querySelector('[data-product-gallery-viewer-arrow="prev"]')
908
+ ?.getAttribute('aria-label')
909
+ ).toBe('Önceki');
910
+ });
911
+ });
912
+
913
+ describe('ProductGalleryBlock — the states the image frame reports', () => {
914
+ it('names magnifying with a token the browser cannot raise on its own', () => {
915
+ const { container } = renderBlock({ enableZoom: true }, PRODUCT);
916
+ const frame = container.querySelector('[data-product-gallery-main]');
917
+ sizeFrame(frame);
918
+
919
+ fireEvent.pointerMove(frame!, {
920
+ pointerType: 'mouse',
921
+ clientX: 50,
922
+ clientY: 100
923
+ });
924
+
925
+ const states = (frame?.getAttribute('data-state') ?? '').split(' ');
926
+ expect(states).toContain('open');
927
+ // `active` also carries the browser's own `:active`, so a brand styling
928
+ // "while magnifying" would get it on every mousedown — magnify off too.
929
+ expect(states).not.toContain('active');
930
+ });
931
+
932
+ it('names the grid cell the shopper is on', () => {
933
+ const { container } = renderBlock({ layout: 'grid' }, PRODUCT);
934
+ const cells = container.querySelectorAll('[data-product-gallery-cell]');
935
+
936
+ expect(cells[0].getAttribute('data-state')).toContain('selected');
937
+ expect(cells[1].getAttribute('data-state') ?? '').not.toContain('selected');
938
+ });
939
+ });
940
+
941
+ /**
942
+ * The other half of the part contract lives in the editor: a state the
943
+ * renderer reports and the editor never declares is a style a designer has no
944
+ * way to write. The editor is a sibling checkout, so this is skipped where it
945
+ * is not cloned rather than failed.
946
+ */
947
+ const DESCRIPTOR_PATH = path.resolve(
948
+ __dirname,
949
+ '../../../../../../pz-dynamic-widget/src/config/blocks/product-blocks.ts'
950
+ );
951
+
952
+ const declaredStatesFor = (part: string): string[] => {
953
+ const source = fs.readFileSync(DESCRIPTOR_PATH, 'utf8');
954
+ const group = new RegExp(
955
+ `partGroup\\('${part}',[\\s\\S]*?states:\\s*\\{([\\s\\S]*?)\\}`
956
+ ).exec(source);
957
+
958
+ if (!group) throw new Error(`no states declared for part "${part}"`);
959
+
960
+ return Array.from(group[1].matchAll(/([a-z][a-z-]*)\s*:/g), (m) => m[1]);
961
+ };
962
+
963
+ (fs.existsSync(DESCRIPTOR_PATH) ? describe : describe.skip)(
964
+ 'ProductGalleryBlock — editor reach over those states',
965
+ () => {
966
+ it('declares every state the image frame reports, and no phantom one', () => {
967
+ expect(declaredStatesFor('main').sort()).toEqual([
968
+ 'empty',
969
+ 'open',
970
+ 'selected'
971
+ ]);
972
+ });
973
+ }
974
+ );