"use client"; import { Children, isValidElement, type ReactNode, useCallback, useEffect, useRef, useState, } from "react"; import { GoArrowDown } from "react-icons/go"; import { useI18n } from "@/translations/provider"; import { useQuestionProgress } from "./question-progress-tracker"; const WHEEL_GESTURE_IDLE_MS = 320; const TOUCH_MIN_DISTANCE = 8; const AUTO_FOCUS_SELECTOR = [ "textarea:not([disabled])", 'input[type="text"]:not([disabled])', 'input[type="number"]:not([disabled])', ].join(", "); type QuestionSnapListProps = { children: ReactNode; className?: string; footer?: ReactNode; firstQuestionHint?: ReactNode; onQuestionExit?: (currentIndex: number, nextIndex: number) => void; onQuestionTransition?: (currentIndex: number, nextIndex: number) => void; }; export function QuestionSnapList({ children, className, footer, firstQuestionHint, onQuestionExit, onQuestionTransition, }: QuestionSnapListProps) { const { dictionary: t } = useI18n(); const { isCompleted } = useQuestionProgress(); const questions = Children.toArray(children); const wheelLockedRef = useRef(false); const wheelUnlockTimeoutRef = useRef(null); const touchStartYRef = useRef(null); const questionRefs = useRef>([]); const previousActiveIndexRef = useRef(null); const [activeIndex, setActiveIndex] = useState(0); // Track whether section was already completed on mount const wasCompletedOnMountRef = useRef(null); const [justCompleted, setJustCompleted] = useState(false); useEffect(() => { if (wasCompletedOnMountRef.current === null) { // First time: capture the initial state wasCompletedOnMountRef.current = isCompleted; return; } // Only show button if it was NOT completed on mount and now becomes completed if (!wasCompletedOnMountRef.current && isCompleted) { setJustCompleted(true); } }, [isCompleted]); 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 (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(() => { const previousActiveIndex = previousActiveIndexRef.current; onQuestionTransition?.(previousActiveIndex ?? activeIndex, activeIndex); previousActiveIndexRef.current = activeIndex; }, [activeIndex, onQuestionTransition]); useEffect(() => { 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); } }; }, []); 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; } touchStartYRef.current = event.touches[0]?.clientY ?? null; }, [], ); const handleTouchEnd = useCallback( (event: React.TouchEvent) => { if (document.body.classList.contains("dropdown-open")) { return; } 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); }, [questions.length, stepQuestion], ); const handleTouchMove = useCallback( (event: React.TouchEvent) => { if (document.body.classList.contains("dropdown-open")) { return; } event.preventDefault(); }, [], ); 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"; if (isActive) { containerStyles = "top-[35%] left-0 -translate-y-1/2 z-10 opacity-100 scale-100 pointer-events-auto"; } else if (isPrev) { containerStyles = "top-4 left-0 z-0 opacity-0 pointer-events-none scale-[0.98]"; wrapperStyles = "w-full max-h-[110px] overflow-hidden pointer-events-none"; } else if (isNext) { containerStyles = "bottom-4 left-0 z-0 opacity-0 pointer-events-none scale-[0.98]"; wrapperStyles = "w-full max-h-[110px] overflow-hidden pointer-events-none"; } else { containerStyles = "top-1/2 left-0 -translate-y-1/2 z-0 opacity-0 pointer-events-none scale-[0.95]"; wrapperStyles = "w-full max-h-[110px] overflow-hidden pointer-events-none"; } return (
{ questionRefs.current[index] = element; }} aria-current={isActive ? "step" : undefined} aria-hidden={isActive ? undefined : true} inert={isActive ? undefined : true} className={[ "absolute flex w-full flex-col justify-center transition-all duration-300 ease-out px-[17px]", containerStyles, ].join(" ")} >
{question}
); })} {footer && activeIndex === questions.length - 1 ? (
{footer}
) : null} {firstQuestionHint ? ( ) : null} {justCompleted && activeIndex < questions.length - 1 ? ( ) : null}
); } export default QuestionSnapList;