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.
 
 
 
 
 

685 lines
19 KiB

"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<number | null>(null);
const touchStartYRef = useRef<number | null>(null);
const questionRefs = useRef<Array<HTMLDivElement | null>>([]);
const containerRef = useRef<HTMLElement | null>(null);
const previousActiveIndexRef = useRef<number | null>(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<SnapDragState>({
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<number>([
...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<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLElement>) => {
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 (
<section
ref={containerRef}
aria-label="Questions"
className={[
"question-snap-list relative touch-none overflow-hidden focus-visible:outline-none",
"flex-1 min-h-0 pt-4 pb-4",
className,
]
.filter(Boolean)
.join(" ")}
onTouchCancel={handleTouchCancel}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove}
onTouchStart={handleTouchStart}
onWheel={handleWheel}
>
{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 (
<div
key={questionKey}
ref={(element) => {
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(" ")}
>
<div className={`question-snap-content ${wrapperStyles}`}>
{question}
</div>
{isActive && (
<div
className="question-snap-spacer h-[20%] shrink min-h-0"
aria-hidden="true"
/>
)}
</div>
);
})}
{firstQuestionHint ? (
<div
aria-hidden="true"
className={[
"pointer-events-none absolute bottom-24 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;