From 90209e0d713b08a85f97857f22d66b5efd915b76 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Tue, 18 Aug 2026 14:52:47 +0330 Subject: [PATCH] fix question list --- .../Componentes/question-snap-list.tsx | 227 +++++++++--------- 1 file changed, 117 insertions(+), 110 deletions(-) diff --git a/src/components/Componentes/question-snap-list.tsx b/src/components/Componentes/question-snap-list.tsx index 2e45ff8..53d5a75 100644 --- a/src/components/Componentes/question-snap-list.tsx +++ b/src/components/Componentes/question-snap-list.tsx @@ -19,7 +19,7 @@ const BACKGROUND_DRAG_SLOP = 10; const OPTION_CARD_DRAG_SLOP = 16; const FAST_FLICK_MIN_DISTANCE = 36; const DRAG_COMMIT_RATIO = 0.3; -const DRAG_FLICK_VELOCITY = 0.5; +const DRAG_FLICK_VELOCITY = 0.4; const SNAP_ANIMATION_MS = 340; const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; const RUBBER_BAND_RESISTANCE = 0.4; @@ -134,18 +134,18 @@ export function QuestionSnapList({ (direction: 1 | -1) => { const nextIndex = Math.max( 0, - Math.min(questions.length - 1, activeIndex + direction), + Math.min(questions.length - 1, activeIndexRef.current + direction), ); - if (nextIndex === activeIndex) { + if (nextIndex === activeIndexRef.current) { return; } resetQuestionKeyboardState(); - onQuestionExit?.(activeIndex, nextIndex); + onQuestionExit?.(activeIndexRef.current, nextIndex); setActiveIndex(nextIndex); }, - [activeIndex, onQuestionExit, questions.length], + [onQuestionExit, questions.length], ); const scheduleWheelUnlock = useCallback(() => { @@ -281,8 +281,6 @@ export function QuestionSnapList({ [], ); - // Positive offset slides the active panel up (towards the next question), - // exactly like following a finger in a reels-style pager. const applyDragOffset = useCallback( (offset: number, animate: boolean) => { const index = activeIndexRef.current; @@ -365,13 +363,74 @@ export function QuestionSnapList({ [questions.length, scheduleWheelUnlock, stepQuestion], ); - const handleTouchStart = useCallback( - (event: React.TouchEvent) => { + const finishDrag = useCallback( + (cancelled: boolean) => { + const drag = dragRef.current; + if (!drag.pointerDown || drag.hardIgnored) { + drag.pointerDown = false; + drag.engaged = false; + touchStartYRef.current = null; + return; + } + drag.pointerDown = false; + touchStartYRef.current = null; + + if (!drag.engaged) { + return; + } + drag.engaged = false; + + // Keep suppressNextClickRef active briefly so trailing click from drag is suppressed + if (drag.didDrag) { + suppressNextClickRef.current = true; + window.setTimeout(() => { + suppressNextClickRef.current = false; + }, 60); + } + + const index = activeIndexRef.current; + const canNext = index < questionsCountRef.current - 1; + const canPrev = index > 0; + const flicked = Math.abs(drag.velocity) > DRAG_FLICK_VELOCITY; + const draggedFar = + Math.abs(drag.offset) > drag.height * DRAG_COMMIT_RATIO; + const movedPastFlick = Math.abs(drag.offset) >= FAST_FLICK_MIN_DISTANCE; + + let direction: 0 | 1 | -1 = 0; + if (!cancelled && (draggedFar || flicked || movedPastFlick)) { + if ((drag.offset > 0 || drag.velocity > 0) && canNext) { + direction = 1; + } else if ((drag.offset < 0 || drag.velocity < 0) && canPrev) { + direction = -1; + } + } + + if (direction === 0) { + snapPanelsTo(0); + return; + } + + const nextIndex = index + direction; + resetQuestionKeyboardState(); + snapPanelsTo(direction * drag.height); + onQuestionExit?.(index, nextIndex); + setActiveIndex(nextIndex); + }, + [onQuestionExit, snapPanelsTo], + ); + + // Native non-passive touch listeners on container element. + // This allows the container to have `touch-action: pan-y` (enabling instant 1st-tap focus on inputs) + // while allowing non-passive `event.preventDefault()` during swipe to prevent WebView native pan cancels. + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const onTouchStart = (event: TouchEvent) => { if (document.body.classList.contains("dropdown-open")) { return; } - // Always reset click suppression on any new touch start suppressNextClickRef.current = false; const drag = dragRef.current; @@ -403,11 +462,11 @@ export function QuestionSnapList({ drag.didDrag = false; drag.height = - containerRef.current?.getBoundingClientRect().height ?? - window.innerHeight; + container.getBoundingClientRect().height || + window.innerHeight || + 600; if (drag.animating && !isOptionCard) { - // Grabbed mid-snap on non-option background: freeze panels and continue drag const activeElement = questionRefs.current[activeIndexRef.current]; if (activeElement) { drag.baseOffset = -readTranslateY(activeElement); @@ -429,15 +488,12 @@ export function QuestionSnapList({ drag.startX = touch?.clientX ?? 0; drag.startY = touch?.clientY ?? 0; drag.lastY = drag.startY; - drag.startTime = event.timeStamp || performance.now(); + drag.startTime = performance.now(); drag.lastMoveTime = drag.startTime; touchStartYRef.current = drag.startY; - }, - [applyDragOffset, readTranslateY], - ); + }; - const handleTouchMove = useCallback( - (event: React.TouchEvent) => { + const onTouchMove = (event: TouchEvent) => { if (document.body.classList.contains("dropdown-open")) { return; } @@ -457,6 +513,8 @@ export function QuestionSnapList({ const absX = Math.abs(deltaX); const absY = Math.abs(deltaY); + const now = performance.now(); + if (!drag.engaged) { const slop = drag.isOptionCard ? OPTION_CARD_DRAG_SLOP @@ -477,15 +535,14 @@ export function QuestionSnapList({ 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; - } + const elapsed = Math.max(1, now - drag.startTime); + drag.velocity = (drag.startY - currentY) / elapsed; } - event.preventDefault(); + // Non-passive preventDefault stops native pan and guarantees gesture ownership + if (event.cancelable) { + event.preventDefault(); + } if (event.touches.length > 1) { drag.engaged = false; @@ -494,12 +551,13 @@ export function QuestionSnapList({ return; } - const now = event.timeStamp || performance.now(); - const deltaTime = now - drag.lastMoveTime; - if (deltaTime > 0) { - const instantVelocity = (drag.lastY - currentY) / deltaTime; - drag.velocity = drag.velocity * 0.72 + instantVelocity * 0.28; - } + const deltaTime = Math.max(1, now - drag.lastMoveTime); + const instantVelocity = (drag.lastY - currentY) / deltaTime; + drag.velocity = + drag.velocity === 0 + ? instantVelocity + : drag.velocity * 0.72 + instantVelocity * 0.28; + drag.lastY = currentY; drag.lastMoveTime = now; @@ -516,67 +574,9 @@ export function QuestionSnapList({ drag.offset = offset; applyDragOffset(offset, false); - }, - [applyDragOffset, snapPanelsTo], - ); - - const finishDrag = useCallback( - (cancelled: boolean) => { - const drag = dragRef.current; - if (!drag.pointerDown || drag.hardIgnored) { - drag.pointerDown = false; - drag.engaged = false; - touchStartYRef.current = null; - return; - } - drag.pointerDown = false; - touchStartYRef.current = null; - - if (!drag.engaged) { - return; - } - drag.engaged = false; - - // Keep suppressNextClickRef active briefly so trailing click from drag is suppressed - if (drag.didDrag) { - suppressNextClickRef.current = true; - window.setTimeout(() => { - suppressNextClickRef.current = false; - }, 60); - } - - const index = activeIndexRef.current; - const canNext = index < questionsCountRef.current - 1; - const canPrev = index > 0; - const flicked = Math.abs(drag.velocity) > DRAG_FLICK_VELOCITY; - const draggedFar = - Math.abs(drag.offset) > drag.height * DRAG_COMMIT_RATIO; - - let direction: 0 | 1 | -1 = 0; - if (!cancelled && (draggedFar || flicked)) { - if ((drag.offset > 0 || drag.velocity > 0) && canNext) { - direction = 1; - } else if ((drag.offset < 0 || drag.velocity < 0) && canPrev) { - direction = -1; - } - } - - if (direction === 0) { - snapPanelsTo(0); - return; - } - - const nextIndex = index + direction; - resetQuestionKeyboardState(); - snapPanelsTo(direction * drag.height); - onQuestionExit?.(index, nextIndex); - setActiveIndex(nextIndex); - }, - [onQuestionExit, snapPanelsTo], - ); + }; - const handleTouchEnd = useCallback( - (event: React.TouchEvent) => { + const onTouchEnd = (event: TouchEvent) => { if (document.body.classList.contains("dropdown-open")) { return; } @@ -622,20 +622,31 @@ export function QuestionSnapList({ } stepQuestion(distance > 0 ? 1 : -1); - }, - [finishDrag, questions.length, stepQuestion], - ); + }; - const handleTouchCancel = useCallback(() => { - const drag = dragRef.current; - if (drag.hardIgnored) { - drag.pointerDown = false; - drag.engaged = false; - touchStartYRef.current = null; - return; - } - finishDrag(true); - }, [finishDrag]); + const onTouchCancel = () => { + const drag = dragRef.current; + if (drag.hardIgnored) { + drag.pointerDown = false; + drag.engaged = false; + touchStartYRef.current = null; + return; + } + finishDrag(true); + }; + + container.addEventListener("touchstart", onTouchStart, { passive: true }); + container.addEventListener("touchmove", onTouchMove, { passive: false }); + container.addEventListener("touchend", onTouchEnd, { passive: true }); + container.addEventListener("touchcancel", onTouchCancel, { passive: true }); + + return () => { + container.removeEventListener("touchstart", onTouchStart); + container.removeEventListener("touchmove", onTouchMove); + container.removeEventListener("touchend", onTouchEnd); + container.removeEventListener("touchcancel", onTouchCancel); + }; + }, [applyDragOffset, finishDrag, questions.length, readTranslateY, snapPanelsTo, stepQuestion]); const handleClickCapture = useCallback( (event: React.MouseEvent) => { @@ -664,17 +675,13 @@ export function QuestionSnapList({ ref={containerRef} aria-label="Questions" className={[ - "question-snap-list relative touch-none overflow-hidden focus-visible:outline-none", + "question-snap-list relative touch-pan-y 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} - onTouchStart={handleTouchStart} onWheel={handleWheel} > {questions.map((question, index) => {