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

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,36 +1,15 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.73-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0cf9ea239]
8
- - Updated dependencies [324f97d55]
9
- - Updated dependencies [51ea06888]
10
- - Updated dependencies
11
- - Updated dependencies [2fea4143]
12
- - Updated dependencies [b55acb768]
13
- - Updated dependencies [760258c1c]
14
- - Updated dependencies [143be2b9d]
15
- - Updated dependencies [7889b08fe]
16
- - Updated dependencies [9f8cd3bc5]
17
- - Updated dependencies [bfafa3f49]
18
- - Updated dependencies [57d7eb305]
19
- - Updated dependencies [d99a6a7d5]
20
- - Updated dependencies [9db81a714]
21
- - Updated dependencies [591e345e1]
22
- - Updated dependencies [4de5303c5]
23
- - Updated dependencies [95b139dc1]
24
- - Updated dependencies [1d00f2d06]
25
- - Updated dependencies [4ac7b2a1e]
26
- - Updated dependencies [e9598c71]
27
- - Updated dependencies [4998a9631]
28
- - Updated dependencies [804d2bd6]
29
- - Updated dependencies [3909d3224]
30
- - Updated dependencies [6a3d8a63]
31
- - Updated dependencies [e18836b20]
32
- - @akinon/next@2.0.73-rc.0
33
- - @akinon/theme-contract@2.0.73-rc.0
3
+ ## 2.0.73
4
+
5
+ ### Patch Changes
6
+
7
+ - bf2f073c: ZERO-4820: Let a variant attribute show only its first N options with the rest behind a View all control (`optionLimits`, `viewAllLabel`, `viewLessLabel`, `view-all` part); the shopper's own selection always stays on screen and a dropdown is never cut
8
+ - bf2f073c: ZERO-4820: Size a variant photo chip by its width so the product image keeps its own proportions instead of being cropped to a square; a colour chip stays square
9
+ - 67e980e4: ZERO-4820: Add a rail placement for the product gallery's column indicator, so the measure follows the scroll down the edge of a stacked gallery instead of sitting at the midpoint of the whole stack
10
+ - 67e980e4: ZERO-4820: Swipe the product gallery hero between photos, refusing the native image drag and reserving vertical panning for the page, so a phone is not left changing photo through the arrows and the dots alone
11
+ - @akinon/next@2.0.73
12
+ - @akinon/theme-contract@2.0.73
34
13
 
35
14
  ## 2.0.72
36
15
 
package/package.json CHANGED
@@ -1,26 +1,26 @@
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.73-rc.0",
4
+ "version": "2.0.73",
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.73-rc.0",
11
+ "@akinon/next": "2.0.73",
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.73-rc.0",
16
+ "@akinon/theme-contract": "2.0.73",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "tailwind-merge": "^2.5.4",
20
20
  "zod": "4.4.1"
21
21
  },
22
22
  "devDependencies": {
23
- "@akinon/next": "2.0.73-rc.0",
23
+ "@akinon/next": "2.0.73",
24
24
  "@testing-library/jest-dom": "5.16.4",
25
25
  "@testing-library/react": "16.3.2",
26
26
  "@testing-library/user-event": "14.1.1",
@@ -972,3 +972,175 @@ const declaredStatesFor = (part: string): string[] => {
972
972
  });
973
973
  }
974
974
  );
975
+
976
+ /**
977
+ * The rail is the measure that travels with the shopper. Absolute positioning
978
+ * — the only spelling available before it — pins the row to the midpoint of
979
+ * the WHOLE stack, which on a seven-photo gallery is off screen for most of
980
+ * the scroll.
981
+ */
982
+ describe('ProductGalleryBlock — the column indicator rail', () => {
983
+ const RAIL = {
984
+ layout: 'grid',
985
+ showDots: true,
986
+ dotDirection: 'column',
987
+ dotPlacement: 'rail'
988
+ };
989
+
990
+ it('hangs the dots in a full-height gutter that lets clicks through', () => {
991
+ const { container } = renderBlock(RAIL, PRODUCT);
992
+ const rail = container.querySelector(
993
+ '[data-part="dot-rail"]'
994
+ ) as HTMLElement;
995
+
996
+ expect(rail).toBeTruthy();
997
+ expect(rail.style.position).toBe('absolute');
998
+ expect(rail.style.height).toBe('100%');
999
+ // The rail spans the whole stack; without this it would swallow every
1000
+ // click meant for the photographs behind it.
1001
+ expect(rail.style.pointerEvents).toBe('none');
1002
+ expect(rail.querySelector('[data-product-gallery-dots]')).toBeTruthy();
1003
+ });
1004
+
1005
+ it('makes the row itself the part that travels', () => {
1006
+ const { container } = renderBlock(RAIL, PRODUCT);
1007
+ const dots = container.querySelector(
1008
+ '[data-product-gallery-dots]'
1009
+ ) as HTMLElement;
1010
+
1011
+ expect(dots.style.position).toBe('sticky');
1012
+ expect(dots.style.top).toBe('50vh');
1013
+ expect(dots.style.transform).toBe('translateY(-50%)');
1014
+ // Taken back from the rail, or the dots would not be clickable at all.
1015
+ expect(dots.style.pointerEvents).toBe('auto');
1016
+ });
1017
+
1018
+ it('leaves a composition that never asked for a rail alone', () => {
1019
+ const { container } = renderBlock(
1020
+ { layout: 'grid', showDots: true, dotDirection: 'column' },
1021
+ PRODUCT
1022
+ );
1023
+ const dots = container.querySelector(
1024
+ '[data-product-gallery-dots]'
1025
+ ) as HTMLElement;
1026
+
1027
+ expect(container.querySelector('[data-part="dot-rail"]')).toBeNull();
1028
+ expect(dots.style.position).toBe('');
1029
+ });
1030
+
1031
+ it('ignores the rail for a row indicator, which has nowhere to travel', () => {
1032
+ const { container } = renderBlock(
1033
+ { layout: 'grid', showDots: true, dotPlacement: 'rail' },
1034
+ PRODUCT
1035
+ );
1036
+
1037
+ expect(container.querySelector('[data-part="dot-rail"]')).toBeNull();
1038
+ });
1039
+ });
1040
+
1041
+ /**
1042
+ * The hero swaps one photo for the next rather than scrolling a track, so a
1043
+ * finger drag on it is not something the browser can answer on its own. These
1044
+ * cover the gesture the shopper expects from every other carousel on the site
1045
+ * — and the click the browser sends after it, which must not reach the viewer
1046
+ * trigger lying over the frame.
1047
+ */
1048
+ describe('ProductGalleryBlock — swiping the hero', () => {
1049
+ const heroSwipe = (
1050
+ container: HTMLElement,
1051
+ from: number,
1052
+ to: number,
1053
+ clientY = 100
1054
+ ) => {
1055
+ const hero = container.querySelector('[data-product-gallery-main]')!;
1056
+ const init = { pointerId: 7, pointerType: 'touch', clientY };
1057
+
1058
+ fireEvent.pointerDown(hero, { ...init, clientX: from });
1059
+ fireEvent.pointerUp(hero, { ...init, clientX: to });
1060
+ };
1061
+
1062
+ const shown = (container: HTMLElement) =>
1063
+ container
1064
+ .querySelector('[data-product-gallery-main] img')
1065
+ ?.getAttribute('src');
1066
+
1067
+ it('moves to the next photo and back', () => {
1068
+ const { container } = renderBlock({}, PRODUCT);
1069
+
1070
+ heroSwipe(container, 300, 100);
1071
+ expect(shown(container)).toBe('https://cdn.example.com/b.jpg');
1072
+
1073
+ heroSwipe(container, 100, 300);
1074
+ expect(shown(container)).toBe('https://cdn.example.com/a.jpg');
1075
+ });
1076
+
1077
+ it('leaves a tap alone, so the viewer still opens on one', () => {
1078
+ const { container } = renderBlock({}, PRODUCT);
1079
+
1080
+ heroSwipe(container, 200, 202);
1081
+ expect(shown(container)).toBe('https://cdn.example.com/a.jpg');
1082
+ });
1083
+
1084
+ it('reads a mostly-vertical drag as a scroll, not a swipe', () => {
1085
+ const { container } = renderBlock({}, PRODUCT);
1086
+
1087
+ const hero = container.querySelector('[data-product-gallery-main]')!;
1088
+ const init = { pointerId: 7, pointerType: 'touch' };
1089
+
1090
+ fireEvent.pointerDown(hero, { ...init, clientX: 200, clientY: 100 });
1091
+ fireEvent.pointerUp(hero, { ...init, clientX: 140, clientY: 400 });
1092
+
1093
+ expect(shown(container)).toBe('https://cdn.example.com/a.jpg');
1094
+ });
1095
+
1096
+ it('wraps past the ends, exactly as the arrows do', () => {
1097
+ const { container } = renderBlock({}, PRODUCT);
1098
+
1099
+ heroSwipe(container, 100, 300);
1100
+ expect(shown(container)).toBe('https://cdn.example.com/c.jpg');
1101
+ });
1102
+
1103
+ it('stops at the ends once looping is switched off', () => {
1104
+ const { container } = renderBlock({ loopArrows: false }, PRODUCT);
1105
+
1106
+ heroSwipe(container, 100, 300);
1107
+ expect(shown(container)).toBe('https://cdn.example.com/a.jpg');
1108
+ });
1109
+
1110
+ it('swallows the click a swipe ends on, so the viewer stays shut', () => {
1111
+ const { container } = renderBlock({ enableViewer: true }, PRODUCT);
1112
+
1113
+ heroSwipe(container, 300, 100);
1114
+ fireEvent.click(
1115
+ container.querySelector('[data-product-gallery-viewer-open]')!
1116
+ );
1117
+
1118
+ expect(document.querySelector('[role="dialog"]')).toBeNull();
1119
+ });
1120
+
1121
+ it('draws no hero to swipe in the grid, where every photo is on the page', () => {
1122
+ const { container } = renderBlock({ layout: 'grid' }, PRODUCT);
1123
+
1124
+ expect(container.querySelector('[data-product-gallery-main]')).toBeNull();
1125
+ expect(container.querySelectorAll('[data-product-gallery-cell]')).toHaveLength(
1126
+ PRODUCT.images.length
1127
+ );
1128
+ });
1129
+
1130
+ it('refuses the native photo drag that would cancel the gesture', () => {
1131
+ const { container } = renderBlock({}, PRODUCT);
1132
+ const hero = container.querySelector('[data-product-gallery-main]')!;
1133
+
1134
+ const dragStart = fireEvent.dragStart(hero);
1135
+
1136
+ // `fireEvent` reports false once a handler called preventDefault.
1137
+ expect(dragStart).toBe(false);
1138
+ });
1139
+
1140
+ it('never swipes under the author pointer in the designer', () => {
1141
+ const { container } = renderBlock({}, PRODUCT, { isDesigner: true });
1142
+
1143
+ heroSwipe(container, 300, 100);
1144
+ expect(shown(container)).toBe('https://cdn.example.com/a.jpg');
1145
+ });
1146
+ });
@@ -0,0 +1,228 @@
1
+ import React from 'react';
2
+ import { render, fireEvent } from '@testing-library/react';
3
+ import VariantSelectorBlock from '../variant-selector-block';
4
+ import { ThemePageContextProvider } from '../../theme-page-context';
5
+
6
+ /**
7
+ * A colour strip authored as one non-wrapping row scrolls with a trackpad and
8
+ * with a finger, but a plain mouse cannot move it at all. `dragToScroll` adds
9
+ * the missing gesture — and must not add anything else: the row that every
10
+ * published composition renders wraps, has nothing to scroll, and keeps its
11
+ * clicks.
12
+ */
13
+ jest.mock('next/navigation', () => ({
14
+ useRouter: () => ({ push: jest.fn() }),
15
+ usePathname: () => '/p/blue',
16
+ useSearchParams: () => new URLSearchParams('')
17
+ }));
18
+
19
+ jest.mock('next-auth/react', () => ({
20
+ useSession: () => ({ status: 'unauthenticated', data: null })
21
+ }));
22
+
23
+ jest.mock('@akinon/next/data/client/wishlist', () => ({
24
+ __esModule: true,
25
+ useAddStockAlertMutation: () => [
26
+ jest.fn(() => ({ unwrap: () => Promise.resolve({}) })),
27
+ { isLoading: false }
28
+ ]
29
+ }));
30
+
31
+ const PRODUCT = {
32
+ pk: 10,
33
+ title: 'Polo',
34
+ variants: [
35
+ {
36
+ attribute_key: 'color',
37
+ attribute_name: 'Color',
38
+ options: [
39
+ {
40
+ label: 'Blue',
41
+ value: 'blue',
42
+ is_selected: true,
43
+ is_selectable: true,
44
+ product: { pk: 11, absolute_url: '/p/blue', stock: 9 }
45
+ },
46
+ {
47
+ label: 'Red',
48
+ value: 'red',
49
+ is_selectable: true,
50
+ product: { pk: 12, absolute_url: '/p/red', stock: 4 }
51
+ }
52
+ ]
53
+ }
54
+ ]
55
+ };
56
+
57
+ const renderBlock = (properties: Record<string, unknown>) =>
58
+ render(
59
+ <ThemePageContextProvider value={{ product: PRODUCT } as never}>
60
+ <VariantSelectorBlock
61
+ {...({
62
+ block: {
63
+ id: 'vs',
64
+ type: 'variant-selector',
65
+ styles: {},
66
+ properties,
67
+ blocks: []
68
+ },
69
+ placeholderId: 'p1',
70
+ sectionId: 's1',
71
+ isDesigner: false
72
+ } as never)}
73
+ />
74
+ </ThemePageContextProvider>
75
+ );
76
+
77
+ /** jsdom lays nothing out and implements no pointer capture; both are stubbed. */
78
+ const makeOverflowing = (element: HTMLElement, scrollWidth = 900) => {
79
+ Object.defineProperty(element, 'scrollWidth', {
80
+ configurable: true,
81
+ value: scrollWidth
82
+ });
83
+ Object.defineProperty(element, 'clientWidth', {
84
+ configurable: true,
85
+ value: 300
86
+ });
87
+ element.setPointerCapture = jest.fn();
88
+ element.releasePointerCapture = jest.fn();
89
+ element.hasPointerCapture = jest.fn(() => true);
90
+ // jsdom's own `scrollLeft` is a no-op that always reads 0, so the value the
91
+ // hook writes has to be stored somewhere the assertions can see.
92
+ let scrollLeft = 0;
93
+ Object.defineProperty(element, 'scrollLeft', {
94
+ configurable: true,
95
+ get: () => scrollLeft,
96
+ set: (value: number) => {
97
+ scrollLeft = value;
98
+ }
99
+ });
100
+ };
101
+
102
+ const strip = (container: HTMLElement) =>
103
+ container.querySelector('[data-part="option-list"]') as HTMLElement;
104
+
105
+ /**
106
+ * jsdom ships no PointerEvent, and the fallback testing-library builds drops
107
+ * `clientX` and `pointerType` — the two fields the gesture is made of. The
108
+ * events are assembled by hand so the handler sees what a browser sends.
109
+ */
110
+ const pointer = (
111
+ element: HTMLElement,
112
+ type: 'pointerdown' | 'pointermove' | 'pointerup',
113
+ init: Record<string, unknown>
114
+ ) => {
115
+ const event = new Event(type, { bubbles: true, cancelable: true });
116
+ Object.assign(event, {
117
+ pointerId: 1,
118
+ pointerType: 'mouse',
119
+ button: 0,
120
+ ...init
121
+ });
122
+ element.dispatchEvent(event);
123
+ };
124
+
125
+ const drag = (element: HTMLElement, from: number, to: number) => {
126
+ pointer(element, 'pointerdown', { clientX: from });
127
+ pointer(element, 'pointermove', { clientX: to });
128
+ pointer(element, 'pointerup', { clientX: to });
129
+ };
130
+
131
+ describe('variant selector drag to scroll', () => {
132
+ it('does not touch the strip when the property is off', () => {
133
+ const { container } = renderBlock({});
134
+ const list = strip(container);
135
+ makeOverflowing(list);
136
+
137
+ drag(list, 200, 60);
138
+
139
+ expect(list.scrollLeft).toBe(0);
140
+ expect(list.style.cursor).toBe('');
141
+ });
142
+
143
+ it('scrolls by the pointer distance while dragging', () => {
144
+ const { container } = renderBlock({ dragToScroll: true });
145
+ const list = strip(container);
146
+ makeOverflowing(list);
147
+
148
+ pointer(list, 'pointerdown', { clientX: 200 });
149
+ pointer(list, 'pointermove', { clientX: 60 });
150
+
151
+ // Dragged 140px to the left, so the strip advanced 140px.
152
+ expect(list.scrollLeft).toBe(140);
153
+
154
+ pointer(list, 'pointerup', { clientX: 60 });
155
+ expect(list.releasePointerCapture).toHaveBeenCalled();
156
+ });
157
+
158
+ it('leaves a strip that fits alone', () => {
159
+ const { container } = renderBlock({ dragToScroll: true });
160
+ const list = strip(container);
161
+ makeOverflowing(list, 300);
162
+
163
+ drag(list, 200, 60);
164
+
165
+ expect(list.scrollLeft).toBe(0);
166
+ });
167
+
168
+ it('swallows the click that ends a real drag, but not a plain click', () => {
169
+ const { container } = renderBlock({ dragToScroll: true });
170
+ const list = strip(container);
171
+ makeOverflowing(list);
172
+ const option = list.querySelector(
173
+ '[data-part="option"]'
174
+ ) as HTMLElement;
175
+
176
+ drag(list, 200, 60);
177
+ const afterDrag = fireEvent.click(option);
178
+ expect(afterDrag).toBe(false); // preventDefault() ran
179
+
180
+ // A press that never moved is a click, and it goes through untouched.
181
+ pointer(list, 'pointerdown', { clientX: 100 });
182
+ pointer(list, 'pointerup', { clientX: 102 });
183
+ expect(fireEvent.click(option)).toBe(true);
184
+ });
185
+
186
+ it('captures the pointer only once the press becomes a drag', () => {
187
+ const { container } = renderBlock({ dragToScroll: true });
188
+ const list = strip(container);
189
+ makeOverflowing(list);
190
+
191
+ // A press that stays put must not capture: capture retargets the click to
192
+ // this element and the colour link underneath would never be followed.
193
+ pointer(list, 'pointerdown', { clientX: 200 });
194
+ pointer(list, 'pointermove', { clientX: 198 });
195
+ expect(list.setPointerCapture).not.toHaveBeenCalled();
196
+
197
+ pointer(list, 'pointermove', { clientX: 120 });
198
+ expect(list.setPointerCapture).toHaveBeenCalledTimes(1);
199
+ });
200
+
201
+ it('cancels the native drag the links and images would start', () => {
202
+ const { container } = renderBlock({ dragToScroll: true });
203
+ const list = strip(container);
204
+ makeOverflowing(list);
205
+ const option = list.querySelector('[data-part="option"]') as HTMLElement;
206
+
207
+ // Uncancelled, this is the browser taking the gesture over and cutting the
208
+ // pointer stream the scroll runs on.
209
+ const dragStart = new Event('dragstart', {
210
+ bubbles: true,
211
+ cancelable: true
212
+ });
213
+ option.dispatchEvent(dragStart);
214
+
215
+ expect(dragStart.defaultPrevented).toBe(true);
216
+ });
217
+
218
+ it('ignores touch, which the browser already scrolls', () => {
219
+ const { container } = renderBlock({ dragToScroll: true });
220
+ const list = strip(container);
221
+ makeOverflowing(list);
222
+
223
+ pointer(list, 'pointerdown', { clientX: 200, pointerType: 'touch' });
224
+ pointer(list, 'pointermove', { clientX: 60 });
225
+
226
+ expect(list.scrollLeft).toBe(0);
227
+ });
228
+ });
@@ -815,3 +815,230 @@ describe('two dialogs open at once', () => {
815
815
  expect(secondPanel.getAttribute('aria-modal')).toBe('true');
816
816
  });
817
817
  });
818
+
819
+ /**
820
+ * Chip Size is the chip's WIDTH. A square cropped a garment photo to its
821
+ * centre, so the photo now keeps its own proportions — while a flat colour,
822
+ * which has no proportions, stays square.
823
+ */
824
+ describe('chip height', () => {
825
+ const PHOTO_PRODUCT = {
826
+ pk: 10,
827
+ variants: [
828
+ {
829
+ attribute_key: 'color',
830
+ attribute_name: 'Color',
831
+ options: [
832
+ {
833
+ label: 'Blue',
834
+ value: 'blue',
835
+ is_selected: true,
836
+ is_selectable: true,
837
+ product: {
838
+ pk: 11,
839
+ absolute_url: '/p/blue',
840
+ stock: 9,
841
+ image: '/media/blue.jpg'
842
+ },
843
+ // Carries both a photo and a colour, so one fixture can answer for
844
+ // either chip content.
845
+ attributes: { color: '#0000ff' }
846
+ }
847
+ ]
848
+ }
849
+ ]
850
+ };
851
+
852
+ const renderChip = (properties: Record<string, unknown>) =>
853
+ render(
854
+ <ThemePageContextProvider value={{ product: PHOTO_PRODUCT } as never}>
855
+ <VariantSelectorBlock
856
+ {...({
857
+ block: {
858
+ id: 'vs',
859
+ type: 'variant-selector',
860
+ styles: {},
861
+ properties: {
862
+ groupLayouts: 'color:chip',
863
+ swatchContent: 'image',
864
+ swatchSize: 70,
865
+ ...properties
866
+ },
867
+ order: 0,
868
+ hidden: false
869
+ },
870
+ placeholderId: 'p',
871
+ sectionId: 's',
872
+ isDesigner: false,
873
+ currentBreakpoint: 'desktop'
874
+ } as never)}
875
+ />
876
+ </ThemePageContextProvider>
877
+ );
878
+
879
+ it('sizes a photo chip by width and lets its height follow the image', () => {
880
+ const photo = renderChip({}).container.querySelector(
881
+ 'img[data-part="swatch"]'
882
+ ) as HTMLImageElement;
883
+
884
+ expect(photo.style.width).toBe('70px');
885
+ expect(photo.style.height).toBe('auto');
886
+ // A crop would defeat the ratio the auto height just preserved.
887
+ expect(photo.style.objectFit).toBe('');
888
+ });
889
+
890
+ it('leaves a colour chip square, which has no ratio to preserve', () => {
891
+ const chip = renderChip({ swatchContent: 'color' }).container.querySelector(
892
+ 'span[data-part="swatch"]'
893
+ ) as HTMLElement;
894
+
895
+ expect(chip).toBeTruthy();
896
+ expect(chip.style.width).toBe('70px');
897
+ expect(chip.style.height).toBe('70px');
898
+ });
899
+ });
900
+
901
+ /* ------------------------------------------------------------------ */
902
+ /* Showing the first N options, with the rest behind a control. */
903
+ /* ------------------------------------------------------------------ */
904
+
905
+ describe('collapsing a long option row', () => {
906
+ /** Twelve colours, the eleventh selected — well past the limit tested. */
907
+ const MANY = {
908
+ pk: 10,
909
+ variants: [
910
+ {
911
+ attribute_key: 'color',
912
+ attribute_name: 'Colors',
913
+ options: Array.from({ length: 12 }, (_, index) => ({
914
+ label: `C${index}`,
915
+ value: `c${index}`,
916
+ is_selected: index === 10,
917
+ is_selectable: true,
918
+ product: { pk: 100 + index, absolute_url: `/p/c${index}` }
919
+ }))
920
+ },
921
+ {
922
+ attribute_key: 'size',
923
+ attribute_name: 'Size',
924
+ options: Array.from({ length: 9 }, (_, index) => ({
925
+ label: `S${index}`,
926
+ value: `s${index}`,
927
+ is_selectable: true,
928
+ product: { pk: 200 + index, absolute_url: `/p/s${index}` }
929
+ }))
930
+ }
931
+ ]
932
+ };
933
+
934
+ const renderMany = (properties: Record<string, unknown>) =>
935
+ render(
936
+ <ThemePageContextProvider value={{ product: MANY } as never}>
937
+ <VariantSelectorBlock
938
+ {...({
939
+ block: {
940
+ id: 'vs',
941
+ type: 'variant-selector',
942
+ styles: {},
943
+ properties,
944
+ order: 0,
945
+ hidden: false
946
+ },
947
+ placeholderId: 'p',
948
+ sectionId: 's',
949
+ isDesigner: false,
950
+ currentBreakpoint: 'desktop'
951
+ } as never)}
952
+ />
953
+ </ThemePageContextProvider>
954
+ );
955
+
956
+ const labelsOf = (container: HTMLElement, groupKey: string) =>
957
+ Array.from(
958
+ container.querySelectorAll(
959
+ `[data-variant-group="${groupKey}"] [data-part="option"]`
960
+ )
961
+ ).map((node) => node.textContent);
962
+
963
+ const viewAll = (container: HTMLElement) =>
964
+ container.querySelector('[data-part="view-all"]') as HTMLButtonElement;
965
+
966
+ it('shows nothing but the first N and offers the rest', () => {
967
+ const { container } = renderMany({ optionLimits: 'color:8' });
968
+
969
+ expect(labelsOf(container, 'color')).toHaveLength(8);
970
+ expect(viewAll(container).textContent).toBe('View all 12 Colors');
971
+ expect(viewAll(container).getAttribute('aria-expanded')).toBe('false');
972
+ });
973
+
974
+ it('keeps the shopper’s own colour on screen while collapsed', () => {
975
+ const { container } = renderMany({ optionLimits: 'color:8' });
976
+ const shown = labelsOf(container, 'color');
977
+
978
+ // A plain slice would have stopped at C7 and hidden the selection.
979
+ expect(shown[7]).toBe('C10');
980
+ expect(shown.slice(0, 7)).toEqual(['C0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6']);
981
+ expect(shown).not.toContain('C7');
982
+ });
983
+
984
+ it('opens the rest out and back on the one control', () => {
985
+ const { container } = renderMany({
986
+ optionLimits: 'color:8',
987
+ viewLessLabel: 'Fewer'
988
+ });
989
+
990
+ fireEvent.click(viewAll(container));
991
+
992
+ expect(labelsOf(container, 'color')).toHaveLength(12);
993
+ expect(viewAll(container).textContent).toBe('Fewer');
994
+ expect(viewAll(container).getAttribute('aria-expanded')).toBe('true');
995
+
996
+ fireEvent.click(viewAll(container));
997
+ expect(labelsOf(container, 'color')).toHaveLength(8);
998
+ });
999
+
1000
+ it('cuts only the attribute it was asked to cut', () => {
1001
+ const { container } = renderMany({ optionLimits: 'color:8' });
1002
+
1003
+ // Nine sizes, no limit named for them: every one of them stays.
1004
+ expect(labelsOf(container, 'size')).toHaveLength(9);
1005
+ expect(
1006
+ container.querySelectorAll('[data-part="view-all"]')
1007
+ ).toHaveLength(1);
1008
+ });
1009
+
1010
+ it('leaves a row shorter than its limit exactly as it was', () => {
1011
+ const { container } = renderMany({ optionLimits: 'color:20' });
1012
+
1013
+ expect(labelsOf(container, 'color')).toHaveLength(12);
1014
+ expect(container.querySelector('[data-part="view-all"]')).toBeNull();
1015
+ });
1016
+
1017
+ it('says the hidden count when the brand asks for it', () => {
1018
+ const { container } = renderMany({
1019
+ optionLimits: 'color:8',
1020
+ viewAllLabel: '+{remaining} more'
1021
+ });
1022
+
1023
+ expect(viewAll(container).textContent).toBe('+4 more');
1024
+ });
1025
+
1026
+ it('never cuts a dropdown, whose list is the only keyboard route', () => {
1027
+ const { container } = renderMany({
1028
+ layout: 'dropdown',
1029
+ optionLimits: 'color:8'
1030
+ });
1031
+
1032
+ expect(
1033
+ container.querySelectorAll('[data-variant-group="color"] option')
1034
+ ).toHaveLength(12);
1035
+ expect(container.querySelector('[data-part="view-all"]')).toBeNull();
1036
+ });
1037
+
1038
+ it('ignores a limit that is not a usable number', () => {
1039
+ const { container } = renderMany({ optionLimits: 'color:0, size:abc' });
1040
+
1041
+ expect(labelsOf(container, 'color')).toHaveLength(12);
1042
+ expect(labelsOf(container, 'size')).toHaveLength(9);
1043
+ });
1044
+ });
@@ -142,10 +142,11 @@ const CloseIcon = () => (
142
142
  * Parts: main, image, zoom-image, zoom-hint, viewer-trigger, viewer-backdrop,
143
143
  * viewer, viewer-close, viewer-stage, viewer-image, viewer-arrow,
144
144
  * viewer-counter, viewer-thumb-list, viewer-thumb, viewer-thumb-image, arrow,
145
- * dot-list, dot, thumb-list, thumb, thumb-image, grid.
145
+ * dot-list, dot-rail, dot, thumb-list, thumb, thumb-image, grid.
146
146
  * Properties: layout, gridColumnsDesktop, gridColumnsMobile, showThumbs,
147
147
  * aspectRatio, objectFit, thumbSize, thumbPosition, thumbLimit, thumbLabel,
148
148
  * gap, showArrows, loopArrows, prevLabel, nextLabel, showDots, dotDirection,
149
+ * dotPlacement,
149
150
  * dotLabel, showCounter, counterLabel, enableZoom, zoomLevel, zoomHint,
150
151
  * highResTemplate, enableViewer, viewerOpenLabel, viewerTitle,
151
152
  * viewerCloseLabel, viewerShowArrows, viewerPrevLabel, viewerNextLabel,
@@ -270,6 +271,25 @@ const ProductGalleryBlock = ({
270
271
  const counterLabel = prop('counterLabel', '{index} / {total}');
271
272
  const dotDirectionValue = prop('dotDirection', 'row');
272
273
  const dotDirection = dotDirectionValue === 'column' ? 'column' : 'row';
274
+ /**
275
+ * Where the column indicator lives.
276
+ *
277
+ * `inline` is what the block has always drawn — the row sits in the flow
278
+ * under the photographs and a designer places it with the Position controls
279
+ * on the Dot Row. That spelling cannot express the pattern this exists for:
280
+ * a measure that stays beside the photograph the shopper is looking at.
281
+ * Absolute positioning pins it to the middle of the WHOLE stack, which on a
282
+ * seven-photo gallery is off screen for most of the scroll.
283
+ *
284
+ * `rail` is the pattern: a full-height gutter down the edge of the stack
285
+ * holding a sticky track, so the measure travels with the viewport and stops
286
+ * at the ends of the gallery. Not the default — every published composition
287
+ * that positioned its own dot row must keep the geometry it authored. A row
288
+ * indicator is left alone either way: a horizontal measure has nothing to
289
+ * travel along.
290
+ */
291
+ const dotPlacement = prop('dotPlacement', 'inline');
292
+ const useRail = dotPlacement === 'rail' && dotDirection === 'column';
273
293
  const dotLabel = prop('dotLabel', 'Go to image {index}');
274
294
 
275
295
  const enableZoom = readToggle(
@@ -761,6 +781,88 @@ const ProductGalleryBlock = ({
761
781
  const isZooming = (index: number) =>
762
782
  zoomPoint !== null && zoomPoint.index === index;
763
783
 
784
+ /**
785
+ * The same swipe the viewer has, on the frame the shopper actually starts
786
+ * on. Without it a phone can only change photo through the arrows or the
787
+ * dots — the drag every other carousel on the site answers to does nothing.
788
+ *
789
+ * The hero draws one photo at a time rather than a track, so there is no
790
+ * scroll to snap and the gesture has to be read by hand. It is deliberately
791
+ * the plainer half of `handleStagePointer*`: no pinch, no pan, because
792
+ * neither is reachable here.
793
+ *
794
+ * Touch pointers capture to the element that received `pointerdown`, so the
795
+ * release is heard even when the finger leaves the frame. A mouse drag that
796
+ * ends outside simply does not swipe, which is the same thing the viewer
797
+ * does.
798
+ *
799
+ * Spread alongside `zoomHandlers` on the frame: the two sets are disjoint
800
+ * (`move`/`leave` against `down`/`up`/`cancel`/`click`), so neither drops
801
+ * the other's listener.
802
+ */
803
+ const heroGestureRef = useRef<{ x: number; y: number; id: number } | null>(
804
+ null
805
+ );
806
+ const heroDraggedRef = useRef(false);
807
+
808
+ const heroSwipe =
809
+ images.length > 1 && !isDesigner && layout !== 'grid'
810
+ ? {
811
+ onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => {
812
+ heroGestureRef.current = {
813
+ x: event.clientX,
814
+ y: event.clientY,
815
+ id: event.pointerId
816
+ };
817
+ heroDraggedRef.current = false;
818
+ },
819
+ onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => {
820
+ const gesture = heroGestureRef.current;
821
+ heroGestureRef.current = null;
822
+
823
+ if (!gesture || gesture.id !== event.pointerId) return;
824
+
825
+ const dx = event.clientX - gesture.x;
826
+ const dy = event.clientY - gesture.y;
827
+
828
+ if (Math.abs(dx) < DRAG_SLOP_PX && Math.abs(dy) < DRAG_SLOP_PX) {
829
+ return;
830
+ }
831
+
832
+ // Past the slop this was a drag, so the click the browser sends
833
+ // next belongs to the gesture — not to the viewer trigger lying
834
+ // over the frame, which would otherwise open on every swipe.
835
+ heroDraggedRef.current = true;
836
+
837
+ if (
838
+ Math.abs(dx) >= SWIPE_THRESHOLD_PX &&
839
+ Math.abs(dx) > Math.abs(dy)
840
+ ) {
841
+ setActiveIndex(step(dx < 0 ? 1 : -1));
842
+ }
843
+ },
844
+ onPointerCancel: () => {
845
+ heroGestureRef.current = null;
846
+ },
847
+ /**
848
+ * A photo is natively draggable, so a mouse press that starts moving
849
+ * over one hands the gesture to the browser's own drag-and-drop and
850
+ * the pointer stream is cancelled — the swipe never reaches its
851
+ * `pointerup`. Refusing the drag is what leaves the gesture ours.
852
+ */
853
+ onDragStart: (event: React.DragEvent<HTMLDivElement>) => {
854
+ event.preventDefault();
855
+ },
856
+ onClickCapture: (event: React.MouseEvent<HTMLDivElement>) => {
857
+ if (!heroDraggedRef.current) return;
858
+
859
+ heroDraggedRef.current = false;
860
+ event.preventDefault();
861
+ event.stopPropagation();
862
+ }
863
+ }
864
+ : null;
865
+
764
866
  // A frame only needs a positioning context once something is layered over
765
867
  // it; adding one unconditionally would change how a brand's own CSS lands.
766
868
  const frameNeedsPosition = showArrows || enableViewer || enableZoom;
@@ -814,7 +916,18 @@ const ProductGalleryBlock = ({
814
916
  gap: '6px',
815
917
  // Only written for the column indicator, so the existing dot row keeps
816
918
  // the exact style attribute it has always rendered.
817
- ...(dotDirection === 'column' ? { flexDirection: 'column' } : null)
919
+ ...(dotDirection === 'column' ? { flexDirection: 'column' } : null),
920
+ // Inside the rail this is the part that travels: sticky against the
921
+ // scroll, centred on the viewport rather than on the stack, and taking
922
+ // back the pointer events the rail gave up.
923
+ ...(useRail
924
+ ? {
925
+ position: 'sticky' as const,
926
+ top: '50vh',
927
+ transform: 'translateY(-50%)',
928
+ pointerEvents: 'auto' as const
929
+ }
930
+ : null)
818
931
  }}
819
932
  >
820
933
  {images.map((image, index) => (
@@ -1262,7 +1375,41 @@ const ProductGalleryBlock = ({
1262
1375
  // carry, and it scrolls to a cell the same way. An authored cell template
1263
1376
  // exposes neither, so the dots there would render and do nothing at all —
1264
1377
  // better no indicator than a dead one.
1265
- const gridDots = customCells ? null : dotsNode;
1378
+ /**
1379
+ * Where the column indicator lives.
1380
+ *
1381
+ * `inline` is what the block has always drawn — the row sits in the flow
1382
+ * under the photographs and a designer places it with the Position controls
1383
+ * on the Dot Row. That spelling cannot express the pattern this exists for:
1384
+ * a measure that stays beside the photograph the shopper is looking at.
1385
+ * Absolute positioning pins it to the middle of the WHOLE stack, which on a
1386
+ * seven-photo gallery is off screen for most of the scroll.
1387
+ *
1388
+ * `rail` is the pattern: a full-height gutter down the edge of the stack
1389
+ * holding a sticky track, so the measure travels with the viewport and stops
1390
+ * at the ends of the gallery. Not the default — every published composition
1391
+ * that positioned its own dot row must keep the geometry it authored.
1392
+ */
1393
+ const gridDots = customCells ? null : useRail ? (
1394
+ <div
1395
+ {...partAttrs('dot-rail')}
1396
+ style={{
1397
+ position: 'absolute',
1398
+ left: 0,
1399
+ top: 0,
1400
+ height: '100%',
1401
+ display: 'flex',
1402
+ // The rail spans the whole stack, so it would otherwise swallow
1403
+ // clicks meant for the photographs behind it. The dots themselves
1404
+ // take their own events back below.
1405
+ pointerEvents: 'none'
1406
+ }}
1407
+ >
1408
+ {dotsNode}
1409
+ </div>
1410
+ ) : (
1411
+ dotsNode
1412
+ );
1266
1413
  // Same reason as the dots: without the built-in cells there is nothing to
1267
1414
  // observe, so the number would sit on 1 for the whole scroll.
1268
1415
  const gridCounter = customCells ? null : counterNode;
@@ -1375,6 +1522,7 @@ const ProductGalleryBlock = ({
1375
1522
  empty: viewerFrame.isFallback
1376
1523
  })}
1377
1524
  {...zoomHandlers(safeIndex)}
1525
+ {...heroSwipe}
1378
1526
  style={{
1379
1527
  // Only a gallery with arrows, zoom or the viewer needs a positioning
1380
1528
  // context; adding it unconditionally would change how a brand's own
@@ -1385,6 +1533,10 @@ const ProductGalleryBlock = ({
1385
1533
  minWidth: isSideThumbs ? 0 : undefined,
1386
1534
  aspectRatio,
1387
1535
  overflow: 'hidden',
1536
+ // A horizontal drag belongs to the swipe, a vertical one to the page
1537
+ // it is scrolling. Left to the browser the finger claims both and
1538
+ // the gesture is cancelled the moment the page moves under it.
1539
+ touchAction: heroSwipe ? 'pan-y' : undefined,
1388
1540
  background: 'var(--product-gallery-bg, #f3f4f6)'
1389
1541
  }}
1390
1542
  >
@@ -7,6 +7,7 @@ import { useSession } from 'next-auth/react';
7
7
  import { useAddStockAlertMutation } from '@akinon/next/data/client/wishlist';
8
8
  import { getResponsiveValue, partAttrs } from '../utils';
9
9
  import { useOverlay } from '../hooks/use-overlay';
10
+ import { useDragScroll } from '../hooks/use-drag-scroll';
10
11
  import { BlockRendererProps } from './block-renderer-registry';
11
12
  import {
12
13
  DesignerPlaceholder,
@@ -75,7 +76,7 @@ const fillTemplate = (
75
76
  template: string,
76
77
  tokens: Record<string, string>
77
78
  ): string =>
78
- template.replace(/\{(option|attribute|product)\}/g, (match, key: string) =>
79
+ template.replace(/\{(option|attribute|product|count|remaining)\}/g, (match, key: string) =>
79
80
  tokens[key] === undefined ? match : tokens[key]
80
81
  );
81
82
 
@@ -99,6 +100,29 @@ type OpenAlert = {
99
100
  * called `swatch` here precisely because that word already means the bordered
100
101
  * box one control higher up the same panel.
101
102
  */
103
+ /**
104
+ * The row of options, as its own component so each group gets its own drag
105
+ * handle: the strip is rendered inside a `groups.map`, and one ref shared by
106
+ * every group would attach the gesture to whichever row rendered last.
107
+ */
108
+ const OptionStrip = ({
109
+ dragToScroll,
110
+ style,
111
+ children
112
+ }: {
113
+ dragToScroll: boolean;
114
+ style: React.CSSProperties;
115
+ children: React.ReactNode;
116
+ }) => {
117
+ const ref = useDragScroll<HTMLDivElement>(dragToScroll);
118
+
119
+ return (
120
+ <div ref={ref} {...partAttrs('option-list')} style={style}>
121
+ {children}
122
+ </div>
123
+ );
124
+ };
125
+
102
126
  const GROUP_LAYOUTS = ['box', 'chip', 'dropdown', 'text'];
103
127
  const SWATCH_CONTENTS = ['auto', 'color', 'image'];
104
128
  const DISABLED_STYLES = ['line-through', 'diagonal', 'none'];
@@ -129,6 +153,54 @@ const parseGroupLayouts = (raw: unknown): Record<string, string> => {
129
153
  return map;
130
154
  };
131
155
 
156
+ /**
157
+ * How many options one attribute shows before it collapses, keyed the same way
158
+ * `groupLayouts` is — `color:8`. Per attribute rather than block-wide on
159
+ * purpose: a colour strip of 36 photos needs cutting, and the size row beside
160
+ * it, nine boxes long, must not be cut with it. A limit of 0 or less, or one
161
+ * that is not a number, is dropped rather than guessed at.
162
+ */
163
+ const parseOptionLimits = (raw: unknown): Record<string, number> => {
164
+ const map: Record<string, number> = {};
165
+
166
+ String(raw || '')
167
+ .split(',')
168
+ .forEach((entry) => {
169
+ const separator = entry.indexOf(':');
170
+ if (separator === -1) return;
171
+
172
+ const key = entry.slice(0, separator).trim().toLowerCase();
173
+ const limit = Math.floor(Number(entry.slice(separator + 1).trim()));
174
+
175
+ if (key && Number.isFinite(limit) && limit > 0) map[key] = limit;
176
+ });
177
+
178
+ return map;
179
+ };
180
+
181
+ /**
182
+ * The visible slice while a group is collapsed.
183
+ *
184
+ * The shopper's own selection is never cut away: if it sits past the limit it
185
+ * takes the last visible slot, because a colour strip that hides the colour
186
+ * the shopper is looking at reads as a bug, and the label above the row would
187
+ * then name a chip nobody can see. Everything else keeps commerce's order.
188
+ */
189
+ const collapseOptions = <T,>(
190
+ options: T[],
191
+ limit: number,
192
+ isSelected: (option: T) => boolean
193
+ ): T[] => {
194
+ if (limit <= 0 || options.length <= limit) return options;
195
+
196
+ const head = options.slice(0, limit);
197
+ if (head.some(isSelected)) return head;
198
+
199
+ const selected = options.find(isSelected);
200
+
201
+ return selected ? [...options.slice(0, limit - 1), selected] : head;
202
+ };
203
+
132
204
  /**
133
205
  * Functional PDP block: renders the commerce variant matrix
134
206
  * (product.variants). Each option is its own product URL, so selecting a
@@ -140,12 +212,14 @@ const parseGroupLayouts = (raw: unknown): Record<string, string> => {
140
212
  * form bound to THAT option's own product id.
141
213
  *
142
214
  * Parts: group, label, selected-value, dropdown, option-list, option, swatch,
143
- * strike, low-stock, alert-backdrop, alert-panel, alert-title,
215
+ * view-all, strike, low-stock, alert-backdrop, alert-panel, alert-title,
144
216
  * alert-description, alert-close, alert-form, alert-label, alert-input,
145
217
  * alert-consent, alert-submit, alert-success, alert-error, alert-signin.
146
- * Properties: layout, groupLayouts, showGroupLabel, showSelectedValue,
218
+ * Properties: layout, groupLayouts, optionLimits, viewAllLabel, viewLessLabel,
219
+ * showGroupLabel, showSelectedValue,
147
220
  * selectedValueSeparator, swatchContent, swatchSize, disabledStyle,
148
- * strikeWidth, lowStockThreshold, lowStockLabel, gap, optionGap, stockAlert,
221
+ * strikeWidth, lowStockThreshold, lowStockLabel, gap, optionGap, dragToScroll,
222
+ * stockAlert,
149
223
  * stockAlertPresentation, stockAlertTriggerLabel, stockAlertTitle,
150
224
  * stockAlertDescription, stockAlertEmailLabel, stockAlertEmailPlaceholder,
151
225
  * stockAlertConsentText, stockAlertConsentRequired, stockAlertSubmitLabel,
@@ -165,6 +239,13 @@ const VariantSelectorBlock = ({
165
239
  const [addStockAlert, { isLoading: isAlertSubmitting }] =
166
240
  useAddStockAlertMutation();
167
241
 
242
+ // Which attributes the shopper has opened out, keyed by group. Collapsed is
243
+ // the server-rendered state for every one of them, so the markup a crawler
244
+ // and the first paint see is the same short row the design asks for.
245
+ const [expandedGroups, setExpandedGroups] = React.useState<
246
+ Record<string, boolean>
247
+ >({});
248
+
168
249
  const [openAlert, setOpenAlert] = React.useState<OpenAlert | null>(null);
169
250
  const [alertEmail, setAlertEmail] = React.useState('');
170
251
  const [alertConsent, setAlertConsent] = React.useState(false);
@@ -194,10 +275,24 @@ const VariantSelectorBlock = ({
194
275
  const optionGap = Number(
195
276
  getResponsiveValue(block.properties?.optionGap, currentBreakpoint, 8)
196
277
  );
278
+ /**
279
+ * Off by default: a wrapping row of options has nothing to drag, and every
280
+ * published composition renders one. It earns its keep only where the author
281
+ * has turned the row into a horizontal strip (`flex-wrap: nowrap` with
282
+ * `overflow-x: auto`), which is a styling decision this block does not make.
283
+ */
284
+ const dragToScroll = readToggle(
285
+ block.properties?.dragToScroll,
286
+ currentBreakpoint,
287
+ false
288
+ );
197
289
 
198
290
  const groupLayouts = parseGroupLayouts(
199
291
  getResponsiveValue(block.properties?.groupLayouts, currentBreakpoint, '')
200
292
  );
293
+ const optionLimits = parseOptionLimits(
294
+ getResponsiveValue(block.properties?.optionLimits, currentBreakpoint, '')
295
+ );
201
296
  const swatchContentValue = String(
202
297
  getResponsiveValue(block.properties?.swatchContent, currentBreakpoint, 'auto')
203
298
  );
@@ -256,6 +351,24 @@ const VariantSelectorBlock = ({
256
351
  )
257
352
  );
258
353
 
354
+ // Every word the shopper reads is the brand's. {count} is the whole option
355
+ // count and {remaining} the hidden tail, so "View all 11 colors" and "+3
356
+ // more" are both authorable without touching the renderer.
357
+ const viewAllLabel = String(
358
+ getResponsiveValue(
359
+ block.properties?.viewAllLabel,
360
+ currentBreakpoint,
361
+ 'View all {count} {attribute}'
362
+ )
363
+ );
364
+ const viewLessLabel = String(
365
+ getResponsiveValue(
366
+ block.properties?.viewLessLabel,
367
+ currentBreakpoint,
368
+ 'Show less'
369
+ )
370
+ );
371
+
259
372
  /**
260
373
  * The whole back-in-stock capability behind one switch, off by default: a
261
374
  * composition nobody edited must keep rendering the inert struck-through
@@ -716,6 +829,31 @@ const VariantSelectorBlock = ({
716
829
  const override = attributeKey ? groupLayouts[attributeKey] : undefined;
717
830
  const groupLayout =
718
831
  override || (layout === 'dropdown' ? 'dropdown' : 'box');
832
+
833
+ // A dropdown is already one line tall, so nothing is gained by cutting
834
+ // its <option> list — and cutting it would hide options from the one
835
+ // control that lets a shopper reach them by keyboard alone.
836
+ const optionLimit =
837
+ groupLayout === 'dropdown' ? 0 : optionLimits[attributeKey] || 0;
838
+ const isExpanded = expandedGroups[groupKey] === true;
839
+ const isCollapsible = optionLimit > 0 && options.length > optionLimit;
840
+ const visibleOptions =
841
+ isCollapsible && !isExpanded
842
+ ? collapseOptions(
843
+ options,
844
+ optionLimit,
845
+ (option) => option?.is_selected === true
846
+ )
847
+ : options;
848
+ const hiddenCount = options.length - visibleOptions.length;
849
+ const viewToggleLabel = fillTemplate(
850
+ isExpanded ? viewLessLabel : viewAllLabel,
851
+ {
852
+ count: String(options.length),
853
+ remaining: String(hiddenCount),
854
+ attribute: String(group.attribute_name || '')
855
+ }
856
+ );
719
857
  const selectedLabel = String(
720
858
  selectedOption?.label || selectedOption?.value || ''
721
859
  );
@@ -778,9 +916,17 @@ const VariantSelectorBlock = ({
778
916
  {...partAttrs('swatch')}
779
917
  style={{
780
918
  display: 'block',
919
+ // Chip Size is the WIDTH here, not a box. A square
920
+ // cropped the photo to its centre, which on a garment
921
+ // strip drew every colour as the same headless torso —
922
+ // so the height follows the image and no object-fit is
923
+ // set, because a crop would defeat the ratio.
924
+ //
925
+ // The COLOUR swatch above stays square: a flat colour
926
+ // has no proportions to preserve, and an auto height
927
+ // there collapses the chip to nothing.
781
928
  width: `${swatchSize}px`,
782
- height: `${swatchSize}px`,
783
- objectFit: 'cover'
929
+ height: 'auto'
784
930
  }}
785
931
  />
786
932
  ) : null;
@@ -1059,16 +1205,48 @@ const VariantSelectorBlock = ({
1059
1205
  ))}
1060
1206
  </select>
1061
1207
  ) : (
1062
- <div
1063
- {...partAttrs('option-list')}
1208
+ <OptionStrip
1209
+ dragToScroll={dragToScroll}
1064
1210
  style={{
1065
1211
  display: 'flex',
1066
1212
  flexWrap: 'wrap',
1067
1213
  gap: `${safeOptionGap}px`
1068
1214
  }}
1069
1215
  >
1070
- {options.map(renderOption)}
1071
- </div>
1216
+ {visibleOptions.map(renderOption)}
1217
+ </OptionStrip>
1218
+ )}
1219
+
1220
+ {/* A real button, not a link: opening the rest of a row navigates
1221
+ nowhere, and every option it reveals is itself a link that
1222
+ does. `aria-controls` is left off — the row it opens has no id
1223
+ of its own, and a broken pointer reads worse than none. */}
1224
+ {isCollapsible && viewToggleLabel && (
1225
+ <button
1226
+ type="button"
1227
+ data-variant-view-all=""
1228
+ {...partAttrs('view-all')}
1229
+ aria-expanded={isExpanded}
1230
+ onClick={() =>
1231
+ setExpandedGroups((current) => ({
1232
+ ...current,
1233
+ [groupKey]: !isExpanded
1234
+ }))
1235
+ }
1236
+ style={{
1237
+ display: 'inline-block',
1238
+ marginTop: '8px',
1239
+ padding: 0,
1240
+ font: 'inherit',
1241
+ color: 'inherit',
1242
+ background: 'transparent',
1243
+ border: 0,
1244
+ cursor: 'pointer',
1245
+ textDecoration: 'underline'
1246
+ }}
1247
+ >
1248
+ {viewToggleLabel}
1249
+ </button>
1072
1250
  )}
1073
1251
 
1074
1252
  {/* Inline: drawn in the group that owns the sold-out option, so
@@ -0,0 +1,153 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef } from 'react';
4
+
5
+ /**
6
+ * Grab-and-drag for a horizontally scrolling strip.
7
+ *
8
+ * A strip authored with `overflow-x: auto` already scrolls: a trackpad, a
9
+ * shift-wheel and a finger all move it. A MOUSE does not — no browser drags a
10
+ * scroll container by its content, so on a desktop with a plain mouse the
11
+ * strip reads as a static crop of a longer row and the shopper never learns
12
+ * the rest is there. That gap is what this closes, and it is the only reason
13
+ * it exists: it adds no scrolling that CSS does not already do, it adds the
14
+ * one gesture CSS cannot express.
15
+ *
16
+ * Pointer events, not mouse events, so pointer capture does the hard part: the
17
+ * drag survives the cursor leaving the element and releases correctly when the
18
+ * button comes up over some other part of the page. Touch is deliberately left
19
+ * alone — the browser's own inertial scrolling is better than anything this
20
+ * can do, and hijacking it costs the fling.
21
+ *
22
+ * A drag that ends on top of a link would otherwise navigate. The click that
23
+ * follows a real drag (more than `DRAG_THRESHOLD` px) is swallowed once, in
24
+ * the capture phase, before it reaches the option underneath. A press that
25
+ * never moved is left completely alone, so picking a colour by clicking it
26
+ * still works exactly as it did.
27
+ */
28
+
29
+ /** Below this the gesture is a click, above it a drag. */
30
+ const DRAG_THRESHOLD = 4;
31
+
32
+ export const useDragScroll = <T extends HTMLElement>(enabled: boolean) => {
33
+ const ref = useRef<T | null>(null);
34
+ const state = useRef({
35
+ active: false,
36
+ captured: false,
37
+ startX: 0,
38
+ startScroll: 0,
39
+ moved: 0
40
+ });
41
+
42
+ const swallowNextClick = useCallback((element: T) => {
43
+ const onClick = (event: MouseEvent) => {
44
+ event.preventDefault();
45
+ event.stopPropagation();
46
+ };
47
+
48
+ element.addEventListener('click', onClick, {
49
+ capture: true,
50
+ once: true
51
+ });
52
+
53
+ // The click only arrives when the pointer came up over a clickable child.
54
+ // Without this the listener would sit there and eat the NEXT honest click
55
+ // instead — the one the shopper meant.
56
+ window.setTimeout(
57
+ () => element.removeEventListener('click', onClick, { capture: true }),
58
+ 0
59
+ );
60
+ }, []);
61
+
62
+ useEffect(() => {
63
+ const element = ref.current;
64
+ if (!element || !enabled) return;
65
+
66
+ const onPointerDown = (event: PointerEvent) => {
67
+ // Touch scrolls itself; a secondary button belongs to the context menu.
68
+ // Written as `> 0` rather than `!== 0` so a synthetic event that carries
69
+ // no button at all still counts as the primary one.
70
+ if (event.pointerType === 'touch' || event.button > 0) return;
71
+ // Nothing to drag: a strip that fits is not a strip.
72
+ if (element.scrollWidth <= element.clientWidth) return;
73
+
74
+ state.current = {
75
+ active: true,
76
+ captured: false,
77
+ startX: event.clientX,
78
+ startScroll: element.scrollLeft,
79
+ moved: 0
80
+ };
81
+ };
82
+
83
+ const onPointerMove = (event: PointerEvent) => {
84
+ if (!state.current.active) return;
85
+
86
+ const dx = event.clientX - state.current.startX;
87
+ state.current.moved = Math.max(state.current.moved, Math.abs(dx));
88
+
89
+ // Nothing happens until the press has travelled far enough to be a drag,
90
+ // and the pointer is captured only then. Capturing on the press instead
91
+ // retargets the compatibility mouse events to THIS element, so the click
92
+ // never reaches the link underneath and picking a colour stops working —
93
+ // the strip scrolled beautifully and navigated nowhere.
94
+ if (state.current.moved <= DRAG_THRESHOLD) return;
95
+
96
+ if (!state.current.captured) {
97
+ state.current.captured = true;
98
+ element.setPointerCapture(event.pointerId);
99
+ element.style.cursor = 'grabbing';
100
+ element.style.userSelect = 'none';
101
+ }
102
+
103
+ element.scrollLeft = state.current.startScroll - dx;
104
+ };
105
+
106
+ const end = (event: PointerEvent) => {
107
+ if (!state.current.active) return;
108
+
109
+ const dragged = state.current.moved > DRAG_THRESHOLD;
110
+ const captured = state.current.captured;
111
+ state.current.active = false;
112
+ state.current.captured = false;
113
+
114
+ if (captured && element.hasPointerCapture(event.pointerId)) {
115
+ element.releasePointerCapture(event.pointerId);
116
+ }
117
+ element.style.cursor = 'grab';
118
+ element.style.removeProperty('user-select');
119
+
120
+ if (dragged) swallowNextClick(element);
121
+ };
122
+
123
+ /**
124
+ * The options are links around images, and both are natively draggable:
125
+ * the browser answers a press-and-move by starting its own drag-and-drop,
126
+ * which STOPS the pointer stream this hook lives on. Cancelling `dragstart`
127
+ * is what hands the gesture back — without it the strip never moves, the
128
+ * ghost image follows the cursor instead, and the only visible sign is the
129
+ * copy cursor. Cancelled here rather than by `preventDefault()` on the
130
+ * press, which would also swallow focus.
131
+ */
132
+ const onDragStart = (event: Event) => event.preventDefault();
133
+
134
+ element.style.cursor = 'grab';
135
+ element.addEventListener('dragstart', onDragStart);
136
+ element.addEventListener('pointerdown', onPointerDown);
137
+ element.addEventListener('pointermove', onPointerMove);
138
+ element.addEventListener('pointerup', end);
139
+ element.addEventListener('pointercancel', end);
140
+
141
+ return () => {
142
+ element.style.removeProperty('cursor');
143
+ element.style.removeProperty('user-select');
144
+ element.removeEventListener('dragstart', onDragStart);
145
+ element.removeEventListener('pointerdown', onPointerDown);
146
+ element.removeEventListener('pointermove', onPointerMove);
147
+ element.removeEventListener('pointerup', end);
148
+ element.removeEventListener('pointercancel', end);
149
+ };
150
+ }, [enabled, swallowNextClick]);
151
+
152
+ return ref;
153
+ };