@akinon/pz-theme 2.0.82-beta.0 → 2.0.82
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 -4
- package/package.json +4 -4
- package/src/blocks/__tests__/link-block.test.tsx +87 -0
- package/src/blocks/__tests__/text-block.test.tsx +88 -0
- package/src/blocks/__tests__/video-block.test.tsx +207 -2
- package/src/blocks/item-template-utils.ts +51 -5
- package/src/blocks/link-block.tsx +43 -0
- package/src/blocks/slider-block.tsx +92 -4
- package/src/blocks/text-block.tsx +42 -4
- package/src/blocks/video-block.tsx +208 -80
- package/src/sections/__tests__/posts-slider-arrows.test.tsx +470 -0
- package/src/sections/__tests__/posts-slider-edges.test.tsx +394 -0
- package/src/sections/__tests__/posts-slider-quick-view-no-fetch.test.tsx +205 -0
- package/src/sections/__tests__/posts-slider-quick-view.test.tsx +660 -0
- package/src/sections/posts-slider-section.tsx +482 -115
- package/src/sections/slider-quick-view.tsx +375 -0
- package/src/utils/__tests__/line-breaks.test.ts +215 -0
- package/src/utils/index.ts +12 -1
- package/src/utils/iterator-utils.ts +46 -0
- package/src/utils/line-breaks.ts +142 -0
- package/src/utils/slider-arrows.tsx +252 -0
|
@@ -7,13 +7,30 @@ import { twMerge } from 'tailwind-merge';
|
|
|
7
7
|
import ThemeBlock from '../theme-block';
|
|
8
8
|
import { useThemeSettingsContext } from '../theme-settings-context';
|
|
9
9
|
import { safeDeepClone } from '../utils/safe-clone';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
getPriceWithCurrency,
|
|
12
|
+
isPriceBinding,
|
|
13
|
+
readProductCurrency,
|
|
14
|
+
readProductPricePair
|
|
15
|
+
} from '../utils/iterator-utils';
|
|
11
16
|
import {
|
|
12
17
|
applyFirstSlideImageLoading,
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
getResponsiveValue,
|
|
19
|
+
SECTION_CONTENT_ATTRIBUTE
|
|
15
20
|
} from '../utils';
|
|
16
21
|
import { DESKTOP_MIN_WIDTH, TABLET_MIN_WIDTH } from '../utils/breakpoints';
|
|
22
|
+
import {
|
|
23
|
+
findSliderArrowBlocks,
|
|
24
|
+
isSliderArrowIconBlock,
|
|
25
|
+
SliderArrow
|
|
26
|
+
} from '../utils/slider-arrows';
|
|
27
|
+
|
|
28
|
+
import SliderQuickView from './slider-quick-view';
|
|
29
|
+
import {
|
|
30
|
+
DESIGNER_SAMPLE_PRODUCT,
|
|
31
|
+
resolveSliderRowProduct
|
|
32
|
+
} from '../blocks/item-template-utils';
|
|
33
|
+
import type { ThemeProduct } from '../blocks/product-block-utils';
|
|
17
34
|
|
|
18
35
|
import { postToEditor } from '../editor-bridge';
|
|
19
36
|
import { PreviewToEditorMessageType } from '@akinon/theme-contract';
|
|
@@ -25,6 +42,14 @@ interface PostsSliderSectionProps {
|
|
|
25
42
|
selectedBlockId?: string | null;
|
|
26
43
|
}
|
|
27
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Things inside a card that own their own click, so quick view must not take
|
|
47
|
+
* it. `a` is deliberately absent: the card IS a link, and swallowing that
|
|
48
|
+
* navigation is the whole point.
|
|
49
|
+
*/
|
|
50
|
+
const INNER_CONTROL_SELECTOR =
|
|
51
|
+
'button, input, select, textarea, label, [role="button"], [contenteditable]';
|
|
52
|
+
|
|
28
53
|
const parseNumber = (value: unknown, fallback: number) => {
|
|
29
54
|
if (value === undefined || value === null || value === '') return fallback;
|
|
30
55
|
const parsed = typeof value === 'string' ? Number(value) : Number(value);
|
|
@@ -65,23 +90,6 @@ const cloneBlock = (block: any): any => ({
|
|
|
65
90
|
: undefined
|
|
66
91
|
});
|
|
67
92
|
|
|
68
|
-
const parseProductPrice = (value: unknown): number => {
|
|
69
|
-
const parsed = Number(value);
|
|
70
|
-
return Number.isFinite(parsed) ? parsed : 0;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const readProductPricePair = (productData: Record<string, unknown>) => {
|
|
74
|
-
const activePrice = (productData as any)?.active_price as
|
|
75
|
-
| Record<string, unknown>
|
|
76
|
-
| undefined;
|
|
77
|
-
return {
|
|
78
|
-
current: parseProductPrice(activePrice?.price ?? (productData as any)?.price),
|
|
79
|
-
retail: parseProductPrice(
|
|
80
|
-
activePrice?.retail_price ?? (productData as any)?.retail_price
|
|
81
|
-
)
|
|
82
|
-
};
|
|
83
|
-
};
|
|
84
|
-
|
|
85
93
|
const replaceBlockValues = (
|
|
86
94
|
blockToReplace: any,
|
|
87
95
|
productData: Record<string, unknown>,
|
|
@@ -126,13 +134,7 @@ const replaceBlockValues = (
|
|
|
126
134
|
const isRetail =
|
|
127
135
|
bindingPath === 'retail_price' ||
|
|
128
136
|
bindingPath === 'active_price.retail_price';
|
|
129
|
-
const
|
|
130
|
-
| Record<string, unknown>
|
|
131
|
-
| undefined;
|
|
132
|
-
const currency =
|
|
133
|
-
activePrice?.currency_type ??
|
|
134
|
-
(productData as any)?.currency_type ??
|
|
135
|
-
(productData as any)?.currency;
|
|
137
|
+
const currency = readProductCurrency(productData);
|
|
136
138
|
|
|
137
139
|
if (isRetail) {
|
|
138
140
|
// A struck-through retail price only means something when it is
|
|
@@ -255,6 +257,17 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
255
257
|
selectedBlockId = null
|
|
256
258
|
}) => {
|
|
257
259
|
const themeSettings = useThemeSettingsContext();
|
|
260
|
+
/**
|
|
261
|
+
* Drives the carousel from outside it.
|
|
262
|
+
*
|
|
263
|
+
* The arrows cannot live where react-multi-carousel puts a `customArrow` —
|
|
264
|
+
* inside `.react-multi-carousel-list`, which carries `overflow: hidden`
|
|
265
|
+
* because that is what hides the off-screen slides. An arrow placed at a
|
|
266
|
+
* negative offset to overhang the band's padding is clipped by it, and the
|
|
267
|
+
* overflow cannot be opened without spilling every slide. So the arrows are
|
|
268
|
+
* rendered as siblings of the carousel and paged through this instead.
|
|
269
|
+
*/
|
|
270
|
+
const carouselRef = React.useRef<Carousel | null>(null);
|
|
258
271
|
|
|
259
272
|
const getConfigEntry = (key: string) =>
|
|
260
273
|
section.properties?.[key] ?? section.styles?.[key];
|
|
@@ -299,7 +312,15 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
299
312
|
{ desktop: 1, tablet: 1, mobile: 1 }
|
|
300
313
|
);
|
|
301
314
|
|
|
315
|
+
// The arrow icons live in `section.blocks` so the editor can list them as
|
|
316
|
+
// layers, but they are chrome rather than slides — matched by label, not by
|
|
317
|
+
// type, so an icon block a designer adds as an actual slide still is one.
|
|
318
|
+
const { left: leftArrowBlock, right: rightArrowBlock } =
|
|
319
|
+
findSliderArrowBlocks(section.blocks);
|
|
320
|
+
const hasOwnArrows = Boolean(leftArrowBlock || rightArrowBlock);
|
|
321
|
+
|
|
302
322
|
const sortedBlocks = [...(section.blocks || [])]
|
|
323
|
+
.filter((block) => !isSliderArrowIconBlock(block))
|
|
303
324
|
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|
304
325
|
.filter((block) => (isDesigner ? true : !block.hidden));
|
|
305
326
|
|
|
@@ -325,7 +346,12 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
325
346
|
...cloned,
|
|
326
347
|
id: `${template.id || block.id}-iter-${index}`,
|
|
327
348
|
__iteratorParentId: block.id,
|
|
328
|
-
__fromIterator: true
|
|
349
|
+
__fromIterator: true,
|
|
350
|
+
// The row behind this slide, when the section is one whose slides are
|
|
351
|
+
// products AND the data source actually returned one for this index.
|
|
352
|
+
// `products[index] || {}` above makes every clone truthy, so quick
|
|
353
|
+
// view cannot gate on the clone — it gates on this.
|
|
354
|
+
__qvProduct: resolveSliderRowProduct(section?.type, products[index])
|
|
329
355
|
};
|
|
330
356
|
});
|
|
331
357
|
});
|
|
@@ -410,25 +436,17 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
410
436
|
: '';
|
|
411
437
|
const hasMaxWidth = maxWidth !== 'none' && maxWidth !== 'full';
|
|
412
438
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
)
|
|
425
|
-
);
|
|
426
|
-
|
|
427
|
-
const sectionStyles = getCSSStyles(
|
|
428
|
-
filteredStyles,
|
|
429
|
-
themeSettings,
|
|
430
|
-
currentBreakpoint
|
|
431
|
-
);
|
|
439
|
+
/*
|
|
440
|
+
* The section's own declarations are NOT applied here.
|
|
441
|
+
*
|
|
442
|
+
* `generateThemeCSS` already emits them against `[data-section-id="…"]`, which
|
|
443
|
+
* is the `<section>` this component renders inside — so applying them again to
|
|
444
|
+
* the box below meant every one of them landed on two nested elements. The
|
|
445
|
+
* ones that compound are the ones that showed: a designer setting 68px of side
|
|
446
|
+
* padding got 136px, with the band's background painted once full-width and
|
|
447
|
+
* again inside it. The section element owns the section's dressing; this box
|
|
448
|
+
* owns nothing but its own centring and content width.
|
|
449
|
+
*/
|
|
432
450
|
|
|
433
451
|
const gapValue = getResponsiveValue(
|
|
434
452
|
section.styles?.gap,
|
|
@@ -465,6 +483,178 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
465
483
|
const useInfinite = infinite && hasMultipleSlides;
|
|
466
484
|
const useAutoPlay = autoPlay && hasMultipleSlides;
|
|
467
485
|
|
|
486
|
+
/**
|
|
487
|
+
* Edge treatment: cards flush to the band, and a lead-in only at the start.
|
|
488
|
+
*
|
|
489
|
+
* A strip that stops short of its own right edge reads as finished. Slicing
|
|
490
|
+
* the next card there is what says "there is more" — which is why the outer
|
|
491
|
+
* half-gaps have to go: with them the last card can never touch the edge.
|
|
492
|
+
* The slice itself comes from a FRACTIONAL item count the author types
|
|
493
|
+
* (3.5), which the library already honours; nothing here computes it.
|
|
494
|
+
*/
|
|
495
|
+
const flushEdges =
|
|
496
|
+
getResponsiveValue(
|
|
497
|
+
getConfigEntry('flush-edges'),
|
|
498
|
+
currentBreakpoint,
|
|
499
|
+
false
|
|
500
|
+
) === true;
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* The lead-in is not padding, and could not be: no padding is
|
|
504
|
+
* position-dependent, and every candidate — on the track, on the wrapper,
|
|
505
|
+
* on the item — either survives the scroll (so the gap never goes away) or
|
|
506
|
+
* is subtracted from the width the item count is measured against (so "3.5
|
|
507
|
+
* items" silently becomes 3.5 items of a narrower strip).
|
|
508
|
+
*
|
|
509
|
+
* `additionalTransfrom` — the library's spelling — is added to the track's
|
|
510
|
+
* own translate in both the render path and the drag path, so it costs the
|
|
511
|
+
* layout nothing and clips nothing. Handing back 0 as soon as the shopper
|
|
512
|
+
* leaves slide 0 is what makes the gap a lead-in rather than an inset, and
|
|
513
|
+
* because the library fires `beforeChange` in the same commit as its own
|
|
514
|
+
* transform, it slides away with the track instead of snapping.
|
|
515
|
+
*/
|
|
516
|
+
// Through `getResponsiveValue` like every other authored knob: the panel
|
|
517
|
+
// stores a property as `{ desktop: 60 }`, and handing that object straight to
|
|
518
|
+
// `parseNumber` yields NaN — which fell back to 0 and silently disabled the
|
|
519
|
+
// whole feature while the editor showed the value as set.
|
|
520
|
+
const leadIn = clampNumber(
|
|
521
|
+
parseNumber(
|
|
522
|
+
getResponsiveValue(getConfigEntry('lead-in'), currentBreakpoint, 0),
|
|
523
|
+
0
|
|
524
|
+
),
|
|
525
|
+
0,
|
|
526
|
+
400
|
|
527
|
+
);
|
|
528
|
+
/**
|
|
529
|
+
* Which edge the gap belongs to, and when it exists.
|
|
530
|
+
*
|
|
531
|
+
* `left` is a LEAD-IN: it sits before the first card while the track is at
|
|
532
|
+
* the start and slides away the moment it moves. `right` is its mirror, a
|
|
533
|
+
* TRAIL-OUT at the far end — because a right-hand gap at the START is not
|
|
534
|
+
* something the geometry can offer: what sits against the right edge there
|
|
535
|
+
* is the sliced next card, and how much of it shows is the item count's
|
|
536
|
+
* job, not a gap's.
|
|
537
|
+
*
|
|
538
|
+
* Both ride the library's own `additionalTransfrom`: +N pushes the track
|
|
539
|
+
* right and opens the left gap, -N pulls it left and opens the right one.
|
|
540
|
+
*/
|
|
541
|
+
const leadInSide =
|
|
542
|
+
String(
|
|
543
|
+
getResponsiveValue(
|
|
544
|
+
getConfigEntry('lead-in-side'),
|
|
545
|
+
currentBreakpoint,
|
|
546
|
+
'left'
|
|
547
|
+
) ?? 'left'
|
|
548
|
+
) === 'right'
|
|
549
|
+
? 'right'
|
|
550
|
+
: 'left';
|
|
551
|
+
|
|
552
|
+
// Infinite mode starts on a clone and never rests at either extreme, so
|
|
553
|
+
// there is no edge for a gap to belong to.
|
|
554
|
+
const useLeadIn = leadIn > 0 && hasMultipleSlides && !useInfinite;
|
|
555
|
+
const [slidePosition, setSlidePosition] = React.useState(0);
|
|
556
|
+
const lastPosition = Math.max(0, visibleCount - itemsPerView);
|
|
557
|
+
const edgeOffset =
|
|
558
|
+
leadInSide === 'left'
|
|
559
|
+
? slidePosition <= 0
|
|
560
|
+
? leadIn
|
|
561
|
+
: 0
|
|
562
|
+
: slidePosition >= lastPosition - 0.001
|
|
563
|
+
? -leadIn
|
|
564
|
+
: 0;
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Quick view: a tap on a card opens a panel instead of leaving for the
|
|
568
|
+
* product page. Off unless the section says otherwise, which is what keeps
|
|
569
|
+
* every composition published before this behaving exactly as it did — the
|
|
570
|
+
* key is absent, so no handler is built and nothing is rendered.
|
|
571
|
+
*
|
|
572
|
+
* Declared above the empty-slider early return below, because hooks are.
|
|
573
|
+
*/
|
|
574
|
+
const [quickViewRow, setQuickViewRow] = React.useState<ThemeProduct | null>(
|
|
575
|
+
null
|
|
576
|
+
);
|
|
577
|
+
const quickViewOpenerRef = React.useRef<HTMLElement | null>(null);
|
|
578
|
+
const bandRef = React.useRef<HTMLDivElement | null>(null);
|
|
579
|
+
|
|
580
|
+
const showQuickView =
|
|
581
|
+
getResponsiveValue(
|
|
582
|
+
getConfigEntry('show-quick-view'),
|
|
583
|
+
currentBreakpoint,
|
|
584
|
+
false
|
|
585
|
+
) === true;
|
|
586
|
+
const quickViewLinkLabel = String(
|
|
587
|
+
getResponsiveValue(
|
|
588
|
+
getConfigEntry('quick-view-link-label'),
|
|
589
|
+
currentBreakpoint,
|
|
590
|
+
'View Product'
|
|
591
|
+
) ?? 'View Product'
|
|
592
|
+
);
|
|
593
|
+
const quickViewCloseLabel = String(
|
|
594
|
+
getResponsiveValue(
|
|
595
|
+
getConfigEntry('quick-view-close-label'),
|
|
596
|
+
currentBreakpoint,
|
|
597
|
+
'Close'
|
|
598
|
+
) ?? 'Close'
|
|
599
|
+
);
|
|
600
|
+
const quickViewShowFavorite =
|
|
601
|
+
getResponsiveValue(
|
|
602
|
+
getConfigEntry('quick-view-show-favorite'),
|
|
603
|
+
currentBreakpoint,
|
|
604
|
+
true
|
|
605
|
+
) !== false;
|
|
606
|
+
// Canvas only: holds the panel visible, in the flow, so it can be styled.
|
|
607
|
+
const quickViewPreview =
|
|
608
|
+
isDesigner &&
|
|
609
|
+
showQuickView &&
|
|
610
|
+
getResponsiveValue(
|
|
611
|
+
getConfigEntry('quick-view-preview'),
|
|
612
|
+
currentBreakpoint,
|
|
613
|
+
false
|
|
614
|
+
) === true;
|
|
615
|
+
|
|
616
|
+
// One expression for "the panel is over the page", so the row and anything
|
|
617
|
+
// keyed off it can never disagree.
|
|
618
|
+
const quickViewOpen = showQuickView && quickViewRow !== null;
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* A row must not outlive the panel that shows it. Resizing past a
|
|
622
|
+
* breakpoint where the author left quick view off unmounts the panel while
|
|
623
|
+
* the row is still held, and without this the section would stay lifted
|
|
624
|
+
* over the page and re-open the panel by itself on the way back.
|
|
625
|
+
*/
|
|
626
|
+
React.useEffect(() => {
|
|
627
|
+
if (!showQuickView) setQuickViewRow(null);
|
|
628
|
+
}, [showQuickView]);
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Lift the section over a sticky header for as long as the panel is up.
|
|
632
|
+
*
|
|
633
|
+
* The band below is `relative z-10`, and so is `[data-section-content]`
|
|
634
|
+
* that `SectionWrapper` renders around it — so the panel's own
|
|
635
|
+
* `z-index: 1000` resolves INSIDE two nested stacking contexts and the
|
|
636
|
+
* section still competes with the page at 10, losing to a sticky header at
|
|
637
|
+
* 40. The element that has to move is the outermost of the two, which this
|
|
638
|
+
* component does not render; reaching for it is the honest cost of not
|
|
639
|
+
* portalling the panel out, and portalling would take it out of
|
|
640
|
+
* `[data-section-id]` and leave every `quick-view-*` part style behind.
|
|
641
|
+
*/
|
|
642
|
+
React.useEffect(() => {
|
|
643
|
+
if (!quickViewOpen) return;
|
|
644
|
+
|
|
645
|
+
const host = bandRef.current?.closest?.(
|
|
646
|
+
`[${SECTION_CONTENT_ATTRIBUTE}]`
|
|
647
|
+
) as HTMLElement | null;
|
|
648
|
+
if (!host) return;
|
|
649
|
+
|
|
650
|
+
const previous = host.style.zIndex;
|
|
651
|
+
host.style.zIndex = '1000';
|
|
652
|
+
|
|
653
|
+
return () => {
|
|
654
|
+
host.style.zIndex = previous;
|
|
655
|
+
};
|
|
656
|
+
}, [quickViewOpen]);
|
|
657
|
+
|
|
468
658
|
// 'overlay' keeps the library's centred side arrows; 'top-right' moves
|
|
469
659
|
// navigation into a header row above the track (with an optional inline
|
|
470
660
|
// title), the pattern fashion sliders use. Composition-driven via section
|
|
@@ -494,7 +684,8 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
494
684
|
'400'
|
|
495
685
|
);
|
|
496
686
|
const titleFont = String(
|
|
497
|
-
getResponsiveValue(getConfigEntry('title-font'), currentBreakpoint, '') ||
|
|
687
|
+
getResponsiveValue(getConfigEntry('title-font'), currentBreakpoint, '') ||
|
|
688
|
+
''
|
|
498
689
|
).trim();
|
|
499
690
|
const titleTransform = String(
|
|
500
691
|
getResponsiveValue(
|
|
@@ -504,7 +695,6 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
504
695
|
) || 'uppercase'
|
|
505
696
|
);
|
|
506
697
|
const useTopNav = showArrows && arrowPosition === 'top-right';
|
|
507
|
-
const carouselRef = React.useRef<Carousel | null>(null);
|
|
508
698
|
|
|
509
699
|
const topNavChevron = (direction: 'left' | 'right') => (
|
|
510
700
|
<svg
|
|
@@ -550,12 +740,25 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
550
740
|
});
|
|
551
741
|
};
|
|
552
742
|
|
|
743
|
+
/**
|
|
744
|
+
* Hand selection to an arrow's own layer.
|
|
745
|
+
*
|
|
746
|
+
* The editor already answers `SELECT_BLOCK_FROM_PREVIEW` by selecting the
|
|
747
|
+
* block, so the arrow becomes clickable in the canvas the way any other block
|
|
748
|
+
* is — without which a designer would have to hunt for it in the layer tree
|
|
749
|
+
* to reach its styles.
|
|
750
|
+
*/
|
|
751
|
+
const postArrowSelection = (blockId: string) => {
|
|
752
|
+
if (!window.parent) return;
|
|
753
|
+
postToEditor({
|
|
754
|
+
type: 'SELECT_BLOCK_FROM_PREVIEW',
|
|
755
|
+
data: { placeholderId, sectionId: section.id, blockId }
|
|
756
|
+
});
|
|
757
|
+
};
|
|
758
|
+
|
|
553
759
|
if (expandedBlocks.length === 0) {
|
|
554
760
|
return (
|
|
555
|
-
<div
|
|
556
|
-
className="p-4 text-gray-400 border border-dashed border-gray-300 rounded"
|
|
557
|
-
style={sectionStyles}
|
|
558
|
-
>
|
|
761
|
+
<div className="p-4 text-gray-400 border border-dashed border-gray-300 rounded">
|
|
559
762
|
No posts available
|
|
560
763
|
</div>
|
|
561
764
|
);
|
|
@@ -563,10 +766,14 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
563
766
|
|
|
564
767
|
return (
|
|
565
768
|
<div
|
|
769
|
+
ref={bandRef}
|
|
566
770
|
className={twMerge(
|
|
567
771
|
clsx('relative z-10 w-full', hasMaxWidth && 'mx-auto', maxWidthClass)
|
|
568
772
|
)}
|
|
569
|
-
|
|
773
|
+
// Focusable only where it is the panel's last-resort focus target — the
|
|
774
|
+
// card the shopper opened from can be gone by the time they close, and
|
|
775
|
+
// focus has to land somewhere in the section rather than on the body.
|
|
776
|
+
tabIndex={showQuickView ? -1 : undefined}
|
|
570
777
|
>
|
|
571
778
|
{useTopNav && (
|
|
572
779
|
<div
|
|
@@ -599,7 +806,9 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
599
806
|
type="button"
|
|
600
807
|
aria-label="Previous"
|
|
601
808
|
style={topNavButtonStyle}
|
|
602
|
-
onClick={() =>
|
|
809
|
+
onClick={() =>
|
|
810
|
+
carouselRef.current?.previous(currentSlidesToSlide)
|
|
811
|
+
}
|
|
603
812
|
>
|
|
604
813
|
{topNavChevron('left')}
|
|
605
814
|
</button>
|
|
@@ -614,67 +823,225 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
614
823
|
</div>
|
|
615
824
|
</div>
|
|
616
825
|
)}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
826
|
+
{/*
|
|
827
|
+
* The box the slides sit in, and only the slides.
|
|
828
|
+
*
|
|
829
|
+
* The arrows are its siblings, not its children, which is the whole point:
|
|
830
|
+
* a design that insets the cards from the left while leaving the arrows at
|
|
831
|
+
* the band's edge has somewhere to put that inset now. Styled through the
|
|
832
|
+
* `slides` part — `generatePartCSS` emits it against this attribute — so
|
|
833
|
+
* padding it does not move anything but the cards.
|
|
834
|
+
*/}
|
|
835
|
+
<div data-part="slides">
|
|
836
|
+
<Carousel
|
|
837
|
+
key={`${desktopItems}-${tabletItems}-${mobileItems}-${desktopSlides}-${tabletSlides}-${mobileSlides}-${visibleCount}-${currentBreakpoint}-${useInfinite}-${useAutoPlay}-${autoPlaySpeed}`}
|
|
838
|
+
responsive={carouselResponsive}
|
|
839
|
+
{...(isDesigner ? { slidesToSlide: currentSlidesToSlide } : {})}
|
|
840
|
+
swipeable={true}
|
|
841
|
+
draggable={true}
|
|
842
|
+
ref={carouselRef}
|
|
843
|
+
// The library's own arrows only when the section neither moved navigation
|
|
844
|
+
// into its header row nor brought arrow blocks of its own; ours are
|
|
845
|
+
// rendered outside the carousel, below.
|
|
846
|
+
arrows={showArrows && !useTopNav && !hasOwnArrows}
|
|
847
|
+
showDots={showDots}
|
|
848
|
+
renderDotsOutside={showDots}
|
|
849
|
+
infinite={useInfinite}
|
|
850
|
+
rewind={useInfinite}
|
|
851
|
+
rewindWithAnimation={useInfinite}
|
|
852
|
+
autoPlay={useAutoPlay}
|
|
853
|
+
autoPlaySpeed={autoPlaySpeed}
|
|
854
|
+
containerClass="posts-slider-carousel"
|
|
855
|
+
dotListClass="posts-slider-dots"
|
|
856
|
+
/*
|
|
857
|
+
* Spread conditionally so a slider that has not opted in is handed
|
|
858
|
+
* the identical prop object it was handed before — there is no
|
|
859
|
+
* `additionalTransfrom: 0` for the library to reason about, and no
|
|
860
|
+
* `beforeChange` for it to call.
|
|
861
|
+
*/
|
|
862
|
+
{...(useLeadIn
|
|
863
|
+
? {
|
|
864
|
+
additionalTransfrom: edgeOffset,
|
|
865
|
+
beforeChange: (nextSlide: number) => setSlidePosition(nextSlide)
|
|
866
|
+
}
|
|
867
|
+
: {})}
|
|
868
|
+
>
|
|
869
|
+
{expandedBlocks.map((block, index) => {
|
|
870
|
+
const controlBlockId = block.__iteratorParentId || block.id;
|
|
871
|
+
const isIteratorClone = Boolean(block.__fromIterator);
|
|
872
|
+
const renderedBlock =
|
|
873
|
+
index === 0 ? applyFirstSlideImageLoading(block) : block;
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* The whole card is the trigger, because the card IS the link —
|
|
877
|
+
* there is no element inside it a designer placed for this.
|
|
878
|
+
*
|
|
879
|
+
* Capture phase, so the card anchor's own navigation never runs;
|
|
880
|
+
* and only for a plain left click, so ⌘/ctrl/shift-click and the
|
|
881
|
+
* middle button still open the product page in a tab, which is
|
|
882
|
+
* what the anchor is for. A slide with no resolved product gets
|
|
883
|
+
* no handler at all and keeps navigating exactly as it does now.
|
|
884
|
+
*/
|
|
885
|
+
const quickViewHandlers =
|
|
886
|
+
showQuickView && !isDesigner && block.__qvProduct
|
|
887
|
+
? {
|
|
888
|
+
onClickCapture: (
|
|
889
|
+
event: React.MouseEvent<HTMLDivElement>
|
|
890
|
+
) => {
|
|
891
|
+
if (
|
|
892
|
+
event.button !== 0 ||
|
|
893
|
+
event.metaKey ||
|
|
894
|
+
event.ctrlKey ||
|
|
895
|
+
event.shiftKey ||
|
|
896
|
+
event.altKey
|
|
897
|
+
) {
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const target = event.target as HTMLElement | null;
|
|
902
|
+
|
|
903
|
+
// A control the designer dropped INTO the card owns its
|
|
904
|
+
// own click — a button carrying an authored action, a
|
|
905
|
+
// favourite heart, a form field. Swallowing those would
|
|
906
|
+
// break them silently: this runs in the capture phase,
|
|
907
|
+
// so nothing downstream ever sees the click to object.
|
|
908
|
+
// Only the card itself is ours to take over.
|
|
909
|
+
if (target?.closest?.(INNER_CONTROL_SELECTOR)) return;
|
|
910
|
+
|
|
911
|
+
event.preventDefault();
|
|
912
|
+
// Stops the card's own bubble handlers, so an authored
|
|
913
|
+
// action on the card group cannot navigate out from
|
|
914
|
+
// under the panel we are about to open.
|
|
915
|
+
event.stopPropagation();
|
|
916
|
+
|
|
917
|
+
// Focus returns to the card LINK. The slide wrapper this
|
|
918
|
+
// handler sits on is a plain div and cannot hold focus,
|
|
919
|
+
// so storing it would drop the shopper at the top of the
|
|
920
|
+
// page on close; leaving the ref null hands the job to
|
|
921
|
+
// the fallback instead.
|
|
922
|
+
quickViewOpenerRef.current =
|
|
923
|
+
target?.closest?.('a[href]') ?? null;
|
|
924
|
+
|
|
925
|
+
setQuickViewRow(block.__qvProduct as ThemeProduct);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
: null;
|
|
929
|
+
|
|
930
|
+
return (
|
|
931
|
+
<div
|
|
932
|
+
key={block.id}
|
|
933
|
+
{...quickViewHandlers}
|
|
934
|
+
style={
|
|
935
|
+
flushEdges
|
|
936
|
+
? {
|
|
937
|
+
// The inter-card gap is unchanged — it just all sits on
|
|
938
|
+
// one side now, so the outer edges carry none. The last
|
|
939
|
+
// card drops its own so its content reaches the edge.
|
|
940
|
+
paddingLeft: 0,
|
|
941
|
+
paddingRight:
|
|
942
|
+
index === expandedBlocks.length - 1 ? 0 : gap
|
|
943
|
+
}
|
|
944
|
+
: {
|
|
945
|
+
paddingLeft: slidePadding,
|
|
946
|
+
paddingRight: slidePadding
|
|
947
|
+
}
|
|
672
948
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
949
|
+
>
|
|
950
|
+
<ThemeBlock
|
|
951
|
+
block={renderedBlock}
|
|
952
|
+
placeholderId={placeholderId}
|
|
953
|
+
sectionId={section.id}
|
|
954
|
+
isDesigner={isDesigner && !isIteratorClone}
|
|
955
|
+
isSelected={selectedBlockId === controlBlockId}
|
|
956
|
+
selectedBlockId={selectedBlockId}
|
|
957
|
+
currentBreakpoint={currentBreakpoint}
|
|
958
|
+
onMoveUp={() =>
|
|
959
|
+
postBlockAction('MOVE_BLOCK_UP', controlBlockId)
|
|
960
|
+
}
|
|
961
|
+
onMoveDown={() =>
|
|
962
|
+
postBlockAction('MOVE_BLOCK_DOWN', controlBlockId)
|
|
963
|
+
}
|
|
964
|
+
onDuplicate={() =>
|
|
965
|
+
postBlockAction('DUPLICATE_BLOCK', controlBlockId)
|
|
966
|
+
}
|
|
967
|
+
onToggleVisibility={() =>
|
|
968
|
+
postBlockAction('TOGGLE_BLOCK_VISIBILITY', controlBlockId)
|
|
969
|
+
}
|
|
970
|
+
onDelete={() =>
|
|
971
|
+
postBlockAction('DELETE_BLOCK', controlBlockId)
|
|
972
|
+
}
|
|
973
|
+
onRename={(newLabel) =>
|
|
974
|
+
postBlockAction('RENAME_BLOCK', controlBlockId, newLabel)
|
|
975
|
+
}
|
|
976
|
+
/>
|
|
977
|
+
</div>
|
|
978
|
+
);
|
|
979
|
+
})}
|
|
980
|
+
</Carousel>
|
|
981
|
+
</div>
|
|
982
|
+
|
|
983
|
+
{/*
|
|
984
|
+
* One panel for the whole section rather than one per card: only one
|
|
985
|
+
* can be open, and it stays a descendant of `[data-section-id]`, which
|
|
986
|
+
* is what lets the section's `quick-view-*` part styles reach it.
|
|
987
|
+
*/}
|
|
988
|
+
{showQuickView && (
|
|
989
|
+
<SliderQuickView
|
|
990
|
+
row={
|
|
991
|
+
quickViewPreview
|
|
992
|
+
? (expandedBlocks.find((block) => block.__qvProduct)
|
|
993
|
+
?.__qvProduct as ThemeProduct) ?? DESIGNER_SAMPLE_PRODUCT
|
|
994
|
+
: quickViewRow
|
|
995
|
+
}
|
|
996
|
+
open={quickViewPreview || quickViewOpen}
|
|
997
|
+
inline={quickViewPreview}
|
|
998
|
+
onClose={() => setQuickViewRow(null)}
|
|
999
|
+
openerRef={quickViewOpenerRef}
|
|
1000
|
+
fallbackRef={bandRef}
|
|
1001
|
+
isDesigner={isDesigner}
|
|
1002
|
+
currentBreakpoint={currentBreakpoint}
|
|
1003
|
+
linkLabel={quickViewLinkLabel}
|
|
1004
|
+
closeLabel={quickViewCloseLabel}
|
|
1005
|
+
showFavorite={quickViewShowFavorite}
|
|
1006
|
+
/>
|
|
1007
|
+
)}
|
|
1008
|
+
|
|
1009
|
+
{/*
|
|
1010
|
+
* Outside the carousel, inside the box that positions it.
|
|
1011
|
+
*
|
|
1012
|
+
* This box is `relative` and clips nothing, so an authored negative offset
|
|
1013
|
+
* reaches past the band's padding — which is what a design asking for the
|
|
1014
|
+
* arrow flush to the page edge while the cards stay inset actually needs.
|
|
1015
|
+
* A `customArrow` could not: it lands inside `overflow: hidden`.
|
|
1016
|
+
*/}
|
|
1017
|
+
{showArrows && leftArrowBlock && (
|
|
1018
|
+
<SliderArrow
|
|
1019
|
+
block={leftArrowBlock}
|
|
1020
|
+
direction="left"
|
|
1021
|
+
currentBreakpoint={currentBreakpoint}
|
|
1022
|
+
themeSettings={themeSettings}
|
|
1023
|
+
isSelected={isDesigner && selectedBlockId === leftArrowBlock.id}
|
|
1024
|
+
onClick={() => carouselRef.current?.previous(currentSlidesToSlide)}
|
|
1025
|
+
onSelect={
|
|
1026
|
+
isDesigner ? () => postArrowSelection(leftArrowBlock.id) : undefined
|
|
1027
|
+
}
|
|
1028
|
+
/>
|
|
1029
|
+
)}
|
|
1030
|
+
{showArrows && rightArrowBlock && (
|
|
1031
|
+
<SliderArrow
|
|
1032
|
+
block={rightArrowBlock}
|
|
1033
|
+
direction="right"
|
|
1034
|
+
currentBreakpoint={currentBreakpoint}
|
|
1035
|
+
themeSettings={themeSettings}
|
|
1036
|
+
isSelected={isDesigner && selectedBlockId === rightArrowBlock.id}
|
|
1037
|
+
onClick={() => carouselRef.current?.next(currentSlidesToSlide)}
|
|
1038
|
+
onSelect={
|
|
1039
|
+
isDesigner
|
|
1040
|
+
? () => postArrowSelection(rightArrowBlock.id)
|
|
1041
|
+
: undefined
|
|
1042
|
+
}
|
|
1043
|
+
/>
|
|
1044
|
+
)}
|
|
678
1045
|
</div>
|
|
679
1046
|
);
|
|
680
1047
|
};
|