@akinon/pz-theme 2.0.131-rc.0 → 2.0.132-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-signals.test.tsx +429 -0
- package/src/blocks/slider-block.tsx +202 -17
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 [dca12811]
|
|
36
|
-
- @akinon/next@2.0.131-rc.0
|
|
37
|
-
- @akinon/theme-contract@2.0.131-rc.0
|
|
3
|
+
## 2.0.132-beta.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [cbbbfd757]
|
|
8
|
+
- @akinon/next@2.0.132-beta.0
|
|
9
|
+
- @akinon/theme-contract@2.0.132-beta.0
|
|
10
|
+
|
|
11
|
+
## 2.0.131
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- cea66db: ZERO-5160: Report slide changes and slider controls as theme events
|
|
16
|
+
- @akinon/next@2.0.131
|
|
17
|
+
- @akinon/theme-contract@2.0.131
|
|
38
18
|
|
|
39
19
|
## 2.0.130
|
|
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.132-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.132-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.132-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.132-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,429 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { fireEvent, render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
// Same passthrough stand-in the sibling suite uses: the library is ESM and
|
|
5
|
+
// DOM-heavy, and every assertion here is either about the prop object it is
|
|
6
|
+
// handed or about markup the block draws itself.
|
|
7
|
+
const mockCarouselProps: Record<string, any>[] = [];
|
|
8
|
+
jest.mock('react-multi-carousel', () => ({
|
|
9
|
+
__esModule: true,
|
|
10
|
+
default: (carouselProps: any) => {
|
|
11
|
+
mockCarouselProps.push(carouselProps);
|
|
12
|
+
return <div data-testid="carousel">{carouselProps.children}</div>;
|
|
13
|
+
}
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
let themeBlockRenders = 0;
|
|
17
|
+
jest.mock('../../theme-block', () => ({
|
|
18
|
+
__esModule: true,
|
|
19
|
+
default: ({ block }: { block: { id: string } }) => {
|
|
20
|
+
themeBlockRenders += 1;
|
|
21
|
+
return <div data-testid="tb" data-id={block.id} />;
|
|
22
|
+
}
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
import SliderBlock, { authoredSlideIndex } from '../slider-block';
|
|
26
|
+
import {
|
|
27
|
+
__resetThemeSignalsForTests,
|
|
28
|
+
subscribeToThemeSignals,
|
|
29
|
+
type ThemeSignalDetail
|
|
30
|
+
} from '../../utils/theme-signal';
|
|
31
|
+
|
|
32
|
+
declare global {
|
|
33
|
+
interface Window {
|
|
34
|
+
__themeEditorDesigner?: boolean;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const child = (id: string, over: Record<string, unknown> = {}): any => ({
|
|
39
|
+
id,
|
|
40
|
+
type: 'text',
|
|
41
|
+
label: id,
|
|
42
|
+
value: '',
|
|
43
|
+
properties: {},
|
|
44
|
+
styles: {},
|
|
45
|
+
order: 0,
|
|
46
|
+
hidden: false,
|
|
47
|
+
...over
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const slides = (count: number) =>
|
|
51
|
+
Array.from({ length: count }, (_unused, index) =>
|
|
52
|
+
child(`slide-${index}`, { order: index })
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The arrows only reach the DOM when they are placed OUTSIDE the track (an
|
|
57
|
+
* authored top/bottom); inside the track they are handed to the carousel as
|
|
58
|
+
* props, which the stand-in above drops.
|
|
59
|
+
*/
|
|
60
|
+
const arrowIcon = (label: string) =>
|
|
61
|
+
child(label, {
|
|
62
|
+
type: 'icon',
|
|
63
|
+
label,
|
|
64
|
+
value: { url: '/a.svg' },
|
|
65
|
+
styles: { top: '0px' }
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const withOutsideArrows = () => ({
|
|
69
|
+
properties: {
|
|
70
|
+
'show-arrows': { desktop: true, mobile: true },
|
|
71
|
+
'items-per-slide': { desktop: 1, mobile: 1 }
|
|
72
|
+
},
|
|
73
|
+
blocks: [
|
|
74
|
+
arrowIcon('Left Arrow Icon'),
|
|
75
|
+
...slides(3),
|
|
76
|
+
arrowIcon('Right Arrow Icon')
|
|
77
|
+
]
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const props = (over: Record<string, unknown> = {}, blockOver = {}) =>
|
|
81
|
+
({
|
|
82
|
+
block: {
|
|
83
|
+
id: 'slider',
|
|
84
|
+
type: 'slider',
|
|
85
|
+
label: 'slider',
|
|
86
|
+
value: '',
|
|
87
|
+
properties: {},
|
|
88
|
+
styles: {},
|
|
89
|
+
order: 0,
|
|
90
|
+
hidden: false,
|
|
91
|
+
blocks: slides(6),
|
|
92
|
+
...blockOver
|
|
93
|
+
},
|
|
94
|
+
placeholderId: 'home-body',
|
|
95
|
+
sectionId: 'section-h3k9q',
|
|
96
|
+
isDesigner: false,
|
|
97
|
+
...over
|
|
98
|
+
} as any);
|
|
99
|
+
|
|
100
|
+
/** Autoplay is what turns the carousel infinite, so it is what clones it. */
|
|
101
|
+
const autoplaying = (blockOver = {}) =>
|
|
102
|
+
props(
|
|
103
|
+
{},
|
|
104
|
+
{
|
|
105
|
+
properties: { autoplay: { desktop: true, mobile: true } },
|
|
106
|
+
...blockOver
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const lastCarouselProps = () => mockCarouselProps[mockCarouselProps.length - 1];
|
|
111
|
+
|
|
112
|
+
describe('slider events (ZERO-5160)', () => {
|
|
113
|
+
let received: ThemeSignalDetail[];
|
|
114
|
+
let detach: () => void;
|
|
115
|
+
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
mockCarouselProps.length = 0;
|
|
118
|
+
themeBlockRenders = 0;
|
|
119
|
+
received = [];
|
|
120
|
+
detach = subscribeToThemeSignals((detail) => received.push(detail));
|
|
121
|
+
__resetThemeSignalsForTests();
|
|
122
|
+
delete window.__themeEditorDesigner;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
afterEach(() => {
|
|
126
|
+
detach();
|
|
127
|
+
delete window.__themeEditorDesigner;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('the change callback is attached to every slider', () => {
|
|
131
|
+
it('reports on an autoplaying slider, which can never have a lead-in', () => {
|
|
132
|
+
// useLeadIn requires !useInfinite, and autoplay makes it infinite — so
|
|
133
|
+
// hanging the report off the lead-in spread would have left exactly the
|
|
134
|
+
// loudest sliders silent.
|
|
135
|
+
render(<SliderBlock {...autoplaying()} />);
|
|
136
|
+
|
|
137
|
+
expect(typeof lastCarouselProps().beforeChange).toBe('function');
|
|
138
|
+
|
|
139
|
+
lastCarouselProps().beforeChange(3, { slidesToShow: 1 });
|
|
140
|
+
|
|
141
|
+
expect(received).toHaveLength(1);
|
|
142
|
+
expect(received[0].name).toBe('slide.changed');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('leaves the lead-in transform off a slider that did not opt in', () => {
|
|
146
|
+
render(<SliderBlock {...props()} />);
|
|
147
|
+
|
|
148
|
+
expect('additionalTransfrom' in lastCarouselProps()).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('does not re-render the slides on a change it was not asked to track', () => {
|
|
152
|
+
// The lead-in's state write stays behind its own condition: without
|
|
153
|
+
// this, every slider re-renders itself and every slide under it on each
|
|
154
|
+
// autoplay tick, to move a transform nothing reads.
|
|
155
|
+
render(<SliderBlock {...autoplaying()} />);
|
|
156
|
+
const before = themeBlockRenders;
|
|
157
|
+
|
|
158
|
+
lastCarouselProps().beforeChange(3, { slidesToShow: 1 });
|
|
159
|
+
|
|
160
|
+
expect(themeBlockRenders).toBe(before);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('the index is the authored one, not the clone one', () => {
|
|
165
|
+
it('maps a cloned index back to the slide the merchant sees', () => {
|
|
166
|
+
render(<SliderBlock {...autoplaying()} />);
|
|
167
|
+
|
|
168
|
+
// Six slides, one in view: the library starts at 2, so its 3 is the
|
|
169
|
+
// merchant's second slide and its 7 is the last one.
|
|
170
|
+
lastCarouselProps().beforeChange(3, { slidesToShow: 1 });
|
|
171
|
+
lastCarouselProps().beforeChange(7, { slidesToShow: 1 });
|
|
172
|
+
|
|
173
|
+
expect(received.map((detail) => detail.index)).toEqual([1, 5]);
|
|
174
|
+
expect(received.every((detail) => detail.total === 6)).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('maps a trailing clone back to the slide it is a copy of', () => {
|
|
178
|
+
render(<SliderBlock {...autoplaying()} />);
|
|
179
|
+
|
|
180
|
+
// Past the end the track shows the leading slides again as clones.
|
|
181
|
+
lastCarouselProps().beforeChange(8, { slidesToShow: 1 });
|
|
182
|
+
lastCarouselProps().beforeChange(9, { slidesToShow: 1 });
|
|
183
|
+
|
|
184
|
+
expect(received.map((detail) => detail.index)).toEqual([0, 1]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('reports a finite slider index unchanged', () => {
|
|
188
|
+
render(<SliderBlock {...props()} />);
|
|
189
|
+
|
|
190
|
+
lastCarouselProps().beforeChange(2, { slidesToShow: 2 });
|
|
191
|
+
|
|
192
|
+
expect(received[0]).toMatchObject({ index: 2, total: 6 });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('handles the short-list clone shape', () => {
|
|
196
|
+
// Three slides with two in view are cloned by concatenation, so the
|
|
197
|
+
// table is a modulo rather than the windowed form.
|
|
198
|
+
expect(authoredSlideIndex(4, 3, 2)).toBe(1);
|
|
199
|
+
expect(authoredSlideIndex(0, 3, 2)).toBe(0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('reports nothing before the library has measured itself', () => {
|
|
203
|
+
// slidesToShow is 0 until mount; the clone table branches on it, so an
|
|
204
|
+
// index resolved against it would be a guess.
|
|
205
|
+
render(<SliderBlock {...autoplaying()} />);
|
|
206
|
+
|
|
207
|
+
lastCarouselProps().beforeChange(3, { slidesToShow: 0 });
|
|
208
|
+
|
|
209
|
+
expect(received).toEqual([]);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('what rides on the change', () => {
|
|
214
|
+
it('carries the measured slides in view, not the block breakpoint guess', () => {
|
|
215
|
+
render(
|
|
216
|
+
<SliderBlock
|
|
217
|
+
{...props(
|
|
218
|
+
{ currentBreakpoint: 'mobile' },
|
|
219
|
+
{ properties: { 'items-per-slide': { desktop: 4, mobile: 1 } } }
|
|
220
|
+
)}
|
|
221
|
+
/>
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
lastCarouselProps().beforeChange(2, { slidesToShow: 3 });
|
|
225
|
+
|
|
226
|
+
expect(received[0].slidesInView).toBe(3);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('omits slides in view rather than reporting zero', () => {
|
|
230
|
+
render(<SliderBlock {...props()} />);
|
|
231
|
+
|
|
232
|
+
lastCarouselProps().beforeChange(1, { slidesToShow: 0 });
|
|
233
|
+
|
|
234
|
+
expect(received[0]).not.toHaveProperty('slidesInView');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('counts the slides a shopper can see, not the hidden ones', () => {
|
|
238
|
+
render(
|
|
239
|
+
<SliderBlock
|
|
240
|
+
{...props(
|
|
241
|
+
{},
|
|
242
|
+
{
|
|
243
|
+
blocks: [
|
|
244
|
+
child('a', { order: 0 }),
|
|
245
|
+
child('b', { order: 1, hidden: true }),
|
|
246
|
+
child('c', { order: 2 }),
|
|
247
|
+
child('d', { order: 3 })
|
|
248
|
+
]
|
|
249
|
+
}
|
|
250
|
+
)}
|
|
251
|
+
/>
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
lastCarouselProps().beforeChange(2, { slidesToShow: 1 });
|
|
255
|
+
|
|
256
|
+
expect(received[0]).toMatchObject({ index: 2, total: 3 });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('carries the identity of the slider that reported', () => {
|
|
260
|
+
render(<SliderBlock {...props()} />);
|
|
261
|
+
|
|
262
|
+
lastCarouselProps().beforeChange(1, { slidesToShow: 1 });
|
|
263
|
+
|
|
264
|
+
expect(received[0]).toMatchObject({
|
|
265
|
+
placeholderId: 'home-body',
|
|
266
|
+
sectionId: 'section-h3k9q',
|
|
267
|
+
blockId: 'slider',
|
|
268
|
+
blockType: 'slider'
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('says nothing about the cause when it cannot know it', () => {
|
|
273
|
+
// Autoplay, the keyboard and an undragged flick all arrive the same way.
|
|
274
|
+
render(<SliderBlock {...props()} />);
|
|
275
|
+
|
|
276
|
+
lastCarouselProps().beforeChange(1, { slidesToShow: 1 });
|
|
277
|
+
|
|
278
|
+
expect(received[0]).not.toHaveProperty('via');
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('controls', () => {
|
|
283
|
+
it('reports an arrow placed outside the track once per click', () => {
|
|
284
|
+
const { container } = render(
|
|
285
|
+
<SliderBlock {...props({}, withOutsideArrows())} />
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
fireEvent.click(
|
|
289
|
+
container.querySelector('button[aria-label="Previous"]') as HTMLElement
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const controls = received.filter(
|
|
293
|
+
(detail) => detail.name === 'slide.control'
|
|
294
|
+
);
|
|
295
|
+
expect(controls).toHaveLength(1);
|
|
296
|
+
expect(controls[0]).toMatchObject({
|
|
297
|
+
control: 'arrow',
|
|
298
|
+
direction: 'previous'
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('reports an arrow inside the track once, not twice', () => {
|
|
303
|
+
// Inside the track the click runs the library's own handler AND the
|
|
304
|
+
// block's; only the library's move survives its throttle, and only one
|
|
305
|
+
// report may leave.
|
|
306
|
+
render(<SliderBlock {...props()} />);
|
|
307
|
+
|
|
308
|
+
const arrow = lastCarouselProps().customRightArrow;
|
|
309
|
+
const { container } = render(
|
|
310
|
+
React.cloneElement(arrow, { onClick: jest.fn() })
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
fireEvent.click(
|
|
314
|
+
container.querySelector('button[aria-label="Next"]') as HTMLElement
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const controls = received.filter(
|
|
318
|
+
(detail) => detail.name === 'slide.control'
|
|
319
|
+
);
|
|
320
|
+
expect(controls).toHaveLength(1);
|
|
321
|
+
expect(controls[0]).toMatchObject({ direction: 'next' });
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('attributes the change that follows a dot to the dot', () => {
|
|
325
|
+
render(<SliderBlock {...props()} />);
|
|
326
|
+
|
|
327
|
+
const dot = lastCarouselProps().customDot;
|
|
328
|
+
dot.props.onSelect(2);
|
|
329
|
+
lastCarouselProps().beforeChange(2, { slidesToShow: 1 });
|
|
330
|
+
|
|
331
|
+
expect(received.map((detail) => detail.name)).toEqual([
|
|
332
|
+
'slide.control',
|
|
333
|
+
'slide.changed'
|
|
334
|
+
]);
|
|
335
|
+
expect(received[0]).toMatchObject({ control: 'dot', dotIndex: 2 });
|
|
336
|
+
expect(received[1]).toMatchObject({ via: 'dot', index: 2 });
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('clears the attribution so the next change does not inherit it', () => {
|
|
340
|
+
render(<SliderBlock {...props()} />);
|
|
341
|
+
|
|
342
|
+
lastCarouselProps().customDot.props.onSelect(1);
|
|
343
|
+
lastCarouselProps().beforeChange(1, { slidesToShow: 1 });
|
|
344
|
+
lastCarouselProps().beforeChange(2, { slidesToShow: 1 });
|
|
345
|
+
|
|
346
|
+
const changes = received.filter(
|
|
347
|
+
(detail) => detail.name === 'slide.changed'
|
|
348
|
+
);
|
|
349
|
+
expect(changes[0].via).toBe('dot');
|
|
350
|
+
expect(changes[1]).not.toHaveProperty('via');
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe('volume', () => {
|
|
355
|
+
it('caps an autoplaying slider well below the route budget', () => {
|
|
356
|
+
render(<SliderBlock {...autoplaying()} />);
|
|
357
|
+
|
|
358
|
+
for (let tick = 0; tick < 30; tick += 1) {
|
|
359
|
+
lastCarouselProps().beforeChange(3 + (tick % 6), { slidesToShow: 1 });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
expect(received).toHaveLength(5);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('still reports a shopper control after the timer has used its budget', () => {
|
|
366
|
+
// The two go on separate tally keys, so a carousel spinning below the
|
|
367
|
+
// fold cannot silence the arrow the shopper actually pressed.
|
|
368
|
+
render(<SliderBlock {...autoplaying()} />);
|
|
369
|
+
|
|
370
|
+
for (let tick = 0; tick < 30; tick += 1) {
|
|
371
|
+
lastCarouselProps().beforeChange(3, { slidesToShow: 1 });
|
|
372
|
+
}
|
|
373
|
+
received.length = 0;
|
|
374
|
+
|
|
375
|
+
lastCarouselProps().customDot.props.onSelect(1);
|
|
376
|
+
lastCarouselProps().beforeChange(4, { slidesToShow: 1 });
|
|
377
|
+
|
|
378
|
+
expect(received.map((detail) => detail.name)).toEqual([
|
|
379
|
+
'slide.control',
|
|
380
|
+
'slide.changed'
|
|
381
|
+
]);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('gives each slider on the page its own budget', () => {
|
|
385
|
+
const { unmount } = render(<SliderBlock {...autoplaying()} />);
|
|
386
|
+
for (let tick = 0; tick < 30; tick += 1) {
|
|
387
|
+
lastCarouselProps().beforeChange(3, { slidesToShow: 1 });
|
|
388
|
+
}
|
|
389
|
+
unmount();
|
|
390
|
+
received.length = 0;
|
|
391
|
+
|
|
392
|
+
render(
|
|
393
|
+
<SliderBlock {...autoplaying({ id: 'slider-two', type: 'slider' })} />
|
|
394
|
+
);
|
|
395
|
+
lastCarouselProps().beforeChange(3, { slidesToShow: 1 });
|
|
396
|
+
|
|
397
|
+
expect(received).toHaveLength(1);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe('the editor', () => {
|
|
402
|
+
it('reports nothing while the editor is attached, browse mode included', () => {
|
|
403
|
+
// Browse mode hands the block isDesigner={false} while the editor is
|
|
404
|
+
// still attached; the emitter reads the raw flag, so the call sites
|
|
405
|
+
// below carry no guard of their own.
|
|
406
|
+
window.__themeEditorDesigner = true;
|
|
407
|
+
|
|
408
|
+
const { container } = render(
|
|
409
|
+
<SliderBlock {...props({}, withOutsideArrows())} />
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
fireEvent.click(
|
|
413
|
+
container.querySelector('button[aria-label="Previous"]') as HTMLElement
|
|
414
|
+
);
|
|
415
|
+
lastCarouselProps().beforeChange(2, { slidesToShow: 1 });
|
|
416
|
+
lastCarouselProps().customDot.props.onSelect(1);
|
|
417
|
+
|
|
418
|
+
expect(received).toEqual([]);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('hands the canvas the same change callback the storefront gets', () => {
|
|
422
|
+
// The canvas must exercise the carousel the shopper gets; gating the
|
|
423
|
+
// prop itself would make the two configurations diverge.
|
|
424
|
+
render(<SliderBlock {...props({ isDesigner: true })} />);
|
|
425
|
+
|
|
426
|
+
expect(typeof lastCarouselProps().beforeChange).toBe('function');
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from '../utils';
|
|
16
16
|
import { DESKTOP_MIN_WIDTH, isNarrowBreakpoint } from '../utils/breakpoints';
|
|
17
17
|
import { useCarouselSsr } from '../utils/carousel-ssr';
|
|
18
|
+
import { useThemeSignal } from '../hooks/use-theme-signal';
|
|
18
19
|
import {
|
|
19
20
|
CAROUSEL_MINIMUM_TOUCH_DRAG,
|
|
20
21
|
carouselDragStyle,
|
|
@@ -67,7 +68,61 @@ const clampNumber = (value: number, min: number, max: number) =>
|
|
|
67
68
|
* render would unmount and remount all of them on each slide change — taking
|
|
68
69
|
* the focus of a keyboard user who had just tabbed to one.
|
|
69
70
|
*/
|
|
70
|
-
|
|
71
|
+
/**
|
|
72
|
+
* How many unattributed slide changes one slider may report per page view.
|
|
73
|
+
*
|
|
74
|
+
* An autoplaying carousel changes slide every few seconds for as long as the
|
|
75
|
+
* tab is open, and nothing in the callback says whether a change was the timer
|
|
76
|
+
* or a shopper. Those go on their own tally key with this much lower ceiling,
|
|
77
|
+
* so a carousel spinning below the fold can neither drown the arrow clicks of
|
|
78
|
+
* the same slider nor eat the whole route's budget and silence every other
|
|
79
|
+
* moment on the page.
|
|
80
|
+
*/
|
|
81
|
+
const UNATTRIBUTED_SLIDE_CHANGE_CEILING = 5;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The authored slide ordinal behind the index the library reports.
|
|
85
|
+
*
|
|
86
|
+
* In infinite mode the carousel prepends and appends clones, so `beforeChange`
|
|
87
|
+
* counts in clone space: a six-slide hero starts at 2 and climbs to 9 before
|
|
88
|
+
* the library silently teleports back. Reporting that raw would give
|
|
89
|
+
* `index: 9, total: 6` and would never mention slides 0 and 1 at all.
|
|
90
|
+
*
|
|
91
|
+
* This is the same mapping the library's own dots use to resolve which one is
|
|
92
|
+
* active (`getOriginalIndexLookupTableByClones`), written out rather than
|
|
93
|
+
* imported: the helper is not on the package's export surface, only its
|
|
94
|
+
* internal deep path, and four lines of arithmetic is a smaller liability than
|
|
95
|
+
* an import that a minor version can move.
|
|
96
|
+
*
|
|
97
|
+
* `slidesToShow` must be the MEASURED value off the callback's state — the
|
|
98
|
+
* table branches on it, so the block's own breakpoint guess would silently
|
|
99
|
+
* select a different table. Before mount the library reports 0, which cannot
|
|
100
|
+
* resolve anything, so the change is not reported at all rather than guessed.
|
|
101
|
+
*/
|
|
102
|
+
export const authoredSlideIndex = (
|
|
103
|
+
reportedIndex: number,
|
|
104
|
+
total: number,
|
|
105
|
+
slidesToShow: number
|
|
106
|
+
): number | null => {
|
|
107
|
+
if (!Number.isFinite(reportedIndex) || total <= 0) return null;
|
|
108
|
+
if (slidesToShow <= 0) return null;
|
|
109
|
+
|
|
110
|
+
const span = 2 * slidesToShow;
|
|
111
|
+
let authored: number;
|
|
112
|
+
|
|
113
|
+
if (total > span) {
|
|
114
|
+
if (reportedIndex < span) authored = total - span + reportedIndex;
|
|
115
|
+
else if (reportedIndex < total + span) authored = reportedIndex - span;
|
|
116
|
+
else authored = reportedIndex - (total + span);
|
|
117
|
+
} else {
|
|
118
|
+
// Short lists are cloned by concatenation, so the table is a modulo.
|
|
119
|
+
authored = ((reportedIndex % total) + total) % total;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return authored >= 0 && authored < total ? authored : null;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const CustomDot = ({ index, active, onClick, onSelect }: any) => (
|
|
71
126
|
<li
|
|
72
127
|
className={clsx(
|
|
73
128
|
'react-multi-carousel-dot',
|
|
@@ -89,7 +144,13 @@ const CustomDot = ({ index, active, onClick }: any) => (
|
|
|
89
144
|
* swallowing it here would have made the Dot panel this change exists to
|
|
90
145
|
* offer unreachable by clicking the dot.
|
|
91
146
|
*/
|
|
92
|
-
onClick={() =>
|
|
147
|
+
onClick={() => {
|
|
148
|
+
// Reported before the library moves: the change callback fires
|
|
149
|
+
// synchronously inside `onClick`, and it reads the note to say what
|
|
150
|
+
// caused the change.
|
|
151
|
+
onSelect?.(index);
|
|
152
|
+
onClick?.();
|
|
153
|
+
}}
|
|
93
154
|
/>
|
|
94
155
|
</li>
|
|
95
156
|
);
|
|
@@ -109,6 +170,12 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
109
170
|
onRename
|
|
110
171
|
}) => {
|
|
111
172
|
const themeSettings = useThemeSettingsContext();
|
|
173
|
+
const signal = useThemeSignal({
|
|
174
|
+
placeholderId,
|
|
175
|
+
sectionId,
|
|
176
|
+
blockId: block.id,
|
|
177
|
+
blockType: block.type
|
|
178
|
+
});
|
|
112
179
|
const carouselRef = React.useRef<Carousel>(null);
|
|
113
180
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
114
181
|
/**
|
|
@@ -293,7 +360,11 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
293
360
|
// Arrows that only appear while the slider is hovered. Off by default, so a
|
|
294
361
|
// slider that never authored it renders exactly as before.
|
|
295
362
|
const arrowsOnHover = Boolean(
|
|
296
|
-
getResponsiveValue(
|
|
363
|
+
getResponsiveValue(
|
|
364
|
+
getConfigEntry('arrows-on-hover'),
|
|
365
|
+
currentBreakpoint,
|
|
366
|
+
false
|
|
367
|
+
)
|
|
297
368
|
);
|
|
298
369
|
const useAutoPlay = getResponsiveValue(
|
|
299
370
|
getConfigEntry('autoplay'),
|
|
@@ -928,10 +999,80 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
928
999
|
rightArrowButtonStyle.top !== undefined ||
|
|
929
1000
|
rightArrowButtonStyle.bottom !== undefined;
|
|
930
1001
|
|
|
1002
|
+
/**
|
|
1003
|
+
* What the shopper did, read and cleared by the change report below.
|
|
1004
|
+
*
|
|
1005
|
+
* The library's change callback carries no cause: an arrow, a dot, a drag,
|
|
1006
|
+
* the autoplay timer and the keyboard all reach it through the same two
|
|
1007
|
+
* methods. Only the two the block owns can be attributed honestly, so those
|
|
1008
|
+
* two leave a note and everything else reports no cause at all rather than
|
|
1009
|
+
* a guessed one.
|
|
1010
|
+
*/
|
|
1011
|
+
const lastControlRef = React.useRef<'arrow' | 'dot' | null>(null);
|
|
1012
|
+
|
|
1013
|
+
const noteControl = (control: 'arrow' | 'dot') => {
|
|
1014
|
+
lastControlRef.current = control;
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* One slide change, in authored terms.
|
|
1019
|
+
*
|
|
1020
|
+
* `index` and `total` count the slides the SHOPPER can see: the arrow icons
|
|
1021
|
+
* are not slides, and a hidden slide is dropped on the storefront while the
|
|
1022
|
+
* canvas keeps it. A slide switched off by its publish window is still
|
|
1023
|
+
* counted here — it occupies a track position and a dot today, blank; that
|
|
1024
|
+
* is existing behaviour and its own ticket.
|
|
1025
|
+
*/
|
|
1026
|
+
const reportSlideChange = (
|
|
1027
|
+
reportedIndex: number,
|
|
1028
|
+
state?: { slidesToShow?: number }
|
|
1029
|
+
) => {
|
|
1030
|
+
const measuredInView = Number(state?.slidesToShow) || 0;
|
|
1031
|
+
const index = useInfinite
|
|
1032
|
+
? authoredSlideIndex(reportedIndex, visibleCount, measuredInView)
|
|
1033
|
+
: reportedIndex;
|
|
1034
|
+
|
|
1035
|
+
if (index === null || index === undefined) return;
|
|
1036
|
+
|
|
1037
|
+
const via = lastControlRef.current;
|
|
1038
|
+
lastControlRef.current = null;
|
|
1039
|
+
|
|
1040
|
+
signal(
|
|
1041
|
+
'slide.changed',
|
|
1042
|
+
{
|
|
1043
|
+
index,
|
|
1044
|
+
total: visibleCount,
|
|
1045
|
+
// The library's measured value, not the block's breakpoint guess:
|
|
1046
|
+
// "slide 2 of 6" means a different thing with one in view than with
|
|
1047
|
+
// four. Absent before mount rather than reported as zero.
|
|
1048
|
+
...(measuredInView > 0 ? { slidesInView: measuredInView } : null),
|
|
1049
|
+
...(via ? { via } : null)
|
|
1050
|
+
},
|
|
1051
|
+
via
|
|
1052
|
+
? { key: block.id }
|
|
1053
|
+
: {
|
|
1054
|
+
key: `${block.id}:unattributed`,
|
|
1055
|
+
ceiling: UNATTRIBUTED_SLIDE_CHANGE_CEILING
|
|
1056
|
+
}
|
|
1057
|
+
);
|
|
1058
|
+
};
|
|
1059
|
+
|
|
931
1060
|
const handleLeftArrowClick = (e: React.MouseEvent) => {
|
|
932
1061
|
e.preventDefault();
|
|
933
1062
|
e.stopPropagation();
|
|
934
1063
|
|
|
1064
|
+
// Both arrow paths land here — the one the library renders inside the
|
|
1065
|
+
// track and the one the block draws when the arrows are placed outside it
|
|
1066
|
+
// — so the control is reported exactly once per click either way.
|
|
1067
|
+
noteControl('arrow');
|
|
1068
|
+
signal(
|
|
1069
|
+
'slide.control',
|
|
1070
|
+
{ control: 'arrow', direction: 'previous' },
|
|
1071
|
+
{
|
|
1072
|
+
key: block.id
|
|
1073
|
+
}
|
|
1074
|
+
);
|
|
1075
|
+
|
|
935
1076
|
carouselRef.current?.previous?.(1);
|
|
936
1077
|
};
|
|
937
1078
|
|
|
@@ -939,9 +1080,33 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
939
1080
|
e.preventDefault();
|
|
940
1081
|
e.stopPropagation();
|
|
941
1082
|
|
|
1083
|
+
noteControl('arrow');
|
|
1084
|
+
signal(
|
|
1085
|
+
'slide.control',
|
|
1086
|
+
{ control: 'arrow', direction: 'next' },
|
|
1087
|
+
{
|
|
1088
|
+
key: block.id
|
|
1089
|
+
}
|
|
1090
|
+
);
|
|
1091
|
+
|
|
942
1092
|
carouselRef.current?.next?.(1);
|
|
943
1093
|
};
|
|
944
1094
|
|
|
1095
|
+
/**
|
|
1096
|
+
* A dot carries its own ordinal, which is NOT the slide ordinal whenever
|
|
1097
|
+
* more than one slide moves at a time — the library derives the target from
|
|
1098
|
+
* it. So the dot reports which dot was pressed and lets the change report
|
|
1099
|
+
* that follows carry the slide it landed on.
|
|
1100
|
+
*/
|
|
1101
|
+
const handleDotSelect = (dotIndex: number) => {
|
|
1102
|
+
noteControl('dot');
|
|
1103
|
+
signal(
|
|
1104
|
+
'slide.control',
|
|
1105
|
+
{ control: 'dot', dotIndex: Number(dotIndex) || 0 },
|
|
1106
|
+
{ key: block.id }
|
|
1107
|
+
);
|
|
1108
|
+
};
|
|
1109
|
+
|
|
945
1110
|
/**
|
|
946
1111
|
* ONE ARROW, DRAWN IN ONE PLACE.
|
|
947
1112
|
*
|
|
@@ -1033,10 +1198,19 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
1033
1198
|
);
|
|
1034
1199
|
};
|
|
1035
1200
|
|
|
1201
|
+
/*
|
|
1202
|
+
* The note is left BEFORE the library's own handler runs, because that call
|
|
1203
|
+
* moves the carousel synchronously and the change report reads the note on
|
|
1204
|
+
* the way past. The order of the two calls is otherwise untouched: the
|
|
1205
|
+
* library's `previous()`/`next()` moves by the authored step, and the
|
|
1206
|
+
* block's own call behind it is swallowed by the library's throttle —
|
|
1207
|
+
* swapping them would change how far one click travels.
|
|
1208
|
+
*/
|
|
1036
1209
|
const CustomLeftArrow = ({ onClick }: any) =>
|
|
1037
1210
|
renderArrowButton('left', (e) => {
|
|
1038
1211
|
e.stopPropagation();
|
|
1039
1212
|
if (!isDesigner) {
|
|
1213
|
+
noteControl('arrow');
|
|
1040
1214
|
onClick?.();
|
|
1041
1215
|
}
|
|
1042
1216
|
handleLeftArrowClick(e);
|
|
@@ -1046,6 +1220,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
1046
1220
|
renderArrowButton('right', (e) => {
|
|
1047
1221
|
e.stopPropagation();
|
|
1048
1222
|
if (!isDesigner) {
|
|
1223
|
+
noteControl('arrow');
|
|
1049
1224
|
onClick?.();
|
|
1050
1225
|
}
|
|
1051
1226
|
handleRightArrowClick(e);
|
|
@@ -1071,12 +1246,8 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
1071
1246
|
{...carouselSsr.gateProps}
|
|
1072
1247
|
// Focus rides along with the pointer so the arrows are reachable by
|
|
1073
1248
|
// keyboard; both events bubble in React, so tabbing INTO an arrow counts.
|
|
1074
|
-
onMouseEnter={
|
|
1075
|
-
|
|
1076
|
-
}
|
|
1077
|
-
onMouseLeave={
|
|
1078
|
-
arrowsOnHover ? () => setPointerInside(false) : undefined
|
|
1079
|
-
}
|
|
1249
|
+
onMouseEnter={arrowsOnHover ? () => setPointerInside(true) : undefined}
|
|
1250
|
+
onMouseLeave={arrowsOnHover ? () => setPointerInside(false) : undefined}
|
|
1080
1251
|
onFocus={arrowsOnHover ? () => setPointerInside(true) : undefined}
|
|
1081
1252
|
onBlur={arrowsOnHover ? () => setPointerInside(false) : undefined}
|
|
1082
1253
|
>
|
|
@@ -1125,7 +1296,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
1125
1296
|
visibleCount > itemsShown
|
|
1126
1297
|
}
|
|
1127
1298
|
showDots={Boolean(showDots)}
|
|
1128
|
-
customDot={<CustomDot />}
|
|
1299
|
+
customDot={<CustomDot onSelect={handleDotSelect} />}
|
|
1129
1300
|
renderDotsOutside={Boolean(showDots)}
|
|
1130
1301
|
infinite={Boolean(useInfinite)}
|
|
1131
1302
|
rewind={Boolean(useInfinite)}
|
|
@@ -1134,16 +1305,30 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
1134
1305
|
autoPlaySpeed={autoPlaySpeed}
|
|
1135
1306
|
containerClass={`slider-carousel ${carouselSsr.scopeClass}`}
|
|
1136
1307
|
dotListClass="slider-dots"
|
|
1308
|
+
/*
|
|
1309
|
+
* Reported on every slider, not only the ones with a lead-in: an
|
|
1310
|
+
* autoplaying hero is infinite, an infinite slider can never have a
|
|
1311
|
+
* lead-in, and those are precisely the sliders whose changes are
|
|
1312
|
+
* worth counting.
|
|
1313
|
+
*
|
|
1314
|
+
* The lead-in's own state write stays behind its condition. Calling
|
|
1315
|
+
* it unconditionally would add a re-render of this block and every
|
|
1316
|
+
* slide under it on each change — three seconds apart, forever,
|
|
1317
|
+
* under autoplay — to move a transform nothing else reads.
|
|
1318
|
+
*/
|
|
1319
|
+
beforeChange={(
|
|
1320
|
+
nextSlide: number,
|
|
1321
|
+
state: { slidesToShow?: number }
|
|
1322
|
+
) => {
|
|
1323
|
+
if (useLeadIn) setSlidePosition(nextSlide);
|
|
1324
|
+
|
|
1325
|
+
reportSlideChange(nextSlide, state);
|
|
1326
|
+
}}
|
|
1137
1327
|
/*
|
|
1138
1328
|
* Spread conditionally so a slider that has not opted in is handed
|
|
1139
|
-
* the identical prop
|
|
1329
|
+
* the identical transform prop it was handed before.
|
|
1140
1330
|
*/
|
|
1141
|
-
{...(useLeadIn
|
|
1142
|
-
? {
|
|
1143
|
-
additionalTransfrom: edgeOffset,
|
|
1144
|
-
beforeChange: (nextSlide: number) => setSlidePosition(nextSlide)
|
|
1145
|
-
}
|
|
1146
|
-
: {})}
|
|
1331
|
+
{...(useLeadIn ? { additionalTransfrom: edgeOffset } : {})}
|
|
1147
1332
|
customLeftArrow={
|
|
1148
1333
|
!isDesigner &&
|
|
1149
1334
|
showArrows &&
|