@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.
- package/CHANGELOG.md +15 -31
- package/package.json +4 -4
- package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
- package/src/__tests__/custom-css.test.ts +181 -0
- package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
- package/src/__tests__/facet-filters-block.test.tsx +707 -0
- package/src/__tests__/item-template.test.tsx +184 -0
- package/src/__tests__/listing-controls.test.tsx +961 -0
- package/src/__tests__/part-style-scope.test.tsx +229 -0
- package/src/__tests__/part-styles.test.ts +311 -0
- package/src/__tests__/registry-parity.test.ts +42 -0
- package/src/__tests__/row-visibility.test.ts +672 -0
- package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
- package/src/__tests__/theme-block-html-id.test.tsx +167 -0
- package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
- package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
- package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
- package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
- package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
- package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
- package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
- package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
- package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
- package/src/blocks/__tests__/share-block.test.tsx +392 -0
- package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
- package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
- package/src/blocks/__tests__/variant-selector-golden.json +20 -0
- package/src/blocks/account-menu-block.tsx +264 -0
- package/src/blocks/add-to-cart-block.tsx +417 -0
- package/src/blocks/address-list-block.tsx +348 -0
- package/src/blocks/applied-filters-block.tsx +288 -0
- package/src/blocks/auth-form-block.tsx +295 -0
- package/src/blocks/basket-items-block.tsx +457 -0
- package/src/blocks/basket-summary-block.tsx +404 -0
- package/src/blocks/block-renderer-registry.tsx +94 -0
- package/src/blocks/change-password-form-block.tsx +240 -0
- package/src/blocks/checkout-steps-block.tsx +288 -0
- package/src/blocks/checkout-summary-block.tsx +449 -0
- package/src/blocks/countdown-block.tsx +182 -0
- package/src/blocks/coupon-list-block.tsx +292 -0
- package/src/blocks/facet-filters-block.tsx +1686 -0
- package/src/blocks/faq-accordion-block.tsx +358 -0
- package/src/blocks/favorite-button-block.tsx +252 -0
- package/src/blocks/favourites-grid-block.tsx +510 -0
- package/src/blocks/forgot-password-block.tsx +155 -0
- package/src/blocks/item-template-utils.ts +131 -0
- package/src/blocks/item-template.tsx +164 -0
- package/src/blocks/listing-breadcrumb-block.tsx +175 -0
- package/src/blocks/listing-context.ts +850 -0
- package/src/blocks/listing-pagination-block.tsx +923 -0
- package/src/blocks/listing-result-count-block.tsx +126 -0
- package/src/blocks/listing-title-block.tsx +115 -0
- package/src/blocks/oauth-buttons-block.tsx +205 -0
- package/src/blocks/order-detail-block.tsx +474 -0
- package/src/blocks/order-list-block.tsx +274 -0
- package/src/blocks/order-success-block.tsx +641 -0
- package/src/blocks/order-tracking-block.tsx +533 -0
- package/src/blocks/product-attributes-block.tsx +307 -0
- package/src/blocks/product-block-utils.tsx +639 -0
- package/src/blocks/product-breadcrumb-block.tsx +215 -0
- package/src/blocks/product-description-block.tsx +169 -0
- package/src/blocks/product-gallery-block.tsx +1523 -0
- package/src/blocks/product-grid-block.tsx +966 -0
- package/src/blocks/product-price-block.tsx +373 -0
- package/src/blocks/product-recommendations-block.tsx +1433 -0
- package/src/blocks/product-title-block.tsx +129 -0
- package/src/blocks/profile-form-block.tsx +297 -0
- package/src/blocks/recently-viewed-store.ts +201 -0
- package/src/blocks/register-form-block.tsx +362 -0
- package/src/blocks/share-block.tsx +667 -0
- package/src/blocks/size-guide-block.tsx +313 -0
- package/src/blocks/sort-select-block.tsx +636 -0
- package/src/blocks/stock-status-block.tsx +168 -0
- package/src/blocks/store-locator-block.tsx +1184 -0
- package/src/blocks/variant-selector-block.tsx +1110 -0
- package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
- 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
|
+
);
|