Skip to content

Commit 48d079b

Browse files
v2: handle opposite direction swiping (#361)
* feat: enhance touch handling in CarouselScrollArea - Implemented touch event prevention for smoother drag interactions. - Updated touch-action CSS property to 'auto' for better default behavior. - Refactored touch event handlers to improve responsiveness and prevent unwanted scrolling. * feat: improve touch swipe handling in CarouselScrollArea - Added logic to prevent snapping when swiping in the wrong direction for both horizontal and vertical carousels. - Introduced new signals to track touch start positions for better swipe detection. - Refactored touch event handling to enhance responsiveness and user experience. * fix: direction swipe * test: add swipe direction tests for carousel component - Implemented tests to verify that vertical swipes on horizontal carousels and horizontal swipes on vertical carousels do not change the current item. - Added checks to ensure item positions remain unchanged after swiping in the non-dominant direction. - Enhanced test coverage for swipe interactions to improve reliability and user experience.
1 parent 057de01 commit 48d079b

File tree

4 files changed

+217
-19
lines changed

4 files changed

+217
-19
lines changed

libs/components/src/carousel/carousel-scroll-area.tsx

Lines changed: 101 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
component$,
55
type PropsOf,
66
Slot,
7+
sync$,
78
useContext,
89
useOnWindow,
910
useSignal,
@@ -15,16 +16,17 @@ import { Render } from "../render/render";
1516
import styles from "./carousel.css?inline";
1617
import { carouselContextId } from "./carousel-root";
1718
import {
18-
getValueFromIndex,
19-
setBoundaries,
20-
setTransform,
21-
setTransition,
2219
getItemPosition,
20+
getValueFromIndex,
2321
getVelocityBasedTargetIndex,
24-
trackDragStart,
22+
isSwipeInCorrectDirection,
23+
type OrientationProps,
2524
resetDragTracking,
25+
setBoundaries,
2626
setInitialItemPos,
27-
type OrientationProps
27+
setTransform,
28+
setTransition,
29+
trackDragStart
2830
} from "./carousel-utils";
2931

3032
export const CarouselScrollArea = component$((props: PropsOf<"div">) => {
@@ -45,6 +47,8 @@ export const CarouselScrollArea = component$((props: PropsOf<"div">) => {
4547
const boundaries = useSignal<{ min: number; max: number } | null>(null);
4648
const isMouseDown = useSignal(false);
4749
const isTouchDevice = useSignal(false);
50+
const touchStartX = useSignal(0);
51+
const touchStartY = useSignal(0);
4852

4953
const givenTransition = useSignal<string>();
5054
const isInitialTransition = useSignal(true);
@@ -109,22 +113,22 @@ export const CarouselScrollArea = component$((props: PropsOf<"div">) => {
109113
const handleDragSnap = $((e?: MouseEvent | TouchEvent) => {
110114
if (!context.scrollAreaRef.value) return;
111115

112-
// Cancel any pending RAF updates
113116
if (rafId.value !== undefined) {
114117
cancelAnimationFrame(rafId.value);
115118
rafId.value = undefined;
116119
}
117120

118-
// Get final pointer position and time
119121
let finalPos: number | undefined = startPos.value;
120122

121-
if (e && "changedTouches" in e && e.changedTouches.length > 0) {
123+
const isTouchEvent = e && "changedTouches" in e && e.changedTouches.length > 0;
124+
const isMouseEvent = e && "clientX" in e;
125+
126+
if (isTouchEvent) {
122127
finalPos = e.changedTouches[0][clientPosition];
123-
} else if (e && "clientX" in e) {
128+
} else if (isMouseEvent) {
124129
finalPos = e[pagePosition];
125130
}
126131

127-
// If it was just a tap (no movement), don't snap - just reset flags
128132
if (!isMouseMoving.value && !isTouchMoving.value) {
129133
isMouseDown.value = false;
130134
isMouseMoving.value = false;
@@ -138,6 +142,30 @@ export const CarouselScrollArea = component$((props: PropsOf<"div">) => {
138142
return;
139143
}
140144

145+
if (isTouchEvent) {
146+
const touch = e.changedTouches[0];
147+
const isCorrectDirection = isSwipeInCorrectDirection({
148+
startX: touchStartX.value,
149+
startY: touchStartY.value,
150+
currentX: touch.clientX,
151+
currentY: touch.clientY,
152+
orientation: context.orientation.value
153+
});
154+
155+
if (!isCorrectDirection) {
156+
isMouseDown.value = false;
157+
isMouseMoving.value = false;
158+
isTouchMoving.value = false;
159+
isTouchStart.value = false;
160+
resetDragTracking({
161+
dragStartPos,
162+
dragStartTime
163+
});
164+
window.removeEventListener("mousemove", handleMouseMove);
165+
return;
166+
}
167+
}
168+
141169
const items = context.itemRefsArray.value;
142170
if (items.length === 0) return;
143171

@@ -342,7 +370,11 @@ export const CarouselScrollArea = component$((props: PropsOf<"div">) => {
342370
if (!context.isDraggable.value || !context.scrollAreaRef.value) return;
343371

344372
const scrollArea = context.scrollAreaRef.value;
345-
const touchPos = e.touches[0][clientPosition];
373+
const touch = e.touches[0];
374+
const touchPos = touch[clientPosition];
375+
376+
touchStartX.value = touch.clientX;
377+
touchStartY.value = touch.clientY;
346378

347379
setTransition({
348380
scrollArea,
@@ -375,9 +407,21 @@ export const CarouselScrollArea = component$((props: PropsOf<"div">) => {
375407
if (!isTouchStart.value || startPos.value === undefined) return;
376408
if (!context.scrollAreaRef.value || !boundaries.value) return;
377409

378-
e.preventDefault();
410+
const touch = e.touches[0];
411+
412+
const isCorrectDirection = isSwipeInCorrectDirection({
413+
startX: touchStartX.value,
414+
startY: touchStartY.value,
415+
currentX: touch.clientX,
416+
currentY: touch.clientY,
417+
orientation: context.orientation.value
418+
});
419+
420+
if (!isCorrectDirection) {
421+
return;
422+
}
379423

380-
const pos = e.touches[0][clientPosition];
424+
const pos = touch[clientPosition];
381425
const dragSpeed = context.sensitivity.value.touch;
382426

383427
const walk = (startPos.value - pos) * dragSpeed;
@@ -449,14 +493,53 @@ export const CarouselScrollArea = component$((props: PropsOf<"div">) => {
449493
void handleDragSnap(e);
450494
});
451495

496+
let preventTouchStartX = 0;
497+
let preventTouchStartY = 0;
498+
let preventIsHorizontal: boolean | null = null;
499+
500+
const preventTouchStart = sync$((e: TouchEvent) => {
501+
const touch = e.touches[0];
502+
if (!touch) return;
503+
504+
const viewport = e.currentTarget as HTMLElement;
505+
const carousel = viewport.querySelector("[ui-qds-carousel-scroll-area]");
506+
507+
if (!carousel) {
508+
preventIsHorizontal = null;
509+
return;
510+
}
511+
512+
preventIsHorizontal = carousel.hasAttribute("ui-horizontal");
513+
preventTouchStartX = touch.clientX;
514+
preventTouchStartY = touch.clientY;
515+
});
516+
517+
const preventTouchMove = sync$((e: TouchEvent) => {
518+
if (preventIsHorizontal === null) return;
519+
520+
const touch = e.touches[0];
521+
if (!touch) return;
522+
523+
const deltaX = Math.abs(touch.clientX - preventTouchStartX);
524+
const deltaY = Math.abs(touch.clientY - preventTouchStartY);
525+
526+
if (preventIsHorizontal) {
527+
if (deltaX > deltaY && deltaX > 5) {
528+
e.preventDefault();
529+
}
530+
} else {
531+
if (deltaY > deltaX && deltaY > 5) {
532+
e.preventDefault();
533+
}
534+
}
535+
});
536+
452537
return (
453538
<div
454-
preventdefault:touchmove
455-
preventdefault:touchstart
456539
ui-qds-carousel-viewport
457540
onMouseDown$={[handleMouseDown, onMouseDown$]}
458-
onTouchStart$={[handleTouchStart, onTouchStart$]}
459-
onTouchMove$={[handleTouchMove, onTouchMove$]}
541+
onTouchStart$={[preventTouchStart, handleTouchStart, onTouchStart$]}
542+
onTouchMove$={[preventTouchMove, handleTouchMove, onTouchMove$]}
460543
onTouchEnd$={[handleTouchEnd, onTouchEnd$]}
461544
onWheel$={handleWheel}
462545
preventdefault:wheel={context.isMouseWheel.value}

libs/components/src/carousel/carousel-utils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,25 @@ export function resetDragTracking(params: {
188188
dragStartTime.value = undefined;
189189
}
190190

191+
export function isSwipeInCorrectDirection(params: {
192+
startX: number;
193+
startY: number;
194+
currentX: number;
195+
currentY: number;
196+
orientation: "horizontal" | "vertical";
197+
}): boolean {
198+
const { startX, startY, currentX, currentY, orientation } = params;
199+
200+
const deltaX = Math.abs(currentX - startX);
201+
const deltaY = Math.abs(currentY - startY);
202+
203+
if (orientation === "horizontal") {
204+
return deltaX >= deltaY;
205+
} else {
206+
return deltaY >= deltaX;
207+
}
208+
}
209+
191210
export function getVelocityBasedTargetIndex(params: {
192211
finalPos: number;
193212
currentIndex: number;

libs/components/src/carousel/carousel.browser.tsx

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,3 +1068,99 @@ test("quick long swipe advances to next item", async () => {
10681068
await expect.element(Items.nth(1)).toHaveAttribute("ui-current");
10691069
await expect.element(Items.nth(0)).not.toHaveAttribute("ui-current");
10701070
});
1071+
1072+
test("vertical swipe on horizontal carousel does not move carousel", async () => {
1073+
render(<Basic />);
1074+
1075+
await expect.element(Items.nth(0)).toBeVisible();
1076+
await expect.element(Items.nth(0)).toHaveAttribute("ui-current");
1077+
await expect.element(Items.nth(1)).toBeVisible();
1078+
1079+
const firstItem = Items.nth(0);
1080+
await expect.element(firstItem).toBeVisible();
1081+
const itemRect = firstItem.element()?.getBoundingClientRect();
1082+
if (!itemRect) throw new Error("Item rect not found");
1083+
1084+
const x = itemRect.x + itemRect.width / 2;
1085+
const startY = itemRect.y + itemRect.height * 0.8;
1086+
const endY = itemRect.y + itemRect.height * 0.2;
1087+
1088+
await dragMouse(firstItem, x, startY, x, endY);
1089+
1090+
await expect.element(Items.nth(0)).toBeVisible();
1091+
await expect.element(Items.nth(0)).toHaveAttribute("ui-current");
1092+
await expect.element(Items.nth(1)).toBeVisible();
1093+
await expect.element(Items.nth(1)).not.toHaveAttribute("ui-current");
1094+
});
1095+
1096+
test("horizontal swipe on vertical carousel does not move carousel", async () => {
1097+
render(<VerticalDirection />);
1098+
1099+
await expect.element(Items.nth(0)).toBeVisible();
1100+
await expect.element(Items.nth(0)).toHaveAttribute("ui-current");
1101+
await expect.element(Items.nth(1)).toBeVisible();
1102+
1103+
const firstItem = Items.nth(0);
1104+
await expect.element(firstItem).toBeVisible();
1105+
const itemRect = firstItem.element()?.getBoundingClientRect();
1106+
if (!itemRect) throw new Error("Item rect not found");
1107+
1108+
const startX = itemRect.x + itemRect.width * 0.8;
1109+
const endX = itemRect.x + itemRect.width * 0.2;
1110+
const y = itemRect.y + itemRect.height / 2;
1111+
1112+
await dragMouse(firstItem, startX, y, endX, y);
1113+
1114+
await expect.element(Items.nth(0)).toBeVisible();
1115+
await expect.element(Items.nth(0)).toHaveAttribute("ui-current");
1116+
await expect.element(Items.nth(1)).toBeVisible();
1117+
await expect.element(Items.nth(1)).not.toHaveAttribute("ui-current");
1118+
});
1119+
1120+
test("vertical swipe on horizontal carousel does not affect item position", async () => {
1121+
render(<Basic />);
1122+
1123+
await expect.element(Items.nth(0)).toBeVisible();
1124+
await expect.element(Items.nth(0)).toHaveAttribute("ui-current");
1125+
1126+
const firstItem = Items.nth(0);
1127+
await expect.element(firstItem).toBeVisible();
1128+
const initialRect = firstItem.element()?.getBoundingClientRect();
1129+
if (!initialRect) throw new Error("Initial rect not found");
1130+
1131+
const x = initialRect.x + initialRect.width / 2;
1132+
const startY = initialRect.y + initialRect.height * 0.8;
1133+
const endY = initialRect.y + initialRect.height * 0.2;
1134+
1135+
await dragMouse(firstItem, x, startY, x, endY);
1136+
1137+
await expect.element(firstItem).toBeVisible();
1138+
const finalRect = firstItem.element()?.getBoundingClientRect();
1139+
if (!finalRect) throw new Error("Final rect not found");
1140+
1141+
expect(Math.abs(finalRect.x - initialRect.x)).toBeLessThan(5);
1142+
});
1143+
1144+
test("horizontal swipe on vertical carousel does not affect item position", async () => {
1145+
render(<VerticalDirection />);
1146+
1147+
await expect.element(Items.nth(0)).toBeVisible();
1148+
await expect.element(Items.nth(0)).toHaveAttribute("ui-current");
1149+
1150+
const firstItem = Items.nth(0);
1151+
await expect.element(firstItem).toBeVisible();
1152+
const initialRect = firstItem.element()?.getBoundingClientRect();
1153+
if (!initialRect) throw new Error("Initial rect not found");
1154+
1155+
const startX = initialRect.x + initialRect.width * 0.8;
1156+
const endX = initialRect.x + initialRect.width * 0.2;
1157+
const y = initialRect.y + initialRect.height / 2;
1158+
1159+
await dragMouse(firstItem, startX, y, endX, y);
1160+
1161+
await expect.element(firstItem).toBeVisible();
1162+
const finalRect = firstItem.element()?.getBoundingClientRect();
1163+
if (!finalRect) throw new Error("Final rect not found");
1164+
1165+
expect(Math.abs(finalRect.y - initialRect.y)).toBeLessThan(5);
1166+
});

libs/components/src/carousel/carousel.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
scroll-snap-type: both mandatory;
2929

3030
/* Optimize touch performance - disable default touch behaviors since we handle them */
31-
touch-action: none;
31+
touch-action: auto;
3232
/* Prevent text selection during drag */
3333
user-select: none;
3434
}

0 commit comments

Comments
 (0)