@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 +15 -35
- package/package.json +4 -4
- package/src/blocks/__tests__/slider-block.test.tsx +438 -3
- package/src/blocks/slider-block.tsx +372 -248
package/CHANGELOG.md
CHANGED
|
@@ -1,40 +1,20 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
- Updated dependencies [
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
576
|
-
|
|
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).
|
|
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
|
|
551
|
-
*
|
|
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
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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 =
|
|
706
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1037
|
-
|
|
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>
|