@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 +16 -35
- package/package.json +4 -4
- package/src/__tests__/custom-catalog-announce.test.ts +6 -3
- package/src/action-capabilities.ts +3 -2
- package/src/blocks/__tests__/slider-block-signals.test.tsx +15 -1
- package/src/blocks/__tests__/video-block-locale.test.tsx +204 -0
- package/src/blocks/slider-block.tsx +11 -57
- package/src/blocks/video-block.tsx +33 -11
- package/src/sections/__tests__/posts-slider-edges.test.tsx +9 -2
- package/src/sections/__tests__/posts-slider-signals.test.tsx +200 -0
- package/src/sections/posts-slider-section.tsx +63 -9
- package/src/utils/carousel-index.ts +65 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,40 +1,21 @@
|
|
|
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
|
-
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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([
|
|
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
|
|
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
|
-
|
|
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}:
|
|
1055
|
-
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
|
|
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 =
|
|
384
|
+
const mobileUrl = localizedMedia(
|
|
365
385
|
properties.mobileUrl,
|
|
366
386
|
currentBreakpoint,
|
|
367
|
-
|
|
368
|
-
)
|
|
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(
|
|
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 =
|
|
455
|
+
const desktopPoster = localizedMedia(
|
|
434
456
|
properties.poster,
|
|
435
457
|
currentBreakpoint,
|
|
436
|
-
|
|
437
|
-
)
|
|
438
|
-
const mobilePoster =
|
|
458
|
+
valueLocale
|
|
459
|
+
);
|
|
460
|
+
const mobilePoster = localizedMedia(
|
|
439
461
|
properties.mobilePoster,
|
|
440
462
|
currentBreakpoint,
|
|
441
|
-
|
|
442
|
-
)
|
|
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
|
|
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
|
-
|
|
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
|
|
933
|
-
* `additionalTransfrom: 0` for the library to reason about
|
|
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;
|