|
|
|
@ -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<HTMLElement>) => { |
|
|
|
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<HTMLElement>) => { |
|
|
|
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) { |
|
|
|
const elapsed = Math.max(1, now - drag.startTime); |
|
|
|
drag.velocity = (drag.startY - currentY) / elapsed; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// 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 deltaTime = Math.max(1, now - drag.lastMoveTime); |
|
|
|
const instantVelocity = (drag.lastY - currentY) / deltaTime; |
|
|
|
drag.velocity = drag.velocity * 0.72 + instantVelocity * 0.28; |
|
|
|
} |
|
|
|
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<HTMLElement>) => { |
|
|
|
const onTouchEnd = (event: TouchEvent) => { |
|
|
|
if (document.body.classList.contains("dropdown-open")) { |
|
|
|
return; |
|
|
|
} |
|
|
|
@ -622,11 +622,9 @@ export function QuestionSnapList({ |
|
|
|
} |
|
|
|
|
|
|
|
stepQuestion(distance > 0 ? 1 : -1); |
|
|
|
}, |
|
|
|
[finishDrag, questions.length, stepQuestion], |
|
|
|
); |
|
|
|
}; |
|
|
|
|
|
|
|
const handleTouchCancel = useCallback(() => { |
|
|
|
const onTouchCancel = () => { |
|
|
|
const drag = dragRef.current; |
|
|
|
if (drag.hardIgnored) { |
|
|
|
drag.pointerDown = false; |
|
|
|
@ -635,7 +633,20 @@ export function QuestionSnapList({ |
|
|
|
return; |
|
|
|
} |
|
|
|
finishDrag(true); |
|
|
|
}, [finishDrag]); |
|
|
|
}; |
|
|
|
|
|
|
|
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<HTMLElement>) => { |
|
|
|
@ -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) => { |
|
|
|
|