You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

238 lines
6.3 KiB

"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<number | null>(null);
const touchStartYRef = useRef<number | null>(null);
const questionRefs = useRef<Array<HTMLDivElement | null>>([]);
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<HTMLElement>) => {
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<HTMLElement>) => {
touchStartYRef.current = event.touches[0]?.clientY ?? null;
},
[],
);
const handleTouchEnd = useCallback(
(event: React.TouchEvent<HTMLElement>) => {
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<HTMLElement>) => {
event.preventDefault();
},
[],
);
if (questions.length === 0) {
return null;
}
return (
<section
aria-label="Questions"
className={[
"relative touch-none overflow-hidden focus-visible:outline-none",
"h-[calc(100svh-150px)] min-h-[430px]",
className,
]
.filter(Boolean)
.join(" ")}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove}
onTouchStart={handleTouchStart}
onWheel={handleWheel}
>
{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 (
<div
key={questionKey}
ref={(element) => {
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}%)` }}
>
<div className="w-full">
{question}
{showFooter ? <div className="mt-6">{footer}</div> : null}
</div>
</div>
);
})}
{firstQuestionHint ? (
<div
aria-hidden="true"
className={[
"pointer-events-none absolute bottom-6 left-1/2 -translate-x-1/2",
"transition-opacity duration-500 motion-safe:animate-bounce",
activeIndex === 0 ? "opacity-100" : "opacity-0",
].join(" ")}
>
{firstQuestionHint}
</div>
) : null}
</section>
);
}
export default QuestionSnapList;