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
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;
|