"use client"; import { Children, isValidElement, type ReactNode, useCallback, useEffect, useRef, useState, } from "react"; import { resetQuestionKeyboardState, useQuestionViewportCoordinator, } from "./question-viewport-coordinator"; const WHEEL_GESTURE_IDLE_MS = 280; const BACKGROUND_DRAG_SLOP = 8; const OPTION_CARD_DRAG_SLOP = 14; const TEXT_INPUT_DRAG_SLOP = 16; const FAST_FLICK_MIN_DISTANCE = 24; const DRAG_COMMIT_RATIO = 0.15; const DRAG_FLICK_VELOCITY = 0.22; const SNAP_ANIMATION_MS = 240; const SNAP_EASE = "cubic-bezier(0.16, 1, 0.3, 1)"; const RUBBER_BAND_RESISTANCE = 0.35; /** * Text inputs and direct embedded controls that MUST allow clean 1st-tap focus * while still permitting intentional vertical swipe when dragged beyond slop. */ const TEXT_INPUT_SELECTOR = [ 'input:not([type="radio"]):not([type="checkbox"])', "textarea", "select", '[contenteditable="true"]', 'input[type="range"]', "[data-snap-drag-ignore]", ].join(", "); /** * Option cards, radio/checkbox labels, and buttons on choice questions * where tap = select option, but vertical drag > slop = swipe question. */ const OPTION_CARD_SELECTOR = [ "label", "button", "a", '[role="button"]', '[role="option"]', '[role="checkbox"]', '[role="radio"]', '[role="switch"]', '[role="combobox"]', '[role="listbox"]', ].join(", "); type SnapDragState = { pointerDown: boolean; hardIgnored: boolean; isTextInput: boolean; isOptionCard: boolean; engaged: boolean; didDrag: boolean; animating: boolean; baseOffset: number; startX: number; startY: number; lastY: number; startTime: number; lastMoveTime: number; velocity: number; offset: number; height: number; trioIndices: number[]; cleanupTimer: number | null; }; type QuestionSnapListProps = { children: ReactNode; className?: string; firstQuestionHint?: ReactNode; onQuestionExit?: (currentIndex: number, nextIndex: number) => void; onQuestionTransition?: (currentIndex: number, nextIndex: number) => void; onActiveIndexChange?: (index: number) => void; alignTop?: boolean; }; export function QuestionSnapList({ children, className, firstQuestionHint, onQuestionExit, onQuestionTransition, onActiveIndexChange, alignTop, }: QuestionSnapListProps) { useQuestionViewportCoordinator(); const questions = Children.toArray(children); const wheelLockedRef = useRef(false); const wheelUnlockTimeoutRef = useRef(null); const touchStartYRef = useRef(null); const questionRefs = useRef>([]); const containerRef = useRef(null); const previousActiveIndexRef = useRef(null); const suppressNextClickRef = useRef(false); const [activeIndex, setActiveIndex] = useState(0); const [isHintVisible, setIsHintVisible] = useState(false); const activeIndexRef = useRef(activeIndex); activeIndexRef.current = activeIndex; const questionsCountRef = useRef(questions.length); questionsCountRef.current = questions.length; const dragRef = useRef({ pointerDown: false, hardIgnored: false, isTextInput: false, isOptionCard: false, engaged: false, didDrag: false, animating: false, baseOffset: 0, startX: 0, startY: 0, lastY: 0, startTime: 0, lastMoveTime: 0, velocity: 0, offset: 0, height: 0, trioIndices: [], cleanupTimer: null, }); const stepQuestion = useCallback( (direction: 1 | -1) => { const nextIndex = Math.max( 0, Math.min(questions.length - 1, activeIndexRef.current + direction), ); if (nextIndex === activeIndexRef.current) { return; } resetQuestionKeyboardState(); onQuestionExit?.(activeIndexRef.current, nextIndex); setActiveIndex(nextIndex); }, [onQuestionExit, questions.length], ); const scheduleWheelUnlock = useCallback(() => { if (wheelUnlockTimeoutRef.current !== null) { window.clearTimeout(wheelUnlockTimeoutRef.current); } wheelUnlockTimeoutRef.current = window.setTimeout(() => { wheelLockedRef.current = false; wheelUnlockTimeoutRef.current = null; }, WHEEL_GESTURE_IDLE_MS); }, []); const hasInitializedActiveIndexRef = useRef(false); useEffect(() => { if (questions.length > 0 && activeIndex >= questions.length) { setActiveIndex(questions.length - 1); } }, [questions.length, activeIndex]); useEffect(() => { if (hasInitializedActiveIndexRef.current || questions.length === 0) { return; } const timerId = window.setTimeout(() => { if (hasInitializedActiveIndexRef.current) { return; } for (let index = 0; index < questions.length; index += 1) { const questionElement = questionRefs.current[index]; if (!questionElement) { continue; } const isDisabled = questionElement.getAttribute("data-question-disabled") === "true" || questionElement.querySelector("[data-question-disabled='true']") !== null; if (isDisabled) { continue; } const explicitAnsweredState = questionElement.getAttribute("data-question-answered") ?? questionElement .querySelector("[data-question-answered]") ?.getAttribute("data-question-answered"); const isAnswered = explicitAnsweredState === "true" || (explicitAnsweredState !== "false" && Array.from( questionElement.querySelectorAll< HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement >("input, select, textarea"), ).some((input) => { if (input instanceof HTMLInputElement) { if (input.type === "checkbox" || input.type === "radio") { return input.checked; } if (input.type === "file") { return input.files !== null && input.files.length > 0; } } return input.value.trim().length > 0; })); if (!isAnswered) { hasInitializedActiveIndexRef.current = true; setActiveIndex(index); return; } } hasInitializedActiveIndexRef.current = true; }, 50); return () => { window.clearTimeout(timerId); }; }, [questions.length]); useEffect(() => { onActiveIndexChange?.(activeIndex); }, [activeIndex, onActiveIndexChange]); useEffect(() => { const previousActiveIndex = previousActiveIndexRef.current; if (previousActiveIndex !== null && previousActiveIndex !== activeIndex) { onQuestionTransition?.(previousActiveIndex, activeIndex); } previousActiveIndexRef.current = activeIndex; }, [activeIndex, onQuestionTransition]); useEffect(() => { let timerId: number | null = null; let isCancelled = false; const runCycle = (phase: "wait" | "show" | "hide") => { if (isCancelled) return; if (phase === "wait" || phase === "hide") { setIsHintVisible(false); timerId = window.setTimeout(() => { if (isCancelled) return; setIsHintVisible(true); runCycle("show"); }, 2000); } else if (phase === "show") { setIsHintVisible(true); timerId = window.setTimeout(() => { if (isCancelled) return; setIsHintVisible(false); runCycle("hide"); }, 2000); } }; runCycle("wait"); const handleUserActivity = () => { if (isCancelled) return; setIsHintVisible(false); if (timerId !== null) { window.clearTimeout(timerId); timerId = null; } runCycle("wait"); }; const container = containerRef.current; if (container) { container.addEventListener("touchstart", handleUserActivity, { passive: true, capture: true, }); container.addEventListener("mousedown", handleUserActivity, { passive: true, capture: true, }); container.addEventListener("keydown", handleUserActivity, { passive: true, capture: true, }); container.addEventListener("wheel", handleUserActivity, { passive: true, capture: true, }); } return () => { isCancelled = true; if (timerId !== null) { window.clearTimeout(timerId); } if (container) { container.removeEventListener("touchstart", handleUserActivity, true); container.removeEventListener("mousedown", handleUserActivity, true); container.removeEventListener("keydown", handleUserActivity, true); container.removeEventListener("wheel", handleUserActivity, true); } }; }, [activeIndex]); useEffect(() => { return () => { if (wheelUnlockTimeoutRef.current !== null) { window.clearTimeout(wheelUnlockTimeoutRef.current); } if (dragRef.current.cleanupTimer !== null) { window.clearTimeout(dragRef.current.cleanupTimer); } }; }, []); const readTranslateY = useCallback((element: HTMLElement): number => { const transform = window.getComputedStyle(element).transform; if (!transform || transform === "none") { return 0; } try { return new DOMMatrix(transform).m42; } catch { return 0; } }, []); const styleDragPanel = useCallback( (element: HTMLDivElement, transform: string, animate: boolean) => { element.style.transition = animate ? `transform ${SNAP_ANIMATION_MS}ms ${SNAP_EASE}` : "none"; element.style.transform = transform; element.style.opacity = "1"; element.style.zIndex = "10"; }, [], ); const applyDragOffset = useCallback( (offset: number, animate: boolean) => { const index = activeIndexRef.current; const height = dragRef.current.height; const panels: Array<[HTMLDivElement | null, number]> = [ [questionRefs.current[index], -offset], [questionRefs.current[index + 1], height - offset], [questionRefs.current[index - 1], -height - offset], ]; for (const [element, translateY] of panels) { if (!element) continue; styleDragPanel(element, `translateY(${translateY}px)`, animate); } }, [styleDragPanel], ); const clearDragStyles = useCallback(() => { const drag = dragRef.current; const index = activeIndexRef.current; const targets = new Set([ ...drag.trioIndices, index - 1, index, index + 1, ]); for (const position of targets) { const element = questionRefs.current[position]; if (!element) continue; element.style.transition = ""; element.style.transform = ""; element.style.opacity = ""; element.style.zIndex = ""; } drag.trioIndices = []; }, []); const snapPanelsTo = useCallback( (offset: number) => { const drag = dragRef.current; const index = activeIndexRef.current; drag.animating = true; drag.trioIndices = [index - 1, index, index + 1]; applyDragOffset(offset, true); if (drag.cleanupTimer !== null) { window.clearTimeout(drag.cleanupTimer); } drag.cleanupTimer = window.setTimeout(() => { drag.cleanupTimer = null; drag.animating = false; clearDragStyles(); }, SNAP_ANIMATION_MS + 40); }, [applyDragOffset, clearDragStyles], ); const handleWheel = useCallback( (event: React.WheelEvent) => { if (document.body.classList.contains("dropdown-open")) { return; } const delta = event.deltaY || event.deltaX; if (delta === 0 || questions.length < 2) { return; } event.preventDefault(); if (!wheelLockedRef.current) { wheelLockedRef.current = true; stepQuestion(delta > 0 ? 1 : -1); } scheduleWheelUnlock(); }, [questions.length, scheduleWheelUnlock, stepQuestion], ); 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; let reason = "none"; if (!cancelled && (draggedFar || flicked || movedPastFlick)) { if ((drag.offset > 0 || drag.velocity > 0) && canNext) { direction = 1; reason = flicked ? "flick_next" : (draggedFar ? "drag_far_next" : "moved_past_flick_next"); } else if ((drag.offset < 0 || drag.velocity < 0) && canPrev) { direction = -1; reason = flicked ? "flick_prev" : (draggedFar ? "drag_far_prev" : "moved_past_flick_prev"); } } console.log(`[Snap] FinishDrag -> dir: ${direction} (${reason}) | offset: ${drag.offset.toFixed(0)}px | vel: ${drag.velocity.toFixed(2)} | threshold: ${(drag.height * DRAG_COMMIT_RATIO).toFixed(0)}px`); 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; } suppressNextClickRef.current = false; const drag = dragRef.current; if (drag.cleanupTimer !== null) { window.clearTimeout(drag.cleanupTimer); drag.cleanupTimer = null; } const target = event.target as HTMLElement | null; // 1. Hard-exclude text inputs and standalone controls const isTextInput = Boolean(target?.closest?.(TEXT_INPUT_SELECTOR)); if (isTextInput) { drag.pointerDown = false; drag.engaged = false; drag.hardIgnored = true; drag.isOptionCard = false; drag.didDrag = false; drag.baseOffset = 0; drag.offset = 0; touchStartYRef.current = null; return; } // 2. Identify option cards / buttons for soft ownership const isOptionCard = Boolean(target?.closest?.(OPTION_CARD_SELECTOR)); drag.hardIgnored = false; drag.isOptionCard = isOptionCard; drag.didDrag = false; drag.height = container.getBoundingClientRect().height || window.innerHeight || 600; if (drag.animating && !isOptionCard) { const activeElement = questionRefs.current[activeIndexRef.current]; if (activeElement) { drag.baseOffset = -readTranslateY(activeElement); applyDragOffset(drag.baseOffset, false); } else { drag.baseOffset = 0; } drag.animating = false; drag.engaged = true; } else { drag.baseOffset = 0; drag.engaged = false; } drag.pointerDown = true; drag.velocity = 0; drag.offset = drag.baseOffset; const touch = event.touches[0]; drag.startX = touch?.clientX ?? 0; drag.startY = touch?.clientY ?? 0; drag.lastY = drag.startY; drag.startTime = performance.now(); drag.lastMoveTime = drag.startTime; touchStartYRef.current = drag.startY; console.log(`[Snap] TouchStart at (${drag.startX.toFixed(0)}, ${drag.startY.toFixed(0)}) on <${target?.tagName?.toLowerCase()}> (isOption: ${isOptionCard})`); }; const onTouchMove = (event: TouchEvent) => { if (document.body.classList.contains("dropdown-open")) { return; } const drag = dragRef.current; if (!drag.pointerDown || drag.hardIgnored) { return; } 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); const now = performance.now(); if (!drag.engaged) { const slop = drag.isOptionCard ? OPTION_CARD_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 * 0.9) { return; } // Pager claims the gesture! drag.engaged = true; drag.didDrag = true; suppressNextClickRef.current = true; if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } const elapsed = Math.max(1, now - drag.startTime); drag.velocity = (drag.startY - currentY) / elapsed; console.log(`[Snap] Drag Engaged: deltaY=${deltaY.toFixed(0)}px, slop=${slop}px`); } // Non-passive preventDefault stops native pan and guarantees gesture ownership if (event.cancelable) { event.preventDefault(); } if (event.touches.length > 1) { drag.engaged = false; drag.pointerDown = false; snapPanelsTo(0); return; } 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; const index = activeIndexRef.current; const canNext = index < questionsCountRef.current - 1; const canPrev = index > 0; let offset = drag.baseOffset + (drag.startY - currentY); if (offset > 0 && !canNext) { offset *= RUBBER_BAND_RESISTANCE; } if (offset < 0 && !canPrev) { offset *= RUBBER_BAND_RESISTANCE; } drag.offset = offset; applyDragOffset(offset, false); }; const onTouchEnd = (event: TouchEvent) => { if (document.body.classList.contains("dropdown-open")) { return; } const drag = dragRef.current; if (drag.hardIgnored) { drag.pointerDown = false; drag.engaged = false; touchStartYRef.current = null; return; } if (drag.engaged) { finishDrag(false); return; } if (!drag.pointerDown) { return; } drag.pointerDown = false; // If it was a clean tap on an option card / button, leave it to native click! if (drag.isOptionCard) { touchStartYRef.current = null; return; } // Fast flick fallback on background area const startY = touchStartYRef.current; const endY = event.changedTouches[0]?.clientY; touchStartYRef.current = null; if (startY === null || endY === undefined || questions.length < 2) { return; } const distance = startY - endY; if (Math.abs(distance) < FAST_FLICK_MIN_DISTANCE) { return; } stepQuestion(distance > 0 ? 1 : -1); }; 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) => { const target = event.target as HTMLElement | null; const isTextInput = Boolean(target?.closest?.(TEXT_INPUT_SELECTOR)); if (isTextInput) { suppressNextClickRef.current = false; return; } if (suppressNextClickRef.current) { event.preventDefault(); event.stopPropagation(); suppressNextClickRef.current = false; } }, [], ); if (questions.length === 0) { return null; } return (
{questions.map((question, index) => { const offset = index - activeIndex; const isActive = offset === 0; const isPrev = offset === -1; const isNext = offset === 1; const questionKey = isValidElement(question) && question.key !== null ? question.key : String(question); let containerStyles = ""; let wrapperStyles = "w-full my-auto shrink-0"; if (isActive) { containerStyles = "top-0 left-0 bottom-0 z-10 opacity-100 scale-100 pointer-events-auto !justify-start"; wrapperStyles = alignTop ? "w-full mt-3 shrink-0" : "w-full my-auto shrink-0"; } else if (isPrev) { containerStyles = "top-0 left-0 z-0 opacity-0 pointer-events-none -translate-y-full"; } else if (isNext) { containerStyles = "top-0 left-0 z-0 opacity-0 pointer-events-none translate-y-full"; } else { containerStyles = "top-1/2 left-0 -translate-y-1/2 z-0 opacity-0 pointer-events-none scale-[0.95]"; } return (
{ questionRefs.current[index] = element; }} aria-current={isActive ? "step" : undefined} aria-hidden={isActive ? undefined : true} inert={isActive ? undefined : true} className={[ "question-snap-item absolute flex w-full flex-col justify-center transition-transform duration-300 ease-out px-[17px] will-change-transform [backface-visibility:hidden]", containerStyles, ].join(" ")} >
{question}
{isActive && ( ); })} {firstQuestionHint ? ( ) : null}
); } export default QuestionSnapList;