"use client"; import { Children, isValidElement, type ReactNode, useCallback, useEffect, useRef, useState, } from "react"; import { useI18n } from "@/translations/provider"; import { useQuestionProgress } from "./question-progress-tracker"; import { useQuestionInputFocusSync } from "./use-sheet-scroll-lock"; const WHEEL_GESTURE_IDLE_MS = 320; const TOUCH_MIN_DISTANCE = 8; const DRAG_ENGAGE_DISTANCE = 10; const DRAG_COMMIT_RATIO = 0.3; const DRAG_FLICK_VELOCITY = 0.55; const SNAP_ANIMATION_MS = 340; const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; const RUBBER_BAND_RESISTANCE = 0.4; const AUTO_FOCUS_SELECTOR = [ "textarea:not([disabled]):not([data-no-auto-focus])", 'input[type="text"]:not([disabled]):not([data-no-auto-focus])', 'input[type="number"]:not([disabled]):not([data-no-auto-focus])', ].join(", "); const DRAG_IGNORE_SELECTOR = [ "input", "textarea", "select", '[contenteditable="true"]', "[data-snap-drag-ignore]", ].join(", "); type SnapDragState = { pointerDown: boolean; ignored: boolean; engaged: boolean; animating: boolean; baseOffset: number; startY: number; lastY: 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) { const { dictionary: t } = useI18n(); const { isCompleted } = useQuestionProgress(); useQuestionInputFocusSync(); 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 [activeIndex, setActiveIndex] = useState(0); const activeIndexRef = useRef(activeIndex); activeIndexRef.current = activeIndex; const questionsCountRef = useRef(questions.length); questionsCountRef.current = questions.length; const dragRef = useRef({ pointerDown: false, ignored: false, engaged: false, animating: false, baseOffset: 0, startY: 0, lastY: 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, activeIndex + direction), ); if (nextIndex === activeIndex) { return; } onQuestionExit?.(activeIndex, nextIndex); onQuestionTransition?.(activeIndex, nextIndex); setActiveIndex(nextIndex); }, [activeIndex, onQuestionExit, onQuestionTransition, 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; onQuestionTransition?.(previousActiveIndex ?? activeIndex, activeIndex); previousActiveIndexRef.current = activeIndex; }, [activeIndex, onQuestionTransition]); useEffect(() => { if (activeIndex === 0) { return; } const activeQuestion = questionRefs.current[activeIndex]; if (!activeQuestion) { return; } const focusFrame = window.requestAnimationFrame(() => { if (document.querySelector('[role="dialog"]')) { return; } const firstFocusableInput = activeQuestion.querySelector< HTMLInputElement | HTMLTextAreaElement >(AUTO_FOCUS_SELECTOR); if (!firstFocusableInput) { return; } const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( navigator.userAgent, ); firstFocusableInput.focus({ preventScroll: !isMobile }); if (isMobile) { setTimeout(() => { firstFocusableInput.scrollIntoView({ behavior: "smooth", block: "center", }); }, 150); } const valueLength = firstFocusableInput.value.length; if ( valueLength > 0 && typeof firstFocusableInput.setSelectionRange === "function" ) { firstFocusableInput.setSelectionRange(valueLength, valueLength); } }); return () => { window.cancelAnimationFrame(focusFrame); }; }, [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"; }, [], ); // 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; 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 handleTouchStart = useCallback( (event: React.TouchEvent) => { if (document.body.classList.contains("dropdown-open")) { return; } const drag = dragRef.current; if (drag.cleanupTimer !== null) { window.clearTimeout(drag.cleanupTimer); drag.cleanupTimer = null; } const target = event.target as HTMLElement | null; drag.ignored = Boolean(target?.closest?.(DRAG_IGNORE_SELECTOR)); drag.height = 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. 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; drag.startY = event.touches[0]?.clientY ?? 0; drag.lastY = drag.startY; drag.lastMoveTime = event.timeStamp || performance.now(); touchStartYRef.current = drag.startY; }, [applyDragOffset, readTranslateY], ); const handleTouchMove = useCallback( (event: React.TouchEvent) => { if (document.body.classList.contains("dropdown-open")) { return; } const drag = dragRef.current; if (!drag.pointerDown) { event.preventDefault(); return; } if (drag.ignored) { return; } const y = event.touches[0]?.clientY ?? drag.lastY; if (!drag.engaged) { if (Math.abs(drag.startY - y) < DRAG_ENGAGE_DISTANCE) { event.preventDefault(); return; } drag.engaged = true; } event.preventDefault(); if (event.touches.length > 1) { drag.engaged = false; drag.pointerDown = false; snapPanelsTo(0); return; } const now = event.timeStamp || performance.now(); const deltaTime = now - drag.lastMoveTime; if (deltaTime > 0) { const instantVelocity = (drag.lastY - y) / deltaTime; drag.velocity = drag.velocity * 0.72 + instantVelocity * 0.28; } drag.lastY = y; drag.lastMoveTime = now; const index = activeIndexRef.current; const canNext = index < questionsCountRef.current - 1; const canPrev = index > 0; let offset = drag.baseOffset + (drag.startY - y); if (offset > 0 && !canNext) { offset *= RUBBER_BAND_RESISTANCE; } if (offset < 0 && !canPrev) { offset *= RUBBER_BAND_RESISTANCE; } drag.offset = offset; applyDragOffset(offset, false); }, [applyDragOffset, snapPanelsTo], ); const finishDrag = useCallback( (cancelled: boolean) => { const drag = dragRef.current; if (!drag.pointerDown) { return; } drag.pointerDown = false; touchStartYRef.current = null; if (!drag.engaged) { return; } drag.engaged = false; 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; snapPanelsTo(direction * drag.height); onQuestionExit?.(index, nextIndex); onQuestionTransition?.(index, nextIndex); setActiveIndex(nextIndex); }, [onQuestionExit, onQuestionTransition, snapPanelsTo], ); const handleTouchEnd = useCallback( (event: React.TouchEvent) => { if (document.body.classList.contains("dropdown-open")) { return; } const drag = dragRef.current; if (drag.engaged) { finishDrag(false); return; } if (!drag.pointerDown) { return; } drag.pointerDown = false; // Fallback for very fast flicks whose touchmove never engaged the // drag layer: fall back to the distance-based step. 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) < TOUCH_MIN_DISTANCE) { return; } stepQuestion(distance > 0 ? 1 : -1); }, [finishDrag, questions.length, stepQuestion], ); const handleTouchCancel = useCallback(() => { finishDrag(true); }, [finishDrag]); 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-all duration-300 ease-out px-[17px]", containerStyles, ].join(" ")} >
{question}
{isActive && ( ); })} {firstQuestionHint ? ( ) : null}
); } export default QuestionSnapList;