@akinon/pz-theme 2.0.116-beta.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 CHANGED
@@ -1,12 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.116-beta.0
3
+ ## 2.0.116
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd757]
8
- - @akinon/next@2.0.116-beta.0
9
- - @akinon/theme-contract@2.0.116-beta.0
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
10
10
 
11
11
  ## 2.0.115
12
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-beta.0",
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-beta.0",
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-beta.0",
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-beta.0",
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 data-part="slides" {...carouselSsr.gateProps}>
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