@akinon/pz-theme 2.0.131-rc.0 → 2.0.131

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,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.131-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c2]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71d]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6c]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a631]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b1]
34
- - Updated dependencies [1595d458]
35
- - Updated dependencies [dca12811]
36
- - @akinon/next@2.0.131-rc.0
37
- - @akinon/theme-contract@2.0.131-rc.0
3
+ ## 2.0.131
4
+
5
+ ### Patch Changes
6
+
7
+ - cea66db: ZERO-5160: Report slide changes and slider controls as theme events
8
+ - @akinon/next@2.0.131
9
+ - @akinon/theme-contract@2.0.131
38
10
 
39
11
  ## 2.0.130
40
12
 
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.131-rc.0",
4
+ "version": "2.0.131",
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.131-rc.0",
11
+ "@akinon/next": "2.0.131",
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.131-rc.0",
16
+ "@akinon/theme-contract": "2.0.131",
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.131-rc.0",
24
+ "@akinon/next": "2.0.131",
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
- const CustomDot = ({ index, active, onClick }: any) => (
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={() => 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(getConfigEntry('arrows-on-hover'), currentBreakpoint, false)
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
- arrowsOnHover ? () => setPointerInside(true) : undefined
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 object it was handed before.
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 &&