@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 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.132",
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",
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",
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",
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, { 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.
@@ -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
  };
@@ -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;