@akinon/pz-theme 2.0.143-beta.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.
@@ -167,8 +167,11 @@ export const resolveListingContext = (
167
167
  const source = findListingSource(context);
168
168
  if (!source) return null;
169
169
 
170
- const products = toArray(source.products)
171
- .map((entry, index) => normalizeListingProduct(entry, index, options))
170
+ const rows = toArray(source.products);
171
+ const products = rows
172
+ .map((entry, index) =>
173
+ normalizeListingProduct(entry, index, { ...options, rows })
174
+ )
172
175
  .filter((product): product is ListingProduct => product !== null);
173
176
 
174
177
  const pagination = toRecord(source.pagination);
@@ -12,6 +12,7 @@
12
12
  * module and the iterator is walked on both sides of hydration.
13
13
  */
14
14
 
15
+ import { resolveSwatchColor } from '../utils/swatch-colors';
15
16
  import { formatMoney, MoneyStyleOptions, parseAmount } from './commerce-format';
16
17
 
17
18
  export type { MoneyStyleOptions };
@@ -60,7 +61,8 @@ export interface ListingProductColor {
60
61
  /**
61
62
  * Hex a circular swatch can be filled with. Commerce sends the colour as a
62
63
  * bare value ("kirmizi") and no colour at all, so this is only present when
63
- * the author mapped that value in `lookups.swatch`.
64
+ * the author mapped that value in `lookups.swatch`, or the brand resolved it
65
+ * through its registered swatch colours (utils/swatch-colors).
64
66
  */
65
67
  color?: string;
66
68
  }
@@ -163,6 +165,8 @@ export interface ListingBasketOffer {
163
165
  price?: string;
164
166
  }
165
167
 
168
+ export type ListingPriceNote = 'lowest' | 'advantageous';
169
+
166
170
  export interface ListingProduct {
167
171
  key: string;
168
172
  /**
@@ -214,6 +218,16 @@ export interface ListingProduct {
214
218
  * the payload would move published cards.
215
219
  */
216
220
  discountPercentFromPrevious?: number;
221
+ /**
222
+ * Which price-history caption a discounted row may carry (EU Omnibus; the
223
+ * listing's "Son 10 günün en düşük fiyatı"). `lowest`: the selling price is
224
+ * the lowest of the period — no `previousPrice`, or one at or above it.
225
+ * `advantageous`: commerce's price-history figure is BELOW the selling
226
+ * price, so a "lowest price" claim would be false and the listing says
227
+ * "Avantajlı Fiyat" instead. Absent on a row with no discount. Which words
228
+ * are printed is the card's: it binds a text to each value.
229
+ */
230
+ priceNote?: ListingPriceNote;
217
231
  /**
218
232
  * A raw commerce value resolved through the author's lookup table (see
219
233
  * `ListingBadgeLookup`). Absent when no table was authored, when the product
@@ -245,6 +259,12 @@ export interface ListingLocalizationOptions {
245
259
  * row stays absent, which is the shape the row has always had.
246
260
  */
247
261
  lookups?: ListingValueLookups;
262
+ /**
263
+ * The list's rows as commerce sent them, for a brand's swatch colours that
264
+ * read a colour off the row's neighbours (utils/swatch-colors). Absent = the
265
+ * row alone.
266
+ */
267
+ rows?: unknown[];
248
268
  }
249
269
 
250
270
 
@@ -511,7 +531,8 @@ const isColorAttributeKey = (key: string): boolean => {
511
531
 
512
532
  const normalizeProductColors = (
513
533
  product: UnknownRecord,
514
- swatch?: Record<string, string>
534
+ swatch?: Record<string, string>,
535
+ listRows?: unknown[]
515
536
  ): ListingProductColor[] | undefined => {
516
537
  const groups = toArray(toRecord(product.extra_data).variants);
517
538
  // A known key wins over the loose suffix match: a payload carrying both
@@ -528,6 +549,13 @@ const normalizeProductColors = (
528
549
 
529
550
  if (!group) return undefined;
530
551
 
552
+ const rows = listRows
553
+ ? listRows.filter(
554
+ (row): row is UnknownRecord =>
555
+ Boolean(row) && typeof row === 'object' && !Array.isArray(row)
556
+ )
557
+ : [product];
558
+
531
559
  // Reshaped once rather than per option, so this table gets the same
532
560
  // case-insensitive matching the badge table gets.
533
561
  const swatchEntries = swatch
@@ -557,7 +585,17 @@ const normalizeProductColors = (
557
585
  // (GANT's `integration_renk` group) carry the code as the option's LABEL
558
586
  // and no value at all, so the label is the key there — the same string
559
587
  // the swatch table is authored against.
560
- const hex = resolveLookupEntry(swatchEntries, value || label)?.color;
588
+ const hex =
589
+ resolveLookupEntry(swatchEntries, value || label)?.color ??
590
+ // Nothing authored for this colour: the brand's own resolution, the
591
+ // one its listing paints with, when it registered one.
592
+ resolveSwatchColor({
593
+ key: value || label,
594
+ label,
595
+ product,
596
+ variant,
597
+ rows
598
+ });
561
599
 
562
600
  return {
563
601
  label,
@@ -588,6 +626,23 @@ const readInStock = (product: UnknownRecord): boolean | undefined => {
588
626
  return undefined;
589
627
  };
590
628
 
629
+ /**
630
+ * The listing's price rule (the old storefront's SHOP-88144, shared by its
631
+ * product item, PDP and basket): only a discounted product carries a caption,
632
+ * and it may only claim the lowest price when commerce's price-history figure
633
+ * is not below the selling price.
634
+ */
635
+ const readPriceNote = (
636
+ hasDiscount: boolean,
637
+ price: number | null,
638
+ previous: number | null
639
+ ): ListingPriceNote | undefined => {
640
+ if (!hasDiscount) return undefined;
641
+ return price !== null && previous !== null && price > previous
642
+ ? 'advantageous'
643
+ : 'lowest';
644
+ };
645
+
591
646
  const normalizeProduct = (
592
647
  entry: unknown,
593
648
  index: number,
@@ -631,7 +686,11 @@ const normalizeProduct = (
631
686
  const rawPreviousPrice = toRecord(product.price_extra_field).previous_price;
632
687
  const numericPrevious = parsePriceValue(rawPreviousPrice);
633
688
  const numericPk = Number(product.pk);
634
- const colors = normalizeProductColors(product, options.lookups?.swatch);
689
+ const colors = normalizeProductColors(
690
+ product,
691
+ options.lookups?.swatch,
692
+ options.rows
693
+ );
635
694
  const attributes = normalizeProductAttributes(product);
636
695
  const percentFromRetail = percentOff(numericRetail, numericPrice);
637
696
 
@@ -663,6 +722,7 @@ const normalizeProduct = (
663
722
  discountPercent: percentFromRetail,
664
723
  discountPercentFromRetail: percentFromRetail,
665
724
  discountPercentFromPrevious: percentOff(numericPrevious, numericPrice),
725
+ priceNote: readPriceNote(hasDiscount, numericPrice, numericPrevious),
666
726
  inStock: readInStock(product),
667
727
  attributes,
668
728
  badge: resolveBadge(attributes, options.lookups?.badge),
@@ -12,6 +12,13 @@ import {
12
12
  TABLET_MEDIA_QUERY_COMPACT
13
13
  } from '../utils/breakpoints';
14
14
  import { neutralizeStyleTagBreakout } from '../utils/css-safety';
15
+ import {
16
+ resolveOwnCardsSlider,
17
+ resolveOwnCardZoom
18
+ } from '../utils/property-switches';
19
+ import { CardListSlider, readListGap } from '../components/card-list-slider';
20
+ import { postBlockSelection } from '../hooks/use-designer-features';
21
+ import { CardZoom } from '../components/card-zoom';
15
22
  import ThemeBlock, { Block } from '../theme-block';
16
23
  import { hasItemTemplate, ItemTemplateList } from './item-template';
17
24
  import { BlockRendererProps } from './block-renderer-registry';
@@ -284,13 +291,18 @@ const readOption = <T extends string>(
284
291
  * so a published grid renders exactly what it rendered before.
285
292
  *
286
293
  * Parts: list, card, image, name, price, sale-price, list-price, badge,
287
- * density, density-label, density-option, density-icon, insert, empty.
294
+ * density, density-label, density-option, density-icon, insert, empty,
295
+ * slider, slider-header, slider-arrow.
288
296
  * Properties: columnsDesktop, columnsMobile, limit, showName, nameLines,
289
297
  * imageRatio, imageFit, showPrice, showRetailPrice, showDiscountBadge,
290
298
  * discountBadgeTemplate, badgePosition, decimals, currencyDisplay,
291
299
  * currencyText, currencyPosition, showDensitySwitcher, densityOptions,
292
300
  * densityStyle, densityPosition, densityLabel, densityAriaLabel,
293
- * previewEmptyState, badgeAttribute, badgeMap, swatchMap.
301
+ * previewEmptyState, badgeAttribute, badgeMap, swatchMap, cardsSlider,
302
+ * cardsSliderShowTitle, cardsSliderTitle, cardsSliderTitleColor,
303
+ * cardsSliderTitleSize, cardsSliderTitleWeight, cardsSliderTitleFont,
304
+ * cardsSliderTitleTransform, cardsPerView, cardsPerViewMobile,
305
+ * cardsSliderArrows, cardImageHoverZoom.
294
306
  * Child properties (on a `grid-insertion` child): insertPosition,
295
307
  * insertColumnSpan, insertRowSpan, insertPages.
296
308
  */
@@ -436,6 +448,15 @@ const ProductGridBlock = ({
436
448
  const badgePosition = String(
437
449
  getResponsiveValue(block.properties?.badgePosition, currentBreakpoint, 'inline')
438
450
  );
451
+ // The grid's "Cards:" slider and zoom (ZERO-5173). The slider is the grid's
452
+ // own layout, so it is read here; the zoom reaches a designer-authored card
453
+ // through the property-switch pass, and the built-in card, drawn here,
454
+ // reads it off the grid directly.
455
+ const cardsSlider = resolveOwnCardsSlider(
456
+ block.properties,
457
+ currentBreakpoint
458
+ );
459
+ const builtInZoom = resolveOwnCardZoom(block.properties, currentBreakpoint);
439
460
 
440
461
  const showDensitySwitcher = readToggle(
441
462
  block.properties?.showDensitySwitcher,
@@ -741,7 +762,29 @@ const ProductGridBlock = ({
741
762
  : null)
742
763
  }}
743
764
  >
744
- {product.image ? (
765
+ {product.image && builtInZoom ? (
766
+ <CardZoom
767
+ attributes={{
768
+ 'data-product-card-image': '',
769
+ ...partAttrs('image')
770
+ }}
771
+ style={
772
+ imageRatio !== 'auto' ? { aspectRatio: imageRatio } : undefined
773
+ }
774
+ >
775
+ <img
776
+ src={product.image}
777
+ alt={product.name}
778
+ loading="lazy"
779
+ style={{
780
+ width: '100%',
781
+ height: imageRatio !== 'auto' ? '100%' : 'auto',
782
+ display: 'block',
783
+ objectFit: imageFit as React.CSSProperties['objectFit']
784
+ }}
785
+ />
786
+ </CardZoom>
787
+ ) : product.image ? (
745
788
  <img
746
789
  src={product.image}
747
790
  alt={product.name}
@@ -839,6 +882,43 @@ const ProductGridBlock = ({
839
882
  interleaveCells(builtInCards, insertionTiles, placedInsertions)
840
883
  );
841
884
 
885
+ // ZERO-5173: with the slider on, the same cards — built-in or authored, and
886
+ // any merchandising tile among them — go into one sliding row instead of
887
+ // the grid. The column template, the tiles' cell pins and the density
888
+ // switcher belong to the grid; the row counts its own cards in view per side
889
+ // of the 1024px line, keeps the gap the grid was authored with, and draws
890
+ // the grid's slider title beside its arrows.
891
+ if (cardsSlider) {
892
+ return (
893
+ <div
894
+ data-product-grid=""
895
+ data-product-grid-id={gridId}
896
+ data-product-grid-slider=""
897
+ >
898
+ <CardListSlider
899
+ sliderId={`grid-${gridId}`}
900
+ settings={cardsSlider}
901
+ gap={readListGap(block.styles?.['list/gap'])}
902
+ defaultGap="var(--product-grid-gap, 16px)"
903
+ isDesigner={isDesigner}
904
+ currentBreakpoint={currentBreakpoint}
905
+ trackAttributes={partAttrs('list')}
906
+ onTitleSelect={() =>
907
+ postBlockSelection({
908
+ placeholderId,
909
+ sectionId,
910
+ blockId: block.id,
911
+ blockType: block.type,
912
+ blockLabel: block.label
913
+ })
914
+ }
915
+ >
916
+ {gridChildren}
917
+ </CardListSlider>
918
+ </div>
919
+ );
920
+ }
921
+
842
922
  const grid = (
843
923
  <div
844
924
  data-product-grid=""
@@ -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;