diff --git a/src/components/Componentes/question-snap-list.test.tsx b/src/components/Componentes/question-snap-list.test.tsx index 95a6373..019bd9c 100644 --- a/src/components/Componentes/question-snap-list.test.tsx +++ b/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( @@ -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( + +
+ + +
+
+ + +
+
, + ); + + 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( + +
+ +
+
Slide 2
+
, + ); + + 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( + +
+ +
+
Slide 2
+
, + ); + + 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); + }); }); }); diff --git a/src/components/Componentes/question-snap-list.tsx b/src/components/Componentes/question-snap-list.tsx index 244635e..b9907b4 100644 --- a/src/components/Componentes/question-snap-list.tsx +++ b/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>([]); const containerRef = useRef(null); const previousActiveIndexRef = useRef(null); + const suppressNextClickRef = useRef(false); const [activeIndex, setActiveIndex] = useState(0); const activeIndexRef = useRef(activeIndex); @@ -92,12 +103,16 @@ export function QuestionSnapList({ const dragRef = useRef({ 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) => { + 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} diff --git a/vitest.config.ts b/vitest.config.ts index fcb3dfb..0e583d4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,5 +10,6 @@ export default defineConfig({ test: { environment: "jsdom", setupFiles: ["./src/test/setup.ts"], + testTimeout: 15000, }, });