"use client"; import { Children, isValidElement, type ReactNode, useCallback, useEffect, useRef, useState, } from "react"; 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; }; export function QuestionSnapList({ children, className, footer, firstQuestionHint, onQuestionExit, }: QuestionSnapListProps) { const questions = Children.toArray(children); const wheelLockedRef = useRef(false); const wheelUnlockTimeoutRef = useRef(null); const touchStartYRef = useRef(null); const questionRefs = useRef>([]); const [activeIndex, setActiveIndex] = useState(0); 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); setActiveIndex(nextIndex); }, [activeIndex, 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); }, []); useEffect(() => { const activeQuestion = questionRefs.current[activeIndex]; if (!activeQuestion) { return; } const focusFrame = window.requestAnimationFrame(() => { const firstFocusableInput = activeQuestion.querySelector< HTMLInputElement | HTMLTextAreaElement >(AUTO_FOCUS_SELECTOR); if (!firstFocusableInput) { return; } firstFocusableInput.focus({ preventScroll: true }); 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) => { 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) => { touchStartYRef.current = event.touches[0]?.clientY ?? null; }, [], ); const handleTouchEnd = useCallback( (event: React.TouchEvent) => { 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) => { event.preventDefault(); }, [], ); if (questions.length === 0) { return null; } return (
{questions.map((question, index) => { const offset = index - activeIndex; const isActive = offset === 0; const isAdjacent = Math.abs(offset) === 1; const showFooter = Boolean( footer && isActive && index === questions.length - 1, ); const questionKey = isValidElement(question) && question.key !== null ? question.key : String(question); return (
{ questionRefs.current[index] = element; }} aria-current={isActive ? "step" : undefined} aria-hidden={isActive ? undefined : true} inert={isActive ? undefined : true} className={[ "absolute inset-0 flex w-full items-center transition-all duration-300 ease-out", isActive ? "pointer-events-auto z-10 scale-100 opacity-100" : "pointer-events-none z-0 scale-[0.96]", !isActive && isAdjacent ? "opacity-20" : "", !isActive && !isAdjacent ? "opacity-0" : "", ].join(" ")} style={{ transform: `translateY(${offset * 50}%)` }} >
{question} {showFooter ?
{footer}
: null}
); })} {firstQuestionHint ? ( ) : null}
); } export default QuestionSnapList;