@akinon/pz-theme 2.0.132 → 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 +9 -0
- package/package.json +4 -4
- package/src/blocks/__tests__/slider-block-signals.test.tsx +15 -1
- package/src/blocks/slider-block.tsx +11 -57
- 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,9 +1,18 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
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
|
+
|
|
3
11
|
## 2.0.132
|
|
4
12
|
|
|
5
13
|
### Patch Changes
|
|
6
14
|
|
|
15
|
+
- d16b7c3: ZERO-5163: Report slide changes from the posts slider section
|
|
7
16
|
- 6a296eb: ZERO-5161: resolve video clip and posters per storefront language
|
|
8
17
|
- @akinon/next@2.0.132
|
|
9
18
|
- @akinon/theme-contract@2.0.132
|
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",
|
|
@@ -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.
|
|
@@ -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
|
};
|
|
@@ -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;
|