Browse Source

fix question list

master
mortezaei 6 days ago
parent
commit
90209e0d71
  1. 227
      src/components/Componentes/question-snap-list.tsx

227
src/components/Componentes/question-snap-list.tsx

@ -19,7 +19,7 @@ const BACKGROUND_DRAG_SLOP = 10;
const OPTION_CARD_DRAG_SLOP = 16;
const FAST_FLICK_MIN_DISTANCE = 36;
const DRAG_COMMIT_RATIO = 0.3;
const DRAG_FLICK_VELOCITY = 0.5;
const DRAG_FLICK_VELOCITY = 0.4;
const SNAP_ANIMATION_MS = 340;
const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const RUBBER_BAND_RESISTANCE = 0.4;
@ -134,18 +134,18 @@ export function QuestionSnapList({
(direction: 1 | -1) => {
const nextIndex = Math.max(
0,
Math.min(questions.length - 1, activeIndex + direction),
Math.min(questions.length - 1, activeIndexRef.current + direction),
);
if (nextIndex === activeIndex) {
if (nextIndex === activeIndexRef.current) {
return;
}
resetQuestionKeyboardState();
onQuestionExit?.(activeIndex, nextIndex);
onQuestionExit?.(activeIndexRef.current, nextIndex);
setActiveIndex(nextIndex);
},
[activeIndex, onQuestionExit, questions.length],
[onQuestionExit, questions.length],
);
const scheduleWheelUnlock = useCallback(() => {
@ -281,8 +281,6 @@ export function QuestionSnapList({
[],
);
// 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;
@ -365,13 +363,74 @@ export function QuestionSnapList({
[questions.length, scheduleWheelUnlock, stepQuestion],
);
const handleTouchStart = useCallback(
(event: React.TouchEvent<HTMLElement>) => {
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;
if (!cancelled && (draggedFar || flicked || movedPastFlick)) {
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;
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;
}
// Always reset click suppression on any new touch start
suppressNextClickRef.current = false;
const drag = dragRef.current;
@ -403,11 +462,11 @@ export function QuestionSnapList({
drag.didDrag = false;
drag.height =
containerRef.current?.getBoundingClientRect().height ??
window.innerHeight;
container.getBoundingClientRect().height ||
window.innerHeight ||
600;
if (drag.animating && !isOptionCard) {
// Grabbed mid-snap on non-option background: freeze panels and continue drag
const activeElement = questionRefs.current[activeIndexRef.current];
if (activeElement) {
drag.baseOffset = -readTranslateY(activeElement);
@ -429,15 +488,12 @@ export function QuestionSnapList({
drag.startX = touch?.clientX ?? 0;
drag.startY = touch?.clientY ?? 0;
drag.lastY = drag.startY;
drag.startTime = event.timeStamp || performance.now();
drag.startTime = performance.now();
drag.lastMoveTime = drag.startTime;
touchStartYRef.current = drag.startY;
},
[applyDragOffset, readTranslateY],
);
};
const handleTouchMove = useCallback(
(event: React.TouchEvent<HTMLElement>) => {
const onTouchMove = (event: TouchEvent) => {
if (document.body.classList.contains("dropdown-open")) {
return;
}
@ -457,6 +513,8 @@ export function QuestionSnapList({
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
@ -477,15 +535,14 @@ export function QuestionSnapList({
drag.didDrag = true;
suppressNextClickRef.current = true;
// Seed initial velocity from total drag trajectory so flick filter doesn't start sluggishly at 0
const now = event.timeStamp || performance.now();
const elapsed = now - drag.startTime;
if (elapsed > 0) {
drag.velocity = (drag.startY - currentY) / elapsed;
}
const elapsed = Math.max(1, now - drag.startTime);
drag.velocity = (drag.startY - currentY) / elapsed;
}
event.preventDefault();
// Non-passive preventDefault stops native pan and guarantees gesture ownership
if (event.cancelable) {
event.preventDefault();
}
if (event.touches.length > 1) {
drag.engaged = false;
@ -494,12 +551,13 @@ export function QuestionSnapList({
return;
}
const now = event.timeStamp || performance.now();
const deltaTime = now - drag.lastMoveTime;
if (deltaTime > 0) {
const instantVelocity = (drag.lastY - currentY) / deltaTime;
drag.velocity = drag.velocity * 0.72 + instantVelocity * 0.28;
}
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;
@ -516,67 +574,9 @@ export function QuestionSnapList({
drag.offset = offset;
applyDragOffset(offset, false);
},
[applyDragOffset, snapPanelsTo],
);
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;
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;
resetQuestionKeyboardState();
snapPanelsTo(direction * drag.height);
onQuestionExit?.(index, nextIndex);
setActiveIndex(nextIndex);
},
[onQuestionExit, snapPanelsTo],
);
};
const handleTouchEnd = useCallback(
(event: React.TouchEvent<HTMLElement>) => {
const onTouchEnd = (event: TouchEvent) => {
if (document.body.classList.contains("dropdown-open")) {
return;
}
@ -622,20 +622,31 @@ export function QuestionSnapList({
}
stepQuestion(distance > 0 ? 1 : -1);
},
[finishDrag, questions.length, stepQuestion],
);
};
const handleTouchCancel = useCallback(() => {
const drag = dragRef.current;
if (drag.hardIgnored) {
drag.pointerDown = false;
drag.engaged = false;
touchStartYRef.current = null;
return;
}
finishDrag(true);
}, [finishDrag]);
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>) => {
@ -664,17 +675,13 @@ export function QuestionSnapList({
ref={containerRef}
aria-label="Questions"
className={[
"question-snap-list relative touch-none overflow-hidden focus-visible:outline-none",
"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}
onTouchCancel={handleTouchCancel}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove}
onTouchStart={handleTouchStart}
onWheel={handleWheel}
>
{questions.map((question, index) => {

Loading…
Cancel
Save