@akinon/pz-theme 2.0.149-rc.0 → 2.0.150-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.149-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 [1595d458e]
35
- - Updated dependencies [dca128112]
36
- - @akinon/next@2.0.149-rc.0
37
- - @akinon/theme-contract@2.0.149-rc.0
3
+ ## 2.0.150-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.150-beta.0
9
+ - @akinon/theme-contract@2.0.150-beta.0
10
+
11
+ ## 2.0.149
12
+
13
+ ### Patch Changes
14
+
15
+ - 99eb4f1: ZERO-5221: hide slider block arrows at the widths they are turned off
16
+ - @akinon/next@2.0.149
17
+ - @akinon/theme-contract@2.0.149
38
18
 
39
19
  ## 2.0.148
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.149-rc.0",
4
+ "version": "2.0.150-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.149-rc.0",
11
+ "@akinon/next": "2.0.150-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.149-rc.0",
16
+ "@akinon/theme-contract": "2.0.150-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.149-rc.0",
24
+ "@akinon/next": "2.0.150-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",
@@ -0,0 +1,223 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ jest.mock('react-multi-carousel', () => ({
5
+ __esModule: true,
6
+ default: (carouselProps: any) => (
7
+ <div data-testid="carousel" className={carouselProps.containerClass}>
8
+ {carouselProps.customLeftArrow}
9
+ {carouselProps.children}
10
+ {carouselProps.customRightArrow}
11
+ </div>
12
+ )
13
+ }));
14
+
15
+ jest.mock('../../theme-block', () => ({
16
+ __esModule: true,
17
+ default: ({ block }: { block: { id: string } }) => (
18
+ <div data-testid="tb" data-id={block.id} />
19
+ )
20
+ }));
21
+
22
+ import SliderBlock from '../slider-block';
23
+
24
+ const child = (id: string, over: Record<string, unknown> = {}): any => ({
25
+ id,
26
+ type: 'text',
27
+ label: id,
28
+ value: '',
29
+ properties: {},
30
+ styles: {},
31
+ order: 0,
32
+ hidden: false,
33
+ ...over
34
+ });
35
+
36
+ const slides = (count: number) =>
37
+ Array.from({ length: count }, (_unused, index) =>
38
+ child(`slide-${index}`, { order: index })
39
+ );
40
+
41
+ const arrowIcon = (label: string, styles: Record<string, unknown> = {}) =>
42
+ child(label, { type: 'icon', label, value: { url: '/a.svg' }, styles });
43
+
44
+ const sliderBlock = (
45
+ showArrows: unknown,
46
+ arrowStyles: Record<string, unknown> = {},
47
+ over: Record<string, unknown> = {}
48
+ ): any => ({
49
+ id: 'slider-1',
50
+ type: 'slider',
51
+ label: 'slider',
52
+ value: '',
53
+ properties: {
54
+ 'show-arrows': showArrows,
55
+ 'items-per-slide': { desktop: 1, mobile: 1 }
56
+ },
57
+ styles: {},
58
+ order: 0,
59
+ hidden: false,
60
+ blocks: [
61
+ arrowIcon('Left Arrow Icon', arrowStyles),
62
+ ...slides(3),
63
+ arrowIcon('Right Arrow Icon', arrowStyles)
64
+ ],
65
+ ...over
66
+ });
67
+
68
+ const renderOnStorefront = (block: any, isDesigner = false) =>
69
+ render(
70
+ <div data-block-id={block.styleSourceId || block.id}>
71
+ <SliderBlock
72
+ {...({
73
+ block,
74
+ placeholderId: 'home-body',
75
+ sectionId: 'section-1',
76
+ isDesigner
77
+ } as any)}
78
+ />
79
+ </div>
80
+ );
81
+
82
+ const arrowRules = (container: HTMLElement) =>
83
+ Array.from(container.querySelectorAll('style'))
84
+ .map((style) => style.textContent || '')
85
+ .filter((css) => css.includes('[data-slider-arrow]'));
86
+
87
+ const hiddenRulesOf = (css: string) =>
88
+ Array.from(
89
+ css.matchAll(/(@media[^{]+)\{([^{]+)\{display:none !important;\}\}/g)
90
+ ).map((match) => ({ query: match[1], selectors: match[2] }));
91
+
92
+ const hiddenSelectorsOf = (css: string) =>
93
+ hiddenRulesOf(css).map((rule) => rule.selectors);
94
+
95
+ describe('slider block arrows at the widths they are turned off', () => {
96
+ it('marks every arrow the block draws inside the track', () => {
97
+ const { container } = renderOnStorefront(
98
+ sliderBlock({ desktop: true, mobile: false })
99
+ );
100
+
101
+ expect(container.querySelectorAll('[data-slider-arrow]')).toHaveLength(2);
102
+ });
103
+
104
+ it('hides below desktop when only mobile is off, and the rules reach both arrows', () => {
105
+ const { container } = renderOnStorefront(
106
+ sliderBlock({ desktop: true, mobile: false })
107
+ );
108
+ const [css] = arrowRules(container);
109
+ const rules = hiddenRulesOf(css);
110
+
111
+ expect(rules.map((rule) => rule.query)).toEqual([
112
+ '@media (min-width:768px) and (max-width:1023px)',
113
+ '@media (max-width:767px)'
114
+ ]);
115
+ rules.forEach((rule) => {
116
+ expect(container.querySelectorAll(rule.selectors)).toHaveLength(2);
117
+ });
118
+ });
119
+
120
+ it('honours a tablet value of its own', () => {
121
+ const { container } = renderOnStorefront(
122
+ sliderBlock({ desktop: true, tablet: false, mobile: true })
123
+ );
124
+ const rules = hiddenRulesOf(arrowRules(container)[0]);
125
+
126
+ expect(rules.map((rule) => rule.query)).toEqual([
127
+ '@media (min-width:768px) and (max-width:1023px)'
128
+ ]);
129
+ expect(container.querySelectorAll(rules[0].selectors)).toHaveLength(2);
130
+ });
131
+
132
+ it('keeps the tablet on when only the phone is turned off', () => {
133
+ const { container } = renderOnStorefront(
134
+ sliderBlock({ desktop: true, tablet: true, mobile: false })
135
+ );
136
+ const rules = hiddenRulesOf(arrowRules(container)[0]);
137
+
138
+ expect(rules.map((rule) => rule.query)).toEqual([
139
+ '@media (max-width:767px)'
140
+ ]);
141
+ });
142
+
143
+ it('reaches arrows placed outside the track just the same', () => {
144
+ const { container } = renderOnStorefront(
145
+ sliderBlock({ desktop: true, mobile: false }, { top: '0px' })
146
+ );
147
+ const [css] = arrowRules(container);
148
+ const [selectors] = hiddenSelectorsOf(css);
149
+
150
+ expect(
151
+ container.querySelector('[data-testid="carousel"] [data-slider-arrow]')
152
+ ).toBeNull();
153
+ expect(container.querySelectorAll(selectors)).toHaveLength(2);
154
+ });
155
+
156
+ it('hides the desktop band instead when that is the one turned off', () => {
157
+ const { container } = renderOnStorefront(
158
+ sliderBlock({ desktop: false, mobile: true })
159
+ );
160
+ const rules = hiddenRulesOf(arrowRules(container)[0]);
161
+
162
+ expect(rules.map((rule) => rule.query)).toEqual([
163
+ '@media (min-width:1024px) and (max-width:4000px)'
164
+ ]);
165
+ expect(container.querySelectorAll(rules[0].selectors)).toHaveLength(2);
166
+ });
167
+
168
+ it('ships arrows placed outside the track even when the desktop band is off', () => {
169
+ const { container } = renderOnStorefront(
170
+ sliderBlock({ desktop: false, mobile: true }, { top: '0px' })
171
+ );
172
+ const [css] = arrowRules(container);
173
+
174
+ expect(container.querySelectorAll('[data-slider-arrow]')).toHaveLength(2);
175
+ expect(css).toContain('(min-width:');
176
+ expect(container.querySelectorAll(hiddenSelectorsOf(css)[0])).toHaveLength(
177
+ 2
178
+ );
179
+ });
180
+
181
+ it('draws no arrows at all when they are off at every width', () => {
182
+ const { container } = renderOnStorefront(
183
+ sliderBlock({ desktop: false, mobile: false }, { top: '0px' })
184
+ );
185
+
186
+ expect(container.querySelectorAll('[data-slider-arrow]')).toHaveLength(0);
187
+ });
188
+
189
+ it('writes no hiding rule while the arrows are on everywhere', () => {
190
+ const { container } = renderOnStorefront(sliderBlock(true));
191
+
192
+ expect(arrowRules(container)).toHaveLength(0);
193
+ expect(container.querySelectorAll('[data-slider-arrow]')).toHaveLength(2);
194
+ });
195
+
196
+ it('follows the wrapper of a row clone, which carries the template id', () => {
197
+ const { container } = renderOnStorefront(
198
+ sliderBlock(
199
+ { desktop: true, mobile: false },
200
+ {},
201
+ {
202
+ id: 'slider-1-row-2',
203
+ styleSourceId: 'slider-1'
204
+ }
205
+ )
206
+ );
207
+ const [css] = arrowRules(container);
208
+
209
+ expect(css).toContain('[data-block-id="slider-1"] [data-slider-arrow]');
210
+ expect(container.querySelectorAll(hiddenSelectorsOf(css)[0])).toHaveLength(
211
+ 2
212
+ );
213
+ });
214
+
215
+ it('leaves the designer canvas to resolve the breakpoint itself', () => {
216
+ const { container } = renderOnStorefront(
217
+ sliderBlock({ desktop: true, mobile: false }),
218
+ true
219
+ );
220
+
221
+ expect(arrowRules(container)).toHaveLength(0);
222
+ });
223
+ });
@@ -15,6 +15,7 @@ import {
15
15
  } from '../utils';
16
16
  import {
17
17
  DESKTOP_MIN_WIDTH,
18
+ TABLET_MIN_WIDTH,
18
19
  isNarrowBreakpoint,
19
20
  setAtAnyBreakpoint
20
21
  } from '../utils/breakpoints';
@@ -325,13 +326,30 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
325
326
  const canOverflow = visibleCount > 1;
326
327
  const renderArrows = arrowsAnywhere && canOverflow;
327
328
 
328
- const arrowCss = buildCarouselArrowCss(
329
- carouselSsrScopeId(block.id) || '',
330
- carouselResponsive,
331
- (band) =>
332
- getResponsiveValue(arrowsConfigured, band, true) === true &&
333
- visibleCount > Number(carouselResponsive?.[band]?.items || 1)
334
- );
329
+ const arrowBands = {
330
+ desktop: carouselResponsive.desktop,
331
+ tablet: {
332
+ breakpoint: { max: DESKTOP_MIN_WIDTH - 1, min: TABLET_MIN_WIDTH },
333
+ items: mobileItems
334
+ },
335
+ mobile: {
336
+ breakpoint: { max: TABLET_MIN_WIDTH - 1, min: 0 },
337
+ items: mobileItems
338
+ }
339
+ };
340
+ const arrowOwnerId = carouselSsrScopeId(block.styleSourceId || block.id);
341
+ const arrowCss =
342
+ isDesigner || !arrowOwnerId
343
+ ? ''
344
+ : buildCarouselArrowCss(
345
+ carouselSsrScopeId(block.id) || '',
346
+ arrowBands,
347
+ (band) =>
348
+ getResponsiveValue(arrowsConfigured, band, true) === true &&
349
+ visibleCount >
350
+ Number(arrowBands[band as keyof typeof arrowBands]?.items || 1),
351
+ `[data-block-id="${arrowOwnerId}"]`
352
+ );
335
353
 
336
354
  const showDots = getResponsiveValue(
337
355
  getConfigEntry('show-dots'),
@@ -1140,6 +1158,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
1140
1158
  * section arrows in `utils/slider-arrows.tsx` already carry this.
1141
1159
  */
1142
1160
  data-block-id={iconBlock?.id}
1161
+ data-slider-arrow=""
1143
1162
  onClick={onActivate}
1144
1163
  className={arrowPlacementClass(
1145
1164
  plateStyle,
@@ -1400,8 +1419,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
1400
1419
  })}
1401
1420
  </Carousel>
1402
1421
 
1403
- {(isDesigner || arrowsOutside) &&
1404
- showArrows &&
1422
+ {(isDesigner ? showArrows : arrowsOutside && arrowsAnywhere) &&
1405
1423
  sortedBlocks.length > 0 &&
1406
1424
  leftArrowIconBlock &&
1407
1425
  rightArrowIconBlock ? (
@@ -0,0 +1,113 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ jest.mock('../../theme-block', () => ({
5
+ __esModule: true,
6
+ default: ({ block }: { block: { id: string } }) => (
7
+ <div data-testid="slide" data-id={block.id} />
8
+ )
9
+ }));
10
+
11
+ jest.mock('react-multi-carousel', () => ({
12
+ __esModule: true,
13
+ default: ({
14
+ children,
15
+ customLeftArrow,
16
+ customRightArrow
17
+ }: {
18
+ children?: React.ReactNode;
19
+ customLeftArrow?: React.ReactNode;
20
+ customRightArrow?: React.ReactNode;
21
+ }) => (
22
+ <div data-testid="carousel">
23
+ {customLeftArrow}
24
+ {customRightArrow}
25
+ {children}
26
+ </div>
27
+ )
28
+ }));
29
+
30
+ import PostsSliderSection from '../posts-slider-section';
31
+ import {
32
+ LEFT_ARROW_ICON_LABEL,
33
+ RIGHT_ARROW_ICON_LABEL
34
+ } from '../../utils/slider-arrows';
35
+
36
+ const slide = (id: string) => ({
37
+ id,
38
+ type: 'group',
39
+ label: id,
40
+ styles: {},
41
+ order: 0,
42
+ blocks: []
43
+ });
44
+
45
+ const arrow = (label: string) => ({
46
+ id: `arrow-${label === LEFT_ARROW_ICON_LABEL ? 'left' : 'right'}`,
47
+ type: 'icon',
48
+ label,
49
+ value: { url: '', alt: label },
50
+ properties: {},
51
+ styles: {},
52
+ order: 9
53
+ });
54
+
55
+ const renderSection = (showArrows: unknown) =>
56
+ render(
57
+ <section data-section-id="sec">
58
+ <PostsSliderSection
59
+ {...({
60
+ section: {
61
+ id: 'sec',
62
+ type: 'flash-deals-slider',
63
+ label: 'sec',
64
+ properties: { 'items-per-slide': 1, 'show-arrows': showArrows },
65
+ styles: {},
66
+ blocks: [
67
+ arrow(LEFT_ARROW_ICON_LABEL),
68
+ slide('a'),
69
+ slide('b'),
70
+ arrow(RIGHT_ARROW_ICON_LABEL)
71
+ ]
72
+ },
73
+ currentBreakpoint: 'desktop',
74
+ placeholderId: 'ph',
75
+ isDesigner: false,
76
+ selectedBlockId: null
77
+ } as any)}
78
+ />
79
+ </section>
80
+ );
81
+
82
+ const arrowRules = (container: HTMLElement) =>
83
+ Array.from(container.querySelectorAll('style'))
84
+ .map((style) => style.textContent || '')
85
+ .filter((css) => css.includes('[data-slider-arrow]'));
86
+
87
+ const hiddenSelectorsOf = (css: string) =>
88
+ Array.from(
89
+ css.matchAll(/@media[^{]+\{([^{]+)\{display:none !important;\}\}/g)
90
+ ).map((match) => match[1]);
91
+
92
+ describe('posts slider arrows at the widths they are turned off', () => {
93
+ it('hides only below desktop, and every rule reaches both arrows', () => {
94
+ const { container } = renderSection({ desktop: true, mobile: false });
95
+ const [css] = arrowRules(container);
96
+
97
+ const selectors = hiddenSelectorsOf(css);
98
+
99
+ expect(css).toBeDefined();
100
+ expect(selectors.length).toBeGreaterThan(0);
101
+ expect(css).not.toMatch(/@media \(min-width:\d+px\)\{/);
102
+ selectors.forEach((selector) => {
103
+ expect(container.querySelectorAll(selector)).toHaveLength(2);
104
+ });
105
+ });
106
+
107
+ it('writes no hiding rule while the arrows are on everywhere', () => {
108
+ const { container } = renderSection(true);
109
+
110
+ expect(arrowRules(container)).toHaveLength(0);
111
+ expect(container.querySelectorAll('[data-slider-arrow]')).toHaveLength(2);
112
+ });
113
+ });