|
|
|
@ -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} |
|
|
|
|