@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.
@@ -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;