@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.
@@ -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 { getPriceWithCurrency, isPriceBinding } from '../utils/iterator-utils';
10
+ import {
11
+ getPriceWithCurrency,
12
+ isPriceBinding,
13
+ readProductCurrency,
14
+ readProductPricePair
15
+ } from '../utils/iterator-utils';
11
16
  import {
12
17
  applyFirstSlideImageLoading,
13
- getCSSStyles,
14
- getResponsiveValue
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 activePrice = (productData as any)?.active_price as
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
- const styleEntries = section.styles || {};
414
- const filteredStyles = Object.fromEntries(
415
- Object.entries(styleEntries).filter(
416
- ([key]) =>
417
- ![
418
- 'items-per-slide',
419
- 'slides-to-slide',
420
- 'show-arrows',
421
- 'show-dots',
422
- 'max-width'
423
- ].includes(key)
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
- style={sectionStyles}
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={() => carouselRef.current?.previous(currentSlidesToSlide)}
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
- <Carousel
618
- key={`${desktopItems}-${tabletItems}-${mobileItems}-${desktopSlides}-${tabletSlides}-${mobileSlides}-${visibleCount}-${currentBreakpoint}-${useInfinite}-${useAutoPlay}-${autoPlaySpeed}`}
619
- ref={carouselRef}
620
- responsive={carouselResponsive}
621
- {...(isDesigner ? { slidesToSlide: currentSlidesToSlide } : {})}
622
- swipeable={true}
623
- draggable={true}
624
- arrows={showArrows && !useTopNav}
625
- showDots={showDots}
626
- renderDotsOutside={showDots}
627
- infinite={useInfinite}
628
- rewind={useInfinite}
629
- rewindWithAnimation={useInfinite}
630
- autoPlay={useAutoPlay}
631
- autoPlaySpeed={autoPlaySpeed}
632
- containerClass="posts-slider-carousel"
633
- dotListClass="posts-slider-dots"
634
- >
635
- {expandedBlocks.map((block, index) => {
636
- const controlBlockId = block.__iteratorParentId || block.id;
637
- const isIteratorClone = Boolean(block.__fromIterator);
638
- const renderedBlock =
639
- index === 0 ? applyFirstSlideImageLoading(block) : block;
640
-
641
- return (
642
- <div
643
- key={block.id}
644
- style={{
645
- paddingLeft: slidePadding,
646
- paddingRight: slidePadding
647
- }}
648
- >
649
- <ThemeBlock
650
- block={renderedBlock}
651
- placeholderId={placeholderId}
652
- sectionId={section.id}
653
- isDesigner={isDesigner && !isIteratorClone}
654
- isSelected={selectedBlockId === controlBlockId}
655
- selectedBlockId={selectedBlockId}
656
- currentBreakpoint={currentBreakpoint}
657
- onMoveUp={() =>
658
- postBlockAction('MOVE_BLOCK_UP', controlBlockId)
659
- }
660
- onMoveDown={() =>
661
- postBlockAction('MOVE_BLOCK_DOWN', controlBlockId)
662
- }
663
- onDuplicate={() =>
664
- postBlockAction('DUPLICATE_BLOCK', controlBlockId)
665
- }
666
- onToggleVisibility={() =>
667
- postBlockAction('TOGGLE_BLOCK_VISIBILITY', controlBlockId)
668
- }
669
- onDelete={() => postBlockAction('DELETE_BLOCK', controlBlockId)}
670
- onRename={(newLabel) =>
671
- postBlockAction('RENAME_BLOCK', controlBlockId, newLabel)
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
- </div>
675
- );
676
- })}
677
- </Carousel>
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
  };