@akinon/pz-theme 2.0.116-rc.0 → 2.0.116
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 -35
- package/package.json +4 -4
- package/src/blocks/__tests__/slider-drag-guard.test.tsx +227 -0
- package/src/blocks/slider-block.tsx +13 -1
- package/src/hooks/use-carousel-drag.ts +166 -0
- package/src/sections/__tests__/posts-slider-drag-guard.test.tsx +245 -0
- package/src/sections/posts-slider-section.tsx +19 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,40 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.116
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
- Updated dependencies [51ea06888]
|
|
11
|
-
- Updated dependencies
|
|
12
|
-
- Updated dependencies [2fea41430]
|
|
13
|
-
- Updated dependencies [b55acb768]
|
|
14
|
-
- Updated dependencies [760258c1c]
|
|
15
|
-
- Updated dependencies [143be2b9d]
|
|
16
|
-
- Updated dependencies [7889b08fe]
|
|
17
|
-
- Updated dependencies [9f8cd3bc5]
|
|
18
|
-
- Updated dependencies [bfafa3f49]
|
|
19
|
-
- Updated dependencies [57d7eb305]
|
|
20
|
-
- Updated dependencies [d99a6a7d5]
|
|
21
|
-
- Updated dependencies [9db81a714]
|
|
22
|
-
- Updated dependencies [591e345e1]
|
|
23
|
-
- Updated dependencies [4de5303c5]
|
|
24
|
-
- Updated dependencies [95b139dc1]
|
|
25
|
-
- Updated dependencies [1d00f2d06]
|
|
26
|
-
- Updated dependencies [4ac7b2a1e]
|
|
27
|
-
- Updated dependencies [e9598c71d]
|
|
28
|
-
- Updated dependencies [4998a9631]
|
|
29
|
-
- Updated dependencies [804d2bd6c]
|
|
30
|
-
- Updated dependencies [3909d3224]
|
|
31
|
-
- Updated dependencies [6a3d8a631]
|
|
32
|
-
- Updated dependencies [e18836b20]
|
|
33
|
-
- Updated dependencies [b1111d7b1]
|
|
34
|
-
- Updated dependencies [1595d45]
|
|
35
|
-
- Updated dependencies [dca1281]
|
|
36
|
-
- @akinon/next@2.0.116-rc.0
|
|
37
|
-
- @akinon/theme-contract@2.0.116-rc.0
|
|
3
|
+
## 2.0.116
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 9062fa0: Give sliders back the drag gesture the browser was stealing: cancel the native image/link drag inside a carousel, stop the text selection it painted, lower the commit threshold a drag has to reach, and swallow the click that ends a real drag so it no longer opens the card it finished on
|
|
8
|
+
- @akinon/next@2.0.116
|
|
9
|
+
- @akinon/theme-contract@2.0.116
|
|
38
10
|
|
|
39
11
|
## 2.0.115
|
|
40
12
|
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-theme",
|
|
3
3
|
"description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
|
|
4
|
-
"version": "2.0.116
|
|
4
|
+
"version": "2.0.116",
|
|
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.116
|
|
11
|
+
"@akinon/next": "2.0.116",
|
|
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.116
|
|
16
|
+
"@akinon/theme-contract": "2.0.116",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"react-multi-carousel": "2.8.4",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"zod": "4.4.1"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@akinon/next": "2.0.116
|
|
24
|
+
"@akinon/next": "2.0.116",
|
|
25
25
|
"@testing-library/jest-dom": "5.16.4",
|
|
26
26
|
"@testing-library/react": "16.3.2",
|
|
27
27
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The drag repair, from the shopper's side.
|
|
6
|
+
*
|
|
7
|
+
* `react-multi-carousel` listens for the drag on its own track and never
|
|
8
|
+
* cancels the browser's native one, so a press-and-move over a slide — a link
|
|
9
|
+
* around a photo — became a drag-and-drop instead of a swipe and the row
|
|
10
|
+
* lurched part of the way and stuck. These tests pin the three things that
|
|
11
|
+
* stop that: the native drag is refused, the slider does not paint a text
|
|
12
|
+
* selection, and the library is handed a commit threshold a real drag can
|
|
13
|
+
* actually reach.
|
|
14
|
+
*
|
|
15
|
+
* The carousel itself is stubbed with a passthrough, exactly as
|
|
16
|
+
* `slider-block.test.tsx` does — the assertions are about the box AROUND it
|
|
17
|
+
* and about the props the call site hands it, neither of which needs the
|
|
18
|
+
* library's measuring to have run.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const carouselProps: Record<string, unknown>[] = [];
|
|
22
|
+
|
|
23
|
+
jest.mock('react-multi-carousel', () => ({
|
|
24
|
+
__esModule: true,
|
|
25
|
+
default: (props: { children: React.ReactNode } & Record<string, unknown>) => {
|
|
26
|
+
carouselProps.push(props);
|
|
27
|
+
return (
|
|
28
|
+
<div data-testid="carousel">
|
|
29
|
+
{/* A slide as the shopper meets it: a link around a photo, both of
|
|
30
|
+
which the browser will happily drag away from the carousel. */}
|
|
31
|
+
<a href="/product" data-testid="slide-link">
|
|
32
|
+
<img src="/p.jpg" alt="" data-testid="slide-image" />
|
|
33
|
+
</a>
|
|
34
|
+
{props.children}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
jest.mock('../../theme-block', () => ({
|
|
41
|
+
__esModule: true,
|
|
42
|
+
default: ({ block }: { block: { id: string } }) => (
|
|
43
|
+
<div data-testid="tb" data-id={block.id} />
|
|
44
|
+
)
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
import SliderBlock from '../slider-block';
|
|
48
|
+
import { CAROUSEL_MINIMUM_TOUCH_DRAG } from '../../hooks/use-carousel-drag';
|
|
49
|
+
|
|
50
|
+
const child = (id: string): any => ({
|
|
51
|
+
id,
|
|
52
|
+
type: 'text',
|
|
53
|
+
label: id,
|
|
54
|
+
value: '',
|
|
55
|
+
properties: {},
|
|
56
|
+
styles: {},
|
|
57
|
+
order: 0,
|
|
58
|
+
hidden: false
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const props = (over: Record<string, unknown> = {}) =>
|
|
62
|
+
({
|
|
63
|
+
block: {
|
|
64
|
+
id: 'slider',
|
|
65
|
+
type: 'slider',
|
|
66
|
+
label: 'slider',
|
|
67
|
+
value: '',
|
|
68
|
+
properties: {},
|
|
69
|
+
styles: {},
|
|
70
|
+
order: 0,
|
|
71
|
+
hidden: false,
|
|
72
|
+
blocks: [child('a'), child('b'), child('c')]
|
|
73
|
+
},
|
|
74
|
+
placeholderId: 'p',
|
|
75
|
+
sectionId: 's',
|
|
76
|
+
isDesigner: false,
|
|
77
|
+
...over
|
|
78
|
+
} as any);
|
|
79
|
+
|
|
80
|
+
/** jsdom has no PointerEvent constructor; the hook only reads mouse fields. */
|
|
81
|
+
const pointer = (type: string, init: MouseEventInit = {}) =>
|
|
82
|
+
new MouseEvent(type, { bubbles: true, cancelable: true, ...init });
|
|
83
|
+
|
|
84
|
+
const guardBox = (container: HTMLElement) =>
|
|
85
|
+
container.querySelector('[data-testid="carousel"]')!
|
|
86
|
+
.parentElement as HTMLElement;
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
carouselProps.length = 0;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('SliderBlock — the native drag no longer steals the swipe', () => {
|
|
93
|
+
it('cancels dragstart on a photo inside the slider', () => {
|
|
94
|
+
const { getByTestId } = render(<SliderBlock {...props()} />);
|
|
95
|
+
|
|
96
|
+
const event = new Event('dragstart', { bubbles: true, cancelable: true });
|
|
97
|
+
getByTestId('slide-image').dispatchEvent(event);
|
|
98
|
+
|
|
99
|
+
expect(event.defaultPrevented).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('cancels dragstart on the link wrapping the photo', () => {
|
|
103
|
+
const { getByTestId } = render(<SliderBlock {...props()} />);
|
|
104
|
+
|
|
105
|
+
const event = new Event('dragstart', { bubbles: true, cancelable: true });
|
|
106
|
+
getByTestId('slide-link').dispatchEvent(event);
|
|
107
|
+
|
|
108
|
+
expect(event.defaultPrevented).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('leaves dragstart alone in the editor, where designer drag-and-drop owns it', () => {
|
|
112
|
+
const { getByTestId } = render(
|
|
113
|
+
<SliderBlock {...props({ isDesigner: true })} />
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const event = new Event('dragstart', { bubbles: true, cancelable: true });
|
|
117
|
+
getByTestId('slide-image').dispatchEvent(event);
|
|
118
|
+
|
|
119
|
+
expect(event.defaultPrevented).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('turns off text selection across the slider', () => {
|
|
123
|
+
const { container } = render(<SliderBlock {...props()} />);
|
|
124
|
+
|
|
125
|
+
expect(guardBox(container).style.userSelect).toBe('none');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('hands the library a commit threshold a real drag reaches', () => {
|
|
129
|
+
render(<SliderBlock {...props()} />);
|
|
130
|
+
|
|
131
|
+
expect(carouselProps[0].minimumTouchDrag).toBe(CAROUSEL_MINIMUM_TOUCH_DRAG);
|
|
132
|
+
expect(carouselProps[0].minimumTouchDrag as number).toBeLessThan(80);
|
|
133
|
+
expect(carouselProps[0].swipeable).toBe(true);
|
|
134
|
+
expect(carouselProps[0].draggable).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('SliderBlock — a drag does not also open the slide', () => {
|
|
139
|
+
it('swallows the click that ends a real drag', () => {
|
|
140
|
+
const { container, getByTestId } = render(<SliderBlock {...props()} />);
|
|
141
|
+
const box = guardBox(container);
|
|
142
|
+
const link = getByTestId('slide-link');
|
|
143
|
+
|
|
144
|
+
const onClick = jest.fn();
|
|
145
|
+
box.addEventListener('click', onClick);
|
|
146
|
+
|
|
147
|
+
box.dispatchEvent(pointer('pointerdown', { clientX: 100 }));
|
|
148
|
+
box.dispatchEvent(pointer('pointermove', { clientX: 40 }));
|
|
149
|
+
box.dispatchEvent(pointer('pointerup', { clientX: 40 }));
|
|
150
|
+
|
|
151
|
+
const click = pointer('click', { clientX: 40 });
|
|
152
|
+
link.dispatchEvent(click);
|
|
153
|
+
|
|
154
|
+
expect(click.defaultPrevented).toBe(true);
|
|
155
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('leaves an honest click completely alone', () => {
|
|
159
|
+
const { container, getByTestId } = render(<SliderBlock {...props()} />);
|
|
160
|
+
const box = guardBox(container);
|
|
161
|
+
const link = getByTestId('slide-link');
|
|
162
|
+
|
|
163
|
+
const onClick = jest.fn();
|
|
164
|
+
box.addEventListener('click', onClick);
|
|
165
|
+
|
|
166
|
+
box.dispatchEvent(pointer('pointerdown', { clientX: 100 }));
|
|
167
|
+
box.dispatchEvent(pointer('pointerup', { clientX: 100 }));
|
|
168
|
+
|
|
169
|
+
const click = pointer('click', { clientX: 100 });
|
|
170
|
+
link.dispatchEvent(click);
|
|
171
|
+
|
|
172
|
+
expect(click.defaultPrevented).toBe(false);
|
|
173
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('treats a press that barely moved as a click, not a drag', () => {
|
|
177
|
+
const { container, getByTestId } = render(<SliderBlock {...props()} />);
|
|
178
|
+
const box = guardBox(container);
|
|
179
|
+
|
|
180
|
+
box.dispatchEvent(pointer('pointerdown', { clientX: 100 }));
|
|
181
|
+
box.dispatchEvent(pointer('pointermove', { clientX: 97 }));
|
|
182
|
+
box.dispatchEvent(pointer('pointerup', { clientX: 97 }));
|
|
183
|
+
|
|
184
|
+
const click = pointer('click', { clientX: 97 });
|
|
185
|
+
getByTestId('slide-link').dispatchEvent(click);
|
|
186
|
+
|
|
187
|
+
expect(click.defaultPrevented).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('swallows only the click that followed the drag, not the next honest one', () => {
|
|
191
|
+
jest.useFakeTimers();
|
|
192
|
+
try {
|
|
193
|
+
const { container, getByTestId } = render(<SliderBlock {...props()} />);
|
|
194
|
+
const box = guardBox(container);
|
|
195
|
+
const link = getByTestId('slide-link');
|
|
196
|
+
|
|
197
|
+
box.dispatchEvent(pointer('pointerdown', { clientX: 100 }));
|
|
198
|
+
box.dispatchEvent(pointer('pointermove', { clientX: 40 }));
|
|
199
|
+
box.dispatchEvent(pointer('pointerup', { clientX: 40 }));
|
|
200
|
+
|
|
201
|
+
// The drag ended somewhere that is not clickable, so no click ever
|
|
202
|
+
// arrives — the guard must not sit there waiting for the next one.
|
|
203
|
+
jest.advanceTimersByTime(1);
|
|
204
|
+
|
|
205
|
+
const click = pointer('click', { clientX: 40 });
|
|
206
|
+
link.dispatchEvent(click);
|
|
207
|
+
|
|
208
|
+
expect(click.defaultPrevented).toBe(false);
|
|
209
|
+
} finally {
|
|
210
|
+
jest.useRealTimers();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('ignores a secondary-button press', () => {
|
|
215
|
+
const { container, getByTestId } = render(<SliderBlock {...props()} />);
|
|
216
|
+
const box = guardBox(container);
|
|
217
|
+
|
|
218
|
+
box.dispatchEvent(pointer('pointerdown', { clientX: 100, button: 2 }));
|
|
219
|
+
box.dispatchEvent(pointer('pointermove', { clientX: 40 }));
|
|
220
|
+
box.dispatchEvent(pointer('pointerup', { clientX: 40 }));
|
|
221
|
+
|
|
222
|
+
const click = pointer('click', { clientX: 40 });
|
|
223
|
+
getByTestId('slide-link').dispatchEvent(click);
|
|
224
|
+
|
|
225
|
+
expect(click.defaultPrevented).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -15,6 +15,11 @@ import {
|
|
|
15
15
|
} from '../utils';
|
|
16
16
|
import { DESKTOP_MIN_WIDTH, isNarrowBreakpoint } from '../utils/breakpoints';
|
|
17
17
|
import { useCarouselSsr } from '../utils/carousel-ssr';
|
|
18
|
+
import {
|
|
19
|
+
CAROUSEL_MINIMUM_TOUCH_DRAG,
|
|
20
|
+
carouselDragStyle,
|
|
21
|
+
useCarouselDrag
|
|
22
|
+
} from '../hooks/use-carousel-drag';
|
|
18
23
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
19
24
|
|
|
20
25
|
import { postToEditor } from '../editor-bridge';
|
|
@@ -45,6 +50,12 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
45
50
|
const themeSettings = useThemeSettingsContext();
|
|
46
51
|
const carouselRef = React.useRef<Carousel>(null);
|
|
47
52
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
53
|
+
/**
|
|
54
|
+
* The slides are links around photos, which the browser would rather
|
|
55
|
+
* drag than let the carousel swipe. Left alone in the editor, where the
|
|
56
|
+
* designer's own drag-and-drop owns `dragstart`.
|
|
57
|
+
*/
|
|
58
|
+
const dragRef = useCarouselDrag<HTMLDivElement>(!isDesigner);
|
|
48
59
|
/**
|
|
49
60
|
* Whether the pointer (or the keyboard) is inside the slider, for the
|
|
50
61
|
* arrows-on-hover option below.
|
|
@@ -853,7 +864,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
853
864
|
onFocus={arrowsOnHover ? () => setPointerInside(true) : undefined}
|
|
854
865
|
onBlur={arrowsOnHover ? () => setPointerInside(false) : undefined}
|
|
855
866
|
>
|
|
856
|
-
<div style={{ position: 'relative' }}>
|
|
867
|
+
<div ref={dragRef} style={{ position: 'relative', ...carouselDragStyle }}>
|
|
857
868
|
<Carousel
|
|
858
869
|
ref={carouselRef}
|
|
859
870
|
key={carouselKey}
|
|
@@ -878,6 +889,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
878
889
|
slidesToSlide={currentSlidesToSlide}
|
|
879
890
|
swipeable={true}
|
|
880
891
|
draggable={true}
|
|
892
|
+
minimumTouchDrag={CAROUSEL_MINIMUM_TOUCH_DRAG}
|
|
881
893
|
/*
|
|
882
894
|
* `visibleCount > itemsShown` is what keeps the server and the
|
|
883
895
|
* mounted library agreeing. The library decides for itself whether
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Handing a `react-multi-carousel` slider back the drag gesture the browser
|
|
7
|
+
* takes away from it.
|
|
8
|
+
*
|
|
9
|
+
* ## The bug this exists to end
|
|
10
|
+
*
|
|
11
|
+
* The library listens for the drag on the track itself — `onMouseDown`,
|
|
12
|
+
* `onMouseMove`, `onMouseUp`, `onMouseLeave` and the touch trio are React
|
|
13
|
+
* props on `<ul class="react-multi-carousel-track">` — and it never calls
|
|
14
|
+
* `preventDefault` on any of them. Its shipped `lib/styles.css` carries no
|
|
15
|
+
* `user-select` rule either.
|
|
16
|
+
*
|
|
17
|
+
* A slide is a link around a photo, and both are natively draggable. So a
|
|
18
|
+
* press-and-move over a slide does not reach the carousel as a drag at all:
|
|
19
|
+
* Chrome answers it with its OWN drag-and-drop, and the moment `dragstart`
|
|
20
|
+
* fires the mouse-move stream stops. `handleMove` had already written a few
|
|
21
|
+
* pixels of `translate3d` straight to the track's style, and now nothing moves
|
|
22
|
+
* it again — the track sits where the gesture died. The `mouseup` lands on the
|
|
23
|
+
* drag layer rather than on the track, so `handleOut` never runs and the slide
|
|
24
|
+
* is never committed or reverted. What the shopper sees is a row that lurches
|
|
25
|
+
* part of the way, a text selection or a ghost image following the cursor, and
|
|
26
|
+
* a slider that has to be fought to move at all.
|
|
27
|
+
*
|
|
28
|
+
* The same failure is documented from the other side in `use-drag-scroll.ts`,
|
|
29
|
+
* which is where a strip authored with `overflow-x: auto` solves it. This hook
|
|
30
|
+
* is that fix for the sliders the library drives.
|
|
31
|
+
*
|
|
32
|
+
* ## What it does, and what it deliberately does not
|
|
33
|
+
*
|
|
34
|
+
* - Cancels `dragstart` inside the slider. That is the whole repair: with the
|
|
35
|
+
* native drag refused, the browser keeps sending the mouse moves the library
|
|
36
|
+
* is already listening for, and the carousel works as it was written to.
|
|
37
|
+
* Cancelled on the container rather than by `preventDefault()` on the press,
|
|
38
|
+
* which would also swallow focus.
|
|
39
|
+
* - Turns off text selection across the slider, so a horizontal drag over a
|
|
40
|
+
* product title paints no selection behind the cards.
|
|
41
|
+
* - Swallows the click that follows a REAL drag, once. Without it the repair
|
|
42
|
+
* creates a new bug: now that the gesture reaches the carousel, a drag that
|
|
43
|
+
* ends on top of a card would also open that card — `posts-slider-section`
|
|
44
|
+
* opens its quick view from a capture-phase click, and a plain slide is an
|
|
45
|
+
* anchor. A press that never moved is left completely alone, so clicking a
|
|
46
|
+
* product still opens it.
|
|
47
|
+
*
|
|
48
|
+
* It does NOT touch the library's own transform maths, its throttle, or touch
|
|
49
|
+
* scrolling. Native events on the container, not React props, so the click
|
|
50
|
+
* guard runs before React's own capture-phase handlers (React 18 delegates
|
|
51
|
+
* from the root container, which is above this element).
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/** Below this the gesture is a click, above it a drag. */
|
|
55
|
+
const DRAG_THRESHOLD = 4;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* How far a drag must travel before the library commits to the next slide.
|
|
59
|
+
*
|
|
60
|
+
* The library's own default is 80px — measured against a press, not against a
|
|
61
|
+
* flick, and far enough that a deliberate mouse drag across a card routinely
|
|
62
|
+
* falls short and springs back. `handleOut` reverts everything below this, so
|
|
63
|
+
* the number IS the "nothing sticks" feeling. 40px is still past any accidental
|
|
64
|
+
* nudge (the drag guard above already ignores anything under 4px) while being
|
|
65
|
+
* reachable in one comfortable movement.
|
|
66
|
+
*/
|
|
67
|
+
export const CAROUSEL_MINIMUM_TOUCH_DRAG = 40;
|
|
68
|
+
|
|
69
|
+
export const useCarouselDrag = <T extends HTMLElement>(enabled = true) => {
|
|
70
|
+
const ref = useRef<T | null>(null);
|
|
71
|
+
const moved = useRef(0);
|
|
72
|
+
const active = useRef(false);
|
|
73
|
+
const startX = useRef(0);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* ON `document`, NOT ON THE SLIDER, AND THAT IS THE WHOLE TRICK.
|
|
77
|
+
*
|
|
78
|
+
* React does not attach `onClickCapture` to the element that declares it; it
|
|
79
|
+
* attaches one listener to the root container and replays the capture
|
|
80
|
+
* traversal from there. The root is ABOVE this slider, so a capture listener
|
|
81
|
+
* on the slider itself runs too LATE — `posts-slider-section` had already
|
|
82
|
+
* opened its quick view by the time the guard saw the click. Cancelling the
|
|
83
|
+
* anchor's navigation still worked, which is exactly the kind of half-fix
|
|
84
|
+
* that reads as fixed. `document` is above React's root, so the guard gets
|
|
85
|
+
* there first and `stopPropagation` keeps the event from ever reaching it.
|
|
86
|
+
*
|
|
87
|
+
* Scoped by `contains`, so a click anywhere else on the page while the guard
|
|
88
|
+
* is armed passes through untouched and leaves the guard armed for the click
|
|
89
|
+
* it was set for.
|
|
90
|
+
*/
|
|
91
|
+
const swallowNextClick = useCallback((element: T) => {
|
|
92
|
+
const onClick = (event: MouseEvent) => {
|
|
93
|
+
const target = event.target as Node | null;
|
|
94
|
+
if (!target || !element.contains(target)) return;
|
|
95
|
+
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
event.stopPropagation();
|
|
98
|
+
document.removeEventListener('click', onClick, true);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
document.addEventListener('click', onClick, true);
|
|
102
|
+
|
|
103
|
+
// The click only arrives when the pointer came up over something
|
|
104
|
+
// clickable. Without this the listener would sit there and eat the NEXT
|
|
105
|
+
// honest click instead — the one the shopper meant.
|
|
106
|
+
window.setTimeout(
|
|
107
|
+
() => document.removeEventListener('click', onClick, true),
|
|
108
|
+
0
|
|
109
|
+
);
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
const element = ref.current;
|
|
114
|
+
if (!element || !enabled) return;
|
|
115
|
+
|
|
116
|
+
const onDragStart = (event: Event) => event.preventDefault();
|
|
117
|
+
|
|
118
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
119
|
+
// A secondary button belongs to the context menu. Written as `> 0` so a
|
|
120
|
+
// synthetic event carrying no button at all still counts as the primary.
|
|
121
|
+
if (event.button > 0) return;
|
|
122
|
+
active.current = true;
|
|
123
|
+
moved.current = 0;
|
|
124
|
+
startX.current = event.clientX;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const onPointerMove = (event: PointerEvent) => {
|
|
128
|
+
if (!active.current) return;
|
|
129
|
+
moved.current = Math.max(
|
|
130
|
+
moved.current,
|
|
131
|
+
Math.abs(event.clientX - startX.current)
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const end = () => {
|
|
136
|
+
if (!active.current) return;
|
|
137
|
+
active.current = false;
|
|
138
|
+
if (moved.current > DRAG_THRESHOLD) swallowNextClick(element);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
element.addEventListener('dragstart', onDragStart);
|
|
142
|
+
element.addEventListener('pointerdown', onPointerDown);
|
|
143
|
+
element.addEventListener('pointermove', onPointerMove);
|
|
144
|
+
element.addEventListener('pointerup', end);
|
|
145
|
+
element.addEventListener('pointercancel', end);
|
|
146
|
+
|
|
147
|
+
return () => {
|
|
148
|
+
element.removeEventListener('dragstart', onDragStart);
|
|
149
|
+
element.removeEventListener('pointerdown', onPointerDown);
|
|
150
|
+
element.removeEventListener('pointermove', onPointerMove);
|
|
151
|
+
element.removeEventListener('pointerup', end);
|
|
152
|
+
element.removeEventListener('pointercancel', end);
|
|
153
|
+
};
|
|
154
|
+
}, [enabled, swallowNextClick]);
|
|
155
|
+
|
|
156
|
+
return ref;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The style half of the fix, kept next to the hook so a call site cannot take
|
|
161
|
+
* one without the other. Both spellings: Safari only dropped the prefix in 17.
|
|
162
|
+
*/
|
|
163
|
+
export const carouselDragStyle = {
|
|
164
|
+
userSelect: 'none',
|
|
165
|
+
WebkitUserSelect: 'none'
|
|
166
|
+
} as const;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { act, render, screen } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The drag repair on the slider a shopper meets most: a row of product cards.
|
|
6
|
+
*
|
|
7
|
+
* `react-multi-carousel` hangs the gesture off its own track and never cancels
|
|
8
|
+
* the browser's native drag, so a press-and-move over a card — an anchor
|
|
9
|
+
* around a photo — became a drag-and-drop, the mouse-move stream died, and the
|
|
10
|
+
* row stuck part of the way across with nothing to commit it. These tests pin
|
|
11
|
+
* the repair and the thing the repair could easily break: a drag that happens
|
|
12
|
+
* to end on a card must not ALSO open that card, because this section turns a
|
|
13
|
+
* card click into quick view from the capture phase.
|
|
14
|
+
*
|
|
15
|
+
* The card stub and the carousel passthrough mirror
|
|
16
|
+
* `posts-slider-quick-view.test.tsx` — the real library renders an empty track
|
|
17
|
+
* at a jsdom width of zero, so no slide would reach the DOM through it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const carouselProps: Record<string, unknown>[] = [];
|
|
21
|
+
|
|
22
|
+
jest.mock('../../theme-block', () => ({
|
|
23
|
+
__esModule: true,
|
|
24
|
+
default: ({ block }: { block: { id: string } }) => (
|
|
25
|
+
<a data-testid="card" data-id={block.id} href={`/product/${block.id}/`}>
|
|
26
|
+
card
|
|
27
|
+
<img src={`/img/${block.id}.jpg`} alt="" data-testid="card-image" />
|
|
28
|
+
</a>
|
|
29
|
+
)
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
jest.mock('react-multi-carousel', () => ({
|
|
33
|
+
__esModule: true,
|
|
34
|
+
default: (props: { children?: React.ReactNode } & Record<string, unknown>) => {
|
|
35
|
+
carouselProps.push(props);
|
|
36
|
+
return <div data-testid="carousel">{props.children}</div>;
|
|
37
|
+
}
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
import PostsSliderSection from '../posts-slider-section';
|
|
41
|
+
import { CAROUSEL_MINIMUM_TOUCH_DRAG } from '../../hooks/use-carousel-drag';
|
|
42
|
+
|
|
43
|
+
const product = (pk: number, name: string, price: number) => ({
|
|
44
|
+
pk,
|
|
45
|
+
name,
|
|
46
|
+
absolute_url: `/product/${pk}/`,
|
|
47
|
+
productimage_set: [{ image: `/img/${pk}.jpg` }],
|
|
48
|
+
active_price: { price, currency_type: 'TRY' }
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const ITERATOR = {
|
|
52
|
+
id: 'iter',
|
|
53
|
+
label: 'Iterator',
|
|
54
|
+
isIterator: true,
|
|
55
|
+
order: 0,
|
|
56
|
+
properties: {},
|
|
57
|
+
styles: {},
|
|
58
|
+
blocks: [
|
|
59
|
+
{
|
|
60
|
+
id: 'card',
|
|
61
|
+
type: 'group',
|
|
62
|
+
label: 'Deal Card 1',
|
|
63
|
+
properties: { tag: 'a' },
|
|
64
|
+
styles: {},
|
|
65
|
+
blocks: []
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const renderSection = (
|
|
71
|
+
properties: Record<string, unknown> = {},
|
|
72
|
+
isDesigner = false
|
|
73
|
+
) =>
|
|
74
|
+
render(
|
|
75
|
+
<div data-section-id="sec">
|
|
76
|
+
<div data-section-content="" className="relative z-10">
|
|
77
|
+
<PostsSliderSection
|
|
78
|
+
{...({
|
|
79
|
+
section: {
|
|
80
|
+
id: 'sec',
|
|
81
|
+
type: 'flash-deals-slider',
|
|
82
|
+
label: 'sec',
|
|
83
|
+
properties: { 'items-per-slide': 1, ...properties },
|
|
84
|
+
styles: {},
|
|
85
|
+
blocks: [ITERATOR],
|
|
86
|
+
dataSource: {
|
|
87
|
+
details: {
|
|
88
|
+
collection: {
|
|
89
|
+
products: [
|
|
90
|
+
product(11, 'Blue Stool', 2790),
|
|
91
|
+
product(22, 'Red Chair', 1490)
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
currentBreakpoint: 'desktop',
|
|
98
|
+
placeholderId: 'ph',
|
|
99
|
+
isDesigner
|
|
100
|
+
} as any)}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
/** The box the guard is attached to — the one holding only the slides. */
|
|
107
|
+
const slidesBox = () =>
|
|
108
|
+
document.querySelector('[data-part="slides"]') as HTMLElement;
|
|
109
|
+
|
|
110
|
+
/** jsdom has no PointerEvent constructor; the hook only reads mouse fields. */
|
|
111
|
+
const pointer = (type: string, init: MouseEventInit = {}) =>
|
|
112
|
+
new MouseEvent(type, { bubbles: true, cancelable: true, ...init });
|
|
113
|
+
|
|
114
|
+
const dragAcross = (from = 200, to = 120) => {
|
|
115
|
+
const box = slidesBox();
|
|
116
|
+
act(() => {
|
|
117
|
+
box.dispatchEvent(pointer('pointerdown', { clientX: from }));
|
|
118
|
+
box.dispatchEvent(pointer('pointermove', { clientX: to }));
|
|
119
|
+
box.dispatchEvent(pointer('pointerup', { clientX: to }));
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const clickCard = (element: Element) => {
|
|
124
|
+
const event = pointer('click', { button: 0 });
|
|
125
|
+
act(() => {
|
|
126
|
+
element.dispatchEvent(event);
|
|
127
|
+
});
|
|
128
|
+
return event;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const panel = () => document.querySelector('[data-part="quick-view-panel"]');
|
|
132
|
+
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
carouselProps.length = 0;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('posts slider — the native drag no longer steals the swipe', () => {
|
|
138
|
+
it('cancels dragstart on a card photo', () => {
|
|
139
|
+
renderSection();
|
|
140
|
+
|
|
141
|
+
const event = new Event('dragstart', { bubbles: true, cancelable: true });
|
|
142
|
+
screen.getAllByTestId('card-image')[0].dispatchEvent(event);
|
|
143
|
+
|
|
144
|
+
expect(event.defaultPrevented).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('cancels dragstart on the card anchor itself', () => {
|
|
148
|
+
renderSection();
|
|
149
|
+
|
|
150
|
+
const event = new Event('dragstart', { bubbles: true, cancelable: true });
|
|
151
|
+
screen.getAllByTestId('card')[0].dispatchEvent(event);
|
|
152
|
+
|
|
153
|
+
expect(event.defaultPrevented).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('leaves dragstart alone in the editor', () => {
|
|
157
|
+
renderSection({}, true);
|
|
158
|
+
|
|
159
|
+
const event = new Event('dragstart', { bubbles: true, cancelable: true });
|
|
160
|
+
screen.getAllByTestId('card-image')[0].dispatchEvent(event);
|
|
161
|
+
|
|
162
|
+
expect(event.defaultPrevented).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('turns off text selection across the slides', () => {
|
|
166
|
+
renderSection();
|
|
167
|
+
|
|
168
|
+
expect(slidesBox().style.userSelect).toBe('none');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('hands the library a commit threshold a real drag reaches', () => {
|
|
172
|
+
renderSection();
|
|
173
|
+
|
|
174
|
+
expect(carouselProps[0].minimumTouchDrag).toBe(CAROUSEL_MINIMUM_TOUCH_DRAG);
|
|
175
|
+
expect(carouselProps[0].minimumTouchDrag as number).toBeLessThan(80);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('leaves a click outside the slider alone while the guard is armed', () => {
|
|
179
|
+
renderSection();
|
|
180
|
+
const outside = document.createElement('button');
|
|
181
|
+
document.body.appendChild(outside);
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
dragAcross();
|
|
185
|
+
|
|
186
|
+
const stray = pointer('click', { button: 0 });
|
|
187
|
+
act(() => {
|
|
188
|
+
outside.dispatchEvent(stray);
|
|
189
|
+
});
|
|
190
|
+
expect(stray.defaultPrevented).toBe(false);
|
|
191
|
+
|
|
192
|
+
// Still armed for the click the drag was actually going to produce.
|
|
193
|
+
const onCard = clickCard(screen.getAllByTestId('card')[0]);
|
|
194
|
+
expect(onCard.defaultPrevented).toBe(true);
|
|
195
|
+
} finally {
|
|
196
|
+
outside.remove();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('posts slider — a drag does not also open the card', () => {
|
|
202
|
+
it('does not navigate when a drag ends on a card', () => {
|
|
203
|
+
renderSection();
|
|
204
|
+
dragAcross();
|
|
205
|
+
|
|
206
|
+
const event = clickCard(screen.getAllByTestId('card')[0]);
|
|
207
|
+
|
|
208
|
+
expect(event.defaultPrevented).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('does not open quick view when a drag ends on a card', () => {
|
|
212
|
+
renderSection({ 'show-quick-view': true });
|
|
213
|
+
dragAcross();
|
|
214
|
+
|
|
215
|
+
clickCard(screen.getAllByTestId('card')[0]);
|
|
216
|
+
|
|
217
|
+
expect(panel()).toBeNull();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('still opens quick view on an honest click', () => {
|
|
221
|
+
renderSection({ 'show-quick-view': true });
|
|
222
|
+
|
|
223
|
+
const event = clickCard(screen.getAllByTestId('card')[0]);
|
|
224
|
+
|
|
225
|
+
expect(event.defaultPrevented).toBe(true);
|
|
226
|
+
expect(panel()).not.toBeNull();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('still navigates on an honest click when quick view is off', () => {
|
|
230
|
+
renderSection();
|
|
231
|
+
|
|
232
|
+
const event = clickCard(screen.getAllByTestId('card')[0]);
|
|
233
|
+
|
|
234
|
+
expect(event.defaultPrevented).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('treats a press that barely moved as a click', () => {
|
|
238
|
+
renderSection({ 'show-quick-view': true });
|
|
239
|
+
dragAcross(200, 198);
|
|
240
|
+
|
|
241
|
+
clickCard(screen.getAllByTestId('card')[0]);
|
|
242
|
+
|
|
243
|
+
expect(panel()).not.toBeNull();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -22,6 +22,11 @@ import {
|
|
|
22
22
|
} from '../utils';
|
|
23
23
|
import { DESKTOP_MIN_WIDTH, TABLET_MIN_WIDTH } from '../utils/breakpoints';
|
|
24
24
|
import { useCarouselSsr } from '../utils/carousel-ssr';
|
|
25
|
+
import {
|
|
26
|
+
CAROUSEL_MINIMUM_TOUCH_DRAG,
|
|
27
|
+
carouselDragStyle,
|
|
28
|
+
useCarouselDrag
|
|
29
|
+
} from '../hooks/use-carousel-drag';
|
|
25
30
|
import {
|
|
26
31
|
findSliderArrowBlocks,
|
|
27
32
|
isSliderArrowIconBlock,
|
|
@@ -276,6 +281,13 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
276
281
|
* rendered as siblings of the carousel and paged through this instead.
|
|
277
282
|
*/
|
|
278
283
|
const carouselRef = React.useRef<Carousel | null>(null);
|
|
284
|
+
/**
|
|
285
|
+
* The cards are links around photos, which the browser would rather drag
|
|
286
|
+
* than let the carousel swipe — and the guard also keeps a drag that ends
|
|
287
|
+
* on a card from opening the quick view below. Left alone in the editor,
|
|
288
|
+
* where the designer's own drag-and-drop owns `dragstart`.
|
|
289
|
+
*/
|
|
290
|
+
const dragRef = useCarouselDrag<HTMLDivElement>(!isDesigner);
|
|
279
291
|
|
|
280
292
|
const getConfigEntry = (key: string) =>
|
|
281
293
|
section.properties?.[key] ?? section.styles?.[key];
|
|
@@ -872,7 +884,12 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
872
884
|
* `slides` part — `generatePartCSS` emits it against this attribute — so
|
|
873
885
|
* padding it does not move anything but the cards.
|
|
874
886
|
*/}
|
|
875
|
-
<div
|
|
887
|
+
<div
|
|
888
|
+
data-part="slides"
|
|
889
|
+
ref={dragRef}
|
|
890
|
+
style={carouselDragStyle}
|
|
891
|
+
{...carouselSsr.gateProps}
|
|
892
|
+
>
|
|
876
893
|
<Carousel
|
|
877
894
|
key={carouselKey}
|
|
878
895
|
responsive={carouselResponsive}
|
|
@@ -895,6 +912,7 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
895
912
|
{...(isDesigner ? { slidesToSlide: currentSlidesToSlide } : {})}
|
|
896
913
|
swipeable={true}
|
|
897
914
|
draggable={true}
|
|
915
|
+
minimumTouchDrag={CAROUSEL_MINIMUM_TOUCH_DRAG}
|
|
898
916
|
ref={carouselRef}
|
|
899
917
|
// The library's own arrows only when the section neither moved navigation
|
|
900
918
|
// into its header row nor brought arrow blocks of its own; ours are
|