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.
773 lines
22 KiB
773 lines
22 KiB
"use client";
|
|
|
|
import {
|
|
Children,
|
|
isValidElement,
|
|
type ReactNode,
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import {
|
|
resetQuestionKeyboardState,
|
|
useQuestionViewportCoordinator,
|
|
} from "./question-viewport-coordinator";
|
|
|
|
const WHEEL_GESTURE_IDLE_MS = 280;
|
|
const BACKGROUND_DRAG_SLOP = 8;
|
|
const OPTION_CARD_DRAG_SLOP = 14;
|
|
const TEXT_INPUT_DRAG_SLOP = 16;
|
|
const FAST_FLICK_MIN_DISTANCE = 24;
|
|
const DRAG_COMMIT_RATIO = 0.15;
|
|
const DRAG_FLICK_VELOCITY = 0.22;
|
|
const SNAP_ANIMATION_MS = 240;
|
|
const SNAP_EASE = "cubic-bezier(0.16, 1, 0.3, 1)";
|
|
const RUBBER_BAND_RESISTANCE = 0.35;
|
|
|
|
/**
|
|
* Text inputs and direct embedded controls that MUST allow clean 1st-tap focus
|
|
* while still permitting intentional vertical swipe when dragged beyond slop.
|
|
*/
|
|
const TEXT_INPUT_SELECTOR = [
|
|
'input:not([type="radio"]):not([type="checkbox"])',
|
|
"textarea",
|
|
"select",
|
|
'[contenteditable="true"]',
|
|
'input[type="range"]',
|
|
"[data-snap-drag-ignore]",
|
|
].join(", ");
|
|
|
|
/**
|
|
* Option cards, radio/checkbox labels, and buttons on choice questions
|
|
* where tap = select option, but vertical drag > slop = swipe question.
|
|
*/
|
|
const OPTION_CARD_SELECTOR = [
|
|
"label",
|
|
"button",
|
|
"a",
|
|
'[role="button"]',
|
|
'[role="option"]',
|
|
'[role="checkbox"]',
|
|
'[role="radio"]',
|
|
'[role="switch"]',
|
|
'[role="combobox"]',
|
|
'[role="listbox"]',
|
|
].join(", ");
|
|
|
|
type SnapDragState = {
|
|
pointerDown: boolean;
|
|
hardIgnored: boolean;
|
|
isTextInput: boolean;
|
|
isOptionCard: boolean;
|
|
engaged: boolean;
|
|
didDrag: boolean;
|
|
animating: boolean;
|
|
baseOffset: number;
|
|
startX: number;
|
|
startY: number;
|
|
lastY: number;
|
|
startTime: 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) {
|
|
useQuestionViewportCoordinator();
|
|
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 suppressNextClickRef = useRef(false);
|
|
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,
|
|
hardIgnored: false,
|
|
isTextInput: false,
|
|
isOptionCard: false,
|
|
engaged: false,
|
|
didDrag: false,
|
|
animating: false,
|
|
baseOffset: 0,
|
|
startX: 0,
|
|
startY: 0,
|
|
lastY: 0,
|
|
startTime: 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, activeIndexRef.current + direction),
|
|
);
|
|
|
|
if (nextIndex === activeIndexRef.current) {
|
|
return;
|
|
}
|
|
|
|
resetQuestionKeyboardState();
|
|
onQuestionExit?.(activeIndexRef.current, nextIndex);
|
|
setActiveIndex(nextIndex);
|
|
},
|
|
[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);
|
|
}, []);
|
|
|
|
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;
|
|
if (previousActiveIndex !== null && previousActiveIndex !== activeIndex) {
|
|
onQuestionTransition?.(previousActiveIndex, activeIndex);
|
|
}
|
|
previousActiveIndexRef.current = activeIndex;
|
|
}, [activeIndex, onQuestionTransition]);
|
|
|
|
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";
|
|
},
|
|
[],
|
|
);
|
|
|
|
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 finishDrag = useCallback(
|
|
(cancelled: boolean) => {
|
|
const drag = dragRef.current;
|
|
if (!drag.pointerDown || drag.hardIgnored) {
|
|
drag.pointerDown = false;
|
|
drag.engaged = false;
|
|
touchStartYRef.current = null;
|
|
return;
|
|
}
|
|
drag.pointerDown = false;
|
|
touchStartYRef.current = null;
|
|
|
|
if (!drag.engaged) {
|
|
return;
|
|
}
|
|
drag.engaged = false;
|
|
|
|
// Keep suppressNextClickRef active briefly so trailing click from drag is suppressed
|
|
if (drag.didDrag) {
|
|
suppressNextClickRef.current = true;
|
|
window.setTimeout(() => {
|
|
suppressNextClickRef.current = false;
|
|
}, 60);
|
|
}
|
|
|
|
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;
|
|
const movedPastFlick = Math.abs(drag.offset) >= FAST_FLICK_MIN_DISTANCE;
|
|
|
|
let direction: 0 | 1 | -1 = 0;
|
|
let reason = "none";
|
|
if (!cancelled && (draggedFar || flicked || movedPastFlick)) {
|
|
if ((drag.offset > 0 || drag.velocity > 0) && canNext) {
|
|
direction = 1;
|
|
reason = flicked ? "flick_next" : (draggedFar ? "drag_far_next" : "moved_past_flick_next");
|
|
} else if ((drag.offset < 0 || drag.velocity < 0) && canPrev) {
|
|
direction = -1;
|
|
reason = flicked ? "flick_prev" : (draggedFar ? "drag_far_prev" : "moved_past_flick_prev");
|
|
}
|
|
}
|
|
|
|
console.log(`[Snap] FinishDrag -> dir: ${direction} (${reason}) | offset: ${drag.offset.toFixed(0)}px | vel: ${drag.velocity.toFixed(2)} | threshold: ${(drag.height * DRAG_COMMIT_RATIO).toFixed(0)}px`);
|
|
|
|
if (direction === 0) {
|
|
snapPanelsTo(0);
|
|
return;
|
|
}
|
|
|
|
const nextIndex = index + direction;
|
|
resetQuestionKeyboardState();
|
|
snapPanelsTo(direction * drag.height);
|
|
onQuestionExit?.(index, nextIndex);
|
|
setActiveIndex(nextIndex);
|
|
},
|
|
[onQuestionExit, snapPanelsTo],
|
|
);
|
|
|
|
// Native non-passive touch listeners on container element.
|
|
// This allows the container to have `touch-action: pan-y` (enabling instant 1st-tap focus on inputs)
|
|
// while allowing non-passive `event.preventDefault()` during swipe to prevent WebView native pan cancels.
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
const onTouchStart = (event: TouchEvent) => {
|
|
if (document.body.classList.contains("dropdown-open")) {
|
|
return;
|
|
}
|
|
|
|
suppressNextClickRef.current = false;
|
|
|
|
const drag = dragRef.current;
|
|
if (drag.cleanupTimer !== null) {
|
|
window.clearTimeout(drag.cleanupTimer);
|
|
drag.cleanupTimer = null;
|
|
}
|
|
|
|
const target = event.target as HTMLElement | null;
|
|
|
|
// 1. Hard-exclude text inputs and standalone controls
|
|
const isTextInput = Boolean(target?.closest?.(TEXT_INPUT_SELECTOR));
|
|
if (isTextInput) {
|
|
drag.pointerDown = false;
|
|
drag.engaged = false;
|
|
drag.hardIgnored = true;
|
|
drag.isOptionCard = false;
|
|
drag.didDrag = false;
|
|
drag.baseOffset = 0;
|
|
drag.offset = 0;
|
|
touchStartYRef.current = null;
|
|
return;
|
|
}
|
|
|
|
// 2. Identify option cards / buttons for soft ownership
|
|
const isOptionCard = Boolean(target?.closest?.(OPTION_CARD_SELECTOR));
|
|
drag.hardIgnored = false;
|
|
drag.isOptionCard = isOptionCard;
|
|
drag.didDrag = false;
|
|
|
|
drag.height =
|
|
container.getBoundingClientRect().height ||
|
|
window.innerHeight ||
|
|
600;
|
|
|
|
if (drag.animating && !isOptionCard) {
|
|
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;
|
|
const touch = event.touches[0];
|
|
drag.startX = touch?.clientX ?? 0;
|
|
drag.startY = touch?.clientY ?? 0;
|
|
drag.lastY = drag.startY;
|
|
drag.startTime = performance.now();
|
|
drag.lastMoveTime = drag.startTime;
|
|
touchStartYRef.current = drag.startY;
|
|
|
|
console.log(`[Snap] TouchStart at (${drag.startX.toFixed(0)}, ${drag.startY.toFixed(0)}) on <${target?.tagName?.toLowerCase()}> (isOption: ${isOptionCard})`);
|
|
};
|
|
|
|
const onTouchMove = (event: TouchEvent) => {
|
|
if (document.body.classList.contains("dropdown-open")) {
|
|
return;
|
|
}
|
|
|
|
const drag = dragRef.current;
|
|
|
|
if (!drag.pointerDown || drag.hardIgnored) {
|
|
return;
|
|
}
|
|
|
|
const touch = event.touches[0];
|
|
const currentX = touch?.clientX ?? drag.startX;
|
|
const currentY = touch?.clientY ?? drag.lastY;
|
|
|
|
const deltaX = currentX - drag.startX;
|
|
const deltaY = currentY - drag.startY;
|
|
const absX = Math.abs(deltaX);
|
|
const absY = Math.abs(deltaY);
|
|
|
|
const now = performance.now();
|
|
|
|
if (!drag.engaged) {
|
|
const slop = drag.isOptionCard
|
|
? OPTION_CARD_DRAG_SLOP
|
|
: BACKGROUND_DRAG_SLOP;
|
|
|
|
// If movement is under slop threshold, do not engage: let native clicks/focus happen
|
|
if (absY < slop) {
|
|
return;
|
|
}
|
|
|
|
// Must be primarily vertical gesture (avoid hijacking horizontal swipe / scroll)
|
|
if (absY < absX * 0.9) {
|
|
return;
|
|
}
|
|
|
|
// Pager claims the gesture!
|
|
drag.engaged = true;
|
|
drag.didDrag = true;
|
|
suppressNextClickRef.current = true;
|
|
|
|
if (document.activeElement instanceof HTMLElement) {
|
|
document.activeElement.blur();
|
|
}
|
|
|
|
const elapsed = Math.max(1, now - drag.startTime);
|
|
drag.velocity = (drag.startY - currentY) / elapsed;
|
|
console.log(`[Snap] Drag Engaged: deltaY=${deltaY.toFixed(0)}px, slop=${slop}px`);
|
|
}
|
|
|
|
// Non-passive preventDefault stops native pan and guarantees gesture ownership
|
|
if (event.cancelable) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
if (event.touches.length > 1) {
|
|
drag.engaged = false;
|
|
drag.pointerDown = false;
|
|
snapPanelsTo(0);
|
|
return;
|
|
}
|
|
|
|
const deltaTime = Math.max(1, now - drag.lastMoveTime);
|
|
const instantVelocity = (drag.lastY - currentY) / deltaTime;
|
|
drag.velocity =
|
|
drag.velocity === 0
|
|
? instantVelocity
|
|
: drag.velocity * 0.72 + instantVelocity * 0.28;
|
|
|
|
drag.lastY = currentY;
|
|
drag.lastMoveTime = now;
|
|
|
|
const index = activeIndexRef.current;
|
|
const canNext = index < questionsCountRef.current - 1;
|
|
const canPrev = index > 0;
|
|
let offset = drag.baseOffset + (drag.startY - currentY);
|
|
if (offset > 0 && !canNext) {
|
|
offset *= RUBBER_BAND_RESISTANCE;
|
|
}
|
|
if (offset < 0 && !canPrev) {
|
|
offset *= RUBBER_BAND_RESISTANCE;
|
|
}
|
|
drag.offset = offset;
|
|
|
|
applyDragOffset(offset, false);
|
|
};
|
|
|
|
const onTouchEnd = (event: TouchEvent) => {
|
|
if (document.body.classList.contains("dropdown-open")) {
|
|
return;
|
|
}
|
|
|
|
const drag = dragRef.current;
|
|
|
|
if (drag.hardIgnored) {
|
|
drag.pointerDown = false;
|
|
drag.engaged = false;
|
|
touchStartYRef.current = null;
|
|
return;
|
|
}
|
|
|
|
if (drag.engaged) {
|
|
finishDrag(false);
|
|
return;
|
|
}
|
|
|
|
if (!drag.pointerDown) {
|
|
return;
|
|
}
|
|
drag.pointerDown = false;
|
|
|
|
// If it was a clean tap on an option card / button, leave it to native click!
|
|
if (drag.isOptionCard) {
|
|
touchStartYRef.current = null;
|
|
return;
|
|
}
|
|
|
|
// Fast flick fallback on background area
|
|
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) < FAST_FLICK_MIN_DISTANCE) {
|
|
return;
|
|
}
|
|
|
|
stepQuestion(distance > 0 ? 1 : -1);
|
|
};
|
|
|
|
const onTouchCancel = () => {
|
|
const drag = dragRef.current;
|
|
if (drag.hardIgnored) {
|
|
drag.pointerDown = false;
|
|
drag.engaged = false;
|
|
touchStartYRef.current = null;
|
|
return;
|
|
}
|
|
finishDrag(true);
|
|
};
|
|
|
|
container.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
container.addEventListener("touchmove", onTouchMove, { passive: false });
|
|
container.addEventListener("touchend", onTouchEnd, { passive: true });
|
|
container.addEventListener("touchcancel", onTouchCancel, { passive: true });
|
|
|
|
return () => {
|
|
container.removeEventListener("touchstart", onTouchStart);
|
|
container.removeEventListener("touchmove", onTouchMove);
|
|
container.removeEventListener("touchend", onTouchEnd);
|
|
container.removeEventListener("touchcancel", onTouchCancel);
|
|
};
|
|
}, [applyDragOffset, finishDrag, questions.length, readTranslateY, snapPanelsTo, stepQuestion]);
|
|
|
|
const handleClickCapture = useCallback(
|
|
(event: React.MouseEvent<HTMLElement>) => {
|
|
const target = event.target as HTMLElement | null;
|
|
const isTextInput = Boolean(target?.closest?.(TEXT_INPUT_SELECTOR));
|
|
if (isTextInput) {
|
|
suppressNextClickRef.current = false;
|
|
return;
|
|
}
|
|
|
|
if (suppressNextClickRef.current) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
suppressNextClickRef.current = false;
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
if (questions.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<section
|
|
ref={containerRef}
|
|
aria-label="Questions"
|
|
className={[
|
|
"question-snap-list relative touch-pan-y overflow-hidden focus-visible:outline-none",
|
|
"flex-1 min-h-0 pt-4 pb-4",
|
|
className,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
onClickCapture={handleClickCapture}
|
|
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-transform duration-300 ease-out px-[17px] will-change-transform [backface-visibility:hidden]",
|
|
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;
|