@akinon/pz-theme 2.0.73-beta.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 +7 -4
- package/package.json +4 -4
- package/src/blocks/__tests__/product-gallery-block.test.tsx +172 -0
- package/src/blocks/__tests__/variant-drag-scroll.test.tsx +228 -0
- package/src/blocks/__tests__/variant-selector-block.test.tsx +227 -0
- package/src/blocks/product-gallery-block.tsx +155 -3
- package/src/blocks/variant-selector-block.tsx +188 -10
- package/src/hooks/use-drag-scroll.ts +153 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.73
|
|
3
|
+
## 2.0.73
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
10
13
|
|
|
11
14
|
## 2.0.72
|
|
12
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
218
|
+
* Properties: layout, groupLayouts, optionLimits, viewAllLabel, viewLessLabel,
|
|
219
|
+
* showGroupLabel, showSelectedValue,
|
|
147
220
|
* selectedValueSeparator, swatchContent, swatchSize, disabledStyle,
|
|
148
|
-
* strikeWidth, lowStockThreshold, lowStockLabel, gap, optionGap,
|
|
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:
|
|
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
|
-
<
|
|
1063
|
-
{
|
|
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
|
-
{
|
|
1071
|
-
</
|
|
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
|
+
};
|