@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.
- package/CHANGELOG.md +5 -4
- 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
|
@@ -167,8 +167,11 @@ export const resolveListingContext = (
|
|
|
167
167
|
const source = findListingSource(context);
|
|
168
168
|
if (!source) return null;
|
|
169
169
|
|
|
170
|
-
const
|
|
171
|
-
|
|
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 =
|
|
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(
|
|
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;
|