@akinon/pz-theme 2.0.120-rc.0 → 2.0.121-beta.0

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 CHANGED
@@ -1,40 +1,20 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.120-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c2]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71d]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6c]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a631]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b1]
34
- - Updated dependencies [1595d458]
35
- - Updated dependencies [dca1281]
36
- - @akinon/next@2.0.120-rc.0
37
- - @akinon/theme-contract@2.0.120-rc.0
3
+ ## 2.0.121-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.121-beta.0
9
+ - @akinon/theme-contract@2.0.121-beta.0
10
+
11
+ ## 2.0.120
12
+
13
+ ### Patch Changes
14
+
15
+ - 08bcba4: Let the panel actually reach a slider's arrows and dots: the arrow buttons took a hand-picked handful of style keys and re-hardcoded the rest afterwards, so Justify Content, Align Items, Border, Box Shadow and Padding were stored, shown back in the panel and then dropped on the way to the canvas; the arrows now honour the whole authored map and carry their own block id, so states, part keys and custom CSS reach them too. The dots were the carousel library's own markup with no hook out of it at all, and are now drawn as a named part, so their box, their hover and — the thing every brand asks for first — the active one can all be styled, while a slider that authored none of this renders exactly as before
16
+ - @akinon/next@2.0.120
17
+ - @akinon/theme-contract@2.0.120
38
18
 
39
19
  ## 2.0.119
40
20
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.120-rc.0",
4
+ "version": "2.0.121-beta.0",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.120-rc.0",
11
+ "@akinon/next": "2.0.121-beta.0",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.120-rc.0",
16
+ "@akinon/theme-contract": "2.0.121-beta.0",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.120-rc.0",
24
+ "@akinon/next": "2.0.121-beta.0",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -572,14 +572,27 @@ describe('SliderBlock — arrow rotate and reveal-on-hover', () => {
572
572
  expect(glyphs(container)[0].style.rotate).toBeFalsy();
573
573
  });
574
574
 
575
- // The rotation must not fight the button's own centring translate.
576
- it('rotates the glyph, never the button', () => {
575
+ /*
576
+ * The rotation must not fight the button's own centring translate — and,
577
+ * since the button carries `data-block-id`, it must not be applied a second
578
+ * time by the generated stylesheet either. `getCSSStyles` drops `rotate` so
579
+ * the sheet can render it, which is right for every other block and wrong
580
+ * here: the plate is not what the designer is turning. `rotate: none` inline
581
+ * is what says so, and inline is what beats the sheet.
582
+ */
583
+ it('rotates the glyph, and pins the button against the generated rule', () => {
577
584
  const { container } = renderSlider(outside({ rotate: { desktop: '180deg' } }));
578
585
 
579
- expect(buttons(container)[0].style.rotate).toBeFalsy();
586
+ expect(buttons(container)[0].style.rotate).toBe('none');
580
587
  expect(buttons(container)[0].style.transform).toBeFalsy();
581
588
  });
582
589
 
590
+ it('leaves the button alone when no rotation is authored', () => {
591
+ const { container } = renderSlider(outside());
592
+
593
+ expect(buttons(container)[0].style.rotate).toBeFalsy();
594
+ });
595
+
583
596
  it('hides the arrows until the slider is hovered when asked to', () => {
584
597
  const { container } = renderSlider(outside(), {}, {
585
598
  'arrows-on-hover': { desktop: true }
@@ -687,3 +700,425 @@ describe('SliderBlock — autoplay speed', () => {
687
700
  expect(lastCarouselProps().autoPlaySpeed).toBe(3000);
688
701
  });
689
702
  });
703
+
704
+ /**
705
+ * THE WHOLE AUTHORED MAP, NOT A HAND-PICKED LIST.
706
+ *
707
+ * The plate used to be assembled from six keys — width, height, the radius
708
+ * shorthand and its corners, the four offsets, the background — so every other
709
+ * declaration a designer typed was stored, shown back in the panel, and then
710
+ * dropped on the way to the button. `justify-content` and `align-items` were
711
+ * worse than dropped: they were re-hardcoded to `center` AFTER the authored
712
+ * spread, so no value could have won even had the key been read.
713
+ */
714
+ describe('SliderBlock — the arrow plate takes the whole style map', () => {
715
+ const arrow = (label: string, styles: Record<string, unknown>) =>
716
+ child(label === 'Left Arrow Icon' ? 'left-arrow' : 'right-arrow', {
717
+ type: 'icon',
718
+ label,
719
+ styles
720
+ });
721
+
722
+ const renderArrows = (styles: Record<string, unknown>) =>
723
+ render(
724
+ <SliderBlock
725
+ {...props({
726
+ isDesigner: true,
727
+ block: {
728
+ ...props().block,
729
+ blocks: [
730
+ arrow('Left Arrow Icon', styles),
731
+ child('s1'),
732
+ arrow('Right Arrow Icon', styles)
733
+ ]
734
+ }
735
+ })}
736
+ />
737
+ );
738
+
739
+ const previous = (container: HTMLElement) =>
740
+ container.querySelector('button[aria-label="Previous"]') as HTMLElement;
741
+
742
+ it('honours justify-content, the key the panel showed doing nothing', () => {
743
+ const { container } = renderArrows({ 'justify-content': 'flex-end' });
744
+
745
+ expect(previous(container).style.justifyContent).toBe('flex-end');
746
+ });
747
+
748
+ it('honours align-items', () => {
749
+ const { container } = renderArrows({ 'align-items': 'flex-start' });
750
+
751
+ expect(previous(container).style.alignItems).toBe('flex-start');
752
+ });
753
+
754
+ it('honours a border, which the hardcoded `none` used to swallow', () => {
755
+ const { container } = renderArrows({ border: '2px solid #ff0000' });
756
+
757
+ expect(previous(container).style.border).toBe('2px solid #ff0000');
758
+ });
759
+
760
+ it('honours the rest of the box a designer reaches for', () => {
761
+ const { container } = renderArrows({
762
+ 'box-shadow': '0 2px 8px rgba(0,0,0,0.4)',
763
+ padding: '6px',
764
+ 'backdrop-filter': 'blur(4px)'
765
+ });
766
+ const style = previous(container).style;
767
+
768
+ expect(style.boxShadow).toBe('0 2px 8px rgba(0,0,0,0.4)');
769
+ expect(style.padding).toBe('6px');
770
+ });
771
+
772
+ it('keeps centring the glyph when nothing asks otherwise', () => {
773
+ const { container } = renderArrows({});
774
+
775
+ // The default moved from the inline object into the class list, which is
776
+ // the whole reason an authored value can now beat it.
777
+ expect(previous(container).className).toContain('items-center');
778
+ expect(previous(container).className).toContain('justify-center');
779
+ expect(previous(container).style.justifyContent).toBe('');
780
+ });
781
+
782
+ it('keeps the legacy plate for a slider that authored none of this', () => {
783
+ const { container } = renderArrows({});
784
+ const style = previous(container).style;
785
+
786
+ expect(style.width).toBe('40px');
787
+ expect(style.height).toBe('40px');
788
+ expect(style.borderRadius).toBe('50%');
789
+ expect(style.backgroundColor).toBe('rgba(0, 0, 0, 0.5)');
790
+ expect(style.cursor).toBe('pointer');
791
+ });
792
+
793
+ it('still lets the authored background replace the default plate', () => {
794
+ const { container } = renderArrows({ background: '#ffffff' });
795
+
796
+ expect(previous(container).style.background).toBe('rgb(255, 255, 255)');
797
+ });
798
+
799
+ /**
800
+ * The line that connects the arrow to everything else in the panel. The
801
+ * generated stylesheet has always emitted `[data-block-id="…"]` rules for
802
+ * these icon blocks — `collectBlockCSS` recurses into every child — and they
803
+ * matched nothing, because the icon children are filtered out of the slide
804
+ * list and the button carried no id. Hover colours, part keys and the
805
+ * custom-CSS hatch all rode on this.
806
+ */
807
+ it('stamps each arrow button with its own block id', () => {
808
+ const { container } = renderArrows({});
809
+
810
+ expect(previous(container).getAttribute('data-block-id')).toBe(
811
+ 'left-arrow'
812
+ );
813
+ expect(
814
+ container
815
+ .querySelector('button[aria-label="Next"]')
816
+ ?.getAttribute('data-block-id')
817
+ ).toBe('right-arrow');
818
+ });
819
+
820
+ it('keeps `size` off the plate — it belongs to the glyph', () => {
821
+ const { container } = renderArrows({ size: 32 });
822
+ const button = previous(container);
823
+
824
+ expect(button.getAttribute('style')).not.toContain('size');
825
+ expect(
826
+ (button.querySelector('img') as HTMLElement).style.width
827
+ ).toBe('32px');
828
+ });
829
+ });
830
+
831
+ /**
832
+ * THE DOTS.
833
+ *
834
+ * react-multi-carousel drew them from its own stylesheet and the panel's only
835
+ * word on the subject was Show Dots, so a brand could have pagination or not
836
+ * have it and nothing in between. The block draws them now, which is what lets
837
+ * `dot/…` and `dot/selected/…` reach them through the same part pipeline the
838
+ * Slide box already uses.
839
+ */
840
+ describe('SliderBlock — dots', () => {
841
+ const renderDots = (properties: Record<string, unknown> = {}) =>
842
+ render(
843
+ <SliderBlock
844
+ {...props({
845
+ block: {
846
+ ...props().block,
847
+ properties: { 'show-dots': true, ...properties },
848
+ blocks: [child('a'), child('b')]
849
+ }
850
+ })}
851
+ />
852
+ );
853
+
854
+ const dotFor = (active: boolean) => {
855
+ renderDots();
856
+ const CustomDot = lastCarouselProps().customDot;
857
+ return render(
858
+ <ul>{React.cloneElement(CustomDot, { index: 1, active })}</ul>
859
+ ).container;
860
+ };
861
+
862
+ it('hands the carousel a dot of its own to draw', () => {
863
+ renderDots();
864
+
865
+ expect(lastCarouselProps().showDots).toBe(true);
866
+ expect(lastCarouselProps().customDot).toBeTruthy();
867
+ });
868
+
869
+ it('names the dot as a part, so the panel can reach it', () => {
870
+ const dot = dotFor(false).querySelector('[data-part="dot"]');
871
+
872
+ expect(dot).toBeTruthy();
873
+ expect(dot?.tagName).toBe('BUTTON');
874
+ });
875
+
876
+ it('stamps the active dot `selected`, which is the state chip', () => {
877
+ expect(
878
+ dotFor(true).querySelector('[data-part="dot"]')?.getAttribute('data-state')
879
+ ).toBe('selected');
880
+ expect(
881
+ dotFor(false)
882
+ .querySelector('[data-part="dot"]')
883
+ ?.getAttribute('data-state')
884
+ ).toBeNull();
885
+ });
886
+
887
+ /*
888
+ * The default look is the LIBRARY's, not a copy of it. Keeping its class
889
+ * names means `react-multi-carousel/lib/styles.css` still paints the dot,
890
+ * still gives it the `:hover:active` press feedback no inline style can
891
+ * express, and leaves `--active` / `data-index` in place for the brand
892
+ * stylesheets, tests and analytics selectors written against them.
893
+ */
894
+ it('keeps the library class contract so its own stylesheet still applies', () => {
895
+ const inactive = dotFor(false).querySelector('li') as HTMLElement;
896
+ const activeItem = dotFor(true).querySelector('li') as HTMLElement;
897
+
898
+ expect(inactive.className).toBe('react-multi-carousel-dot');
899
+ expect(activeItem.className).toContain('react-multi-carousel-dot');
900
+ expect(activeItem.className).toContain('react-multi-carousel-dot--active');
901
+ expect(inactive.getAttribute('data-index')).toBe('1');
902
+ });
903
+
904
+ /*
905
+ * An inline declaration outranks every generated rule that is not
906
+ * `!important` — so a dot styled inline here would sit in front of the root
907
+ * and custom-CSS routes the panel also offers for this block.
908
+ */
909
+ it('writes no inline style, so nothing is in front of the panel', () => {
910
+ const dot = dotFor(false).querySelector('[data-part="dot"]') as HTMLElement;
911
+
912
+ expect(dot.getAttribute('style')).toBeNull();
913
+ expect((dotFor(false).querySelector('li') as HTMLElement).getAttribute('style')).toBeNull();
914
+ });
915
+
916
+ it('marks the active dot for aria as well as for the state chip', () => {
917
+ expect(
918
+ dotFor(true)
919
+ .querySelector('[data-part="dot"]')
920
+ ?.getAttribute('aria-current')
921
+ ).toBe('true');
922
+ });
923
+
924
+ it('goes to the slide it stands for', () => {
925
+ const onClick = jest.fn();
926
+ renderDots();
927
+ const { container } = render(
928
+ <ul>
929
+ {React.cloneElement(lastCarouselProps().customDot, {
930
+ index: 2,
931
+ active: false,
932
+ onClick
933
+ })}
934
+ </ul>
935
+ );
936
+
937
+ fireEvent.click(container.querySelector('[data-part="dot"]') as HTMLElement);
938
+ expect(onClick).toHaveBeenCalledTimes(1);
939
+ });
940
+
941
+ /*
942
+ * The designer resolves which PART was clicked from a bubble handler on the
943
+ * block wrapper (`use-designer-features`). A `stopPropagation` here would
944
+ * have made the Dot panel this change exists to offer unreachable by
945
+ * clicking the dot — and the library's own dot never stopped the event.
946
+ */
947
+ it('lets the click bubble, or the canvas cannot select the dot', () => {
948
+ renderDots();
949
+ const onWrapper = jest.fn();
950
+ const { container } = render(
951
+ <div onClick={onWrapper}>
952
+ <ul>
953
+ {React.cloneElement(lastCarouselProps().customDot, {
954
+ index: 0,
955
+ active: false,
956
+ onClick: () => undefined
957
+ })}
958
+ </ul>
959
+ </div>
960
+ );
961
+
962
+ fireEvent.click(container.querySelector('[data-part="dot"]') as HTMLElement);
963
+ expect(onWrapper).toHaveBeenCalledTimes(1);
964
+ });
965
+
966
+ it('labels each dot for a screen reader, as the library did', () => {
967
+ const dot = dotFor(false).querySelector('[data-part="dot"]');
968
+
969
+ expect(dot?.getAttribute('aria-label')).toBe('Go to slide 2');
970
+ });
971
+ });
972
+
973
+ /**
974
+ * WHAT `getCSSStyles` HANDS BACK THAT AN INLINE STYLE OBJECT CANNOT USE.
975
+ *
976
+ * The hand-picked reader this replaced absorbed both of these silently, so they
977
+ * are regressions the widened path had to buy back rather than new features.
978
+ * Both have the same double failure: the declaration is thrown away by the CSS
979
+ * parser AND it still counts as "authored" to the placement tests, so the class
980
+ * default steps aside for nothing and the arrow lands somewhere neither the
981
+ * designer nor the old code would have put it.
982
+ */
983
+ describe('SliderBlock — values the plate has to refuse', () => {
984
+ const arrow = (label: string, styles: Record<string, unknown>) =>
985
+ child(label === 'Left Arrow Icon' ? 'left' : 'right', {
986
+ type: 'icon',
987
+ label,
988
+ styles
989
+ });
990
+
991
+ const renderArrows = (
992
+ styles: Record<string, unknown>,
993
+ over: Record<string, unknown> = {}
994
+ ) =>
995
+ render(
996
+ <SliderBlock
997
+ {...props({
998
+ isDesigner: true,
999
+ block: {
1000
+ ...props().block,
1001
+ blocks: [
1002
+ arrow('Left Arrow Icon', styles),
1003
+ child('s1'),
1004
+ arrow('Right Arrow Icon', styles)
1005
+ ]
1006
+ },
1007
+ ...over
1008
+ })}
1009
+ />
1010
+ );
1011
+
1012
+ const previous = (container: HTMLElement) =>
1013
+ container.querySelector('button[aria-label="Previous"]') as HTMLElement;
1014
+
1015
+ // The panel's offset and corner boxes are free text, so '12' reaches the
1016
+ // published JSON as often as '12px' does.
1017
+ it('appends px to a unitless length stored as a string', () => {
1018
+ const { container } = renderArrows({ left: '12', width: '28' });
1019
+ const button = previous(container);
1020
+
1021
+ expect(button.style.left).toBe('12px');
1022
+ expect(button.style.width).toBe('28px');
1023
+ expect(button.className).not.toContain('left-4');
1024
+ });
1025
+
1026
+ it('leaves a zero, a unit and a keyword alone', () => {
1027
+ const { container } = renderArrows({
1028
+ left: '0',
1029
+ width: '100%',
1030
+ height: '3rem',
1031
+ 'border-radius': '50%'
1032
+ });
1033
+ const style = previous(container).style;
1034
+
1035
+ expect(style.left).toBe('0px');
1036
+ expect(style.width).toBe('100%');
1037
+ expect(style.height).toBe('3rem');
1038
+ expect(style.borderRadius).toBe('50%');
1039
+ });
1040
+
1041
+ /*
1042
+ * THE COMMONEST WAY TO GET AN EMPTY STRING, AND IT IS NOT A CLEARED FIELD.
1043
+ * Authoring a value on Mobile only stores `{ mobile: … }` with no desktop
1044
+ * sibling, and `getResponsiveValue` has no chain from desktop — so every
1045
+ * desktop render of that key resolves to ''.
1046
+ */
1047
+ it('ignores a key authored at another breakpoint instead of wiping the default', () => {
1048
+ const { container } = renderArrows({ top: { mobile: 20 } });
1049
+ const button = previous(container);
1050
+
1051
+ expect(button.style.top).toBe('');
1052
+ // The half that actually moved the arrow: the centring must not step aside
1053
+ // for a declaration that paints nothing.
1054
+ expect(button.className).toContain('top-1/2');
1055
+ expect(button.className).toContain('-translate-y-1/2');
1056
+ });
1057
+
1058
+ it('keeps the plate defaults when a field is cleared to an empty string', () => {
1059
+ const { container } = renderArrows({
1060
+ 'border-radius': { desktop: '' },
1061
+ width: { desktop: '' },
1062
+ height: { desktop: '' }
1063
+ });
1064
+ const style = previous(container).style;
1065
+
1066
+ expect(style.borderRadius).toBe('50%');
1067
+ expect(style.width).toBe('40px');
1068
+ expect(style.height).toBe('40px');
1069
+ });
1070
+
1071
+ /*
1072
+ * `position` is what makes the arrow an overlay rather than a block after the
1073
+ * track. It is offered on every icon block by `positionStyleControls`, it was
1074
+ * inert before this, and honouring it now would drop published arrows out of
1075
+ * the slider.
1076
+ */
1077
+ it('refuses an authored position, which would drop the arrow out of the overlay', () => {
1078
+ const { container } = renderArrows({ position: 'relative' });
1079
+ const button = previous(container);
1080
+
1081
+ expect(button.style.position).toBe('');
1082
+ expect(button.className).toContain('absolute');
1083
+ });
1084
+
1085
+ /*
1086
+ * `-translate-y-1/2` is a class, and Tailwind 4 compiles it to the individual
1087
+ * `translate` property — the one `getCSSStyles` drops so the generated
1088
+ * `[data-block-id]` rule can render it. That rule matches this button now, so
1089
+ * the centring is declared inline where it cannot lose.
1090
+ */
1091
+ it('pins the centring translate inline so the generated rule cannot move it', () => {
1092
+ const { container } = renderArrows({});
1093
+
1094
+ expect(previous(container).style.translate).toBe('0 -50%');
1095
+ });
1096
+
1097
+ it('drops the pin once the arrow is placed by an offset of its own', () => {
1098
+ const { container } = renderArrows({ top: '0px' });
1099
+
1100
+ expect(previous(container).style.translate).toBeFalsy();
1101
+ });
1102
+
1103
+ /*
1104
+ * Tailwind implements `ring-*` as `box-shadow`, and `box-shadow` is one of
1105
+ * the keys that now flows onto the plate — so the ring had to stop being one.
1106
+ */
1107
+ it('marks the selected arrow with an outline a box-shadow cannot erase', () => {
1108
+ const { container } = renderArrows(
1109
+ { 'box-shadow': '0 2px 8px rgba(0,0,0,0.4)' },
1110
+ { selectedBlockId: 'left' }
1111
+ );
1112
+ const button = previous(container);
1113
+
1114
+ expect(button.style.outline).toBe('2px solid #3b82f6');
1115
+ expect(button.style.boxShadow).toBe('0 2px 8px rgba(0,0,0,0.4)');
1116
+ expect(button.className).not.toContain('ring-2');
1117
+ });
1118
+
1119
+ it('leaves the outline off an arrow that is not selected', () => {
1120
+ const { container } = renderArrows({});
1121
+
1122
+ expect(previous(container).style.outline).toBe('');
1123
+ });
1124
+ });
@@ -33,6 +33,67 @@ const parseNumber = (value: unknown, fallback: number) => {
33
33
  const clampNumber = (value: number, min: number, max: number) =>
34
34
  Math.min(Math.max(value, min), max);
35
35
 
36
+ /**
37
+ * THE DOTS, WHICH WERE NOT OURS TO STYLE UNTIL NOW.
38
+ *
39
+ * `showDots` handed the whole pagination row to react-multi-carousel, and
40
+ * `dotListClass="slider-dots"` was the only hook back out — a class nothing in
41
+ * either repo has ever written a rule for. So the panel offered Show Dots and
42
+ * then nothing: no size, no colour, no shape, and above all no way to say what
43
+ * the ACTIVE one looks like, which is the whole job of a dot.
44
+ *
45
+ * `customDot` replaces the library's `<li>` with this one, and the button is
46
+ * named as a part — so every key the panel writes under `dot/…`, and
47
+ * `dot/selected/…` and `dot/hover/…` with it, is carried by the generated
48
+ * stylesheet exactly as the Slide part already is. Part declarations are
49
+ * emitted `!important` (`generatePartCSS`), which is what lets them win over
50
+ * the library's own rules below.
51
+ *
52
+ * NOTHING IS STYLED INLINE HERE, AND THE LIBRARY'S CLASS NAMES ARE KEPT.
53
+ *
54
+ * `react-multi-carousel/lib/styles.css` is imported at the top of this file and
55
+ * paints `.react-multi-carousel-dot button` and
56
+ * `.react-multi-carousel-dot--active button`. Reproducing those declarations
57
+ * inline instead — the first shape this took — quietly cost three things: the
58
+ * `:hover:active` press feedback, which no inline style can express; the
59
+ * `--active` and `data-index` hooks that brand stylesheets, integration tests
60
+ * and analytics selectors are written against; and it put an inline
61
+ * declaration in front of every part key that is not `!important`. Keeping the
62
+ * library's markup contract means the default dot is byte-for-byte the one
63
+ * that shipped, and the panel is layered cleanly on top of it.
64
+ *
65
+ * Declared at module scope, not inside the renderer: react-multi-carousel
66
+ * clones this element once per dot, and a component identity that changed every
67
+ * render would unmount and remount all of them on each slide change — taking
68
+ * the focus of a keyboard user who had just tabbed to one.
69
+ */
70
+ const CustomDot = ({ index, active, onClick }: any) => (
71
+ <li
72
+ className={clsx(
73
+ 'react-multi-carousel-dot',
74
+ active && 'react-multi-carousel-dot--active'
75
+ )}
76
+ data-index={index}
77
+ >
78
+ <button
79
+ type="button"
80
+ {...partAttrs('dot', { selected: Boolean(active) })}
81
+ // `aria-current` is also one of the expressions the `selected` state
82
+ // matches (see STATE_SELECTORS), so it earns its place twice.
83
+ aria-current={Boolean(active)}
84
+ aria-label={`Go to slide ${(index ?? 0) + 1}`}
85
+ /*
86
+ * No `stopPropagation`. The library's own dot did not stop the event
87
+ * either, and the designer canvas resolves which PART was clicked from a
88
+ * bubble handler on the block wrapper (`use-designer-features`) — so
89
+ * swallowing it here would have made the Dot panel this change exists to
90
+ * offer unreachable by clicking the dot.
91
+ */
92
+ onClick={() => onClick?.()}
93
+ />
94
+ </li>
95
+ );
96
+
36
97
  const SliderBlock: React.FC<BlockRendererProps> = ({
37
98
  block,
38
99
  placeholderId,
@@ -547,16 +608,16 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
547
608
  * clobber it under Tailwind 3).
548
609
  *
549
610
  * NOTE THE DIVERGENCE, which is a real one: the section arrows in
550
- * utils/slider-arrows.tsx put `data-block-id` on the BUTTON, so there the
551
- * generated rule for this same `rotate` key turns the plate and the glyph
552
- * together. Here only the glyph turns.
611
+ * utils/slider-arrows.tsx let the generated rule for this same `rotate` key
612
+ * turn the plate and the glyph together. Here only the glyph turns.
553
613
  *
554
- * A rule for this icon block's `rotate` is in fact already in the generated
555
- * stylesheet — `collectBlockCSS` recurses into every child block — and
556
- * matches nothing only because this button carries no `data-block-id`. If one
557
- * is ever added (to let borders, shadows or part keys reach the plate), that
558
- * rule and the inline value below would both apply and the glyph would sit at
559
- * twice the authored angle; drop one of the two when that happens.
614
+ * That divergence used to be free, because the button carried no
615
+ * `data-block-id` and the rule `collectBlockCSS` emits for this icon block
616
+ * matched nothing. The button carries one now — it is what lets borders,
617
+ * shadows, hover colours and part keys reach the plate — so the rule applies
618
+ * and would leave the glyph at twice the authored angle. `arrowPlateStyle`
619
+ * therefore pins `rotate: none` inline on the plate whenever this value is
620
+ * set, which is the "drop one of the two" this note used to ask for.
560
621
  */
561
622
  const leftArrowIconRotate = arrowAngle(
562
623
  getResponsiveValue(
@@ -605,105 +666,192 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
605
666
  alt: 'Next'
606
667
  };
607
668
 
608
- const leftArrowBackground = resolveThemeCssVariables(
609
- String(
610
- getResponsiveValue(
611
- leftArrowIconBlock?.styles?.['background'] ??
612
- leftArrowIconBlock?.styles?.['backgroundColor'],
613
- currentBreakpoint,
614
- 'rgba(0, 0, 0, 0.5)'
615
- )
616
- ),
617
- themeSettings
618
- );
669
+ /**
670
+ * THE ARROW PLATE, AND WHY IT STOPPED BEING A HAND-PICKED LIST.
671
+ *
672
+ * The geometry the panel offers used to be read key by key — width, height,
673
+ * the radius shorthand, its four corners, the four offsets, and the
674
+ * background — and every OTHER declaration a designer typed reached the theme
675
+ * JSON and stopped there. `border`, `box-shadow`, `padding`, `justify-content`
676
+ * and `align-items` were all stored, shown back in the panel, and silently
677
+ * dropped on the way to the button: the panel said the field had taken and
678
+ * the canvas said nothing had happened.
679
+ *
680
+ * The whole map goes through `getCSSStyles` now — the same call every other
681
+ * block renderer makes, and the one the slider SECTIONS' arrows already made
682
+ * (`utils/slider-arrows.tsx`, written for exactly this reason). Theme tokens
683
+ * resolve for free, because `getCSSStyles` runs every string through
684
+ * `resolveThemeCssVariables`.
685
+ *
686
+ * `size`, `color`, `opacity` and `rotate` stay bound to the glyph below.
687
+ * `getCSSStyles` already drops the last three for every block; `size` is not
688
+ * CSS at all, so it is removed here or it would render as an unknown
689
+ * declaration on the plate.
690
+ *
691
+ * `position` is removed for a different reason: it is not a styling choice.
692
+ * The arrow is chrome the block lays over the track, exactly as `absolute` in
693
+ * the class list says, and a `position: relative` sitting on an icon block —
694
+ * `positionStyleControls` offers it on every one of them — would drop the
695
+ * arrow out of the overlay and into the flow after the carousel. It was
696
+ * inert before this and stays inert.
697
+ */
698
+ const ARROW_GLYPH_ONLY_STYLE_KEYS = ['size'];
699
+ const ARROW_PLATE_REMOVED_STYLE_KEYS = ['position'];
619
700
 
620
- const rightArrowBackground = resolveThemeCssVariables(
621
- String(
622
- getResponsiveValue(
623
- rightArrowIconBlock?.styles?.['background'] ??
624
- rightArrowIconBlock?.styles?.['backgroundColor'],
625
- currentBreakpoint,
626
- 'rgba(0, 0, 0, 0.5)'
627
- )
628
- ),
629
- themeSettings
630
- );
701
+ /**
702
+ * WHAT `getCSSStyles` HANDS BACK THAT AN INLINE STYLE OBJECT CANNOT USE.
703
+ *
704
+ * Two shapes, both of which the hand-picked reader used to absorb silently
705
+ * and both of which would otherwise land on the button as a declaration the
706
+ * CSS parser throws away — while still counting as "authored" to the
707
+ * placement tests below, which is the half that actually moves the arrow.
708
+ *
709
+ * AN EMPTY STRING. `getCSSStyles` only drops `undefined` and `null`, and ''
710
+ * is what a cleared text field stores AND what `getResponsiveValue` returns
711
+ * for a key authored at one breakpoint only — `top: { mobile: 20 }` read at
712
+ * desktop is ''. Left in, it would overwrite the 40px/50% defaults it was
713
+ * supposed to fall back to, and make `style.top === undefined` false so the
714
+ * centring class stepped aside for a declaration that paints nothing.
715
+ *
716
+ * A UNITLESS NUMBER AS A STRING. The panel's offset and corner boxes are free
717
+ * text, so '12' reaches the JSON as often as '12px' (page-account-loyalty
718
+ * stores `"size": {"desktop": "8"}`), and published compositions hold it. The
719
+ * old `arrowLength` appended `px` to exactly this; React only does it for a
720
+ * real `number`, so without this the value is dropped and the class default
721
+ * is suppressed at the same time — the arrow lands at its static position,
722
+ * further from what the designer asked for than doing nothing would have been.
723
+ */
724
+ const ARROW_LENGTH_KEYS = new Set([
725
+ 'width',
726
+ 'height',
727
+ 'minWidth',
728
+ 'minHeight',
729
+ 'maxWidth',
730
+ 'maxHeight',
731
+ 'left',
732
+ 'right',
733
+ 'top',
734
+ 'bottom',
735
+ 'borderRadius',
736
+ 'borderTopLeftRadius',
737
+ 'borderTopRightRadius',
738
+ 'borderBottomRightRadius',
739
+ 'borderBottomLeftRadius'
740
+ ]);
741
+
742
+ const usableArrowStyles = (
743
+ resolved: Record<string, unknown>
744
+ ): Record<string, unknown> => {
745
+ const usable: Record<string, unknown> = {};
746
+
747
+ Object.entries(resolved).forEach(([key, value]) => {
748
+ if (typeof value === 'string') {
749
+ const text = value.trim();
750
+ if (!text) return;
751
+
752
+ // '0' is already valid CSS and must not become '0px' — it is also the
753
+ // one bare number the old reader deliberately let through unchanged.
754
+ usable[key] =
755
+ ARROW_LENGTH_KEYS.has(key) && /^-?(?:\d+(?:\.\d+)?|\.\d+)$/.test(text)
756
+ ? `${text}px`
757
+ : text;
758
+ return;
759
+ }
631
760
 
632
- // The arrow button's geometry used to live here and nowhere else — a 40px
633
- // circle — so the panel could set the button's background and nothing else: a
634
- // half-round tab, a square, a wider pill were all unreachable, and a Corner
635
- // Radius typed in the editor was stored and then ignored. Geometry now comes
636
- // from the arrow icon block's own styles, with the old circle as the default
637
- // so a slider that authored none of it renders exactly as before.
638
- //
639
- // Only geometry is read. `color` and `opacity` stay bound to the icon image
640
- // below; reading them here as well would apply each of them twice.
641
- const arrowLength = (value: unknown): string | undefined => {
642
- const text = String(value ?? '').trim();
643
- if (!text) return undefined;
644
- // A bare number is what the editor's numeric controls store; its free-text
645
- // corner fields carry their own unit (`32px`, `50%`).
646
- return /^-?\d*\.?\d+$/.test(text) ? `${text}px` : text;
761
+ if (value === undefined || value === null) return;
762
+ usable[key] = value;
763
+ });
764
+
765
+ return usable;
647
766
  };
648
767
 
649
- const arrowGeometry = (iconBlock?: {
650
- styles?: Record<string, unknown>;
651
- }): React.CSSProperties => {
652
- const styles = iconBlock?.styles ?? {};
653
- const read = (key: string) => {
654
- // Published JSON may carry either spelling of a two-word key.
655
- const camelKey = key.replace(/-([a-z])/g, (_, letter: string) =>
656
- letter.toUpperCase()
657
- );
658
- return arrowLength(
659
- getResponsiveValue(
660
- styles[key] ?? styles[camelKey],
661
- currentBreakpoint,
662
- ''
663
- )
664
- );
665
- };
666
-
667
- const corners: Record<string, string> = {};
668
- (
669
- [
670
- ['border-top-left-radius', 'borderTopLeftRadius'],
671
- ['border-top-right-radius', 'borderTopRightRadius'],
672
- ['border-bottom-right-radius', 'borderBottomRightRadius'],
673
- ['border-bottom-left-radius', 'borderBottomLeftRadius']
674
- ] as const
675
- ).forEach(([styleKey, cssProperty]) => {
676
- const value = read(styleKey);
677
- if (value) corners[cssProperty] = value;
678
- });
768
+ /**
769
+ * The four corners, moved to the end of the declaration list.
770
+ *
771
+ * `border-radius` is a shorthand and resets all four corners, so a map that
772
+ * happens to list `border-top-left-radius` first would have it wiped by a
773
+ * shorthand declared after it. The cascade settles this by source order and
774
+ * an authored map carries whatever order the panel wrote its keys in, so the
775
+ * order is imposed here rather than trusted.
776
+ */
777
+ const ARROW_CORNER_KEYS = [
778
+ 'borderTopLeftRadius',
779
+ 'borderTopRightRadius',
780
+ 'borderBottomRightRadius',
781
+ 'borderBottomLeftRadius'
782
+ ] as const;
783
+
784
+ const arrowPlateStyle = (
785
+ iconBlock?: {
786
+ styles?: Record<string, unknown>;
787
+ },
788
+ /** True when the glyph below is already turning by the authored angle. */
789
+ glyphCarriesRotation?: boolean
790
+ ): React.CSSProperties => {
791
+ const authored = { ...(iconBlock?.styles ?? {}) };
792
+ [...ARROW_GLYPH_ONLY_STYLE_KEYS, ...ARROW_PLATE_REMOVED_STYLE_KEYS].forEach(
793
+ (key) => {
794
+ delete authored[key];
795
+ }
796
+ );
797
+
798
+ const resolved = usableArrowStyles(
799
+ getCSSStyles(authored, themeSettings, currentBreakpoint) as Record<
800
+ string,
801
+ unknown
802
+ >
803
+ );
679
804
 
680
- const geometry: React.CSSProperties = {
681
- width: read('width') ?? '40px',
682
- height: read('height') ?? '40px'
683
- };
684
-
685
- // The shorthand is written before the corners so an authored corner
686
- // overrides just that corner, the way the cascade does. The 50% default
687
- // only stands in when no radius was authored at all — otherwise rounding
688
- // one corner would leave the other three on the legacy circle.
689
- const radius = read('border-radius');
690
- if (radius) geometry.borderRadius = radius;
691
- else if (Object.keys(corners).length === 0) geometry.borderRadius = '50%';
692
-
693
- // The editor offers these four on the arrow's icon block, but only the
694
- // sizes above were ever read — so an authored offset reached the theme
695
- // JSON and stopped there, leaving the arrow pinned to the class default
696
- // with no feedback that the field did nothing.
697
- (['left', 'right', 'top', 'bottom'] as const).forEach((key) => {
698
- const value = read(key);
699
- if (value) geometry[key] = value;
805
+ const corners: Record<string, unknown> = {};
806
+ ARROW_CORNER_KEYS.forEach((key) => {
807
+ if (resolved[key] === undefined) return;
808
+ corners[key] = resolved[key];
809
+ delete resolved[key];
700
810
  });
701
811
 
702
- return { ...geometry, ...corners };
812
+ // The legacy circle, and only when no radius was authored at all —
813
+ // otherwise rounding one corner would leave the other three round.
814
+ const hasRadius =
815
+ resolved.borderRadius !== undefined || Object.keys(corners).length > 0;
816
+
817
+ return {
818
+ // Defaults first, so every one of them is something the panel can simply
819
+ // replace. The 40px circle is what this block has always drawn, which is
820
+ // what keeps a slider that authored none of this rendering as before.
821
+ width: '40px',
822
+ height: '40px',
823
+ ...(hasRadius ? null : { borderRadius: '50%' }),
824
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
825
+ border: 'none',
826
+ cursor: 'pointer',
827
+ padding: 0,
828
+ ...resolved,
829
+ ...corners,
830
+ /*
831
+ * THE ONE DECLARATION THAT HAS TO WIN OVER THE GENERATED SHEET.
832
+ *
833
+ * `getCSSStyles` drops `rotate` for every block because the generated
834
+ * `[data-block-id]` rule renders it instead — and the button now carries
835
+ * that id, so that rule finally matches. The glyph below is still turned
836
+ * inline, on purpose (see `arrowAngle`: the designer wants the ARROW
837
+ * pointing the other way, not the plate behind it turning with it), and
838
+ * the two together would leave the arrow at twice the authored angle.
839
+ * This is the "drop one of the two" the note there asked for, and it is
840
+ * the plate's copy that goes: inline beats the sheet, and `none` is the
841
+ * property's own initial value.
842
+ */
843
+ ...(glyphCarriesRotation ? { rotate: 'none' } : null)
844
+ } as React.CSSProperties;
703
845
  };
704
846
 
705
- const leftArrowButtonStyle = arrowGeometry(leftArrowIconBlock);
706
- const rightArrowButtonStyle = arrowGeometry(rightArrowIconBlock);
847
+ const leftArrowButtonStyle = arrowPlateStyle(
848
+ leftArrowIconBlock,
849
+ Boolean(leftArrowIconRotate)
850
+ );
851
+ const rightArrowButtonStyle = arrowPlateStyle(
852
+ rightArrowIconBlock,
853
+ Boolean(rightArrowIconRotate)
854
+ );
707
855
 
708
856
  /**
709
857
  * What the arrows-on-hover option adds to every arrow button.
@@ -731,18 +879,31 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
731
879
  * over-constrains the box and CSS drops the `right`, and the centering
732
880
  * translate would shift an authored `top` by half the button's height.
733
881
  */
882
+ const isArrowVerticallyCentred = (style: React.CSSProperties) =>
883
+ style.top === undefined && style.bottom === undefined;
884
+
734
885
  const arrowPlacementClass = (
735
886
  style: React.CSSProperties,
736
887
  horizontalFallback: string
737
888
  ) =>
738
889
  clsx(
739
- 'absolute z-10',
890
+ // The centring moved OUT of the inline style object and into the class
891
+ // list, which is the whole reason `justify-content` and `align-items`
892
+ // now reach the button: written inline after the authored map, as they
893
+ // were, they overwrote whatever the designer had just typed. A class
894
+ // loses to any inline declaration, so the default still centres an arrow
895
+ // nobody has touched and steps aside for one they have.
896
+ //
897
+ // These four are safe to leave as classes where a colour or a size would
898
+ // not be: the header note above warns that a brand whose Tailwind
899
+ // `content` skips this package never generates a class only this file
900
+ // writes, and `flex`/`items-center`/`justify-center`/`z-10` are in every
901
+ // brand's own source — as `left-4` and `top-1/2` already were.
902
+ 'absolute z-10 flex items-center justify-center',
740
903
  style.left === undefined && style.right === undefined
741
904
  ? horizontalFallback
742
905
  : undefined,
743
- style.top === undefined && style.bottom === undefined
744
- ? 'top-1/2 -translate-y-1/2'
745
- : undefined
906
+ isArrowVerticallyCentred(style) ? 'top-1/2 -translate-y-1/2' : undefined
746
907
  );
747
908
 
748
909
  const arrowsOutside =
@@ -765,89 +926,114 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
765
926
  carouselRef.current?.next?.(1);
766
927
  };
767
928
 
768
- const CustomLeftArrow = ({ onClick }: any) => (
769
- <button
770
- onClick={(e) => {
771
- e.stopPropagation();
772
- if (!isDesigner) {
773
- onClick?.();
774
- }
775
- handleLeftArrowClick(e);
776
- }}
777
- className={clsx(
778
- arrowPlacementClass(leftArrowButtonStyle, 'left-4'),
779
- isDesigner &&
780
- selectedBlockId === leftArrowIconBlock?.id &&
781
- 'ring-2 ring-blue-500 ring-offset-2'
782
- )}
783
- style={{
784
- backgroundColor: leftArrowBackground,
785
- border: 'none',
786
- ...leftArrowButtonStyle,
787
- display: 'flex',
788
- alignItems: 'center',
789
- justifyContent: 'center',
790
- cursor: 'pointer',
791
- padding: 0,
792
- ...arrowRevealStyle
793
- }}
794
- aria-label="Previous"
795
- >
796
- <img
797
- src={leftArrowIcon.url}
798
- alt={leftArrowIcon.alt}
799
- style={{
800
- width: `${leftArrowIconSize}px`,
801
- height: `${leftArrowIconSize}px`,
802
- opacity: leftArrowIconOpacity,
803
- rotate: leftArrowIconRotate,
804
- pointerEvents: 'none'
805
- }}
806
- />
807
- </button>
808
- );
929
+ /**
930
+ * ONE ARROW, DRAWN IN ONE PLACE.
931
+ *
932
+ * There used to be four copies of this markup — the two the carousel gets as
933
+ * `customLeftArrow`/`customRightArrow`, and the two the block draws itself
934
+ * for the designer canvas and for arrows placed outside the track. Four
935
+ * copies of a style object is four chances for a fix to land in three of
936
+ * them, which is how the canvas and the storefront drift apart on a block
937
+ * whose entire job is to show the merchant what the shopper will see.
938
+ */
939
+ const renderArrowButton = (
940
+ direction: 'left' | 'right',
941
+ onActivate: (event: React.MouseEvent) => void
942
+ ) => {
943
+ const isLeft = direction === 'left';
944
+ const iconBlock = isLeft ? leftArrowIconBlock : rightArrowIconBlock;
945
+ const plateStyle = isLeft ? leftArrowButtonStyle : rightArrowButtonStyle;
946
+ const glyph = isLeft ? leftArrowIcon : rightArrowIcon;
947
+ const glyphSize = isLeft ? leftArrowIconSize : rightArrowIconSize;
948
+ const glyphOpacity = isLeft ? leftArrowIconOpacity : rightArrowIconOpacity;
949
+ const glyphRotate = isLeft ? leftArrowIconRotate : rightArrowIconRotate;
809
950
 
810
- const CustomRightArrow = ({ onClick }: any) => (
811
- <button
812
- onClick={(e) => {
813
- e.stopPropagation();
814
- if (!isDesigner) {
815
- onClick?.();
816
- }
817
- handleRightArrowClick(e);
818
- }}
819
- className={clsx(
820
- arrowPlacementClass(rightArrowButtonStyle, 'right-4'),
821
- isDesigner &&
822
- selectedBlockId === rightArrowIconBlock?.id &&
823
- 'ring-2 ring-blue-500 ring-offset-2'
824
- )}
825
- style={{
826
- backgroundColor: rightArrowBackground,
827
- border: 'none',
828
- ...rightArrowButtonStyle,
829
- display: 'flex',
830
- alignItems: 'center',
831
- justifyContent: 'center',
832
- cursor: 'pointer',
833
- padding: 0,
834
- ...arrowRevealStyle
835
- }}
836
- aria-label="Next"
837
- >
838
- <img
839
- src={rightArrowIcon.url}
840
- alt={rightArrowIcon.alt}
951
+ return (
952
+ <button
953
+ type="button"
954
+ /*
955
+ * WHAT MAKES THE REST OF THE PANEL REACH THIS BUTTON.
956
+ *
957
+ * The arrow icon blocks are filtered out of `sortedBlocks`, so nothing
958
+ * in the DOM ever carried their id — while `collectBlockCSS` walks
959
+ * every block in the tree and had been emitting
960
+ * `[data-block-id="…"]` rules for them all along. Every hover colour,
961
+ * part key and line of custom CSS authored on an arrow was therefore
962
+ * generated into the stylesheet and matched nothing at all. The
963
+ * section arrows in `utils/slider-arrows.tsx` already carry this.
964
+ */
965
+ data-block-id={iconBlock?.id}
966
+ onClick={onActivate}
967
+ className={arrowPlacementClass(
968
+ plateStyle,
969
+ isLeft ? 'left-4' : 'right-4'
970
+ )}
841
971
  style={{
842
- width: `${rightArrowIconSize}px`,
843
- height: `${rightArrowIconSize}px`,
844
- opacity: rightArrowIconOpacity,
845
- rotate: rightArrowIconRotate,
846
- pointerEvents: 'none'
972
+ ...plateStyle,
973
+ /*
974
+ * THE CENTRING TRANSLATE, PINNED FOR THE SAME REASON AS `rotate`.
975
+ *
976
+ * `-translate-y-1/2` is a class, and Tailwind 4 compiles it to the
977
+ * individual `translate` property — the very property `getCSSStyles`
978
+ * drops so the generated `[data-block-id]` rule can render it. That
979
+ * rule now matches this button, ties the class on specificity, and
980
+ * is settled by stylesheet order: a designer who types anything into
981
+ * the Rotate box's neighbour would find the arrow jump to the top of
982
+ * the track rather than nudge. Declared inline, the centring is the
983
+ * class's own value and cannot lose — which also means an authored
984
+ * `translate` stays as inert as it was before any of this, rather
985
+ * than becoming a new way to break the arrow.
986
+ */
987
+ ...(isArrowVerticallyCentred(plateStyle)
988
+ ? { translate: '0 -50%' }
989
+ : null),
990
+ ...arrowRevealStyle,
991
+ /*
992
+ * The canvas selection ring, as an OUTLINE rather than Tailwind's
993
+ * `ring-*`. Tailwind implements a ring as `box-shadow`, and
994
+ * `box-shadow` is one of the keys that now flows onto the plate — so
995
+ * the first designer to give an arrow a drop shadow would lose the
996
+ * only affordance telling them which arrow is selected. An outline
997
+ * is a property nothing else here writes.
998
+ */
999
+ ...(selectedBlockId === iconBlock?.id
1000
+ ? { outline: '2px solid #3b82f6', outlineOffset: '2px' }
1001
+ : null)
847
1002
  }}
848
- />
849
- </button>
850
- );
1003
+ aria-label={isLeft ? 'Previous' : 'Next'}
1004
+ >
1005
+ <img
1006
+ src={glyph.url}
1007
+ alt={glyph.alt}
1008
+ style={{
1009
+ width: `${glyphSize}px`,
1010
+ height: `${glyphSize}px`,
1011
+ opacity: glyphOpacity,
1012
+ rotate: glyphRotate,
1013
+ pointerEvents: 'none'
1014
+ }}
1015
+ />
1016
+ </button>
1017
+ );
1018
+ };
1019
+
1020
+ const CustomLeftArrow = ({ onClick }: any) =>
1021
+ renderArrowButton('left', (e) => {
1022
+ e.stopPropagation();
1023
+ if (!isDesigner) {
1024
+ onClick?.();
1025
+ }
1026
+ handleLeftArrowClick(e);
1027
+ });
1028
+
1029
+ const CustomRightArrow = ({ onClick }: any) =>
1030
+ renderArrowButton('right', (e) => {
1031
+ e.stopPropagation();
1032
+ if (!isDesigner) {
1033
+ onClick?.();
1034
+ }
1035
+ handleRightArrowClick(e);
1036
+ });
851
1037
 
852
1038
  if (visibleCount === 0) {
853
1039
  return (
@@ -922,6 +1108,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
922
1108
  visibleCount > itemsShown
923
1109
  }
924
1110
  showDots={Boolean(showDots)}
1111
+ customDot={<CustomDot />}
925
1112
  renderDotsOutside={Boolean(showDots)}
926
1113
  infinite={Boolean(useInfinite)}
927
1114
  rewind={Boolean(useInfinite)}
@@ -1033,71 +1220,8 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
1033
1220
  leftArrowIconBlock &&
1034
1221
  rightArrowIconBlock ? (
1035
1222
  <>
1036
- <button
1037
- onClick={handleLeftArrowClick}
1038
- className={clsx(
1039
- arrowPlacementClass(leftArrowButtonStyle, 'left-4'),
1040
- selectedBlockId === leftArrowIconBlock?.id &&
1041
- 'ring-2 ring-blue-500 ring-offset-2'
1042
- )}
1043
- style={{
1044
- backgroundColor: leftArrowBackground,
1045
- border: 'none',
1046
- ...leftArrowButtonStyle,
1047
- display: 'flex',
1048
- alignItems: 'center',
1049
- justifyContent: 'center',
1050
- cursor: 'pointer',
1051
- padding: 0,
1052
- ...arrowRevealStyle
1053
- }}
1054
- aria-label="Previous"
1055
- >
1056
- <img
1057
- src={leftArrowIcon.url}
1058
- alt={leftArrowIcon.alt}
1059
- style={{
1060
- width: `${leftArrowIconSize}px`,
1061
- height: `${leftArrowIconSize}px`,
1062
- opacity: leftArrowIconOpacity,
1063
- rotate: leftArrowIconRotate,
1064
- pointerEvents: 'none'
1065
- }}
1066
- />
1067
- </button>
1068
-
1069
- <button
1070
- onClick={handleRightArrowClick}
1071
- className={clsx(
1072
- arrowPlacementClass(rightArrowButtonStyle, 'right-4'),
1073
- selectedBlockId === rightArrowIconBlock?.id &&
1074
- 'ring-2 ring-blue-500 ring-offset-2'
1075
- )}
1076
- style={{
1077
- backgroundColor: rightArrowBackground,
1078
- border: 'none',
1079
- ...rightArrowButtonStyle,
1080
- display: 'flex',
1081
- alignItems: 'center',
1082
- justifyContent: 'center',
1083
- cursor: 'pointer',
1084
- padding: 0,
1085
- ...arrowRevealStyle
1086
- }}
1087
- aria-label="Next"
1088
- >
1089
- <img
1090
- src={rightArrowIcon.url}
1091
- alt={rightArrowIcon.alt}
1092
- style={{
1093
- width: `${rightArrowIconSize}px`,
1094
- height: `${rightArrowIconSize}px`,
1095
- opacity: rightArrowIconOpacity,
1096
- rotate: rightArrowIconRotate,
1097
- pointerEvents: 'none'
1098
- }}
1099
- />
1100
- </button>
1223
+ {renderArrowButton('left', handleLeftArrowClick)}
1224
+ {renderArrowButton('right', handleRightArrowClick)}
1101
1225
  </>
1102
1226
  ) : null}
1103
1227
  </div>