@akinon/pz-theme 2.0.143-rc.0 → 2.0.143
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 +8 -35
- package/package.json +4 -4
- package/src/__tests__/designer-live-rows.test.tsx +257 -0
- package/src/blocks/__tests__/favorite-button-icons.test.tsx +102 -0
- package/src/blocks/__tests__/listing-price-note.test.ts +47 -0
- package/src/blocks/favorite-button-block.tsx +36 -15
- package/src/blocks/group-block.tsx +52 -1
- package/src/blocks/image-block.tsx +26 -2
- package/src/blocks/listing-context.ts +5 -2
- package/src/blocks/listing-row.ts +64 -4
- package/src/blocks/product-grid-block.tsx +83 -3
- package/src/components/card-list-slider.tsx +374 -0
- package/src/components/card-list-views.tsx +278 -0
- package/src/components/card-zoom.tsx +53 -0
- package/src/components/with-designer-features.tsx +18 -0
- package/src/theme-block.tsx +11 -0
- package/src/theme-placeholder-wrapper.tsx +52 -7
- package/src/utils/__tests__/card-media.test.tsx +789 -0
- package/src/utils/__tests__/card-views.test.tsx +332 -0
- package/src/utils/__tests__/swatch-colors.test.ts +145 -0
- package/src/utils/card-media.ts +331 -0
- package/src/utils/iterator-utils.ts +10 -3
- package/src/utils/live-rows.ts +169 -34
- package/src/utils/property-switches.ts +248 -2
- package/src/utils/swatch-colors.ts +81 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { MOBILE_MEDIA_QUERY_COMPACT } from '../utils/breakpoints';
|
|
6
|
+
import type { CardsSliderSettings } from '../utils/card-media';
|
|
7
|
+
import { neutralizeStyleTagBreakout } from '../utils/css-safety';
|
|
8
|
+
import { partAttrs } from '../utils/part-styles';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A list of product cards as one sliding row (ZERO-5173) — what the product
|
|
12
|
+
* grid and the Product Cards section draw once their slider is on.
|
|
13
|
+
*
|
|
14
|
+
* Modelled on the GANT rail ("Sizin için seçtiklerimiz", the posts slider
|
|
15
|
+
* with its arrows top right): a row above the cards with the list's title on
|
|
16
|
+
* the left and the two chevrons on the right, laid out and typed as that rail
|
|
17
|
+
* lays out its own, and the cards underneath, `perView` of them in view on desktop and
|
|
18
|
+
* `perViewMobile` below 1024px. The strip is a native scroller with scroll
|
|
19
|
+
* snap, so a touch screen swipes it as it is, the server renders it complete,
|
|
20
|
+
* and a swipe is a scroll — it is never read as a click on a card. The
|
|
21
|
+
* chevrons step one card and leave the row when every card already fits; at
|
|
22
|
+
* either end the one with nowhere to go is disabled, drawn like the other as
|
|
23
|
+
* the rail draws it, and stamped so a design can style that state.
|
|
24
|
+
*
|
|
25
|
+
* The cards are the host's own elements, unwrapped: every direct child of the
|
|
26
|
+
* strip is one card. Their width comes from the slider's scoped stylesheet, so
|
|
27
|
+
* it is right before any script runs.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface CardListSliderProps {
|
|
31
|
+
/** Selector-safe and unique on the page: scopes the slider's stylesheet. */
|
|
32
|
+
sliderId: string;
|
|
33
|
+
settings: CardsSliderSettings;
|
|
34
|
+
/**
|
|
35
|
+
* The space between cards, desktop and below — the list's own gap, so the
|
|
36
|
+
* strip keeps the spacing the grid had. Each falls back to `defaultGap`.
|
|
37
|
+
*/
|
|
38
|
+
gap?: { desktop?: unknown; mobile?: unknown };
|
|
39
|
+
defaultGap?: string;
|
|
40
|
+
isDesigner?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* The canvas breakpoint. The phone and tablet canvases show the phone
|
|
43
|
+
* count; the storefront decides by media query, which the canvas iframe
|
|
44
|
+
* cannot answer for the device it previews.
|
|
45
|
+
*/
|
|
46
|
+
currentBreakpoint?: string;
|
|
47
|
+
/** The host's marks for the element holding the cards (its list part). */
|
|
48
|
+
trackAttributes?: Record<string, string>;
|
|
49
|
+
/**
|
|
50
|
+
* The canvas's selection for a click on the title: the list whose settings
|
|
51
|
+
* hold it — the Product Cards section, the product grid. The title is no
|
|
52
|
+
* block of its own, and the block it is drawn in is not where it is set.
|
|
53
|
+
*/
|
|
54
|
+
onTitleSelect?: () => void;
|
|
55
|
+
prevLabel?: string;
|
|
56
|
+
nextLabel?: string;
|
|
57
|
+
children: React.ReactNode;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* An authored gap as a CSS length, or undefined. Numbers are pixels; strings
|
|
62
|
+
* must be a plain length, since the value lands inside a stylesheet.
|
|
63
|
+
*/
|
|
64
|
+
export const readGapLength = (value: unknown): string | undefined => {
|
|
65
|
+
if (typeof value === 'number' && Number.isFinite(value)) return `${value}px`;
|
|
66
|
+
if (typeof value !== 'string') return undefined;
|
|
67
|
+
|
|
68
|
+
const text = value.trim();
|
|
69
|
+
const match = text.match(/^(\d+(?:\.\d+)?)(px|rem|em|%)?$/);
|
|
70
|
+
if (!match) return undefined;
|
|
71
|
+
|
|
72
|
+
return match[2] ? text : `${match[1]}px`;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const readBreakpointValue = (value: unknown, breakpoint: string): unknown => {
|
|
76
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
77
|
+
const record = value as Record<string, unknown>;
|
|
78
|
+
return breakpoint === 'desktop'
|
|
79
|
+
? record.desktop
|
|
80
|
+
: record[breakpoint] ?? record.mobile ?? record.desktop;
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** The authored gap of a list, per side of the 1024px line. */
|
|
86
|
+
export const readListGap = (
|
|
87
|
+
value: unknown
|
|
88
|
+
): { desktop?: unknown; mobile?: unknown } => ({
|
|
89
|
+
desktop: readBreakpointValue(value, 'desktop'),
|
|
90
|
+
mobile: readBreakpointValue(value, 'mobile')
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/** One card's width with `count` in view and the gap between them. */
|
|
94
|
+
const cardWidth = (count: number) =>
|
|
95
|
+
`calc((100% - ${count - 1} * var(--card-slider-gap)) / ${count})`;
|
|
96
|
+
|
|
97
|
+
export const buildCardSliderCss = (
|
|
98
|
+
sliderId: string,
|
|
99
|
+
settings: CardsSliderSettings,
|
|
100
|
+
gapDesktop: string,
|
|
101
|
+
gapMobile: string,
|
|
102
|
+
onPhoneCanvas: boolean
|
|
103
|
+
): string => {
|
|
104
|
+
const scope = `[data-card-slider="${sliderId}"]`;
|
|
105
|
+
const cards = `${scope}>[data-card-slider-frame]>[data-card-slider-track]>*`;
|
|
106
|
+
const phone = `${scope}{--card-slider-gap:${gapMobile}}${cards}{flex-basis:${cardWidth(
|
|
107
|
+
settings.perViewMobile
|
|
108
|
+
)}}`;
|
|
109
|
+
|
|
110
|
+
return neutralizeStyleTagBreakout(
|
|
111
|
+
`${scope}{--card-slider-gap:${gapDesktop}}` +
|
|
112
|
+
`${cards}{flex:0 0 ${cardWidth(
|
|
113
|
+
settings.perView
|
|
114
|
+
)};min-width:0;scroll-snap-align:start}` +
|
|
115
|
+
`${scope}>[data-card-slider-frame]>[data-card-slider-track]::-webkit-scrollbar{display:none}` +
|
|
116
|
+
// The canvas previews the phone in whatever width the iframe has, so it
|
|
117
|
+
// takes the phone count outright; the storefront asks the viewport.
|
|
118
|
+
(onPhoneCanvas ? phone : `${MOBILE_MEDIA_QUERY_COMPACT}{${phone}}`)
|
|
119
|
+
);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The slider's chevron: the 8 × 12 glyph the GANT rail's arrows draw
|
|
124
|
+
* (posts-slider's top-right navigation), mirrored for "next", so a list
|
|
125
|
+
* slider and the rail beside it read as one design.
|
|
126
|
+
*/
|
|
127
|
+
const Chevron = ({ direction }: { direction: 'prev' | 'next' }) => (
|
|
128
|
+
<svg
|
|
129
|
+
width="8"
|
|
130
|
+
height="12"
|
|
131
|
+
viewBox="0 0 8 12"
|
|
132
|
+
fill="none"
|
|
133
|
+
aria-hidden="true"
|
|
134
|
+
style={{
|
|
135
|
+
display: 'block',
|
|
136
|
+
...(direction === 'next' ? { transform: 'scaleX(-1)' } : null)
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
<path d="M6.5 1L1.5 6l5 5" stroke="currentColor" strokeWidth="1.2" />
|
|
140
|
+
</svg>
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const prefersReducedMotion = (): boolean =>
|
|
144
|
+
typeof window !== 'undefined' &&
|
|
145
|
+
typeof window.matchMedia === 'function' &&
|
|
146
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
147
|
+
|
|
148
|
+
export const CardListSlider = ({
|
|
149
|
+
sliderId,
|
|
150
|
+
settings,
|
|
151
|
+
gap,
|
|
152
|
+
defaultGap = '16px',
|
|
153
|
+
isDesigner = false,
|
|
154
|
+
currentBreakpoint = 'desktop',
|
|
155
|
+
trackAttributes,
|
|
156
|
+
onTitleSelect,
|
|
157
|
+
prevLabel = 'Previous',
|
|
158
|
+
nextLabel = 'Next',
|
|
159
|
+
children
|
|
160
|
+
}: CardListSliderProps) => {
|
|
161
|
+
const trackRef = React.useRef<HTMLDivElement>(null);
|
|
162
|
+
// Before the strip is measured it is assumed to overflow, which is the
|
|
163
|
+
// common case: the server renders the chevrons, the first one disabled,
|
|
164
|
+
// and they only leave the row once a measurement says every card fits.
|
|
165
|
+
const [edges, setEdges] = React.useState({ atStart: true, atEnd: false });
|
|
166
|
+
|
|
167
|
+
const measure = React.useCallback(() => {
|
|
168
|
+
const track = trackRef.current;
|
|
169
|
+
if (!track) return;
|
|
170
|
+
|
|
171
|
+
// Negative in a right-to-left strip.
|
|
172
|
+
const offset = Math.abs(track.scrollLeft);
|
|
173
|
+
const next = {
|
|
174
|
+
atStart: offset <= 1,
|
|
175
|
+
atEnd: offset + track.clientWidth >= track.scrollWidth - 1
|
|
176
|
+
};
|
|
177
|
+
setEdges((previous) =>
|
|
178
|
+
previous.atStart === next.atStart && previous.atEnd === next.atEnd
|
|
179
|
+
? previous
|
|
180
|
+
: next
|
|
181
|
+
);
|
|
182
|
+
}, []);
|
|
183
|
+
|
|
184
|
+
// After every render: a page of cards appended by Load More, a breakpoint
|
|
185
|
+
// or a count changed in the panel can each turn a full strip into one that
|
|
186
|
+
// fits, and back.
|
|
187
|
+
React.useEffect(() => {
|
|
188
|
+
measure();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
React.useEffect(() => {
|
|
192
|
+
window.addEventListener('resize', measure);
|
|
193
|
+
return () => window.removeEventListener('resize', measure);
|
|
194
|
+
}, [measure]);
|
|
195
|
+
|
|
196
|
+
const step = (direction: 1 | -1) => {
|
|
197
|
+
const track = trackRef.current;
|
|
198
|
+
if (!track) return;
|
|
199
|
+
|
|
200
|
+
const card = track.firstElementChild as HTMLElement | null;
|
|
201
|
+
const computed = window.getComputedStyle(track);
|
|
202
|
+
const spacing = parseFloat(computed.columnGap) || 0;
|
|
203
|
+
const distance =
|
|
204
|
+
(card ? card.getBoundingClientRect().width : track.clientWidth) + spacing;
|
|
205
|
+
const sign = computed.direction === 'rtl' ? -1 : 1;
|
|
206
|
+
|
|
207
|
+
track.scrollBy({
|
|
208
|
+
left: sign * direction * distance,
|
|
209
|
+
behavior: prefersReducedMotion() ? 'auto' : 'smooth'
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const onPhoneCanvas = isDesigner && currentBreakpoint !== 'desktop';
|
|
214
|
+
const gapDesktop = readGapLength(gap?.desktop) ?? defaultGap;
|
|
215
|
+
const gapMobile = readGapLength(gap?.mobile) ?? gapDesktop;
|
|
216
|
+
const css = buildCardSliderCss(
|
|
217
|
+
sliderId,
|
|
218
|
+
settings,
|
|
219
|
+
gapDesktop,
|
|
220
|
+
gapMobile,
|
|
221
|
+
onPhoneCanvas
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const fits = edges.atStart && edges.atEnd;
|
|
225
|
+
const showsArrows = settings.arrows !== 'none' && !fits;
|
|
226
|
+
const title = settings.title;
|
|
227
|
+
const showsHeader =
|
|
228
|
+
Boolean(title) || (showsArrows && settings.arrows === 'top-right');
|
|
229
|
+
|
|
230
|
+
const arrow = (direction: 'prev' | 'next', placement: 'row' | 'side') => {
|
|
231
|
+
const disabled = direction === 'prev' ? edges.atStart : edges.atEnd;
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
data-card-slider-arrow={direction}
|
|
237
|
+
{...partAttrs('slider-arrow', { disabled })}
|
|
238
|
+
aria-label={direction === 'prev' ? prevLabel : nextLabel}
|
|
239
|
+
disabled={disabled}
|
|
240
|
+
onClick={(event) => {
|
|
241
|
+
// Inside a card list that may sit in a link or a designer selection
|
|
242
|
+
// target: the page turn is the whole of what this click means.
|
|
243
|
+
event.preventDefault();
|
|
244
|
+
event.stopPropagation();
|
|
245
|
+
step(direction === 'prev' ? -1 : 1);
|
|
246
|
+
}}
|
|
247
|
+
style={{
|
|
248
|
+
display: 'flex',
|
|
249
|
+
alignItems: 'center',
|
|
250
|
+
justifyContent: 'center',
|
|
251
|
+
width: '32px',
|
|
252
|
+
height: '32px',
|
|
253
|
+
padding: 0,
|
|
254
|
+
border: 0,
|
|
255
|
+
color: 'inherit',
|
|
256
|
+
background: placement === 'side' ? '#ffffff' : 'transparent',
|
|
257
|
+
cursor: disabled ? 'default' : 'pointer',
|
|
258
|
+
...(placement === 'side'
|
|
259
|
+
? {
|
|
260
|
+
position: 'absolute',
|
|
261
|
+
top: '50%',
|
|
262
|
+
transform: 'translateY(-50%)',
|
|
263
|
+
zIndex: 1,
|
|
264
|
+
...(direction === 'prev' ? { left: '8px' } : { right: '8px' })
|
|
265
|
+
}
|
|
266
|
+
: null)
|
|
267
|
+
}}
|
|
268
|
+
>
|
|
269
|
+
<Chevron direction={direction} />
|
|
270
|
+
</button>
|
|
271
|
+
);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<div
|
|
276
|
+
data-card-slider={sliderId}
|
|
277
|
+
{...partAttrs('slider')}
|
|
278
|
+
style={{
|
|
279
|
+
position: 'relative',
|
|
280
|
+
width: '100%',
|
|
281
|
+
minWidth: 0,
|
|
282
|
+
// A grid host keeps its own column template; the slider spans it.
|
|
283
|
+
gridColumn: '1 / -1'
|
|
284
|
+
}}
|
|
285
|
+
>
|
|
286
|
+
<style dangerouslySetInnerHTML={{ __html: css }} />
|
|
287
|
+
{showsHeader ? (
|
|
288
|
+
<div
|
|
289
|
+
data-card-slider-header=""
|
|
290
|
+
{...partAttrs('slider-header')}
|
|
291
|
+
style={{
|
|
292
|
+
display: 'flex',
|
|
293
|
+
alignItems: 'center',
|
|
294
|
+
justifyContent: 'space-between',
|
|
295
|
+
gap: '24px',
|
|
296
|
+
// The rail's row: the title 24px in from the left, the chevrons
|
|
297
|
+
// 14px in from the right, 16px above and below. The list's
|
|
298
|
+
// Slider Header part restyles it; a host with no part styles of
|
|
299
|
+
// its own (the Product Cards section) through its custom CSS.
|
|
300
|
+
padding: 'var(--card-slider-header-padding, 16px 14px 16px 24px)'
|
|
301
|
+
}}
|
|
302
|
+
>
|
|
303
|
+
{title ? (
|
|
304
|
+
// The rail's title, typed as the list's settings say. Not a block:
|
|
305
|
+
// clicking it in the canvas selects the list that owns it, whose
|
|
306
|
+
// panel holds the title's settings.
|
|
307
|
+
<h2
|
|
308
|
+
data-card-slider-title=""
|
|
309
|
+
onClick={
|
|
310
|
+
isDesigner && onTitleSelect
|
|
311
|
+
? (event) => {
|
|
312
|
+
event.preventDefault();
|
|
313
|
+
event.stopPropagation();
|
|
314
|
+
onTitleSelect();
|
|
315
|
+
}
|
|
316
|
+
: undefined
|
|
317
|
+
}
|
|
318
|
+
style={{
|
|
319
|
+
minWidth: 0,
|
|
320
|
+
margin: 0,
|
|
321
|
+
color: title.color,
|
|
322
|
+
fontSize: `${title.size}px`,
|
|
323
|
+
fontWeight: title.weight,
|
|
324
|
+
lineHeight: 1.4,
|
|
325
|
+
textTransform: title.transform,
|
|
326
|
+
fontFamily: title.font || 'var(--theme-font-body, inherit)'
|
|
327
|
+
}}
|
|
328
|
+
>
|
|
329
|
+
{title.text}
|
|
330
|
+
</h2>
|
|
331
|
+
) : (
|
|
332
|
+
<span />
|
|
333
|
+
)}
|
|
334
|
+
{showsArrows && settings.arrows === 'top-right' ? (
|
|
335
|
+
<div
|
|
336
|
+
data-card-slider-arrows="top-right"
|
|
337
|
+
style={{ display: 'flex', alignItems: 'center' }}
|
|
338
|
+
>
|
|
339
|
+
{arrow('prev', 'row')}
|
|
340
|
+
{arrow('next', 'row')}
|
|
341
|
+
</div>
|
|
342
|
+
) : null}
|
|
343
|
+
</div>
|
|
344
|
+
) : null}
|
|
345
|
+
<div data-card-slider-frame="" style={{ position: 'relative' }}>
|
|
346
|
+
<div
|
|
347
|
+
ref={trackRef}
|
|
348
|
+
data-card-slider-track=""
|
|
349
|
+
{...trackAttributes}
|
|
350
|
+
onScroll={measure}
|
|
351
|
+
style={{
|
|
352
|
+
display: 'flex',
|
|
353
|
+
gap: 'var(--card-slider-gap)',
|
|
354
|
+
overflowX: 'auto',
|
|
355
|
+
overflowY: 'hidden',
|
|
356
|
+
scrollSnapType: 'x mandatory',
|
|
357
|
+
overscrollBehaviorX: 'contain',
|
|
358
|
+
scrollbarWidth: 'none'
|
|
359
|
+
}}
|
|
360
|
+
>
|
|
361
|
+
{children}
|
|
362
|
+
</div>
|
|
363
|
+
{showsArrows && settings.arrows === 'sides' ? (
|
|
364
|
+
<>
|
|
365
|
+
{arrow('prev', 'side')}
|
|
366
|
+
{arrow('next', 'side')}
|
|
367
|
+
</>
|
|
368
|
+
) : null}
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
export default CardListSlider;
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { MOBILE_MEDIA_QUERY_COMPACT } from '../utils/breakpoints';
|
|
6
|
+
import {
|
|
7
|
+
CARD_VIEW_COLUMNS,
|
|
8
|
+
CARD_VIEWS,
|
|
9
|
+
type CardView,
|
|
10
|
+
cardViewFromHash,
|
|
11
|
+
type CardViewSettings,
|
|
12
|
+
type CardViewToggles,
|
|
13
|
+
HIDE_IN_CARD_VIEW_ATTRIBUTE
|
|
14
|
+
} from '../utils/card-media';
|
|
15
|
+
import { neutralizeStyleTagBreakout } from '../utils/css-safety';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The GANT listing's grid views for a card list that is not sliding
|
|
19
|
+
* (ZERO-5173): the cards laid out as the view says — catalogue three across
|
|
20
|
+
* (one below 1024px), classic the list's own grid, archive eight across (four
|
|
21
|
+
* below) with the cards' captions left out — in the view the designer picked,
|
|
22
|
+
* and, when the shopper is offered them, the listing toolbar's three toggles.
|
|
23
|
+
*
|
|
24
|
+
* A SHOPPER'S SWITCH IS THE PAGE'S, IN THE HASH — the listing's own contract
|
|
25
|
+
* (`#catalogue` / `#classic` / `#archive`): read on mount and on `hashchange`,
|
|
26
|
+
* rewritten in place on a switch, so a shared link opens the view it was
|
|
27
|
+
* shared in and every list offering the toggles on a page shows the same one.
|
|
28
|
+
* No hash is no choice: each list shows its designer's view, which is also
|
|
29
|
+
* what the server — which never sees the hash — renders.
|
|
30
|
+
*
|
|
31
|
+
* The layout is CSS only: the cards are the list's own elements and keep
|
|
32
|
+
* rendering exactly as they are; a view only overrides the list's column
|
|
33
|
+
* template and hides what the archive leaves out.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** The shopper's choice: undefined before the hash is read, null for none. */
|
|
37
|
+
let current: CardView | null | undefined;
|
|
38
|
+
const listeners = new Set<() => void>();
|
|
39
|
+
|
|
40
|
+
const read = (): CardView | null => {
|
|
41
|
+
if (current === undefined) {
|
|
42
|
+
current = cardViewFromHash(
|
|
43
|
+
typeof window === 'undefined' ? '' : window.location.hash
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return current;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const notify = () => listeners.forEach((listener) => listener());
|
|
50
|
+
|
|
51
|
+
const onHashChange = () => {
|
|
52
|
+
current = cardViewFromHash(window.location.hash);
|
|
53
|
+
notify();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const subscribe = (listener: () => void) => {
|
|
57
|
+
if (listeners.size === 0) {
|
|
58
|
+
window.addEventListener('hashchange', onHashChange);
|
|
59
|
+
}
|
|
60
|
+
listeners.add(listener);
|
|
61
|
+
|
|
62
|
+
return () => {
|
|
63
|
+
listeners.delete(listener);
|
|
64
|
+
if (listeners.size === 0) {
|
|
65
|
+
window.removeEventListener('hashchange', onHashChange);
|
|
66
|
+
// The next page reads its own hash.
|
|
67
|
+
current = undefined;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Switch the page's view. The hash is rewritten in place — no navigation, no
|
|
74
|
+
* scroll — except on the canvas, where the preview's URL is the editor's.
|
|
75
|
+
*/
|
|
76
|
+
export const setCardView = (
|
|
77
|
+
view: CardView,
|
|
78
|
+
{ writeHash = true }: { writeHash?: boolean } = {}
|
|
79
|
+
): void => {
|
|
80
|
+
current = view;
|
|
81
|
+
if (writeHash && typeof window !== 'undefined') {
|
|
82
|
+
const url = new URL(window.location.href);
|
|
83
|
+
url.hash = `#${view}`;
|
|
84
|
+
window.history.replaceState(window.history.state, '', url.toString());
|
|
85
|
+
}
|
|
86
|
+
notify();
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** The shopper's choice on this page — none on the server and while hydrating. */
|
|
90
|
+
export const useCardViewChoice = (): CardView | null =>
|
|
91
|
+
React.useSyncExternalStore(subscribe, read, () => null);
|
|
92
|
+
|
|
93
|
+
/** Test-only: forget the view between cases. */
|
|
94
|
+
export const __resetCardViewForTests = (): void => {
|
|
95
|
+
current = undefined;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const SAFE_ID = /^[A-Za-z0-9_-]+$/;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The view's layout for the list whose wrapper is `[data-block-id=scopeId]`.
|
|
102
|
+
* The attribute twice outranks the list's own column template without
|
|
103
|
+
* `!important`; classic writes nothing, so it is the list as authored.
|
|
104
|
+
*/
|
|
105
|
+
export const buildCardViewCss = (
|
|
106
|
+
scopeId: string,
|
|
107
|
+
view: CardView,
|
|
108
|
+
onPhoneCanvas: boolean,
|
|
109
|
+
toggles?: CardViewToggles
|
|
110
|
+
): string => {
|
|
111
|
+
if (!SAFE_ID.test(scopeId)) return '';
|
|
112
|
+
|
|
113
|
+
const list = `[data-block-id="${scopeId}"][data-block-id="${scopeId}"]`;
|
|
114
|
+
let css = '';
|
|
115
|
+
|
|
116
|
+
if (toggles) {
|
|
117
|
+
// The toolbar's toggles: buttons 8px wider than their glyph from 1024px
|
|
118
|
+
// up, the gap between them authored; 44px squares flush together below.
|
|
119
|
+
const switcher = `[data-card-view-switcher="${scopeId}"]`;
|
|
120
|
+
const button = toggles.size + 8;
|
|
121
|
+
const touch = Math.max(44, button);
|
|
122
|
+
const phoneSwitcher = `${switcher}{gap:0}${switcher}>button{width:${touch}px;height:${touch}px}`;
|
|
123
|
+
css +=
|
|
124
|
+
`${switcher}{gap:${toggles.gap}px}${switcher}>button{width:${button}px;height:${button}px}` +
|
|
125
|
+
(onPhoneCanvas
|
|
126
|
+
? phoneSwitcher
|
|
127
|
+
: `${MOBILE_MEDIA_QUERY_COMPACT}{${phoneSwitcher}}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (view !== 'classic') {
|
|
131
|
+
const { desktop, mobile } = CARD_VIEW_COLUMNS[view];
|
|
132
|
+
const columns = (count: number) =>
|
|
133
|
+
`${list}{grid-template-columns:repeat(${count},minmax(0,1fr))}`;
|
|
134
|
+
css += onPhoneCanvas
|
|
135
|
+
? columns(mobile)
|
|
136
|
+
: `${columns(desktop)}${MOBILE_MEDIA_QUERY_COMPACT}{${columns(mobile)}}`;
|
|
137
|
+
css += `${list} [${HIDE_IN_CARD_VIEW_ATTRIBUTE}~="${view}"]{display:none}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return neutralizeStyleTagBreakout(css);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** The listing toolbar's 24-unit glyphs: one cell, four, six. */
|
|
144
|
+
const VIEW_GLYPHS: Record<CardView, string> = {
|
|
145
|
+
catalogue: 'M18.5 17.5h-13v-11h13zm-12-1h11v-9h-11z',
|
|
146
|
+
classic:
|
|
147
|
+
'M18.5 17.5h-13v-11h13zm-12-1h5v-4h-5zm6 0h5v-4h-5zm-6-5h5v-4h-5zm6 0h5v-4h-5z',
|
|
148
|
+
archive:
|
|
149
|
+
'M18.5 17.5h-13v-11h13zm-12-1h3v-4h-3zm4 0h3v-4h-3zm4 0h3v-4h-3zm-8-5h3v-4h-3zm4 0h3v-4h-3zm4 0h3v-4h-3z'
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const VIEW_LABELS: Record<CardView, string> = {
|
|
153
|
+
catalogue: 'Catalogue view',
|
|
154
|
+
classic: 'Classic view',
|
|
155
|
+
archive: 'Archive view'
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export interface CardListViewsProps {
|
|
159
|
+
/** The list's wrapper `data-block-id` — what its own grid styles target. */
|
|
160
|
+
scopeId: string;
|
|
161
|
+
settings: CardViewSettings;
|
|
162
|
+
isDesigner?: boolean;
|
|
163
|
+
currentBreakpoint?: string;
|
|
164
|
+
/**
|
|
165
|
+
* The canvas's selection for a click on the toggles: the list whose settings
|
|
166
|
+
* hold them — they are no block of their own.
|
|
167
|
+
*/
|
|
168
|
+
onSelect?: () => void;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const JUSTIFY: Record<CardViewToggles['align'], string> = {
|
|
172
|
+
left: 'flex-start',
|
|
173
|
+
center: 'center',
|
|
174
|
+
right: 'flex-end'
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The view's stylesheet and, when offered, the toggles — drawn as the first
|
|
179
|
+
* item of the list's grid and spanning its columns. Its own component, so only
|
|
180
|
+
* it re-renders when the view changes; the cards never do.
|
|
181
|
+
*/
|
|
182
|
+
export const CardListViews = ({
|
|
183
|
+
scopeId,
|
|
184
|
+
settings,
|
|
185
|
+
isDesigner = false,
|
|
186
|
+
currentBreakpoint = 'desktop',
|
|
187
|
+
onSelect
|
|
188
|
+
}: CardListViewsProps) => {
|
|
189
|
+
const choice = useCardViewChoice();
|
|
190
|
+
const { toggles } = settings;
|
|
191
|
+
// Only a list that offers the toggles answers to the shopper's switch; one
|
|
192
|
+
// that does not is the view its designer picked, whatever the page says.
|
|
193
|
+
const view = toggles ? choice ?? settings.view : settings.view;
|
|
194
|
+
const css = buildCardViewCss(
|
|
195
|
+
scopeId,
|
|
196
|
+
view,
|
|
197
|
+
isDesigner && currentBreakpoint !== 'desktop',
|
|
198
|
+
toggles
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<>
|
|
203
|
+
<style dangerouslySetInnerHTML={{ __html: css }} />
|
|
204
|
+
{toggles ? (
|
|
205
|
+
<div
|
|
206
|
+
data-card-view-switcher={scopeId}
|
|
207
|
+
role="group"
|
|
208
|
+
aria-label="Grid views"
|
|
209
|
+
style={{
|
|
210
|
+
gridColumn: '1 / -1',
|
|
211
|
+
display: 'flex',
|
|
212
|
+
alignItems: 'center',
|
|
213
|
+
justifyContent: JUSTIFY[toggles.align],
|
|
214
|
+
padding: toggles.padding,
|
|
215
|
+
color: toggles.color
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
{CARD_VIEWS.map((option) => {
|
|
219
|
+
const active = option === view;
|
|
220
|
+
return (
|
|
221
|
+
<button
|
|
222
|
+
key={option}
|
|
223
|
+
type="button"
|
|
224
|
+
data-card-view-option={option}
|
|
225
|
+
aria-pressed={active}
|
|
226
|
+
aria-label={VIEW_LABELS[option]}
|
|
227
|
+
onClick={(event) => {
|
|
228
|
+
// Inside a list the canvas selects on click: the toggle is
|
|
229
|
+
// the whole of what this click means — and on the canvas it
|
|
230
|
+
// also opens the list's settings, where the toggles are set.
|
|
231
|
+
event.preventDefault();
|
|
232
|
+
event.stopPropagation();
|
|
233
|
+
setCardView(option, { writeHash: !isDesigner });
|
|
234
|
+
if (isDesigner) onSelect?.();
|
|
235
|
+
}}
|
|
236
|
+
style={{
|
|
237
|
+
display: 'flex',
|
|
238
|
+
alignItems: 'center',
|
|
239
|
+
justifyContent: 'center',
|
|
240
|
+
padding: 0,
|
|
241
|
+
border: 0,
|
|
242
|
+
background: 'transparent',
|
|
243
|
+
color: 'inherit',
|
|
244
|
+
cursor: 'pointer'
|
|
245
|
+
}}
|
|
246
|
+
>
|
|
247
|
+
<span
|
|
248
|
+
style={{
|
|
249
|
+
display: 'flex',
|
|
250
|
+
alignItems: 'flex-start',
|
|
251
|
+
width: `${toggles.size}px`,
|
|
252
|
+
height: `${toggles.size + 1}px`,
|
|
253
|
+
borderBottom: `1px solid ${
|
|
254
|
+
active ? 'currentColor' : 'transparent'
|
|
255
|
+
}`
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
<svg
|
|
259
|
+
viewBox="0 0 24 24"
|
|
260
|
+
width={toggles.size}
|
|
261
|
+
height={toggles.size}
|
|
262
|
+
fill="none"
|
|
263
|
+
aria-hidden="true"
|
|
264
|
+
style={{ display: 'block' }}
|
|
265
|
+
>
|
|
266
|
+
<path fill="currentColor" d={VIEW_GLYPHS[option]} />
|
|
267
|
+
</svg>
|
|
268
|
+
</span>
|
|
269
|
+
</button>
|
|
270
|
+
);
|
|
271
|
+
})}
|
|
272
|
+
</div>
|
|
273
|
+
) : null}
|
|
274
|
+
</>
|
|
275
|
+
);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
export default CardListViews;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { CARD_ZOOM_CSS, CARD_ZOOM_STYLE_ID } from '../utils/card-media';
|
|
6
|
+
|
|
7
|
+
const ensureCardZoomStyles = () => {
|
|
8
|
+
if (typeof document === 'undefined') return;
|
|
9
|
+
if (document.getElementById(CARD_ZOOM_STYLE_ID)) return;
|
|
10
|
+
|
|
11
|
+
const style = document.createElement('style');
|
|
12
|
+
style.id = CARD_ZOOM_STYLE_ID;
|
|
13
|
+
style.textContent = CARD_ZOOM_CSS;
|
|
14
|
+
document.head.appendChild(style);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
interface CardZoomProps {
|
|
18
|
+
/** The box the picture was drawn in, so the card around it does not move. */
|
|
19
|
+
style?: React.CSSProperties;
|
|
20
|
+
/** The host's own marks for its picture, written on the frame. */
|
|
21
|
+
attributes?: Record<string, string>;
|
|
22
|
+
children: React.ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The product card's picture, zooming in on hover (ZERO-5173).
|
|
27
|
+
*
|
|
28
|
+
* A frame that clips the picture to its own box, so the enlarged picture
|
|
29
|
+
* never spills over the caption or a neighbouring card. The hover rule is a
|
|
30
|
+
* stylesheet injected once per document: it only does anything where a
|
|
31
|
+
* pointer hovers, so the server render — and a phone — draw the picture
|
|
32
|
+
* exactly as before.
|
|
33
|
+
*/
|
|
34
|
+
export const CardZoom = ({ style, attributes, children }: CardZoomProps) => {
|
|
35
|
+
React.useInsertionEffect(ensureCardZoomStyles, []);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
data-card-zoom=""
|
|
40
|
+
{...attributes}
|
|
41
|
+
style={{
|
|
42
|
+
position: 'relative',
|
|
43
|
+
display: 'block',
|
|
44
|
+
overflow: 'hidden',
|
|
45
|
+
...style
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
{children}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export default CardZoom;
|