Browse Source

refactor: improve snap-list drag gesture logic and suppress accidental clicks on interactive elements

master
mortezaei 6 days ago
parent
commit
af37dda4f0
  1. 114
      src/components/Componentes/question-snap-list.test.tsx
  2. 145
      src/components/Componentes/question-snap-list.tsx
  3. 1
      vitest.config.ts

114
src/components/Componentes/question-snap-list.test.tsx

@ -254,7 +254,7 @@ describe("QuestionSnapList keyboard interaction", () => {
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
});
it("does not step question on buttons or labels", () => {
it("does not step question on option button/label with micro-jitter (clean tap)", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
@ -269,22 +269,93 @@ describe("QuestionSnapList keyboard interaction", () => {
const button = screen.getByRole("button", { name: "Select Option" });
// Small jitter (delta 8px < INTERACTIVE_DRAG_SLOP 16px)
fireEvent.touchStart(button, {
touches: [{ clientY: 250 }],
touches: [{ clientX: 100, clientY: 250 }],
target: button,
});
fireEvent.touchMove(button, {
touches: [{ clientY: 235 }],
touches: [{ clientX: 100, clientY: 242 }],
target: button,
});
fireEvent.touchEnd(button, {
changedTouches: [{ clientY: 235 }],
changedTouches: [{ clientX: 100, clientY: 242 }],
target: button,
});
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
});
it("allows intentional vertical drag starting on a label/button to swipe questions (soft ownership)", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<label htmlFor="opt-1">Option 1</label>
<input id="opt-1" type="radio" name="opt" />
</div>
<div>
<label htmlFor="opt-2">Option 2</label>
<input id="opt-2" type="radio" name="opt" />
</div>
</QuestionSnapList>,
);
const label = screen.getByText("Option 1");
// Intentional drag (startY: 300, endY: 200 -> deltaY = 100 > slop)
fireEvent.touchStart(label, {
touches: [{ clientX: 100, clientY: 300 }],
target: label,
});
fireEvent.touchMove(label, {
touches: [{ clientX: 100, clientY: 200 }],
target: label,
});
fireEvent.touchEnd(label, {
changedTouches: [{ clientX: 100, clientY: 200 }],
target: label,
});
expect(onActiveIndexChange).toHaveBeenCalledWith(1);
});
it("suppresses trailing click on option after vertical drag swipe", () => {
const onClick = vi.fn();
render(
<QuestionSnapList>
<div>
<button type="button" onClick={onClick}>
Option Button
</button>
</div>
<div>Slide 2</div>
</QuestionSnapList>,
);
const button = screen.getByRole("button", { name: "Option Button" });
const region = screen.getByRole("region", { name: "Questions" });
// Swipe drag starting on button
fireEvent.touchStart(button, {
touches: [{ clientX: 100, clientY: 300 }],
target: button,
});
fireEvent.touchMove(button, {
touches: [{ clientX: 100, clientY: 200 }],
target: button,
});
fireEvent.touchEnd(button, {
changedTouches: [{ clientX: 100, clientY: 200 }],
target: button,
});
// Synthetic click dispatched after drag
fireEvent.click(button);
expect(onClick).not.toHaveBeenCalled();
});
it("allows intentional vertical swipe on non-interactive question background", () => {
const onActiveIndexChange = vi.fn();
render(
@ -296,17 +367,46 @@ describe("QuestionSnapList keyboard interaction", () => {
const region = screen.getByRole("region", { name: "Questions" });
// Fast upward flick on background area (startY = 400, endY = 320 -> delta = 80 > TOUCH_MIN_DISTANCE)
// Fast upward flick on background area
fireEvent.touchStart(region, {
touches: [{ clientY: 400 }],
touches: [{ clientX: 100, clientY: 400 }],
target: region,
});
fireEvent.touchEnd(region, {
changedTouches: [{ clientY: 320 }],
changedTouches: [{ clientX: 100, clientY: 320 }],
target: region,
});
expect(onActiveIndexChange).toHaveBeenCalledWith(1);
});
it("does not swipe on hard-ignored controls like range sliders", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<input type="range" aria-label="Volume slider" />
</div>
<div>Slide 2</div>
</QuestionSnapList>,
);
const slider = screen.getByRole("slider", { name: "Volume slider" });
fireEvent.touchStart(slider, {
touches: [{ clientX: 100, clientY: 300 }],
target: slider,
});
fireEvent.touchMove(slider, {
touches: [{ clientX: 100, clientY: 200 }],
target: slider,
});
fireEvent.touchEnd(slider, {
changedTouches: [{ clientX: 100, clientY: 200 }],
target: slider,
});
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
});
});
});

145
src/components/Componentes/question-snap-list.tsx

@ -15,14 +15,21 @@ import {
} from "./question-viewport-coordinator";
const WHEEL_GESTURE_IDLE_MS = 320;
const TOUCH_MIN_DISTANCE = 40;
const DRAG_ENGAGE_DISTANCE = 16;
const BACKGROUND_DRAG_SLOP = 10;
const INTERACTIVE_DRAG_SLOP = 16;
const FAST_FLICK_MIN_DISTANCE = 36;
const DRAG_COMMIT_RATIO = 0.3;
const DRAG_FLICK_VELOCITY = 0.55;
const DRAG_FLICK_VELOCITY = 0.5;
const SNAP_ANIMATION_MS = 340;
const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const RUBBER_BAND_RESISTANCE = 0.4;
const DRAG_IGNORE_SELECTOR = [
const HARD_DRAG_IGNORE_SELECTOR = [
'input[type="range"]',
"[data-snap-drag-ignore]",
].join(", ");
const INTERACTIVE_TAP_SELECTOR = [
"input",
"textarea",
"select",
@ -37,17 +44,20 @@ const DRAG_IGNORE_SELECTOR = [
'[role="combobox"]',
'[role="listbox"]',
'[contenteditable="true"]',
"[data-snap-drag-ignore]",
].join(", ");
type SnapDragState = {
pointerDown: boolean;
ignored: boolean;
hardIgnored: boolean;
isInteractiveTap: boolean;
engaged: boolean;
didDrag: boolean;
animating: boolean;
baseOffset: number;
startX: number;
startY: number;
lastY: number;
startTime: number;
lastMoveTime: number;
velocity: number;
offset: number;
@ -83,6 +93,7 @@ export function QuestionSnapList({
const questionRefs = useRef<Array<HTMLDivElement | null>>([]);
const containerRef = useRef<HTMLElement | null>(null);
const previousActiveIndexRef = useRef<number | null>(null);
const suppressNextClickRef = useRef(false);
const [activeIndex, setActiveIndex] = useState(0);
const activeIndexRef = useRef(activeIndex);
@ -92,12 +103,16 @@ export function QuestionSnapList({
const dragRef = useRef<SnapDragState>({
pointerDown: false,
ignored: false,
hardIgnored: false,
isInteractiveTap: false,
engaged: false,
didDrag: false,
animating: false,
baseOffset: 0,
startX: 0,
startY: 0,
lastY: 0,
startTime: 0,
lastMoveTime: 0,
velocity: 0,
offset: 0,
@ -119,12 +134,9 @@ export function QuestionSnapList({
resetQuestionKeyboardState();
onQuestionExit?.(activeIndex, nextIndex);
onQuestionTransition?.(activeIndex, nextIndex);
setActiveIndex(nextIndex);
},
[activeIndex, onQuestionExit, onQuestionTransition, questions.length],
[activeIndex, onQuestionExit, questions.length],
);
const scheduleWheelUnlock = useCallback(() => {
@ -218,8 +230,9 @@ export function QuestionSnapList({
useEffect(() => {
const previousActiveIndex = previousActiveIndexRef.current;
onQuestionTransition?.(previousActiveIndex ?? activeIndex, activeIndex);
if (previousActiveIndex !== null && previousActiveIndex !== activeIndex) {
onQuestionTransition?.(previousActiveIndex, activeIndex);
}
previousActiveIndexRef.current = activeIndex;
}, [activeIndex, onQuestionTransition]);
@ -356,12 +369,18 @@ export function QuestionSnapList({
}
const target = event.target as HTMLElement | null;
const isIgnored = Boolean(target?.closest?.(DRAG_IGNORE_SELECTOR));
drag.ignored = isIgnored;
const isHardIgnored = Boolean(
target?.closest?.(HARD_DRAG_IGNORE_SELECTOR),
);
const isInteractive = Boolean(
target?.closest?.(INTERACTIVE_TAP_SELECTOR),
);
drag.hardIgnored = isHardIgnored;
drag.isInteractiveTap = isInteractive;
drag.didDrag = false;
if (isIgnored) {
// When touching an interactive element (input, textarea, button, etc.),
// completely isolate it from the snap/drag gesture engine so native focus & clicks work on first tap.
if (isHardIgnored) {
drag.pointerDown = false;
drag.engaged = false;
drag.baseOffset = 0;
@ -374,9 +393,8 @@ export function QuestionSnapList({
containerRef.current?.getBoundingClientRect().height ??
window.innerHeight;
if (drag.animating) {
// Grabbed mid-snap (or mid wheel transition): freeze the panels
// exactly where they currently are and continue the drag from there.
if (drag.animating && !isInteractive) {
// Grabbed mid-snap on background area: freeze panels and continue drag
const activeElement = questionRefs.current[activeIndexRef.current];
if (activeElement) {
drag.baseOffset = -readTranslateY(activeElement);
@ -394,9 +412,12 @@ export function QuestionSnapList({
drag.pointerDown = true;
drag.velocity = 0;
drag.offset = drag.baseOffset;
drag.startY = event.touches[0]?.clientY ?? 0;
const touch = event.touches[0];
drag.startX = touch?.clientX ?? 0;
drag.startY = touch?.clientY ?? 0;
drag.lastY = drag.startY;
drag.lastMoveTime = event.timeStamp || performance.now();
drag.startTime = event.timeStamp || performance.now();
drag.lastMoveTime = drag.startTime;
touchStartYRef.current = drag.startY;
},
[applyDragOffset, readTranslateY],
@ -410,18 +431,45 @@ export function QuestionSnapList({
const drag = dragRef.current;
if (drag.ignored || !drag.pointerDown) {
if (!drag.pointerDown || drag.hardIgnored) {
return;
}
const y = event.touches[0]?.clientY ?? drag.lastY;
const touch = event.touches[0];
const currentX = touch?.clientX ?? drag.startX;
const currentY = touch?.clientY ?? drag.lastY;
const deltaX = currentX - drag.startX;
const deltaY = currentY - drag.startY;
const absX = Math.abs(deltaX);
const absY = Math.abs(deltaY);
if (!drag.engaged) {
if (Math.abs(drag.startY - y) < DRAG_ENGAGE_DISTANCE) {
// Allow micro-movements during a tap to produce native click/focus events
const slop = drag.isInteractiveTap
? INTERACTIVE_DRAG_SLOP
: BACKGROUND_DRAG_SLOP;
// If movement is under slop threshold, do not engage: let native clicks/focus happen
if (absY < slop) {
return;
}
// Must be primarily vertical gesture (avoid hijacking horizontal swipe / scroll)
if (absY < absX * 1.1) {
return;
}
// Pager claims the gesture!
drag.engaged = true;
drag.didDrag = true;
suppressNextClickRef.current = true;
// Seed initial velocity from total drag trajectory so flick filter doesn't start sluggishly at 0
const now = event.timeStamp || performance.now();
const elapsed = now - drag.startTime;
if (elapsed > 0) {
drag.velocity = (drag.startY - currentY) / elapsed;
}
}
event.preventDefault();
@ -436,16 +484,16 @@ export function QuestionSnapList({
const now = event.timeStamp || performance.now();
const deltaTime = now - drag.lastMoveTime;
if (deltaTime > 0) {
const instantVelocity = (drag.lastY - y) / deltaTime;
const instantVelocity = (drag.lastY - currentY) / deltaTime;
drag.velocity = drag.velocity * 0.72 + instantVelocity * 0.28;
}
drag.lastY = y;
drag.lastY = currentY;
drag.lastMoveTime = now;
const index = activeIndexRef.current;
const canNext = index < questionsCountRef.current - 1;
const canPrev = index > 0;
let offset = drag.baseOffset + (drag.startY - y);
let offset = drag.baseOffset + (drag.startY - currentY);
if (offset > 0 && !canNext) {
offset *= RUBBER_BAND_RESISTANCE;
}
@ -462,7 +510,7 @@ export function QuestionSnapList({
const finishDrag = useCallback(
(cancelled: boolean) => {
const drag = dragRef.current;
if (drag.ignored || !drag.pointerDown) {
if (!drag.pointerDown || drag.hardIgnored) {
drag.pointerDown = false;
drag.engaged = false;
touchStartYRef.current = null;
@ -476,6 +524,12 @@ export function QuestionSnapList({
}
drag.engaged = false;
// Keep suppressNextClickRef active so trailing click is suppressed
suppressNextClickRef.current = true;
window.setTimeout(() => {
suppressNextClickRef.current = false;
}, 300);
const index = activeIndexRef.current;
const canNext = index < questionsCountRef.current - 1;
const canPrev = index > 0;
@ -501,10 +555,9 @@ export function QuestionSnapList({
resetQuestionKeyboardState();
snapPanelsTo(direction * drag.height);
onQuestionExit?.(index, nextIndex);
onQuestionTransition?.(index, nextIndex);
setActiveIndex(nextIndex);
},
[onQuestionExit, onQuestionTransition, snapPanelsTo],
[onQuestionExit, snapPanelsTo],
);
const handleTouchEnd = useCallback(
@ -515,7 +568,7 @@ export function QuestionSnapList({
const drag = dragRef.current;
if (drag.ignored) {
if (drag.hardIgnored) {
drag.pointerDown = false;
drag.engaged = false;
touchStartYRef.current = null;
@ -532,6 +585,12 @@ export function QuestionSnapList({
}
drag.pointerDown = false;
// If it was an interactive tap target with micro-movement, leave it to native focus/click!
if (drag.isInteractiveTap) {
touchStartYRef.current = null;
return;
}
// Fallback for fast flicks whose touchmove never engaged continuous drag
const startY = touchStartYRef.current;
const endY = event.changedTouches[0]?.clientY;
@ -543,7 +602,7 @@ export function QuestionSnapList({
const distance = startY - endY;
if (Math.abs(distance) < TOUCH_MIN_DISTANCE) {
if (Math.abs(distance) < FAST_FLICK_MIN_DISTANCE) {
return;
}
@ -554,7 +613,7 @@ export function QuestionSnapList({
const handleTouchCancel = useCallback(() => {
const drag = dragRef.current;
if (drag.ignored) {
if (drag.hardIgnored) {
drag.pointerDown = false;
drag.engaged = false;
touchStartYRef.current = null;
@ -563,6 +622,17 @@ export function QuestionSnapList({
finishDrag(true);
}, [finishDrag]);
const handleClickCapture = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (suppressNextClickRef.current) {
event.preventDefault();
event.stopPropagation();
suppressNextClickRef.current = false;
}
},
[],
);
if (questions.length === 0) {
return null;
}
@ -572,12 +642,13 @@ export function QuestionSnapList({
ref={containerRef}
aria-label="Questions"
className={[
"question-snap-list relative touch-pan-y overflow-hidden focus-visible:outline-none",
"question-snap-list relative touch-none overflow-hidden focus-visible:outline-none",
"flex-1 min-h-0 pt-4 pb-4",
className,
]
.filter(Boolean)
.join(" ")}
onClickCapture={handleClickCapture}
onTouchCancel={handleTouchCancel}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove}

1
vitest.config.ts

@ -10,5 +10,6 @@ export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
testTimeout: 15000,
},
});
Loading…
Cancel
Save