@akinon/pz-theme 2.0.132-rc.0 → 2.0.133-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,21 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.132-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.132-rc.0
37
- - @akinon/theme-contract@2.0.132-rc.0
3
+ ## 2.0.133-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.133-beta.0
9
+ - @akinon/theme-contract@2.0.133-beta.0
10
+
11
+ ## 2.0.132
12
+
13
+ ### Patch Changes
14
+
15
+ - d16b7c3: ZERO-5163: Report slide changes from the posts slider section
16
+ - 6a296eb: ZERO-5161: resolve video clip and posters per storefront language
17
+ - @akinon/next@2.0.132
18
+ - @akinon/theme-contract@2.0.132
38
19
 
39
20
  ## 2.0.131
40
21
 
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.132-rc.0",
4
+ "version": "2.0.133-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.132-rc.0",
11
+ "@akinon/next": "2.0.133-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.132-rc.0",
16
+ "@akinon/theme-contract": "2.0.133-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.132-rc.0",
24
+ "@akinon/next": "2.0.133-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",
@@ -375,7 +375,10 @@ describe('PLACEHOLDERS_AVAILABLE announce', () => {
375
375
  // The literal string rather than the constant: the editor matches it by
376
376
  // name and drops a name it does not know, so this pins the wire value a
377
377
  // rename would silently break.
378
- expect(frames()[0].actionCapabilities).toEqual(['add-to-cart-after-add']);
378
+ expect(frames()[0].actionCapabilities).toEqual([
379
+ 'add-to-cart-after-add',
380
+ 'localized-video'
381
+ ]);
379
382
  });
380
383
 
381
384
  it('keeps the action capability on the designer re-announce too', () => {
@@ -401,8 +404,8 @@ describe('PLACEHOLDERS_AVAILABLE announce', () => {
401
404
  // panel back to "On Click" halfway through a session.
402
405
  expect(postMessage).toHaveBeenCalledTimes(2);
403
406
  expect(frames().map((frame) => frame.actionCapabilities)).toEqual([
404
- ['add-to-cart-after-add'],
405
- ['add-to-cart-after-add']
407
+ ['add-to-cart-after-add', 'localized-video'],
408
+ ['add-to-cart-after-add', 'localized-video']
406
409
  ]);
407
410
  });
408
411
 
@@ -31,8 +31,9 @@
31
31
  * the placeholder registry announces this, and it must not have to pull a
32
32
  * client component in to say one list of strings.
33
33
  */
34
- export type ActionCapability = 'add-to-cart-after-add';
34
+ export type ActionCapability = 'add-to-cart-after-add' | 'localized-video';
35
35
 
36
36
  export const ACTION_CAPABILITIES: readonly ActionCapability[] = [
37
- 'add-to-cart-after-add'
37
+ 'add-to-cart-after-add',
38
+ 'localized-video'
38
39
  ];
@@ -22,7 +22,8 @@ jest.mock('../../theme-block', () => ({
22
22
  }
23
23
  }));
24
24
 
25
- import SliderBlock, { authoredSlideIndex } from '../slider-block';
25
+ import SliderBlock from '../slider-block';
26
+ import { authoredSlideIndex } from '../../utils/carousel-index';
26
27
  import {
27
28
  __resetThemeSignalsForTests,
28
29
  subscribeToThemeSignals,
@@ -362,6 +363,19 @@ describe('slider events (ZERO-5160)', () => {
362
363
  expect(received).toHaveLength(5);
363
364
  });
364
365
 
366
+ it('does not cap a slider the shopper is driving', () => {
367
+ // With autoplay off nothing can be changing the slide but the shopper —
368
+ // a drag or a keyboard arrow — so the timer's low ceiling must not
369
+ // apply, even though neither leaves an attribution note.
370
+ render(<SliderBlock {...props()} />);
371
+
372
+ for (let tick = 0; tick < 20; tick += 1) {
373
+ lastCarouselProps().beforeChange(tick % 6, { slidesToShow: 1 });
374
+ }
375
+
376
+ expect(received).toHaveLength(20);
377
+ });
378
+
365
379
  it('still reports a shopper control after the timer has used its budget', () => {
366
380
  // The two go on separate tally keys, so a carousel spinning below the
367
381
  // fold cannot silence the arrow the shopper actually pressed.
@@ -0,0 +1,204 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ let localizationValue: Record<string, unknown> = {};
5
+
6
+ jest.mock('@akinon/next/hooks', () => ({
7
+ __esModule: true,
8
+ useLocalization: () => localizationValue
9
+ }));
10
+
11
+ import VideoBlock from '../video-block';
12
+ import type { BlockRendererProps } from '../block-renderer-registry';
13
+
14
+ const setLocalization = (
15
+ locale: string,
16
+ locales: string[] = ['tr', 'en'],
17
+ defaultLocaleValue = 'tr'
18
+ ) => {
19
+ localizationValue = {
20
+ locale,
21
+ locales: locales.map((value) => ({ value, label: value.toUpperCase() })),
22
+ defaultLocaleValue
23
+ };
24
+ };
25
+
26
+ const TR = 'https://cdn.example.com/hero-tr.mp4';
27
+ const EN = 'https://cdn.example.com/hero-en.mp4';
28
+ const TR_MOBILE = 'https://cdn.example.com/hero-tr-mobile.mp4';
29
+ const EN_MOBILE = 'https://cdn.example.com/hero-en-mobile.mp4';
30
+ const TR_POSTER = 'https://cdn.example.com/poster-tr.jpg';
31
+ const EN_POSTER = 'https://cdn.example.com/poster-en.jpg';
32
+
33
+ const mount = (
34
+ value: unknown,
35
+ properties: Record<string, unknown> = {},
36
+ currentBreakpoint = 'desktop'
37
+ ) =>
38
+ render(
39
+ <VideoBlock
40
+ {...({
41
+ block: { id: 'v', type: 'video', value, properties },
42
+ placeholderId: 'p1',
43
+ sectionId: 's1',
44
+ isDesigner: false,
45
+ currentBreakpoint
46
+ } as unknown as BlockRendererProps)}
47
+ />
48
+ ).container;
49
+
50
+ const srcOf = (
51
+ value: unknown,
52
+ properties: Record<string, unknown> = {},
53
+ currentBreakpoint = 'desktop'
54
+ ) =>
55
+ mount(value, properties, currentBreakpoint)
56
+ .querySelector('video')
57
+ ?.getAttribute('src') ?? null;
58
+
59
+ const posterOf = (
60
+ properties: Record<string, unknown>,
61
+ currentBreakpoint = 'desktop'
62
+ ) =>
63
+ mount(TR, properties, currentBreakpoint)
64
+ .querySelector('video')
65
+ ?.getAttribute('poster') ?? null;
66
+
67
+ const withViewport = (narrow: boolean, run: () => void) => {
68
+ const original = window.matchMedia;
69
+ window.matchMedia = ((query: string) => ({
70
+ matches: narrow && query.includes('max-width'),
71
+ media: query,
72
+ onchange: null,
73
+ addListener: () => {},
74
+ removeListener: () => {},
75
+ addEventListener: () => {},
76
+ removeEventListener: () => {},
77
+ dispatchEvent: () => false
78
+ })) as unknown as typeof window.matchMedia;
79
+ try {
80
+ run();
81
+ } finally {
82
+ window.matchMedia = original;
83
+ }
84
+ };
85
+
86
+ beforeEach(() => setLocalization('tr'));
87
+
88
+ describe('the clip, per language', () => {
89
+ it('keeps playing a plain address exactly as before', () => {
90
+ expect(srcOf(TR)).toBe(TR);
91
+
92
+ setLocalization('en');
93
+ expect(srcOf(TR)).toBe(TR);
94
+ });
95
+
96
+ it('picks the entry for the page’s language from a per-language map', () => {
97
+ expect(srcOf({ tr: TR, en: EN })).toBe(TR);
98
+
99
+ setLocalization('en');
100
+ expect(srcOf({ tr: TR, en: EN })).toBe(EN);
101
+ });
102
+
103
+ it('falls back to the default language, then to the first clip there is', () => {
104
+ setLocalization('en');
105
+ expect(srcOf({ tr: TR, en: '' })).toBe(TR);
106
+
107
+ setLocalization('en', ['tr', 'en'], 'de');
108
+ expect(srcOf({ tr: TR, en: '' })).toBe(TR);
109
+ });
110
+
111
+ it('reads the map the storefront receives serialised in a widget attribute', () => {
112
+ setLocalization('en');
113
+ expect(srcOf(JSON.stringify({ tr: TR, en: EN }))).toBe(EN);
114
+ });
115
+
116
+ it('shows nothing rather than breaking when the map names languages the store lacks', () => {
117
+ setLocalization('de', ['de']);
118
+ const container = mount({ tr: TR, en: EN });
119
+
120
+ expect(container.querySelector('video')).toBeNull();
121
+ expect(container.querySelector('iframe')).toBeNull();
122
+ });
123
+
124
+ it('resolves an embed per language too', () => {
125
+ const value = {
126
+ tr: 'https://www.youtube.com/watch?v=trclip01',
127
+ en: 'https://www.youtube.com/watch?v=enclip01'
128
+ };
129
+
130
+ expect(mount(value).querySelector('iframe')?.getAttribute('src')).toContain(
131
+ 'trclip01'
132
+ );
133
+
134
+ setLocalization('en');
135
+ expect(mount(value).querySelector('iframe')?.getAttribute('src')).toContain(
136
+ 'enclip01'
137
+ );
138
+ });
139
+ });
140
+
141
+ describe('the mobile clip, per language', () => {
142
+ const mobile = (mobileUrl: unknown) => ({
143
+ useDifferentMobileVideo: true,
144
+ mobileUrl
145
+ });
146
+
147
+ it('keeps honouring a plain address and the per-breakpoint shape older JSON holds', () => {
148
+ withViewport(true, () => {
149
+ expect(srcOf(TR, mobile(TR_MOBILE), 'mobile')).toBe(TR_MOBILE);
150
+ expect(srcOf(TR, mobile({ desktop: TR_MOBILE }), 'mobile')).toBe(
151
+ TR_MOBILE
152
+ );
153
+ });
154
+ });
155
+
156
+ it('picks the mobile clip for the page’s language', () => {
157
+ withViewport(true, () => {
158
+ expect(
159
+ srcOf(TR, mobile({ tr: TR_MOBILE, en: EN_MOBILE }), 'mobile')
160
+ ).toBe(TR_MOBILE);
161
+
162
+ setLocalization('en');
163
+ expect(
164
+ srcOf(EN, mobile({ tr: TR_MOBILE, en: EN_MOBILE }), 'mobile')
165
+ ).toBe(EN_MOBILE);
166
+ });
167
+ });
168
+
169
+ it('leaves the desktop clip alone on a wide screen', () => {
170
+ setLocalization('en');
171
+ withViewport(false, () => {
172
+ expect(
173
+ srcOf({ tr: TR, en: EN }, mobile({ tr: TR_MOBILE, en: EN_MOBILE }))
174
+ ).toBe(EN);
175
+ });
176
+ });
177
+ });
178
+
179
+ describe('the poster, per language', () => {
180
+ it('keeps a plain poster as it is', () => {
181
+ expect(posterOf({ poster: TR_POSTER })).toBe(TR_POSTER);
182
+ });
183
+
184
+ it('picks the poster for the page’s language, on desktop and on the phone', () => {
185
+ expect(posterOf({ poster: { tr: TR_POSTER, en: EN_POSTER } })).toBe(
186
+ TR_POSTER
187
+ );
188
+
189
+ setLocalization('en');
190
+ expect(posterOf({ poster: { tr: TR_POSTER, en: EN_POSTER } })).toBe(
191
+ EN_POSTER
192
+ );
193
+ expect(
194
+ posterOf(
195
+ {
196
+ useDifferentMobileVideo: true,
197
+ mobileUrl: EN_MOBILE,
198
+ mobilePoster: { tr: TR_POSTER, en: EN_POSTER }
199
+ },
200
+ 'mobile'
201
+ )
202
+ ).toBe(EN_POSTER);
203
+ });
204
+ });
@@ -15,6 +15,10 @@ 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 {
19
+ authoredSlideIndex,
20
+ TIMER_SLIDE_CHANGE_CEILING
21
+ } from '../utils/carousel-index';
18
22
  import { useThemeSignal } from '../hooks/use-theme-signal';
19
23
  import {
20
24
  CAROUSEL_MINIMUM_TOUCH_DRAG,
@@ -68,60 +72,6 @@ const clampNumber = (value: number, min: number, max: number) =>
68
72
  * render would unmount and remount all of them on each slide change — taking
69
73
  * the focus of a keyboard user who had just tabbed to one.
70
74
  */
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
75
  const CustomDot = ({ index, active, onClick, onSelect }: any) => (
126
76
  <li
127
77
  className={clsx(
@@ -1048,11 +998,15 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
1048
998
  ...(measuredInView > 0 ? { slidesInView: measuredInView } : null),
1049
999
  ...(via ? { via } : null)
1050
1000
  },
1051
- via
1001
+ // The low ceiling is for the autoplay timer, which reports no cause and
1002
+ // never stops. With autoplay off an unattributed change is a drag or a
1003
+ // keyboard arrow — a real shopper action, and capping those at five
1004
+ // would lose most of them.
1005
+ via || !useAutoPlay
1052
1006
  ? { key: block.id }
1053
1007
  : {
1054
- key: `${block.id}:unattributed`,
1055
- ceiling: UNATTRIBUTED_SLIDE_CHANGE_CEILING
1008
+ key: `${block.id}:timer`,
1009
+ ceiling: TIMER_SLIDE_CHANGE_CEILING
1056
1010
  }
1057
1011
  );
1058
1012
  };
@@ -9,6 +9,11 @@ import {
9
9
  partAttrs
10
10
  } from '../utils';
11
11
  import { BlockRendererProps } from './block-renderer-registry';
12
+ import {
13
+ resolveLocalizedValue,
14
+ useValueLocale,
15
+ type ValueLocale
16
+ } from '../utils/localized-value';
12
17
 
13
18
  /**
14
19
  * What an author's URL turned out to be.
@@ -311,6 +316,17 @@ const setAtAnyBreakpoint = (value: unknown): boolean => {
311
316
  };
312
317
 
313
318
  /** The first address a per-breakpoint value carries, asked the same way. */
319
+ const localizedMedia = (
320
+ value: unknown,
321
+ currentBreakpoint: string,
322
+ valueLocale: ValueLocale
323
+ ): string => {
324
+ const localized = resolveLocalizedValue(value, valueLocale);
325
+ const atBreakpoint = getResponsiveValue(localized, currentBreakpoint, '');
326
+ const resolved = resolveLocalizedValue(atBreakpoint, valueLocale);
327
+ return typeof resolved === 'string' ? resolved : '';
328
+ };
329
+
314
330
  const firstUrlAtAnyBreakpoint = (value: unknown): string => {
315
331
  if (typeof value === 'string') return value;
316
332
  if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
@@ -334,7 +350,11 @@ const VideoBlock = ({
334
350
  isDesigner = false,
335
351
  currentBreakpoint = 'desktop'
336
352
  }: BlockRendererProps) => {
337
- const desktopUrl = block.value as string;
353
+ const valueLocale = useValueLocale();
354
+ const localizedValue = resolveLocalizedValue(block.value, valueLocale);
355
+ const desktopUrl = (
356
+ typeof localizedValue === 'string' ? localizedValue : block.value
357
+ ) as string;
338
358
  const properties = (block.properties || {}) as VideoProperties;
339
359
 
340
360
  /**
@@ -361,11 +381,11 @@ const VideoBlock = ({
361
381
  currentBreakpoint,
362
382
  false
363
383
  );
364
- const mobileUrl = getResponsiveValue(
384
+ const mobileUrl = localizedMedia(
365
385
  properties.mobileUrl,
366
386
  currentBreakpoint,
367
- ''
368
- ) as string;
387
+ valueLocale
388
+ );
369
389
  const videoUrl =
370
390
  isMobile && useDifferentMobileVideo && mobileUrl ? mobileUrl : desktopUrl;
371
391
 
@@ -400,7 +420,9 @@ const VideoBlock = ({
400
420
  * the element with no source at all and the effect below names the one file
401
421
  * this viewport needs — see it for why the server cannot decide this.
402
422
  */
403
- const mobileUrlAnywhere = firstUrlAtAnyBreakpoint(properties.mobileUrl);
423
+ const mobileUrlAnywhere = firstUrlAtAnyBreakpoint(
424
+ resolveLocalizedValue(properties.mobileUrl, valueLocale)
425
+ );
404
426
  const hasDistinctMobileVideo =
405
427
  setAtAnyBreakpoint(properties.useDifferentMobileVideo) &&
406
428
  !!mobileUrlAnywhere &&
@@ -430,16 +452,16 @@ const VideoBlock = ({
430
452
  const nativeControls =
431
453
  getResponsiveValue(properties.controls, currentBreakpoint, true) !== false;
432
454
 
433
- const desktopPoster = getResponsiveValue(
455
+ const desktopPoster = localizedMedia(
434
456
  properties.poster,
435
457
  currentBreakpoint,
436
- ''
437
- ) as string;
438
- const mobilePoster = getResponsiveValue(
458
+ valueLocale
459
+ );
460
+ const mobilePoster = localizedMedia(
439
461
  properties.mobilePoster,
440
462
  currentBreakpoint,
441
- ''
442
- ) as string;
463
+ valueLocale
464
+ );
443
465
  const posterUrl =
444
466
  isMobile && useDifferentMobileVideo && mobilePoster
445
467
  ? mobilePoster
@@ -101,11 +101,18 @@ const slidePadding = (container: HTMLElement) =>
101
101
  );
102
102
 
103
103
  describe('a slider that has not opted in', () => {
104
- it('is handed no edge props at all', () => {
104
+ it('is handed no edge transform, but is still reported on', () => {
105
105
  const { props } = renderSection();
106
106
 
107
+ // The transform is the lead-in's, and a slider that did not opt in must
108
+ // still be handed the identical prop object it was handed before.
107
109
  expect('additionalTransfrom' in props).toBe(false);
108
- expect('beforeChange' in props).toBe(false);
110
+
111
+ // The change callback is no longer part of that opt-in (ZERO-5163): every
112
+ // posts slider reports its slide changes, and a lead-in requires a finite
113
+ // track, so hanging the report off it would leave every looping slider
114
+ // silent. The lead-in's own state write is still behind its condition.
115
+ expect(typeof props.beforeChange).toBe('function');
109
116
  });
110
117
 
111
118
  it('keeps the half-gap on both sides of every slide', () => {
@@ -0,0 +1,200 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ /**
5
+ * What the posts slider reports when a slide changes (ZERO-5163).
6
+ *
7
+ * The sibling `slider` block got this first; this section draws the same
8
+ * carousel, and the one thing the two must not do is disagree about whether a
9
+ * slide change is worth reporting or what a slide index means.
10
+ *
11
+ * The library renders nothing under jsdom, so — as in the edges suite — the
12
+ * assertions go through the prop object it was handed.
13
+ */
14
+
15
+ const carouselProps: Record<string, any>[] = [];
16
+
17
+ let themeBlockRenders = 0;
18
+ jest.mock('../../theme-block', () => ({
19
+ __esModule: true,
20
+ default: ({ block }: { block: { id: string } }) => {
21
+ themeBlockRenders += 1;
22
+ return <div data-testid="slide-content" data-id={block.id} />;
23
+ }
24
+ }));
25
+
26
+ jest.mock('react-multi-carousel', () => ({
27
+ __esModule: true,
28
+ default: (props: Record<string, any>) => {
29
+ carouselProps.push(props);
30
+ return <div data-testid="carousel">{props.children}</div>;
31
+ }
32
+ }));
33
+
34
+ import PostsSliderSection from '../posts-slider-section';
35
+ import {
36
+ __resetThemeSignalsForTests,
37
+ subscribeToThemeSignals,
38
+ type ThemeSignalDetail
39
+ } from '../../utils/theme-signal';
40
+
41
+ declare global {
42
+ interface Window {
43
+ __themeEditorDesigner?: boolean;
44
+ }
45
+ }
46
+
47
+ const slide = (id: string) => ({
48
+ id,
49
+ type: 'group',
50
+ label: id,
51
+ properties: {},
52
+ styles: {},
53
+ order: 0,
54
+ blocks: []
55
+ });
56
+
57
+ const renderSection = (
58
+ properties: Record<string, unknown> = {},
59
+ slideCount = 6
60
+ ) => {
61
+ carouselProps.length = 0;
62
+ themeBlockRenders = 0;
63
+
64
+ const result = render(
65
+ <PostsSliderSection
66
+ {...({
67
+ section: {
68
+ id: 'sec',
69
+ type: 'flash-deals-slider',
70
+ label: 'sec',
71
+ properties: { 'items-per-slide': 1, ...properties },
72
+ styles: {},
73
+ blocks: Array.from({ length: slideCount }, (_unused, index) =>
74
+ slide(`s${index}`)
75
+ )
76
+ },
77
+ currentBreakpoint: 'desktop',
78
+ placeholderId: 'ph',
79
+ isDesigner: false
80
+ } as any)}
81
+ />
82
+ );
83
+
84
+ return { ...result, props: carouselProps[carouselProps.length - 1] };
85
+ };
86
+
87
+ describe('posts slider events (ZERO-5163)', () => {
88
+ let received: ThemeSignalDetail[];
89
+ let detach: () => void;
90
+
91
+ beforeEach(() => {
92
+ received = [];
93
+ detach = subscribeToThemeSignals((detail) => received.push(detail));
94
+ __resetThemeSignalsForTests();
95
+ delete window.__themeEditorDesigner;
96
+ });
97
+
98
+ afterEach(() => {
99
+ detach();
100
+ delete window.__themeEditorDesigner;
101
+ });
102
+
103
+ it('reports a change with the section identity', () => {
104
+ const { props } = renderSection();
105
+
106
+ props.beforeChange(2, { slidesToShow: 1 });
107
+
108
+ expect(received).toEqual([
109
+ {
110
+ name: 'slide.changed',
111
+ placeholderId: 'ph',
112
+ sectionId: 'sec',
113
+ sectionType: 'flash-deals-slider',
114
+ index: 2,
115
+ total: 6,
116
+ slidesInView: 1
117
+ }
118
+ ]);
119
+ });
120
+
121
+ it('never claims to know what caused the change', () => {
122
+ // This section uses the library's own arrows and dots rather than
123
+ // components it owns, so nothing here could attribute a change honestly.
124
+ const { props } = renderSection();
125
+
126
+ props.beforeChange(1, { slidesToShow: 1 });
127
+ props.beforeChange(2, { slidesToShow: 1 });
128
+
129
+ expect(received.some((detail) => 'via' in detail)).toBe(false);
130
+ });
131
+
132
+ it('maps a clone index back to the slide the merchant authored', () => {
133
+ const { props } = renderSection({ infinite: true });
134
+
135
+ // Six slides one at a time: the library starts at 2, so its 3 is the
136
+ // second slide and its 7 is the last.
137
+ props.beforeChange(3, { slidesToShow: 1 });
138
+ props.beforeChange(7, { slidesToShow: 1 });
139
+
140
+ expect(received.map((detail) => detail.index)).toEqual([1, 5]);
141
+ });
142
+
143
+ it('reports nothing before the library has measured itself', () => {
144
+ const { props } = renderSection({ infinite: true });
145
+
146
+ props.beforeChange(3, { slidesToShow: 0 });
147
+
148
+ expect(received).toEqual([]);
149
+ });
150
+
151
+ it('omits slides in view rather than reporting zero', () => {
152
+ const { props } = renderSection();
153
+
154
+ props.beforeChange(1, { slidesToShow: 0 });
155
+
156
+ expect(received[0]).not.toHaveProperty('slidesInView');
157
+ });
158
+
159
+ it('does not re-render the cards on a change it was not asked to track', () => {
160
+ const { props } = renderSection();
161
+ const before = themeBlockRenders;
162
+
163
+ props.beforeChange(2, { slidesToShow: 1 });
164
+
165
+ expect(themeBlockRenders).toBe(before);
166
+ });
167
+
168
+ describe('volume', () => {
169
+ it('caps the autoplay timer', () => {
170
+ const { props } = renderSection({ autoplay: true });
171
+
172
+ for (let tick = 0; tick < 20; tick += 1) {
173
+ props.beforeChange(tick % 6, { slidesToShow: 1 });
174
+ }
175
+
176
+ expect(received).toHaveLength(5);
177
+ });
178
+
179
+ it('does not cap a slider the shopper is driving', () => {
180
+ // With autoplay off nothing can be reporting but the shopper — a drag
181
+ // or a keyboard arrow — so the low ceiling would lose most of them.
182
+ const { props } = renderSection();
183
+
184
+ for (let tick = 0; tick < 20; tick += 1) {
185
+ props.beforeChange(tick % 6, { slidesToShow: 1 });
186
+ }
187
+
188
+ expect(received).toHaveLength(20);
189
+ });
190
+ });
191
+
192
+ it('reports nothing while the theme editor is attached', () => {
193
+ window.__themeEditorDesigner = true;
194
+
195
+ const { props } = renderSection();
196
+ props.beforeChange(2, { slidesToShow: 1 });
197
+
198
+ expect(received).toEqual([]);
199
+ });
200
+ });
@@ -22,6 +22,11 @@ import {
22
22
  } from '../utils';
23
23
  import { DESKTOP_MIN_WIDTH, TABLET_MIN_WIDTH } from '../utils/breakpoints';
24
24
  import { useCarouselSsr } from '../utils/carousel-ssr';
25
+ import {
26
+ authoredSlideIndex,
27
+ TIMER_SLIDE_CHANGE_CEILING
28
+ } from '../utils/carousel-index';
29
+ import { useThemeSignal } from '../hooks/use-theme-signal';
25
30
  import {
26
31
  CAROUSEL_MINIMUM_TOUCH_DRAG,
27
32
  carouselDragStyle,
@@ -605,6 +610,44 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
605
610
  // there is no edge for a gap to belong to.
606
611
  const useLeadIn = leadIn > 0 && hasMultipleSlides && !useInfinite;
607
612
  const [slidePosition, setSlidePosition] = React.useState(0);
613
+
614
+ const signal = useThemeSignal({
615
+ placeholderId,
616
+ sectionId: section.id,
617
+ sectionType: section.type
618
+ });
619
+
620
+ /**
621
+ * One slide change, in authored terms (ZERO-5163).
622
+ *
623
+ * The same report the `slider` block sends, minus the cause: this section
624
+ * uses the library's own arrows and dots rather than components it owns, so
625
+ * there is nothing here that could attribute a change honestly. `total` is
626
+ * the expanded count — what the shopper can actually page through.
627
+ */
628
+ const reportSlideChange = (
629
+ reportedIndex: number,
630
+ state?: { slidesToShow?: number }
631
+ ) => {
632
+ const measuredInView = Number(state?.slidesToShow) || 0;
633
+ const index = useInfinite
634
+ ? authoredSlideIndex(reportedIndex, visibleCount, measuredInView)
635
+ : reportedIndex;
636
+
637
+ if (index === null || index === undefined) return;
638
+
639
+ signal(
640
+ 'slide.changed',
641
+ {
642
+ index,
643
+ total: visibleCount,
644
+ ...(measuredInView > 0 ? { slidesInView: measuredInView } : null)
645
+ },
646
+ useAutoPlay
647
+ ? { key: `${section.id}:timer`, ceiling: TIMER_SLIDE_CHANGE_CEILING }
648
+ : { key: section.id }
649
+ );
650
+ };
608
651
  const lastPosition = Math.max(0, visibleCount - itemsPerView);
609
652
  const edgeOffset =
610
653
  leadInSide === 'left'
@@ -927,18 +970,29 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
927
970
  autoPlaySpeed={autoPlaySpeed}
928
971
  containerClass={`posts-slider-carousel ${carouselSsr.scopeClass}`}
929
972
  dotListClass="posts-slider-dots"
973
+ /*
974
+ * Reported on every posts slider, not only the ones with a lead-in:
975
+ * a lead-in requires a finite track, so hanging the report off it
976
+ * would leave every looping slider silent.
977
+ *
978
+ * The lead-in's own state write stays behind its condition. Calling
979
+ * it unconditionally would re-render this section and every card
980
+ * under it on each change, to move a transform nothing else reads.
981
+ */
982
+ beforeChange={(
983
+ nextSlide: number,
984
+ state: { slidesToShow?: number }
985
+ ) => {
986
+ if (useLeadIn) setSlidePosition(nextSlide);
987
+
988
+ reportSlideChange(nextSlide, state);
989
+ }}
930
990
  /*
931
991
  * Spread conditionally so a slider that has not opted in is handed
932
- * the identical prop object it was handed before — there is no
933
- * `additionalTransfrom: 0` for the library to reason about, and no
934
- * `beforeChange` for it to call.
992
+ * the identical transform prop it was handed before — there is no
993
+ * `additionalTransfrom: 0` for the library to reason about.
935
994
  */
936
- {...(useLeadIn
937
- ? {
938
- additionalTransfrom: edgeOffset,
939
- beforeChange: (nextSlide: number) => setSlidePosition(nextSlide)
940
- }
941
- : {})}
995
+ {...(useLeadIn ? { additionalTransfrom: edgeOffset } : {})}
942
996
  >
943
997
  {expandedBlocks.map((block, index) => {
944
998
  const controlBlockId = block.__iteratorParentId || block.id;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The authored slide ordinal behind the index react-multi-carousel reports.
3
+ *
4
+ * In infinite mode the carousel prepends and appends clones, so its change
5
+ * callback counts in clone space: a six-slide hero starts at 2 and climbs to 9
6
+ * before the library silently teleports back. Reported raw that reads
7
+ * `index: 9` against `total: 6`, and slides 0 and 1 are never mentioned at all.
8
+ *
9
+ * This is the same mapping the library's own dots use to resolve which one is
10
+ * active (`getOriginalIndexLookupTableByClones`), written out rather than
11
+ * imported: the helper is not on the package's export surface, only its
12
+ * internal deep path, and four lines of arithmetic is a smaller liability than
13
+ * an import a minor version can move.
14
+ *
15
+ * Its own module because two renderers draw the same carousel — the `slider`
16
+ * block and the posts slider section — and the one thing they must not do is
17
+ * disagree about what a slide index means.
18
+ *
19
+ * @param reportedIndex what the library said, in clone space
20
+ * @param total the authored slide count, after any iterator expansion
21
+ * @param slidesToShow the MEASURED value off the callback's state. The table
22
+ * branches on it, so a renderer's own breakpoint guess
23
+ * would silently select a different table. Before mount
24
+ * the library reports 0, which cannot resolve anything —
25
+ * hence null, so the caller reports nothing rather than
26
+ * guessing.
27
+ */
28
+ export const authoredSlideIndex = (
29
+ reportedIndex: number,
30
+ total: number,
31
+ slidesToShow: number
32
+ ): number | null => {
33
+ if (!Number.isFinite(reportedIndex) || total <= 0) return null;
34
+ if (slidesToShow <= 0) return null;
35
+
36
+ const span = 2 * slidesToShow;
37
+ let authored: number;
38
+
39
+ if (total > span) {
40
+ if (reportedIndex < span) authored = total - span + reportedIndex;
41
+ else if (reportedIndex < total + span) authored = reportedIndex - span;
42
+ else authored = reportedIndex - (total + span);
43
+ } else {
44
+ // Short lists are cloned by concatenation, so the table is a modulo.
45
+ authored = ((reportedIndex % total) + total) % total;
46
+ }
47
+
48
+ return authored >= 0 && authored < total ? authored : null;
49
+ };
50
+
51
+ /**
52
+ * How many slide changes one carousel may report per page view when nothing
53
+ * attributed them to a shopper.
54
+ *
55
+ * An autoplaying carousel changes slide every few seconds for as long as the
56
+ * tab is open, and the library's callback carries no cause. Those go on their
57
+ * own tally key with this much lower ceiling, so a carousel spinning below the
58
+ * fold can neither drown out the controls of the same carousel nor eat the
59
+ * route budget every other moment on the page shares.
60
+ *
61
+ * It applies ONLY while a timer could be driving the carousel. With autoplay
62
+ * off, an unattributed change is a drag or a keyboard arrow — a real shopper
63
+ * action — and capping those at five would lose most of them.
64
+ */
65
+ export const TIMER_SLIDE_CHANGE_CEILING = 5;