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); 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(); const onActiveIndexChange = vi.fn();
render( render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}> <QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
@ -269,22 +269,93 @@ describe("QuestionSnapList keyboard interaction", () => {
const button = screen.getByRole("button", { name: "Select Option" }); const button = screen.getByRole("button", { name: "Select Option" });
// Small jitter (delta 8px < INTERACTIVE_DRAG_SLOP 16px)
fireEvent.touchStart(button, { fireEvent.touchStart(button, {
touches: [{ clientY: 250 }],
touches: [{ clientX: 100, clientY: 250 }],
target: button, target: button,
}); });
fireEvent.touchMove(button, { fireEvent.touchMove(button, {
touches: [{ clientY: 235 }],
touches: [{ clientX: 100, clientY: 242 }],
target: button, target: button,
}); });
fireEvent.touchEnd(button, { fireEvent.touchEnd(button, {
changedTouches: [{ clientY: 235 }],
changedTouches: [{ clientX: 100, clientY: 242 }],
target: button, target: button,
}); });
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1); 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", () => { it("allows intentional vertical swipe on non-interactive question background", () => {
const onActiveIndexChange = vi.fn(); const onActiveIndexChange = vi.fn();
render( render(
@ -296,17 +367,46 @@ describe("QuestionSnapList keyboard interaction", () => {
const region = screen.getByRole("region", { name: "Questions" }); 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, { fireEvent.touchStart(region, {
touches: [{ clientY: 400 }],
touches: [{ clientX: 100, clientY: 400 }],
target: region, target: region,
}); });
fireEvent.touchEnd(region, { fireEvent.touchEnd(region, {
changedTouches: [{ clientY: 320 }],
changedTouches: [{ clientX: 100, clientY: 320 }],
target: region, target: region,
}); });
expect(onActiveIndexChange).toHaveBeenCalledWith(1); 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"; } from "./question-viewport-coordinator";
const WHEEL_GESTURE_IDLE_MS = 320; 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_COMMIT_RATIO = 0.3;
const DRAG_FLICK_VELOCITY = 0.55;
const DRAG_FLICK_VELOCITY = 0.5;
const SNAP_ANIMATION_MS = 340; const SNAP_ANIMATION_MS = 340;
const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const RUBBER_BAND_RESISTANCE = 0.4; 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", "input",
"textarea", "textarea",
"select", "select",
@ -37,17 +44,20 @@ const DRAG_IGNORE_SELECTOR = [
'[role="combobox"]', '[role="combobox"]',
'[role="listbox"]', '[role="listbox"]',
'[contenteditable="true"]', '[contenteditable="true"]',
"[data-snap-drag-ignore]",
].join(", "); ].join(", ");
type SnapDragState = { type SnapDragState = {
pointerDown: boolean; pointerDown: boolean;
ignored: boolean;
hardIgnored: boolean;
isInteractiveTap: boolean;
engaged: boolean; engaged: boolean;
didDrag: boolean;
animating: boolean; animating: boolean;
baseOffset: number; baseOffset: number;
startX: number;
startY: number; startY: number;
lastY: number; lastY: number;
startTime: number;
lastMoveTime: number; lastMoveTime: number;
velocity: number; velocity: number;
offset: number; offset: number;
@ -83,6 +93,7 @@ export function QuestionSnapList({
const questionRefs = useRef<Array<HTMLDivElement | null>>([]); const questionRefs = useRef<Array<HTMLDivElement | null>>([]);
const containerRef = useRef<HTMLElement | null>(null); const containerRef = useRef<HTMLElement | null>(null);
const previousActiveIndexRef = useRef<number | null>(null); const previousActiveIndexRef = useRef<number | null>(null);
const suppressNextClickRef = useRef(false);
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const activeIndexRef = useRef(activeIndex); const activeIndexRef = useRef(activeIndex);
@ -92,12 +103,16 @@ export function QuestionSnapList({
const dragRef = useRef<SnapDragState>({ const dragRef = useRef<SnapDragState>({
pointerDown: false, pointerDown: false,
ignored: false,
hardIgnored: false,
isInteractiveTap: false,
engaged: false, engaged: false,
didDrag: false,
animating: false, animating: false,
baseOffset: 0, baseOffset: 0,
startX: 0,
startY: 0, startY: 0,
lastY: 0, lastY: 0,
startTime: 0,
lastMoveTime: 0, lastMoveTime: 0,
velocity: 0, velocity: 0,
offset: 0, offset: 0,
@ -119,12 +134,9 @@ export function QuestionSnapList({
resetQuestionKeyboardState(); resetQuestionKeyboardState();
onQuestionExit?.(activeIndex, nextIndex); onQuestionExit?.(activeIndex, nextIndex);
onQuestionTransition?.(activeIndex, nextIndex);
setActiveIndex(nextIndex); setActiveIndex(nextIndex);
}, },
[activeIndex, onQuestionExit, onQuestionTransition, questions.length],
[activeIndex, onQuestionExit, questions.length],
); );
const scheduleWheelUnlock = useCallback(() => { const scheduleWheelUnlock = useCallback(() => {
@ -218,8 +230,9 @@ export function QuestionSnapList({
useEffect(() => { useEffect(() => {
const previousActiveIndex = previousActiveIndexRef.current; const previousActiveIndex = previousActiveIndexRef.current;
onQuestionTransition?.(previousActiveIndex ?? activeIndex, activeIndex);
if (previousActiveIndex !== null && previousActiveIndex !== activeIndex) {
onQuestionTransition?.(previousActiveIndex, activeIndex);
}
previousActiveIndexRef.current = activeIndex; previousActiveIndexRef.current = activeIndex;
}, [activeIndex, onQuestionTransition]); }, [activeIndex, onQuestionTransition]);
@ -356,12 +369,18 @@ export function QuestionSnapList({
} }
const target = event.target as HTMLElement | null; 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.pointerDown = false;
drag.engaged = false; drag.engaged = false;
drag.baseOffset = 0; drag.baseOffset = 0;
@ -374,9 +393,8 @@ export function QuestionSnapList({
containerRef.current?.getBoundingClientRect().height ?? containerRef.current?.getBoundingClientRect().height ??
window.innerHeight; 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]; const activeElement = questionRefs.current[activeIndexRef.current];
if (activeElement) { if (activeElement) {
drag.baseOffset = -readTranslateY(activeElement); drag.baseOffset = -readTranslateY(activeElement);
@ -394,9 +412,12 @@ export function QuestionSnapList({
drag.pointerDown = true; drag.pointerDown = true;
drag.velocity = 0; drag.velocity = 0;
drag.offset = drag.baseOffset; 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.lastY = drag.startY;
drag.lastMoveTime = event.timeStamp || performance.now();
drag.startTime = event.timeStamp || performance.now();
drag.lastMoveTime = drag.startTime;
touchStartYRef.current = drag.startY; touchStartYRef.current = drag.startY;
}, },
[applyDragOffset, readTranslateY], [applyDragOffset, readTranslateY],
@ -410,18 +431,45 @@ export function QuestionSnapList({
const drag = dragRef.current; const drag = dragRef.current;
if (drag.ignored || !drag.pointerDown) {
if (!drag.pointerDown || drag.hardIgnored) {
return; 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 (!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; 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.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(); event.preventDefault();
@ -436,16 +484,16 @@ export function QuestionSnapList({
const now = event.timeStamp || performance.now(); const now = event.timeStamp || performance.now();
const deltaTime = now - drag.lastMoveTime; const deltaTime = now - drag.lastMoveTime;
if (deltaTime > 0) { 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.velocity = drag.velocity * 0.72 + instantVelocity * 0.28;
} }
drag.lastY = y;
drag.lastY = currentY;
drag.lastMoveTime = now; drag.lastMoveTime = now;
const index = activeIndexRef.current; const index = activeIndexRef.current;
const canNext = index < questionsCountRef.current - 1; const canNext = index < questionsCountRef.current - 1;
const canPrev = index > 0; const canPrev = index > 0;
let offset = drag.baseOffset + (drag.startY - y);
let offset = drag.baseOffset + (drag.startY - currentY);
if (offset > 0 && !canNext) { if (offset > 0 && !canNext) {
offset *= RUBBER_BAND_RESISTANCE; offset *= RUBBER_BAND_RESISTANCE;
} }
@ -462,7 +510,7 @@ export function QuestionSnapList({
const finishDrag = useCallback( const finishDrag = useCallback(
(cancelled: boolean) => { (cancelled: boolean) => {
const drag = dragRef.current; const drag = dragRef.current;
if (drag.ignored || !drag.pointerDown) {
if (!drag.pointerDown || drag.hardIgnored) {
drag.pointerDown = false; drag.pointerDown = false;
drag.engaged = false; drag.engaged = false;
touchStartYRef.current = null; touchStartYRef.current = null;
@ -476,6 +524,12 @@ export function QuestionSnapList({
} }
drag.engaged = false; 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 index = activeIndexRef.current;
const canNext = index < questionsCountRef.current - 1; const canNext = index < questionsCountRef.current - 1;
const canPrev = index > 0; const canPrev = index > 0;
@ -501,10 +555,9 @@ export function QuestionSnapList({
resetQuestionKeyboardState(); resetQuestionKeyboardState();
snapPanelsTo(direction * drag.height); snapPanelsTo(direction * drag.height);
onQuestionExit?.(index, nextIndex); onQuestionExit?.(index, nextIndex);
onQuestionTransition?.(index, nextIndex);
setActiveIndex(nextIndex); setActiveIndex(nextIndex);
}, },
[onQuestionExit, onQuestionTransition, snapPanelsTo],
[onQuestionExit, snapPanelsTo],
); );
const handleTouchEnd = useCallback( const handleTouchEnd = useCallback(
@ -515,7 +568,7 @@ export function QuestionSnapList({
const drag = dragRef.current; const drag = dragRef.current;
if (drag.ignored) {
if (drag.hardIgnored) {
drag.pointerDown = false; drag.pointerDown = false;
drag.engaged = false; drag.engaged = false;
touchStartYRef.current = null; touchStartYRef.current = null;
@ -532,6 +585,12 @@ export function QuestionSnapList({
} }
drag.pointerDown = false; 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 // Fallback for fast flicks whose touchmove never engaged continuous drag
const startY = touchStartYRef.current; const startY = touchStartYRef.current;
const endY = event.changedTouches[0]?.clientY; const endY = event.changedTouches[0]?.clientY;
@ -543,7 +602,7 @@ export function QuestionSnapList({
const distance = startY - endY; const distance = startY - endY;
if (Math.abs(distance) < TOUCH_MIN_DISTANCE) {
if (Math.abs(distance) < FAST_FLICK_MIN_DISTANCE) {
return; return;
} }
@ -554,7 +613,7 @@ export function QuestionSnapList({
const handleTouchCancel = useCallback(() => { const handleTouchCancel = useCallback(() => {
const drag = dragRef.current; const drag = dragRef.current;
if (drag.ignored) {
if (drag.hardIgnored) {
drag.pointerDown = false; drag.pointerDown = false;
drag.engaged = false; drag.engaged = false;
touchStartYRef.current = null; touchStartYRef.current = null;
@ -563,6 +622,17 @@ export function QuestionSnapList({
finishDrag(true); finishDrag(true);
}, [finishDrag]); }, [finishDrag]);
const handleClickCapture = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (suppressNextClickRef.current) {
event.preventDefault();
event.stopPropagation();
suppressNextClickRef.current = false;
}
},
[],
);
if (questions.length === 0) { if (questions.length === 0) {
return null; return null;
} }
@ -572,12 +642,13 @@ export function QuestionSnapList({
ref={containerRef} ref={containerRef}
aria-label="Questions" aria-label="Questions"
className={[ 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", "flex-1 min-h-0 pt-4 pb-4",
className, className,
] ]
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
onClickCapture={handleClickCapture}
onTouchCancel={handleTouchCancel} onTouchCancel={handleTouchCancel}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}

1
vitest.config.ts

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