@akinon/pz-theme 2.0.37-beta.0 → 2.0.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -4
- 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,1523 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { useOverlay } from '../hooks/use-overlay';
|
|
5
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
6
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
7
|
+
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
8
|
+
import {
|
|
9
|
+
DesignerPlaceholder,
|
|
10
|
+
ThemeProductImage,
|
|
11
|
+
getProductName,
|
|
12
|
+
pickProductField,
|
|
13
|
+
readToggle,
|
|
14
|
+
useThemeProduct
|
|
15
|
+
} from './product-block-utils';
|
|
16
|
+
|
|
17
|
+
const ALLOWED_ASPECT_RATIOS = ['1/1', '3/4', '4/3'];
|
|
18
|
+
const ALLOWED_OBJECT_FITS = ['cover', 'contain', 'fill', 'scale-down'];
|
|
19
|
+
const ALLOWED_THUMB_POSITIONS = ['bottom', 'top', 'left', 'right'];
|
|
20
|
+
const ALLOWED_LAYOUTS = ['carousel', 'grid'];
|
|
21
|
+
|
|
22
|
+
/** How far a one-finger drag must travel before it counts as a swipe. */
|
|
23
|
+
const SWIPE_THRESHOLD_PX = 48;
|
|
24
|
+
/** Below this a drag is a tap, not a gesture — thumbs wobble on every press. */
|
|
25
|
+
const DRAG_SLOP_PX = 4;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A merchant-authored image url, or '' when it is not one.
|
|
29
|
+
*
|
|
30
|
+
* The fallback photo and the high-resolution template are typed into the
|
|
31
|
+
* editor by a brand and both end up in `src`. Browsers strip whitespace and
|
|
32
|
+
* control characters while parsing a url, so a tab wedged into "java script:"
|
|
33
|
+
* still resolves to a live handler — the value is normalised the same way
|
|
34
|
+
* before it is judged.
|
|
35
|
+
*/
|
|
36
|
+
const sanitizeImageUrl = (value: unknown): string => {
|
|
37
|
+
const url = String(value ?? '').trim();
|
|
38
|
+
if (!url) return '';
|
|
39
|
+
|
|
40
|
+
const probe = url.replace(/[\u0000-\u0020]/g, '').toLowerCase();
|
|
41
|
+
if (/^(?:javascript|vbscript):/.test(probe)) return '';
|
|
42
|
+
|
|
43
|
+
// An inline raster image is a legitimate fallback; everything else in the
|
|
44
|
+
// data: scheme is markup, and svg is the one image type that carries markup
|
|
45
|
+
// with it.
|
|
46
|
+
if (probe.startsWith('data:')) {
|
|
47
|
+
return probe.startsWith('data:image/') &&
|
|
48
|
+
!probe.startsWith('data:image/svg')
|
|
49
|
+
? url
|
|
50
|
+
: '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return url;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const clamp = (value: number, min: number, max: number): number =>
|
|
57
|
+
Math.min(max, Math.max(min, value));
|
|
58
|
+
|
|
59
|
+
const pointerDistance = (
|
|
60
|
+
a: { x: number; y: number },
|
|
61
|
+
b: { x: number; y: number }
|
|
62
|
+
): number => Math.hypot(a.x - b.x, a.y - b.y);
|
|
63
|
+
|
|
64
|
+
const clampColumns = (value: unknown, fallback: number, max: number): number => {
|
|
65
|
+
const numeric = Math.round(Number(value));
|
|
66
|
+
|
|
67
|
+
return Number.isFinite(numeric) && numeric >= 1
|
|
68
|
+
? Math.min(numeric, max)
|
|
69
|
+
: fallback;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Accessible copy is authored, not hardcoded, so a non-English storefront can
|
|
74
|
+
* translate it. `{name}` / `{index}` / `{total}` are the bindings a brand gets.
|
|
75
|
+
*/
|
|
76
|
+
const fillTemplate = (
|
|
77
|
+
template: string,
|
|
78
|
+
values: { name: string; index: number; total: number }
|
|
79
|
+
): string =>
|
|
80
|
+
template
|
|
81
|
+
.replace(/\{name\}/g, values.name)
|
|
82
|
+
.replace(/\{index\}/g, String(values.index))
|
|
83
|
+
.replace(/\{total\}/g, String(values.total));
|
|
84
|
+
|
|
85
|
+
const ChevronIcon = ({ direction }: { direction: 'prev' | 'next' }) => (
|
|
86
|
+
<svg
|
|
87
|
+
width="16"
|
|
88
|
+
height="16"
|
|
89
|
+
viewBox="0 0 24 24"
|
|
90
|
+
fill="none"
|
|
91
|
+
stroke="currentColor"
|
|
92
|
+
strokeWidth="1.8"
|
|
93
|
+
strokeLinecap="round"
|
|
94
|
+
strokeLinejoin="round"
|
|
95
|
+
aria-hidden="true"
|
|
96
|
+
>
|
|
97
|
+
<path d={direction === 'prev' ? 'M15 5l-7 7 7 7' : 'M9 5l7 7-7 7'} />
|
|
98
|
+
</svg>
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const CloseIcon = () => (
|
|
102
|
+
<svg
|
|
103
|
+
width="20"
|
|
104
|
+
height="20"
|
|
105
|
+
viewBox="0 0 24 24"
|
|
106
|
+
fill="none"
|
|
107
|
+
stroke="currentColor"
|
|
108
|
+
strokeWidth="1.8"
|
|
109
|
+
strokeLinecap="round"
|
|
110
|
+
aria-hidden="true"
|
|
111
|
+
>
|
|
112
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
113
|
+
</svg>
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Functional PDP block: main product image plus a thumbnail strip, or — when
|
|
118
|
+
* the grid layout is chosen — every product photo laid out in columns. The
|
|
119
|
+
* image CONTENT stays commerce-driven (product.images / product.image); the
|
|
120
|
+
* editor owns placement, aspect ratio, thumbnail visibility, the optional
|
|
121
|
+
* arrows and dots, and — through named parts — the styling of every piece.
|
|
122
|
+
*
|
|
123
|
+
* Enlargement is the second half of the block and every piece of it is off
|
|
124
|
+
* until a designer asks for it:
|
|
125
|
+
*
|
|
126
|
+
* - POINTER ZOOM magnifies a region under the cursor. Commerce sends ONE url
|
|
127
|
+
* per photo, so by default the magnified layer is the SAME asset drawn
|
|
128
|
+
* larger — it goes soft past the intrinsic resolution, and that is the
|
|
129
|
+
* honest limit of one url. `highResTemplate` is how a brand asks its CDN for
|
|
130
|
+
* a bigger one (`{url}` is the binding, e.g. `{url}?width=2000`); if that
|
|
131
|
+
* url fails to load the photo falls back to the asset commerce sent, once,
|
|
132
|
+
* remembered per url so a dead template cannot become a request loop.
|
|
133
|
+
* - The VIEWER is a full-screen dialog with every photo reachable from inside
|
|
134
|
+
* it, an optional thumbnail rail, pinch-to-zoom, drag-to-pan and swipe. Its
|
|
135
|
+
* close button is not a toggle: a viewer a phone user cannot leave is worse
|
|
136
|
+
* than no viewer.
|
|
137
|
+
* - The FALLBACK photo covers the three cases that actually happen — a product
|
|
138
|
+
* with no images, a url that 404s wherever it is first requested (the viewer
|
|
139
|
+
* included, since lazy grid cells are often never fetched before it opens),
|
|
140
|
+
* and an empty string in the payload.
|
|
141
|
+
*
|
|
142
|
+
* Parts: main, image, zoom-image, zoom-hint, viewer-trigger, viewer-backdrop,
|
|
143
|
+
* viewer, viewer-close, viewer-stage, viewer-image, viewer-arrow,
|
|
144
|
+
* viewer-counter, viewer-thumb-list, viewer-thumb, viewer-thumb-image, arrow,
|
|
145
|
+
* dot-list, dot, thumb-list, thumb, thumb-image, grid.
|
|
146
|
+
* Properties: layout, gridColumnsDesktop, gridColumnsMobile, showThumbs,
|
|
147
|
+
* aspectRatio, objectFit, thumbSize, thumbPosition, thumbLimit, thumbLabel,
|
|
148
|
+
* gap, showArrows, loopArrows, prevLabel, nextLabel, showDots, dotDirection,
|
|
149
|
+
* dotLabel, showCounter, counterLabel, enableZoom, zoomLevel, zoomHint,
|
|
150
|
+
* highResTemplate, enableViewer, viewerOpenLabel, viewerTitle,
|
|
151
|
+
* viewerCloseLabel, viewerShowArrows, viewerPrevLabel, viewerNextLabel,
|
|
152
|
+
* viewerShowThumbs, viewerThumbLabel, viewerShowCounter, viewerCounterLabel,
|
|
153
|
+
* viewerCloseOnEscape, viewerLockScroll, viewerGestures, viewerMaxZoom,
|
|
154
|
+
* fallbackImage, fallbackAlt.
|
|
155
|
+
*/
|
|
156
|
+
const ProductGalleryBlock = ({
|
|
157
|
+
block,
|
|
158
|
+
placeholderId,
|
|
159
|
+
sectionId,
|
|
160
|
+
selectedBlockId = null,
|
|
161
|
+
currentBreakpoint = 'desktop',
|
|
162
|
+
isDesigner = false
|
|
163
|
+
}: BlockRendererProps) => {
|
|
164
|
+
const product = useThemeProduct();
|
|
165
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
166
|
+
/** Which frame the pointer is magnifying, and where inside it, in percent. */
|
|
167
|
+
const [zoomPoint, setZoomPoint] = useState<{
|
|
168
|
+
index: number;
|
|
169
|
+
x: number;
|
|
170
|
+
y: number;
|
|
171
|
+
} | null>(null);
|
|
172
|
+
const [viewerOpen, setViewerOpen] = useState(false);
|
|
173
|
+
const [viewerScale, setViewerScale] = useState(1);
|
|
174
|
+
const [viewerPan, setViewerPan] = useState({ x: 0, y: 0 });
|
|
175
|
+
/** Photo urls the browser could not load, so the fallback takes over. */
|
|
176
|
+
const [failedUrls, setFailedUrls] = useState<Record<string, boolean>>({});
|
|
177
|
+
/** Photos whose high-resolution variant does not exist at the CDN. */
|
|
178
|
+
const [failedHighRes, setFailedHighRes] = useState<Record<string, boolean>>(
|
|
179
|
+
{}
|
|
180
|
+
);
|
|
181
|
+
// Only populated by the grid layout; the carousel has a single frame.
|
|
182
|
+
const cellRefs = useRef<Array<HTMLDivElement | null>>([]);
|
|
183
|
+
/** What opened the viewer, so focus can be handed back to exactly that. */
|
|
184
|
+
const openerRef = useRef<HTMLElement | null>(null);
|
|
185
|
+
/** Every hit area that can open the viewer, by photo index. */
|
|
186
|
+
const triggerRefs = useRef<Array<HTMLElement | null>>([]);
|
|
187
|
+
const gestureRef = useRef<{
|
|
188
|
+
distance: number;
|
|
189
|
+
scale: number;
|
|
190
|
+
panX: number;
|
|
191
|
+
panY: number;
|
|
192
|
+
x: number;
|
|
193
|
+
y: number;
|
|
194
|
+
} | null>(null);
|
|
195
|
+
|
|
196
|
+
const prop = (key: string, fallback: string) =>
|
|
197
|
+
String(
|
|
198
|
+
getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const layoutValue = prop('layout', 'carousel');
|
|
202
|
+
const layout = ALLOWED_LAYOUTS.includes(layoutValue)
|
|
203
|
+
? layoutValue
|
|
204
|
+
: 'carousel';
|
|
205
|
+
const gridColumnsDesktop = clampColumns(
|
|
206
|
+
getResponsiveValue(
|
|
207
|
+
block.properties?.gridColumnsDesktop,
|
|
208
|
+
currentBreakpoint,
|
|
209
|
+
2
|
|
210
|
+
),
|
|
211
|
+
2,
|
|
212
|
+
4
|
|
213
|
+
);
|
|
214
|
+
const gridColumnsMobile = clampColumns(
|
|
215
|
+
getResponsiveValue(block.properties?.gridColumnsMobile, currentBreakpoint, 1),
|
|
216
|
+
1,
|
|
217
|
+
3
|
|
218
|
+
);
|
|
219
|
+
const showThumbs = readToggle(
|
|
220
|
+
block.properties?.showThumbs,
|
|
221
|
+
currentBreakpoint,
|
|
222
|
+
true
|
|
223
|
+
);
|
|
224
|
+
const aspectRatioValue = String(
|
|
225
|
+
getResponsiveValue(block.properties?.aspectRatio, currentBreakpoint, '3/4')
|
|
226
|
+
);
|
|
227
|
+
const aspectRatio = ALLOWED_ASPECT_RATIOS.includes(aspectRatioValue)
|
|
228
|
+
? aspectRatioValue
|
|
229
|
+
: '3/4';
|
|
230
|
+
const objectFitValue = prop('objectFit', 'cover');
|
|
231
|
+
const objectFit = (
|
|
232
|
+
ALLOWED_OBJECT_FITS.includes(objectFitValue) ? objectFitValue : 'cover'
|
|
233
|
+
) as React.CSSProperties['objectFit'];
|
|
234
|
+
const thumbPositionValue = prop('thumbPosition', 'bottom');
|
|
235
|
+
const thumbPosition = ALLOWED_THUMB_POSITIONS.includes(thumbPositionValue)
|
|
236
|
+
? thumbPositionValue
|
|
237
|
+
: 'bottom';
|
|
238
|
+
const thumbSize = Number(
|
|
239
|
+
getResponsiveValue(block.properties?.thumbSize, currentBreakpoint, 64)
|
|
240
|
+
);
|
|
241
|
+
const thumbLimit = Number(
|
|
242
|
+
getResponsiveValue(block.properties?.thumbLimit, currentBreakpoint, 0)
|
|
243
|
+
);
|
|
244
|
+
const gap = Number(
|
|
245
|
+
getResponsiveValue(block.properties?.gap, currentBreakpoint, 8)
|
|
246
|
+
);
|
|
247
|
+
const thumbLabel = prop('thumbLabel', '{name} image {index}');
|
|
248
|
+
const showArrows = readToggle(
|
|
249
|
+
block.properties?.showArrows,
|
|
250
|
+
currentBreakpoint,
|
|
251
|
+
false
|
|
252
|
+
);
|
|
253
|
+
const loopArrows = readToggle(
|
|
254
|
+
block.properties?.loopArrows,
|
|
255
|
+
currentBreakpoint,
|
|
256
|
+
true
|
|
257
|
+
);
|
|
258
|
+
const prevLabel = prop('prevLabel', 'Previous image');
|
|
259
|
+
const nextLabel = prop('nextLabel', 'Next image');
|
|
260
|
+
const showDots = readToggle(
|
|
261
|
+
block.properties?.showDots,
|
|
262
|
+
currentBreakpoint,
|
|
263
|
+
false
|
|
264
|
+
);
|
|
265
|
+
const showCounter = readToggle(
|
|
266
|
+
block.properties?.showCounter,
|
|
267
|
+
currentBreakpoint,
|
|
268
|
+
false
|
|
269
|
+
);
|
|
270
|
+
const counterLabel = prop('counterLabel', '{index} / {total}');
|
|
271
|
+
const dotDirectionValue = prop('dotDirection', 'row');
|
|
272
|
+
const dotDirection = dotDirectionValue === 'column' ? 'column' : 'row';
|
|
273
|
+
const dotLabel = prop('dotLabel', 'Go to image {index}');
|
|
274
|
+
|
|
275
|
+
const enableZoom = readToggle(
|
|
276
|
+
block.properties?.enableZoom,
|
|
277
|
+
currentBreakpoint,
|
|
278
|
+
false
|
|
279
|
+
);
|
|
280
|
+
const zoomLevelValue = Number(
|
|
281
|
+
getResponsiveValue(block.properties?.zoomLevel, currentBreakpoint, 2)
|
|
282
|
+
);
|
|
283
|
+
const zoomLevel = Number.isFinite(zoomLevelValue)
|
|
284
|
+
? clamp(zoomLevelValue, 1, 6)
|
|
285
|
+
: 2;
|
|
286
|
+
const zoomHint = prop('zoomHint', '');
|
|
287
|
+
/**
|
|
288
|
+
* Ignored unless it carries the binding: without `{url}` every photo would
|
|
289
|
+
* resolve to the same fixed address, which is a broken gallery rather than a
|
|
290
|
+
* sharper one.
|
|
291
|
+
*/
|
|
292
|
+
const highResTemplateValue = prop('highResTemplate', '');
|
|
293
|
+
const highResTemplate = highResTemplateValue.includes('{url}')
|
|
294
|
+
? highResTemplateValue
|
|
295
|
+
: '';
|
|
296
|
+
|
|
297
|
+
const enableViewer = readToggle(
|
|
298
|
+
block.properties?.enableViewer,
|
|
299
|
+
currentBreakpoint,
|
|
300
|
+
false
|
|
301
|
+
);
|
|
302
|
+
const viewerOpenLabel = prop('viewerOpenLabel', 'Enlarge image');
|
|
303
|
+
const viewerTitle = prop('viewerTitle', 'Product images');
|
|
304
|
+
const viewerCloseLabel = prop('viewerCloseLabel', 'Close');
|
|
305
|
+
const viewerShowArrows = readToggle(
|
|
306
|
+
block.properties?.viewerShowArrows,
|
|
307
|
+
currentBreakpoint,
|
|
308
|
+
true
|
|
309
|
+
);
|
|
310
|
+
const viewerPrevLabel = prop('viewerPrevLabel', 'Previous image');
|
|
311
|
+
const viewerNextLabel = prop('viewerNextLabel', 'Next image');
|
|
312
|
+
const viewerShowThumbs = readToggle(
|
|
313
|
+
block.properties?.viewerShowThumbs,
|
|
314
|
+
currentBreakpoint,
|
|
315
|
+
false
|
|
316
|
+
);
|
|
317
|
+
const viewerThumbLabel = prop('viewerThumbLabel', '{name} image {index}');
|
|
318
|
+
const viewerShowCounter = readToggle(
|
|
319
|
+
block.properties?.viewerShowCounter,
|
|
320
|
+
currentBreakpoint,
|
|
321
|
+
false
|
|
322
|
+
);
|
|
323
|
+
const viewerCounterLabel = prop('viewerCounterLabel', '{index} / {total}');
|
|
324
|
+
const viewerCloseOnEscape = readToggle(
|
|
325
|
+
block.properties?.viewerCloseOnEscape,
|
|
326
|
+
currentBreakpoint,
|
|
327
|
+
true
|
|
328
|
+
);
|
|
329
|
+
const viewerLockScroll = readToggle(
|
|
330
|
+
block.properties?.viewerLockScroll,
|
|
331
|
+
currentBreakpoint,
|
|
332
|
+
true
|
|
333
|
+
);
|
|
334
|
+
const viewerGestures = readToggle(
|
|
335
|
+
block.properties?.viewerGestures,
|
|
336
|
+
currentBreakpoint,
|
|
337
|
+
true
|
|
338
|
+
);
|
|
339
|
+
const viewerMaxZoomValue = Number(
|
|
340
|
+
getResponsiveValue(block.properties?.viewerMaxZoom, currentBreakpoint, 3)
|
|
341
|
+
);
|
|
342
|
+
const viewerMaxZoom = Number.isFinite(viewerMaxZoomValue)
|
|
343
|
+
? clamp(viewerMaxZoomValue, 1, 6)
|
|
344
|
+
: 3;
|
|
345
|
+
|
|
346
|
+
// Merchant-typed and headed for `src`, so it is judged before it is used.
|
|
347
|
+
const fallbackImage = sanitizeImageUrl(prop('fallbackImage', ''));
|
|
348
|
+
const fallbackAlt = prop('fallbackAlt', '');
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Where focus goes when the hit area it came from is gone by the time the
|
|
352
|
+
* viewer closes. A PDP swaps the photo list under the gallery when the
|
|
353
|
+
* shopper picks another colour, and it can do that with the viewer still up,
|
|
354
|
+
* taking that cell with it — the handover then lands on a detached node and
|
|
355
|
+
* a keyboard shopper is dropped at the top of the document, nowhere near the
|
|
356
|
+
* gallery they were reading. Read at close rather than captured at open, so
|
|
357
|
+
* it reports whichever hit area is still on the page.
|
|
358
|
+
*/
|
|
359
|
+
const survivingTriggerRef = useMemo(
|
|
360
|
+
() => ({
|
|
361
|
+
get current(): HTMLElement | null {
|
|
362
|
+
return triggerRefs.current.find((node) => node?.isConnected) ?? null;
|
|
363
|
+
}
|
|
364
|
+
}),
|
|
365
|
+
[]
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Focus, Escape, the scroll lock and the book of pointers still down are
|
|
370
|
+
* page-wide concerns rather than gallery ones — a page can carry a second
|
|
371
|
+
* gallery, or a filter panel, and every one of those pieces of state is
|
|
372
|
+
* shared. The primitive owns them so the overlays queue instead of fighting
|
|
373
|
+
* over focus, so the page's own overflow survives more than one holder, and
|
|
374
|
+
* so a finger still down when the viewer closes cannot stay booked for the
|
|
375
|
+
* rest of the session.
|
|
376
|
+
*/
|
|
377
|
+
const overlay = useOverlay<HTMLDivElement>({
|
|
378
|
+
open: enableViewer && viewerOpen,
|
|
379
|
+
onClose: () => setViewerOpen(false),
|
|
380
|
+
isDesigner,
|
|
381
|
+
closeOnEscape: viewerCloseOnEscape,
|
|
382
|
+
lockScroll: viewerLockScroll,
|
|
383
|
+
returnFocusRef: openerRef,
|
|
384
|
+
fallbackFocusRef: survivingTriggerRef,
|
|
385
|
+
// The pinch baseline was measured from pointers that are gone, so it has
|
|
386
|
+
// to be dropped with them or the next visit starts mid-gesture.
|
|
387
|
+
onDeactivate: () => {
|
|
388
|
+
gestureRef.current = null;
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const productImages = useMemo(() => {
|
|
393
|
+
const list = pickProductField<ThemeProductImage[]>(product, 'images');
|
|
394
|
+
const resolved = (Array.isArray(list) ? list : [])
|
|
395
|
+
.map((item) => ({
|
|
396
|
+
url: String(item?.url || item?.image || '').trim(),
|
|
397
|
+
alt: String(item?.alt || '').trim()
|
|
398
|
+
}))
|
|
399
|
+
.filter((item) => item.url);
|
|
400
|
+
|
|
401
|
+
if (resolved.length > 0) return resolved;
|
|
402
|
+
|
|
403
|
+
const single = String(
|
|
404
|
+
pickProductField<string>(product, 'image') || ''
|
|
405
|
+
).trim();
|
|
406
|
+
|
|
407
|
+
return single ? [{ url: single, alt: '' }] : [];
|
|
408
|
+
}, [product]);
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* A product with no photos at all — the payload carried none, or carried
|
|
412
|
+
* only empty strings, which the memo above drops for the same reason. With
|
|
413
|
+
* no fallback authored the block behaves exactly as it always has: nothing
|
|
414
|
+
* on the storefront, a hint on the canvas. With one, the gallery is that
|
|
415
|
+
* single photo, so the page keeps its shape instead of collapsing.
|
|
416
|
+
*
|
|
417
|
+
* The identity is deliberate: without a fallback this IS `productImages`,
|
|
418
|
+
* so nothing downstream can tell the two apart.
|
|
419
|
+
*/
|
|
420
|
+
const fallbackApplied = fallbackImage !== '' && productImages.length === 0;
|
|
421
|
+
const images = fallbackApplied
|
|
422
|
+
? [{ url: fallbackImage, alt: fallbackAlt }]
|
|
423
|
+
: productImages;
|
|
424
|
+
|
|
425
|
+
const imageCount = images.length;
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The grid has no hero, so "which photo am I on" is a scroll position. The
|
|
429
|
+
* band across the viewport middle is what the indicator reports; the effect
|
|
430
|
+
* does nothing at all in the carousel layout, which is why that path is
|
|
431
|
+
* unchanged.
|
|
432
|
+
*/
|
|
433
|
+
useEffect(() => {
|
|
434
|
+
// Cells write their ref by index and nothing ever shortens the array, so a
|
|
435
|
+
// product with fewer photos than the last render can leave a detached node
|
|
436
|
+
// sitting past the end. It survives .filter(Boolean) and shifts every index
|
|
437
|
+
// the observer reports, which points the indicator at the wrong photo.
|
|
438
|
+
cellRefs.current.length = imageCount;
|
|
439
|
+
|
|
440
|
+
if (layout !== 'grid' || !showDots || imageCount < 2) return;
|
|
441
|
+
if (typeof IntersectionObserver === 'undefined') return;
|
|
442
|
+
|
|
443
|
+
const nodes = cellRefs.current.filter(Boolean) as HTMLElement[];
|
|
444
|
+
if (nodes.length === 0) return;
|
|
445
|
+
|
|
446
|
+
const observer = new IntersectionObserver(
|
|
447
|
+
(entries) => {
|
|
448
|
+
entries.forEach((entry) => {
|
|
449
|
+
if (!entry.isIntersecting) return;
|
|
450
|
+
|
|
451
|
+
const index = nodes.indexOf(entry.target as HTMLElement);
|
|
452
|
+
if (index >= 0) setActiveIndex(index);
|
|
453
|
+
});
|
|
454
|
+
},
|
|
455
|
+
{ rootMargin: '-50% 0px -50% 0px', threshold: 0 }
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
nodes.forEach((node) => observer.observe(node));
|
|
459
|
+
|
|
460
|
+
return () => observer.disconnect();
|
|
461
|
+
}, [layout, showDots, imageCount]);
|
|
462
|
+
|
|
463
|
+
useEffect(() => {
|
|
464
|
+
// A photo the shopper zoomed into stays zoomed when they swipe to the next
|
|
465
|
+
// one otherwise, which lands them somewhere in the middle of a picture
|
|
466
|
+
// they have not seen yet. Closing resets it for the next open.
|
|
467
|
+
setViewerScale(1);
|
|
468
|
+
setViewerPan((current) =>
|
|
469
|
+
current.x === 0 && current.y === 0 ? current : { x: 0, y: 0 }
|
|
470
|
+
);
|
|
471
|
+
}, [activeIndex, viewerOpen]);
|
|
472
|
+
|
|
473
|
+
const hasCustomThumbs = hasItemTemplate(block, currentBreakpoint);
|
|
474
|
+
|
|
475
|
+
// Both layouts scope a stylesheet to their own instance, so the id is shared.
|
|
476
|
+
const galleryId = String(block.id || 'gallery').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
477
|
+
|
|
478
|
+
if (images.length === 0) {
|
|
479
|
+
return isDesigner ? (
|
|
480
|
+
<DesignerPlaceholder
|
|
481
|
+
label="Product gallery (no product images)"
|
|
482
|
+
attribute="data-product-gallery"
|
|
483
|
+
/>
|
|
484
|
+
) : null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const safeThumbSize = Number.isFinite(thumbSize) && thumbSize > 0 ? thumbSize : 64;
|
|
488
|
+
const safeGap = Number.isFinite(gap) && gap >= 0 ? gap : 8;
|
|
489
|
+
const safeIndex = Math.min(Math.max(activeIndex, 0), images.length - 1);
|
|
490
|
+
const activeImage = images[safeIndex];
|
|
491
|
+
const productName = getProductName(product) || 'Product';
|
|
492
|
+
// 0 means "all": a limit is only a cap, never a way to hide the strip.
|
|
493
|
+
const safeThumbLimit =
|
|
494
|
+
Number.isFinite(thumbLimit) && thumbLimit > 0
|
|
495
|
+
? Math.min(thumbLimit, images.length)
|
|
496
|
+
: images.length;
|
|
497
|
+
const thumbImages = images.slice(0, safeThumbLimit);
|
|
498
|
+
|
|
499
|
+
const isSideThumbs = thumbPosition === 'left' || thumbPosition === 'right';
|
|
500
|
+
const rootDirection =
|
|
501
|
+
thumbPosition === 'top'
|
|
502
|
+
? 'column-reverse'
|
|
503
|
+
: thumbPosition === 'left'
|
|
504
|
+
? 'row-reverse'
|
|
505
|
+
: thumbPosition === 'right'
|
|
506
|
+
? 'row'
|
|
507
|
+
: 'column';
|
|
508
|
+
|
|
509
|
+
const atFirst = safeIndex === 0;
|
|
510
|
+
const atLast = safeIndex === images.length - 1;
|
|
511
|
+
const step = (delta: number) => {
|
|
512
|
+
const next = safeIndex + delta;
|
|
513
|
+
if (next < 0) return loopArrows ? images.length - 1 : 0;
|
|
514
|
+
if (next > images.length - 1) return loopArrows ? 0 : images.length - 1;
|
|
515
|
+
return next;
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
// In the carousel a dot swaps the hero; in the grid every photo is already
|
|
519
|
+
// on the page, so it scrolls to the one the shopper asked for.
|
|
520
|
+
const selectImage = (index: number) => {
|
|
521
|
+
setActiveIndex(index);
|
|
522
|
+
|
|
523
|
+
if (layout !== 'grid') return;
|
|
524
|
+
|
|
525
|
+
const node = cellRefs.current[index];
|
|
526
|
+
if (node && typeof node.scrollIntoView === 'function') {
|
|
527
|
+
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* What one frame actually shows.
|
|
533
|
+
*
|
|
534
|
+
* `isFallback` is true either because the whole gallery IS the fallback (the
|
|
535
|
+
* product carried no photo) or because this photo's url failed to load — the
|
|
536
|
+
* two are the same thing to a shopper and to a designer styling the `empty`
|
|
537
|
+
* state, so they resolve to one answer here.
|
|
538
|
+
*
|
|
539
|
+
* With no fallback authored this is the identity: the src and alt are the
|
|
540
|
+
* exact expressions the block has always rendered.
|
|
541
|
+
*/
|
|
542
|
+
const resolveFrame = (image: { url: string; alt: string }) => {
|
|
543
|
+
const failed =
|
|
544
|
+
fallbackImage !== '' &&
|
|
545
|
+
image.url !== fallbackImage &&
|
|
546
|
+
failedUrls[image.url] === true;
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
src: failed ? fallbackImage : image.url,
|
|
550
|
+
alt: failed
|
|
551
|
+
? fallbackAlt || image.alt || productName
|
|
552
|
+
: image.alt || productName,
|
|
553
|
+
isFallback: failed || fallbackApplied
|
|
554
|
+
};
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Commerce sends one url per photo. Without a template the magnified layer
|
|
559
|
+
* is that same asset drawn larger — sharp up to its intrinsic resolution and
|
|
560
|
+
* soft past it. With one, the brand's CDN is asked for a bigger rendition;
|
|
561
|
+
* a rendition that does not exist is remembered so the next paint asks for
|
|
562
|
+
* the original instead of retrying forever.
|
|
563
|
+
*/
|
|
564
|
+
const highResFor = (url: string): string => {
|
|
565
|
+
if (!highResTemplate || !url || failedHighRes[url]) return url;
|
|
566
|
+
|
|
567
|
+
return sanitizeImageUrl(highResTemplate.replace(/\{url\}/g, url)) || url;
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Only attached once a fallback exists, so a gallery without one carries no
|
|
572
|
+
* handler at all. The fallback's own failure is ignored: there is nothing
|
|
573
|
+
* further to fall back to, and re-recording it would loop.
|
|
574
|
+
*/
|
|
575
|
+
const handleImageError = fallbackImage
|
|
576
|
+
? (url: string) => {
|
|
577
|
+
if (!url || url === fallbackImage) return;
|
|
578
|
+
setFailedUrls((current) =>
|
|
579
|
+
current[url] ? current : { ...current, [url]: true }
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
: undefined;
|
|
583
|
+
|
|
584
|
+
const handleHighResError = (url: string) => {
|
|
585
|
+
if (!url) return;
|
|
586
|
+
setFailedHighRes((current) =>
|
|
587
|
+
current[url] ? current : { ...current, [url]: true }
|
|
588
|
+
);
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* The viewer is regularly the FIRST thing to request a photo: grid cells
|
|
593
|
+
* past the first row are lazy, so one the shopper never scrolled to has
|
|
594
|
+
* never been fetched, and the viewer's request is the first anything hears
|
|
595
|
+
* of a dead url. Its failure therefore has to walk both steps rather than
|
|
596
|
+
* only the first — drop the larger rendition when that is what was asked
|
|
597
|
+
* for, and otherwise give up on the url itself so the fallback takes over
|
|
598
|
+
* and the frame reports `empty`. Without the second step the viewer sits on
|
|
599
|
+
* a broken image for the rest of the visit.
|
|
600
|
+
*
|
|
601
|
+
* The magnifier needs none of this: it draws over a frame whose own photo
|
|
602
|
+
* has already been requested, so a dead url is recorded before the
|
|
603
|
+
* magnified layer is ever painted.
|
|
604
|
+
*/
|
|
605
|
+
const handleViewerImageError = (url: string) => {
|
|
606
|
+
if (highResFor(url) !== url) {
|
|
607
|
+
handleHighResError(url);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
handleImageError?.(url);
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const openViewer = (index: number, opener: HTMLElement | null) => {
|
|
615
|
+
openerRef.current = opener;
|
|
616
|
+
setActiveIndex(index);
|
|
617
|
+
setViewerOpen(true);
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The transparent hit area that opens the viewer. A sibling rather than a
|
|
622
|
+
* wrapper: the photo keeps the exact element and part it has always had, and
|
|
623
|
+
* the arrows — which come after it in the frame — stay on top of it.
|
|
624
|
+
*/
|
|
625
|
+
const viewerTrigger = (index: number) =>
|
|
626
|
+
enableViewer ? (
|
|
627
|
+
<button
|
|
628
|
+
type="button"
|
|
629
|
+
ref={(node) => {
|
|
630
|
+
triggerRefs.current[index] = node;
|
|
631
|
+
}}
|
|
632
|
+
data-product-gallery-viewer-open=""
|
|
633
|
+
{...partAttrs('viewer-trigger')}
|
|
634
|
+
aria-haspopup="dialog"
|
|
635
|
+
aria-label={fillTemplate(viewerOpenLabel, {
|
|
636
|
+
name: productName,
|
|
637
|
+
index: index + 1,
|
|
638
|
+
total: imageCount
|
|
639
|
+
})}
|
|
640
|
+
onClick={(event) => openViewer(index, event.currentTarget)}
|
|
641
|
+
style={{
|
|
642
|
+
position: 'absolute',
|
|
643
|
+
inset: 0,
|
|
644
|
+
width: '100%',
|
|
645
|
+
height: '100%',
|
|
646
|
+
padding: 0,
|
|
647
|
+
border: 'none',
|
|
648
|
+
background: 'transparent',
|
|
649
|
+
cursor: 'zoom-in'
|
|
650
|
+
}}
|
|
651
|
+
/>
|
|
652
|
+
) : null;
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* The magnified layer. A separate image rather than a transform on the photo
|
|
656
|
+
* itself: the hero keeps its own element untouched, and while the larger
|
|
657
|
+
* asset is still downloading the shopper looks at the photo underneath
|
|
658
|
+
* instead of a blank frame.
|
|
659
|
+
*
|
|
660
|
+
* Touch pointers are ignored — a finger has no hover, and a tap there is the
|
|
661
|
+
* shopper asking for the viewer, not for a magnifier stuck under their thumb.
|
|
662
|
+
*/
|
|
663
|
+
const zoomLayer = (index: number, image: { url: string; alt: string }) => {
|
|
664
|
+
if (!enableZoom || !zoomPoint || zoomPoint.index !== index) return null;
|
|
665
|
+
|
|
666
|
+
const frame = resolveFrame(image);
|
|
667
|
+
|
|
668
|
+
return (
|
|
669
|
+
<img
|
|
670
|
+
data-product-gallery-zoom=""
|
|
671
|
+
{...partAttrs('zoom-image', { empty: frame.isFallback })}
|
|
672
|
+
src={highResFor(frame.src)}
|
|
673
|
+
alt=""
|
|
674
|
+
aria-hidden="true"
|
|
675
|
+
onError={() => handleHighResError(frame.src)}
|
|
676
|
+
style={{
|
|
677
|
+
position: 'absolute',
|
|
678
|
+
inset: 0,
|
|
679
|
+
width: '100%',
|
|
680
|
+
height: '100%',
|
|
681
|
+
objectFit,
|
|
682
|
+
transform: `scale(${zoomLevel})`,
|
|
683
|
+
transformOrigin: `${zoomPoint.x}% ${zoomPoint.y}%`,
|
|
684
|
+
// The magnifier must never eat the click that opens the viewer.
|
|
685
|
+
pointerEvents: 'none'
|
|
686
|
+
}}
|
|
687
|
+
/>
|
|
688
|
+
);
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Magnify is a hover affordance, and a finger has no hover — so with the
|
|
693
|
+
* viewer switched off there is no enlargement a touch shopper can reach at
|
|
694
|
+
* all. The hint would still be drawn over the photo, and a brand that
|
|
695
|
+
* authored "Tap to enlarge" would be promising something a tap cannot
|
|
696
|
+
* deliver. On a pointer that cannot hover it is therefore not drawn.
|
|
697
|
+
*
|
|
698
|
+
* A media rule rather than a runtime check: the block is server-rendered and
|
|
699
|
+
* hover capability is not knowable until the client, so deciding this in JS
|
|
700
|
+
* would mean shipping one answer and then correcting it.
|
|
701
|
+
*/
|
|
702
|
+
const hintNeedsHoverGate = zoomHint !== '' && enableZoom && !enableViewer;
|
|
703
|
+
|
|
704
|
+
const hintHoverGateNode = hintNeedsHoverGate ? (
|
|
705
|
+
<style>
|
|
706
|
+
{`@media (hover:none){[data-product-gallery-id="${galleryId}"] [data-part="zoom-hint"]{display:none;}}`}
|
|
707
|
+
</style>
|
|
708
|
+
) : null;
|
|
709
|
+
|
|
710
|
+
const hintNode =
|
|
711
|
+
(enableZoom || enableViewer) && zoomHint ? (
|
|
712
|
+
<span
|
|
713
|
+
data-product-gallery-zoom-hint=""
|
|
714
|
+
{...partAttrs('zoom-hint')}
|
|
715
|
+
aria-hidden="true"
|
|
716
|
+
style={{
|
|
717
|
+
position: 'absolute',
|
|
718
|
+
left: '8px',
|
|
719
|
+
bottom: '8px',
|
|
720
|
+
padding: '2px 8px',
|
|
721
|
+
background: 'var(--product-gallery-hint-bg, rgba(255, 255, 255, 0.85))',
|
|
722
|
+
color: 'inherit',
|
|
723
|
+
pointerEvents: 'none'
|
|
724
|
+
}}
|
|
725
|
+
>
|
|
726
|
+
{zoomHint}
|
|
727
|
+
</span>
|
|
728
|
+
) : null;
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Pointer zoom is a mouse affordance, and in the designer the author's
|
|
732
|
+
* pointer belongs to the canvas — magnifying under it would fight every
|
|
733
|
+
* attempt to select or drag the block. The `open` state is still reachable
|
|
734
|
+
* there through the editor's part-state preview.
|
|
735
|
+
*/
|
|
736
|
+
const zoomHandlers = (index: number) =>
|
|
737
|
+
enableZoom && !isDesigner
|
|
738
|
+
? {
|
|
739
|
+
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => {
|
|
740
|
+
if (event.pointerType === 'touch') return;
|
|
741
|
+
|
|
742
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
743
|
+
if (!rect.width || !rect.height) return;
|
|
744
|
+
|
|
745
|
+
setZoomPoint({
|
|
746
|
+
index,
|
|
747
|
+
x: clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100),
|
|
748
|
+
y: clamp(((event.clientY - rect.top) / rect.height) * 100, 0, 100)
|
|
749
|
+
});
|
|
750
|
+
},
|
|
751
|
+
onPointerLeave: () => setZoomPoint(null)
|
|
752
|
+
}
|
|
753
|
+
: null;
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Reported as `open` rather than `active`: the `active` state also carries
|
|
757
|
+
* the browser's own `:active`, so a brand styling "while the cursor is
|
|
758
|
+
* magnifying it" would get that styling on every mousedown — including on a
|
|
759
|
+
* gallery with magnify switched off entirely.
|
|
760
|
+
*/
|
|
761
|
+
const isZooming = (index: number) =>
|
|
762
|
+
zoomPoint !== null && zoomPoint.index === index;
|
|
763
|
+
|
|
764
|
+
// A frame only needs a positioning context once something is layered over
|
|
765
|
+
// it; adding one unconditionally would change how a brand's own CSS lands.
|
|
766
|
+
const frameNeedsPosition = showArrows || enableViewer || enableZoom;
|
|
767
|
+
|
|
768
|
+
const arrowStyle = (side: 'left' | 'right'): React.CSSProperties => ({
|
|
769
|
+
position: 'absolute',
|
|
770
|
+
top: '50%',
|
|
771
|
+
[side]: '8px',
|
|
772
|
+
transform: 'translateY(-50%)',
|
|
773
|
+
display: 'inline-flex',
|
|
774
|
+
alignItems: 'center',
|
|
775
|
+
justifyContent: 'center',
|
|
776
|
+
width: '32px',
|
|
777
|
+
height: '32px',
|
|
778
|
+
padding: 0,
|
|
779
|
+
border: 'none',
|
|
780
|
+
borderRadius: '50%',
|
|
781
|
+
background: 'var(--product-gallery-arrow-bg, rgba(255, 255, 255, 0.85))',
|
|
782
|
+
color: 'inherit',
|
|
783
|
+
cursor: 'pointer'
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
// "2 / 10" — the position read as text rather than counted off a dot row.
|
|
787
|
+
// Both are allowed at once: a design that uses dots on desktop and the
|
|
788
|
+
// readout on a phone is a breakpoint choice, not an exclusive one.
|
|
789
|
+
const counterNode = showCounter && imageCount > 0 && (
|
|
790
|
+
<div
|
|
791
|
+
data-product-gallery-counter=""
|
|
792
|
+
{...partAttrs('counter')}
|
|
793
|
+
// Announced only when the shopper moves, not read out mid-swipe.
|
|
794
|
+
aria-live="polite"
|
|
795
|
+
aria-atomic="true"
|
|
796
|
+
style={{ textAlign: 'center' }}
|
|
797
|
+
>
|
|
798
|
+
{fillTemplate(counterLabel, {
|
|
799
|
+
name: productName,
|
|
800
|
+
index: safeIndex + 1,
|
|
801
|
+
total: imageCount
|
|
802
|
+
})}
|
|
803
|
+
</div>
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
const dotsNode = showDots && images.length > 1 && (
|
|
807
|
+
<div
|
|
808
|
+
data-product-gallery-dots=""
|
|
809
|
+
{...partAttrs('dot-list')}
|
|
810
|
+
style={{
|
|
811
|
+
display: 'flex',
|
|
812
|
+
alignItems: 'center',
|
|
813
|
+
justifyContent: 'center',
|
|
814
|
+
gap: '6px',
|
|
815
|
+
// Only written for the column indicator, so the existing dot row keeps
|
|
816
|
+
// the exact style attribute it has always rendered.
|
|
817
|
+
...(dotDirection === 'column' ? { flexDirection: 'column' } : null)
|
|
818
|
+
}}
|
|
819
|
+
>
|
|
820
|
+
{images.map((image, index) => (
|
|
821
|
+
<button
|
|
822
|
+
key={`dot-${image.url}-${index}`}
|
|
823
|
+
type="button"
|
|
824
|
+
data-product-gallery-dot={index === safeIndex ? 'active' : 'default'}
|
|
825
|
+
{...partAttrs('dot', { selected: index === safeIndex })}
|
|
826
|
+
aria-label={fillTemplate(dotLabel, {
|
|
827
|
+
name: productName,
|
|
828
|
+
index: index + 1,
|
|
829
|
+
total: images.length
|
|
830
|
+
})}
|
|
831
|
+
aria-current={index === safeIndex}
|
|
832
|
+
onClick={() => selectImage(index)}
|
|
833
|
+
style={{
|
|
834
|
+
width: '8px',
|
|
835
|
+
height: '8px',
|
|
836
|
+
padding: 0,
|
|
837
|
+
border: 'none',
|
|
838
|
+
borderRadius: '50%',
|
|
839
|
+
cursor: 'pointer',
|
|
840
|
+
background:
|
|
841
|
+
index === safeIndex
|
|
842
|
+
? 'currentColor'
|
|
843
|
+
: 'var(--product-gallery-dot, #d1d5db)'
|
|
844
|
+
}}
|
|
845
|
+
/>
|
|
846
|
+
))}
|
|
847
|
+
</div>
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
// A designer who marked a child as the thumbnail layout gets that layout
|
|
851
|
+
// repeated per image; otherwise the built-in thumbnail button renders.
|
|
852
|
+
const customThumbs = hasCustomThumbs ? (
|
|
853
|
+
<ItemTemplateList
|
|
854
|
+
block={block}
|
|
855
|
+
items={thumbImages.map((image, index) => ({
|
|
856
|
+
image: image.url,
|
|
857
|
+
alt: image.alt || productName,
|
|
858
|
+
name: productName,
|
|
859
|
+
index: index + 1,
|
|
860
|
+
total: images.length,
|
|
861
|
+
active: index === safeIndex
|
|
862
|
+
}))}
|
|
863
|
+
placeholderId={placeholderId}
|
|
864
|
+
sectionId={sectionId}
|
|
865
|
+
isDesigner={isDesigner}
|
|
866
|
+
selectedBlockId={selectedBlockId}
|
|
867
|
+
currentBreakpoint={currentBreakpoint}
|
|
868
|
+
designerPlaceholderCount={4}
|
|
869
|
+
/>
|
|
870
|
+
) : null;
|
|
871
|
+
|
|
872
|
+
const viewerFrame = resolveFrame(activeImage);
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Pinch, pan and swipe share one pointer book: a phone reports each finger
|
|
876
|
+
* separately, and telling a two-finger pinch from a one-finger drag is only
|
|
877
|
+
* possible by counting the ones that are still down. The book belongs to the
|
|
878
|
+
* overlay because a finger still down when the viewer closes is never
|
|
879
|
+
* followed by a pointerup — kept per block, that id stayed booked for the
|
|
880
|
+
* rest of the session and every later one-finger drag was read as the second
|
|
881
|
+
* finger of a pinch that never started, so no swipe fired again.
|
|
882
|
+
*/
|
|
883
|
+
const pointers = overlay.pointers;
|
|
884
|
+
|
|
885
|
+
const handleStagePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
886
|
+
if (!viewerGestures) return;
|
|
887
|
+
|
|
888
|
+
// Tracking also takes pointer capture, which keeps the gesture alive when
|
|
889
|
+
// the finger leaves the stage — exactly what a fast swipe does — and the
|
|
890
|
+
// overlay hands every capture back when it closes.
|
|
891
|
+
pointers.track(event);
|
|
892
|
+
|
|
893
|
+
const points = pointers.points();
|
|
894
|
+
|
|
895
|
+
gestureRef.current = {
|
|
896
|
+
distance:
|
|
897
|
+
points.length >= 2 ? pointerDistance(points[0], points[1]) : 0,
|
|
898
|
+
scale: viewerScale,
|
|
899
|
+
panX: viewerPan.x,
|
|
900
|
+
panY: viewerPan.y,
|
|
901
|
+
x: event.clientX,
|
|
902
|
+
y: event.clientY
|
|
903
|
+
};
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
const handleStagePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
907
|
+
const gesture = gestureRef.current;
|
|
908
|
+
if (!viewerGestures || !gesture || !pointers.has(event.pointerId)) return;
|
|
909
|
+
|
|
910
|
+
pointers.track(event);
|
|
911
|
+
const points = pointers.points();
|
|
912
|
+
|
|
913
|
+
if (points.length >= 2) {
|
|
914
|
+
const spread = pointerDistance(points[0], points[1]);
|
|
915
|
+
// The second finger can land a frame after the first, so the baseline is
|
|
916
|
+
// taken on the first move that actually has two.
|
|
917
|
+
if (!gesture.distance) {
|
|
918
|
+
gesture.distance = spread;
|
|
919
|
+
gesture.scale = viewerScale;
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
setViewerScale(
|
|
924
|
+
clamp((gesture.scale * spread) / gesture.distance, 1, viewerMaxZoom)
|
|
925
|
+
);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// One finger on a photo that is already magnified moves the photo; on one
|
|
930
|
+
// that is not, the same drag is on its way to being a swipe.
|
|
931
|
+
if (viewerScale > 1) {
|
|
932
|
+
setViewerPan({
|
|
933
|
+
x: gesture.panX + (event.clientX - gesture.x),
|
|
934
|
+
y: gesture.panY + (event.clientY - gesture.y)
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
const handleStagePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
940
|
+
const gesture = gestureRef.current;
|
|
941
|
+
pointers.release(event.pointerId);
|
|
942
|
+
|
|
943
|
+
if (!viewerGestures || !gesture) return;
|
|
944
|
+
|
|
945
|
+
if (pointers.size() > 0) {
|
|
946
|
+
// Coming out of a pinch with one finger still down: re-baseline against
|
|
947
|
+
// it so the photo does not jump when the drag continues.
|
|
948
|
+
const [remaining] = pointers.points();
|
|
949
|
+
gestureRef.current = {
|
|
950
|
+
distance: 0,
|
|
951
|
+
scale: viewerScale,
|
|
952
|
+
panX: viewerPan.x,
|
|
953
|
+
panY: viewerPan.y,
|
|
954
|
+
x: remaining.x,
|
|
955
|
+
y: remaining.y
|
|
956
|
+
};
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
gestureRef.current = null;
|
|
961
|
+
|
|
962
|
+
const dx = event.clientX - gesture.x;
|
|
963
|
+
const dy = event.clientY - gesture.y;
|
|
964
|
+
|
|
965
|
+
if (Math.abs(dx) < DRAG_SLOP_PX && Math.abs(dy) < DRAG_SLOP_PX) return;
|
|
966
|
+
// A magnified photo is being panned, not swiped away.
|
|
967
|
+
if (viewerScale > 1 || images.length < 2) return;
|
|
968
|
+
|
|
969
|
+
if (Math.abs(dx) >= SWIPE_THRESHOLD_PX && Math.abs(dx) > Math.abs(dy)) {
|
|
970
|
+
setActiveIndex(step(dx < 0 ? 1 : -1));
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* The way back out of a pinch, and the only zoom a mouse has inside the
|
|
976
|
+
* viewer. Browsers synthesise `dblclick` from a double tap, so one handler
|
|
977
|
+
* serves both.
|
|
978
|
+
*/
|
|
979
|
+
const handleStageDoubleClick = () => {
|
|
980
|
+
if (!viewerGestures) return;
|
|
981
|
+
|
|
982
|
+
setViewerScale((current) => (current > 1 ? 1 : viewerMaxZoom));
|
|
983
|
+
setViewerPan({ x: 0, y: 0 });
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
const viewerNode = enableViewer && viewerOpen && (
|
|
987
|
+
<div
|
|
988
|
+
data-product-gallery-viewer-root=""
|
|
989
|
+
style={{
|
|
990
|
+
position: 'fixed',
|
|
991
|
+
inset: 0,
|
|
992
|
+
zIndex: 1000,
|
|
993
|
+
display: 'flex'
|
|
994
|
+
}}
|
|
995
|
+
>
|
|
996
|
+
{/* The dark sheet, and only that. The dialog below is a full-viewport
|
|
997
|
+
sibling painted after it, so nothing a shopper taps ever reaches
|
|
998
|
+
this element — a dismiss handler here would be dead code that reads
|
|
999
|
+
like an affordance. The reachable ways out are the close button and
|
|
1000
|
+
Escape; the stage cannot be one of them, because the first half of a
|
|
1001
|
+
double-tap-to-zoom lands there. */}
|
|
1002
|
+
<div
|
|
1003
|
+
data-product-gallery-viewer-backdrop=""
|
|
1004
|
+
{...partAttrs('viewer-backdrop', { open: true })}
|
|
1005
|
+
style={{
|
|
1006
|
+
position: 'absolute',
|
|
1007
|
+
inset: 0,
|
|
1008
|
+
background: 'var(--product-gallery-viewer-bg, rgba(0, 0, 0, 0.92))'
|
|
1009
|
+
}}
|
|
1010
|
+
/>
|
|
1011
|
+
|
|
1012
|
+
<div
|
|
1013
|
+
data-product-gallery-viewer=""
|
|
1014
|
+
{...partAttrs('viewer', { open: true })}
|
|
1015
|
+
/**
|
|
1016
|
+
* Carries the ref and the `aria-modal` claim, which the primitive
|
|
1017
|
+
* makes only where it is true: not in the designer, where focus is
|
|
1018
|
+
* deliberately left with the author, and not for the lower of two open
|
|
1019
|
+
* dialogs, which is not the one holding the page.
|
|
1020
|
+
*/
|
|
1021
|
+
{...overlay.overlayProps}
|
|
1022
|
+
role="dialog"
|
|
1023
|
+
aria-label={viewerTitle}
|
|
1024
|
+
// Focus target of last resort, before the close button mounts.
|
|
1025
|
+
tabIndex={-1}
|
|
1026
|
+
style={{
|
|
1027
|
+
position: 'relative',
|
|
1028
|
+
display: 'flex',
|
|
1029
|
+
flexDirection: 'column',
|
|
1030
|
+
margin: 'auto',
|
|
1031
|
+
width: '100%',
|
|
1032
|
+
height: '100%',
|
|
1033
|
+
color: 'var(--product-gallery-viewer-color, #ffffff)',
|
|
1034
|
+
outline: 'none'
|
|
1035
|
+
}}
|
|
1036
|
+
>
|
|
1037
|
+
{/* Not a toggle. Every other way out is a designer choice, and a
|
|
1038
|
+
viewer a phone user cannot leave is worse than no viewer. */}
|
|
1039
|
+
<button
|
|
1040
|
+
type="button"
|
|
1041
|
+
data-product-gallery-viewer-close=""
|
|
1042
|
+
{...partAttrs('viewer-close')}
|
|
1043
|
+
aria-label={viewerCloseLabel}
|
|
1044
|
+
onClick={() => setViewerOpen(false)}
|
|
1045
|
+
style={{
|
|
1046
|
+
position: 'absolute',
|
|
1047
|
+
top: '12px',
|
|
1048
|
+
right: '12px',
|
|
1049
|
+
zIndex: 2,
|
|
1050
|
+
display: 'inline-flex',
|
|
1051
|
+
alignItems: 'center',
|
|
1052
|
+
justifyContent: 'center',
|
|
1053
|
+
width: '40px',
|
|
1054
|
+
height: '40px',
|
|
1055
|
+
padding: 0,
|
|
1056
|
+
border: 'none',
|
|
1057
|
+
background: 'transparent',
|
|
1058
|
+
color: 'inherit',
|
|
1059
|
+
cursor: 'pointer'
|
|
1060
|
+
}}
|
|
1061
|
+
>
|
|
1062
|
+
<CloseIcon />
|
|
1063
|
+
</button>
|
|
1064
|
+
|
|
1065
|
+
<div
|
|
1066
|
+
data-product-gallery-viewer-stage=""
|
|
1067
|
+
{...partAttrs('viewer-stage', { active: viewerScale > 1 })}
|
|
1068
|
+
onPointerDown={handleStagePointerDown}
|
|
1069
|
+
onPointerMove={handleStagePointerMove}
|
|
1070
|
+
onPointerUp={handleStagePointerUp}
|
|
1071
|
+
onPointerCancel={handleStagePointerUp}
|
|
1072
|
+
onDoubleClick={handleStageDoubleClick}
|
|
1073
|
+
style={{
|
|
1074
|
+
position: 'relative',
|
|
1075
|
+
flex: '1 1 auto',
|
|
1076
|
+
minHeight: 0,
|
|
1077
|
+
overflow: 'hidden',
|
|
1078
|
+
display: 'flex',
|
|
1079
|
+
alignItems: 'center',
|
|
1080
|
+
justifyContent: 'center',
|
|
1081
|
+
// Without this the browser claims the pinch and the drag for its
|
|
1082
|
+
// own page zoom and scroll, and neither gesture ever arrives.
|
|
1083
|
+
touchAction: viewerGestures ? 'none' : undefined
|
|
1084
|
+
}}
|
|
1085
|
+
>
|
|
1086
|
+
<img
|
|
1087
|
+
data-product-gallery-viewer-image=""
|
|
1088
|
+
{...partAttrs('viewer-image', {
|
|
1089
|
+
empty: viewerFrame.isFallback,
|
|
1090
|
+
active: viewerScale > 1
|
|
1091
|
+
})}
|
|
1092
|
+
src={highResFor(viewerFrame.src)}
|
|
1093
|
+
alt={viewerFrame.alt}
|
|
1094
|
+
draggable={false}
|
|
1095
|
+
onError={() => handleViewerImageError(viewerFrame.src)}
|
|
1096
|
+
style={{
|
|
1097
|
+
maxWidth: '100%',
|
|
1098
|
+
maxHeight: '100%',
|
|
1099
|
+
objectFit: 'contain',
|
|
1100
|
+
transform: `translate(${viewerPan.x}px, ${viewerPan.y}px) scale(${viewerScale})`,
|
|
1101
|
+
// The photo is the thing being dragged; the stage above owns the
|
|
1102
|
+
// pointer so a drag that starts on the image is not two gestures.
|
|
1103
|
+
pointerEvents: 'none',
|
|
1104
|
+
userSelect: 'none'
|
|
1105
|
+
}}
|
|
1106
|
+
/>
|
|
1107
|
+
|
|
1108
|
+
{viewerShowArrows && images.length > 1 && (
|
|
1109
|
+
<>
|
|
1110
|
+
<button
|
|
1111
|
+
type="button"
|
|
1112
|
+
data-product-gallery-viewer-arrow="prev"
|
|
1113
|
+
{...partAttrs('viewer-arrow', {
|
|
1114
|
+
disabled: !loopArrows && atFirst
|
|
1115
|
+
})}
|
|
1116
|
+
aria-label={viewerPrevLabel}
|
|
1117
|
+
disabled={!loopArrows && atFirst}
|
|
1118
|
+
onClick={() => setActiveIndex(step(-1))}
|
|
1119
|
+
style={arrowStyle('left')}
|
|
1120
|
+
>
|
|
1121
|
+
<ChevronIcon direction="prev" />
|
|
1122
|
+
</button>
|
|
1123
|
+
<button
|
|
1124
|
+
type="button"
|
|
1125
|
+
data-product-gallery-viewer-arrow="next"
|
|
1126
|
+
{...partAttrs('viewer-arrow', {
|
|
1127
|
+
disabled: !loopArrows && atLast
|
|
1128
|
+
})}
|
|
1129
|
+
aria-label={viewerNextLabel}
|
|
1130
|
+
disabled={!loopArrows && atLast}
|
|
1131
|
+
onClick={() => setActiveIndex(step(1))}
|
|
1132
|
+
style={arrowStyle('right')}
|
|
1133
|
+
>
|
|
1134
|
+
<ChevronIcon direction="next" />
|
|
1135
|
+
</button>
|
|
1136
|
+
</>
|
|
1137
|
+
)}
|
|
1138
|
+
</div>
|
|
1139
|
+
|
|
1140
|
+
{viewerShowCounter && (
|
|
1141
|
+
<div
|
|
1142
|
+
data-product-gallery-viewer-counter=""
|
|
1143
|
+
{...partAttrs('viewer-counter')}
|
|
1144
|
+
aria-live="polite"
|
|
1145
|
+
aria-atomic="true"
|
|
1146
|
+
style={{ flex: '0 0 auto', padding: '8px', textAlign: 'center' }}
|
|
1147
|
+
>
|
|
1148
|
+
{fillTemplate(viewerCounterLabel, {
|
|
1149
|
+
name: productName,
|
|
1150
|
+
index: safeIndex + 1,
|
|
1151
|
+
total: imageCount
|
|
1152
|
+
})}
|
|
1153
|
+
</div>
|
|
1154
|
+
)}
|
|
1155
|
+
|
|
1156
|
+
{viewerShowThumbs && images.length > 1 && (
|
|
1157
|
+
<div
|
|
1158
|
+
data-product-gallery-viewer-thumbs=""
|
|
1159
|
+
{...partAttrs('viewer-thumb-list')}
|
|
1160
|
+
style={{
|
|
1161
|
+
display: 'flex',
|
|
1162
|
+
flex: '0 0 auto',
|
|
1163
|
+
gap: `${safeGap}px`,
|
|
1164
|
+
padding: '8px',
|
|
1165
|
+
justifyContent: 'center',
|
|
1166
|
+
overflowX: 'auto',
|
|
1167
|
+
flexWrap: 'nowrap'
|
|
1168
|
+
}}
|
|
1169
|
+
>
|
|
1170
|
+
{images.map((image, index) => {
|
|
1171
|
+
const thumbFrame = resolveFrame(image);
|
|
1172
|
+
|
|
1173
|
+
return (
|
|
1174
|
+
<button
|
|
1175
|
+
key={`viewer-thumb-${image.url}-${index}`}
|
|
1176
|
+
type="button"
|
|
1177
|
+
data-product-gallery-viewer-thumb={
|
|
1178
|
+
index === safeIndex ? 'active' : 'default'
|
|
1179
|
+
}
|
|
1180
|
+
{...partAttrs('viewer-thumb', {
|
|
1181
|
+
selected: index === safeIndex,
|
|
1182
|
+
empty: thumbFrame.isFallback
|
|
1183
|
+
})}
|
|
1184
|
+
aria-label={fillTemplate(viewerThumbLabel, {
|
|
1185
|
+
name: productName,
|
|
1186
|
+
index: index + 1,
|
|
1187
|
+
total: imageCount
|
|
1188
|
+
})}
|
|
1189
|
+
aria-current={index === safeIndex}
|
|
1190
|
+
onClick={() => setActiveIndex(index)}
|
|
1191
|
+
style={{
|
|
1192
|
+
flex: '0 0 auto',
|
|
1193
|
+
width: `${safeThumbSize}px`,
|
|
1194
|
+
height: `${safeThumbSize}px`,
|
|
1195
|
+
padding: 0,
|
|
1196
|
+
cursor: 'pointer',
|
|
1197
|
+
overflow: 'hidden',
|
|
1198
|
+
background: 'transparent',
|
|
1199
|
+
border:
|
|
1200
|
+
index === safeIndex
|
|
1201
|
+
? '1px solid var(--product-gallery-thumb-active, currentColor)'
|
|
1202
|
+
: '1px solid var(--product-gallery-thumb-border, rgba(255, 255, 255, 0.4))'
|
|
1203
|
+
}}
|
|
1204
|
+
>
|
|
1205
|
+
<img
|
|
1206
|
+
src={thumbFrame.src}
|
|
1207
|
+
alt=""
|
|
1208
|
+
loading="lazy"
|
|
1209
|
+
{...partAttrs('viewer-thumb-image')}
|
|
1210
|
+
onError={
|
|
1211
|
+
handleImageError
|
|
1212
|
+
? () => handleImageError(image.url)
|
|
1213
|
+
: undefined
|
|
1214
|
+
}
|
|
1215
|
+
style={{
|
|
1216
|
+
display: 'block',
|
|
1217
|
+
width: '100%',
|
|
1218
|
+
height: '100%',
|
|
1219
|
+
objectFit: 'cover'
|
|
1220
|
+
}}
|
|
1221
|
+
/>
|
|
1222
|
+
</button>
|
|
1223
|
+
);
|
|
1224
|
+
})}
|
|
1225
|
+
</div>
|
|
1226
|
+
)}
|
|
1227
|
+
</div>
|
|
1228
|
+
</div>
|
|
1229
|
+
);
|
|
1230
|
+
|
|
1231
|
+
if (layout === 'grid') {
|
|
1232
|
+
// Column counts are block PROPERTIES, and properties resolve against the
|
|
1233
|
+
// designer breakpoint only — on the storefront currentBreakpoint is always
|
|
1234
|
+
// 'desktop'. A scoped rule with the same 1023px cutoff the theme CSS
|
|
1235
|
+
// generator uses keeps real mobile viewports in sync with the editor.
|
|
1236
|
+
const baseColumns =
|
|
1237
|
+
currentBreakpoint === 'mobile' ? gridColumnsMobile : gridColumnsDesktop;
|
|
1238
|
+
|
|
1239
|
+
// The same authored row the thumbnail strip would repeat, given every
|
|
1240
|
+
// photo: a designer who built one keeps it in this layout too.
|
|
1241
|
+
const customCells = hasCustomThumbs ? (
|
|
1242
|
+
<ItemTemplateList
|
|
1243
|
+
block={block}
|
|
1244
|
+
items={images.map((image, index) => ({
|
|
1245
|
+
image: image.url,
|
|
1246
|
+
alt: image.alt || productName,
|
|
1247
|
+
name: productName,
|
|
1248
|
+
index: index + 1,
|
|
1249
|
+
total: images.length,
|
|
1250
|
+
active: index === safeIndex
|
|
1251
|
+
}))}
|
|
1252
|
+
placeholderId={placeholderId}
|
|
1253
|
+
sectionId={sectionId}
|
|
1254
|
+
isDesigner={isDesigner}
|
|
1255
|
+
selectedBlockId={selectedBlockId}
|
|
1256
|
+
currentBreakpoint={currentBreakpoint}
|
|
1257
|
+
designerPlaceholderCount={4}
|
|
1258
|
+
/>
|
|
1259
|
+
) : null;
|
|
1260
|
+
|
|
1261
|
+
// The indicator follows the scroll through refs only the built-in cells
|
|
1262
|
+
// carry, and it scrolls to a cell the same way. An authored cell template
|
|
1263
|
+
// exposes neither, so the dots there would render and do nothing at all —
|
|
1264
|
+
// better no indicator than a dead one.
|
|
1265
|
+
const gridDots = customCells ? null : dotsNode;
|
|
1266
|
+
// Same reason as the dots: without the built-in cells there is nothing to
|
|
1267
|
+
// observe, so the number would sit on 1 for the whole scroll.
|
|
1268
|
+
const gridCounter = customCells ? null : counterNode;
|
|
1269
|
+
|
|
1270
|
+
return (
|
|
1271
|
+
<div
|
|
1272
|
+
data-product-gallery=""
|
|
1273
|
+
data-product-gallery-layout="grid"
|
|
1274
|
+
data-product-gallery-id={galleryId}
|
|
1275
|
+
style={{
|
|
1276
|
+
// The indicator is placed with the position controls on the dot row,
|
|
1277
|
+
// which need a positioned ancestor to resolve against.
|
|
1278
|
+
position:
|
|
1279
|
+
(showDots || showCounter) && !customCells ? 'relative' : undefined,
|
|
1280
|
+
width: '100%'
|
|
1281
|
+
}}
|
|
1282
|
+
>
|
|
1283
|
+
<style>
|
|
1284
|
+
{`[data-product-gallery-id="${galleryId}"] [data-part="grid"]{grid-template-columns:repeat(${baseColumns},minmax(0,1fr));}` +
|
|
1285
|
+
`@media (max-width:1023px){[data-product-gallery-id="${galleryId}"] [data-part="grid"]{grid-template-columns:repeat(${gridColumnsMobile},minmax(0,1fr));}}`}
|
|
1286
|
+
</style>
|
|
1287
|
+
|
|
1288
|
+
{hintHoverGateNode}
|
|
1289
|
+
|
|
1290
|
+
<div
|
|
1291
|
+
{...partAttrs('grid')}
|
|
1292
|
+
style={{ display: 'grid', gap: `${safeGap}px`, width: '100%' }}
|
|
1293
|
+
>
|
|
1294
|
+
{customCells ??
|
|
1295
|
+
images.map((image, index) => {
|
|
1296
|
+
const frame = resolveFrame(image);
|
|
1297
|
+
|
|
1298
|
+
return (
|
|
1299
|
+
<div
|
|
1300
|
+
key={`${image.url}-${index}`}
|
|
1301
|
+
ref={(node) => {
|
|
1302
|
+
cellRefs.current[index] = node;
|
|
1303
|
+
}}
|
|
1304
|
+
data-product-gallery-cell={index === safeIndex ? 'active' : 'default'}
|
|
1305
|
+
{...partAttrs('main', {
|
|
1306
|
+
selected: index === safeIndex,
|
|
1307
|
+
open: isZooming(index),
|
|
1308
|
+
empty: frame.isFallback
|
|
1309
|
+
})}
|
|
1310
|
+
{...zoomHandlers(index)}
|
|
1311
|
+
style={{
|
|
1312
|
+
// Undefined until something is layered over the cell, so the
|
|
1313
|
+
// shipped grid keeps the exact style attribute it had.
|
|
1314
|
+
position: frameNeedsPosition ? 'relative' : undefined,
|
|
1315
|
+
aspectRatio,
|
|
1316
|
+
overflow: 'hidden',
|
|
1317
|
+
background: 'var(--product-gallery-bg, #f3f4f6)'
|
|
1318
|
+
}}
|
|
1319
|
+
>
|
|
1320
|
+
<img
|
|
1321
|
+
src={frame.src}
|
|
1322
|
+
alt={frame.alt}
|
|
1323
|
+
{...partAttrs('image', { empty: frame.isFallback })}
|
|
1324
|
+
// The first rows are above the fold on a PDP grid.
|
|
1325
|
+
loading={index < baseColumns ? 'eager' : 'lazy'}
|
|
1326
|
+
onError={
|
|
1327
|
+
handleImageError
|
|
1328
|
+
? () => handleImageError(image.url)
|
|
1329
|
+
: undefined
|
|
1330
|
+
}
|
|
1331
|
+
style={{
|
|
1332
|
+
display: 'block',
|
|
1333
|
+
width: '100%',
|
|
1334
|
+
height: '100%',
|
|
1335
|
+
objectFit
|
|
1336
|
+
}}
|
|
1337
|
+
/>
|
|
1338
|
+
|
|
1339
|
+
{zoomLayer(index, image)}
|
|
1340
|
+
{viewerTrigger(index)}
|
|
1341
|
+
{hintNode}
|
|
1342
|
+
</div>
|
|
1343
|
+
);
|
|
1344
|
+
})}
|
|
1345
|
+
</div>
|
|
1346
|
+
|
|
1347
|
+
{gridDots}
|
|
1348
|
+
{gridCounter}
|
|
1349
|
+
{viewerNode}
|
|
1350
|
+
</div>
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
return (
|
|
1355
|
+
<div
|
|
1356
|
+
data-product-gallery=""
|
|
1357
|
+
data-product-gallery-thumb-position={thumbPosition}
|
|
1358
|
+
// Only written when the hover gate above needs something to scope to, so
|
|
1359
|
+
// a gallery without one keeps the exact attributes it has always had.
|
|
1360
|
+
data-product-gallery-id={hintNeedsHoverGate ? galleryId : undefined}
|
|
1361
|
+
style={{
|
|
1362
|
+
display: 'flex',
|
|
1363
|
+
flexDirection: rootDirection,
|
|
1364
|
+
alignItems: isSideThumbs ? 'flex-start' : undefined,
|
|
1365
|
+
gap: `${safeGap}px`,
|
|
1366
|
+
width: '100%'
|
|
1367
|
+
}}
|
|
1368
|
+
>
|
|
1369
|
+
{hintHoverGateNode}
|
|
1370
|
+
|
|
1371
|
+
<div
|
|
1372
|
+
data-product-gallery-main=""
|
|
1373
|
+
{...partAttrs('main', {
|
|
1374
|
+
open: isZooming(safeIndex),
|
|
1375
|
+
empty: viewerFrame.isFallback
|
|
1376
|
+
})}
|
|
1377
|
+
{...zoomHandlers(safeIndex)}
|
|
1378
|
+
style={{
|
|
1379
|
+
// Only a gallery with arrows, zoom or the viewer needs a positioning
|
|
1380
|
+
// context; adding it unconditionally would change how a brand's own
|
|
1381
|
+
// CSS lands.
|
|
1382
|
+
position: frameNeedsPosition ? 'relative' : undefined,
|
|
1383
|
+
width: isSideThumbs ? undefined : '100%',
|
|
1384
|
+
flex: isSideThumbs ? '1 1 auto' : undefined,
|
|
1385
|
+
minWidth: isSideThumbs ? 0 : undefined,
|
|
1386
|
+
aspectRatio,
|
|
1387
|
+
overflow: 'hidden',
|
|
1388
|
+
background: 'var(--product-gallery-bg, #f3f4f6)'
|
|
1389
|
+
}}
|
|
1390
|
+
>
|
|
1391
|
+
<img
|
|
1392
|
+
key={activeImage.url}
|
|
1393
|
+
src={viewerFrame.src}
|
|
1394
|
+
alt={viewerFrame.alt}
|
|
1395
|
+
{...partAttrs('image', { empty: viewerFrame.isFallback })}
|
|
1396
|
+
// The PDP hero is above the fold, so the first frame is never lazy.
|
|
1397
|
+
loading={safeIndex === 0 ? 'eager' : 'lazy'}
|
|
1398
|
+
onError={
|
|
1399
|
+
handleImageError
|
|
1400
|
+
? () => handleImageError(activeImage.url)
|
|
1401
|
+
: undefined
|
|
1402
|
+
}
|
|
1403
|
+
style={{
|
|
1404
|
+
display: 'block',
|
|
1405
|
+
width: '100%',
|
|
1406
|
+
height: '100%',
|
|
1407
|
+
objectFit
|
|
1408
|
+
}}
|
|
1409
|
+
/>
|
|
1410
|
+
|
|
1411
|
+
{zoomLayer(safeIndex, activeImage)}
|
|
1412
|
+
{viewerTrigger(safeIndex)}
|
|
1413
|
+
{hintNode}
|
|
1414
|
+
|
|
1415
|
+
{showArrows && images.length > 1 && (
|
|
1416
|
+
<>
|
|
1417
|
+
<button
|
|
1418
|
+
type="button"
|
|
1419
|
+
data-product-gallery-arrow="prev"
|
|
1420
|
+
{...partAttrs('arrow', { disabled: !loopArrows && atFirst })}
|
|
1421
|
+
aria-label={prevLabel}
|
|
1422
|
+
disabled={!loopArrows && atFirst}
|
|
1423
|
+
onClick={() => setActiveIndex(step(-1))}
|
|
1424
|
+
style={arrowStyle('left')}
|
|
1425
|
+
>
|
|
1426
|
+
<ChevronIcon direction="prev" />
|
|
1427
|
+
</button>
|
|
1428
|
+
<button
|
|
1429
|
+
type="button"
|
|
1430
|
+
data-product-gallery-arrow="next"
|
|
1431
|
+
{...partAttrs('arrow', { disabled: !loopArrows && atLast })}
|
|
1432
|
+
aria-label={nextLabel}
|
|
1433
|
+
disabled={!loopArrows && atLast}
|
|
1434
|
+
onClick={() => setActiveIndex(step(1))}
|
|
1435
|
+
style={arrowStyle('right')}
|
|
1436
|
+
>
|
|
1437
|
+
<ChevronIcon direction="next" />
|
|
1438
|
+
</button>
|
|
1439
|
+
</>
|
|
1440
|
+
)}
|
|
1441
|
+
</div>
|
|
1442
|
+
|
|
1443
|
+
{dotsNode}
|
|
1444
|
+
{counterNode}
|
|
1445
|
+
|
|
1446
|
+
{showThumbs && images.length > 1 && (
|
|
1447
|
+
<div
|
|
1448
|
+
data-product-gallery-thumbs=""
|
|
1449
|
+
{...partAttrs('thumb-list')}
|
|
1450
|
+
style={{
|
|
1451
|
+
display: 'flex',
|
|
1452
|
+
flexDirection: isSideThumbs ? 'column' : 'row',
|
|
1453
|
+
gap: `${safeGap}px`,
|
|
1454
|
+
overflowX: isSideThumbs ? 'visible' : 'auto',
|
|
1455
|
+
overflowY: isSideThumbs ? 'auto' : 'visible',
|
|
1456
|
+
flexWrap: 'nowrap'
|
|
1457
|
+
}}
|
|
1458
|
+
>
|
|
1459
|
+
{customThumbs ??
|
|
1460
|
+
thumbImages.map((image, index) => {
|
|
1461
|
+
const thumbFrame = resolveFrame(image);
|
|
1462
|
+
|
|
1463
|
+
return (
|
|
1464
|
+
<button
|
|
1465
|
+
key={`${image.url}-${index}`}
|
|
1466
|
+
type="button"
|
|
1467
|
+
data-product-gallery-thumb={
|
|
1468
|
+
index === safeIndex ? 'active' : 'default'
|
|
1469
|
+
}
|
|
1470
|
+
{...partAttrs('thumb', {
|
|
1471
|
+
selected: index === safeIndex,
|
|
1472
|
+
empty: thumbFrame.isFallback
|
|
1473
|
+
})}
|
|
1474
|
+
aria-label={fillTemplate(thumbLabel, {
|
|
1475
|
+
name: productName,
|
|
1476
|
+
index: index + 1,
|
|
1477
|
+
total: images.length
|
|
1478
|
+
})}
|
|
1479
|
+
aria-current={index === safeIndex}
|
|
1480
|
+
onClick={() => setActiveIndex(index)}
|
|
1481
|
+
style={{
|
|
1482
|
+
flex: '0 0 auto',
|
|
1483
|
+
width: `${safeThumbSize}px`,
|
|
1484
|
+
height: `${safeThumbSize}px`,
|
|
1485
|
+
padding: 0,
|
|
1486
|
+
cursor: 'pointer',
|
|
1487
|
+
overflow: 'hidden',
|
|
1488
|
+
background: 'transparent',
|
|
1489
|
+
border:
|
|
1490
|
+
index === safeIndex
|
|
1491
|
+
? '1px solid var(--product-gallery-thumb-active, currentColor)'
|
|
1492
|
+
: '1px solid var(--product-gallery-thumb-border, #e5e7eb)'
|
|
1493
|
+
}}
|
|
1494
|
+
>
|
|
1495
|
+
<img
|
|
1496
|
+
src={thumbFrame.src}
|
|
1497
|
+
alt=""
|
|
1498
|
+
loading="lazy"
|
|
1499
|
+
{...partAttrs('thumb-image', { empty: thumbFrame.isFallback })}
|
|
1500
|
+
onError={
|
|
1501
|
+
handleImageError
|
|
1502
|
+
? () => handleImageError(image.url)
|
|
1503
|
+
: undefined
|
|
1504
|
+
}
|
|
1505
|
+
style={{
|
|
1506
|
+
display: 'block',
|
|
1507
|
+
width: '100%',
|
|
1508
|
+
height: '100%',
|
|
1509
|
+
objectFit: 'cover'
|
|
1510
|
+
}}
|
|
1511
|
+
/>
|
|
1512
|
+
</button>
|
|
1513
|
+
);
|
|
1514
|
+
})}
|
|
1515
|
+
</div>
|
|
1516
|
+
)}
|
|
1517
|
+
|
|
1518
|
+
{viewerNode}
|
|
1519
|
+
</div>
|
|
1520
|
+
);
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1523
|
+
export default ProductGalleryBlock;
|