Browse Source

fix question list

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

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

@ -19,7 +19,7 @@ const BACKGROUND_DRAG_SLOP = 10;
const OPTION_CARD_DRAG_SLOP = 16; const OPTION_CARD_DRAG_SLOP = 16;
const FAST_FLICK_MIN_DISTANCE = 36; const FAST_FLICK_MIN_DISTANCE = 36;
const DRAG_COMMIT_RATIO = 0.3; 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_ANIMATION_MS = 340;
const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const RUBBER_BAND_RESISTANCE = 0.4; const RUBBER_BAND_RESISTANCE = 0.4;
@ -134,18 +134,18 @@ export function QuestionSnapList({
(direction: 1 | -1) => { (direction: 1 | -1) => {
const nextIndex = Math.max( const nextIndex = Math.max(
0, 0,
Math.min(questions.length - 1, activeIndex + direction),
Math.min(questions.length - 1, activeIndexRef.current + direction),
); );
if (nextIndex === activeIndex) {
if (nextIndex === activeIndexRef.current) {
return; return;
} }
resetQuestionKeyboardState(); resetQuestionKeyboardState();
onQuestionExit?.(activeIndex, nextIndex);
onQuestionExit?.(activeIndexRef.current, nextIndex);
setActiveIndex(nextIndex); setActiveIndex(nextIndex);
}, },
[activeIndex, onQuestionExit, questions.length],
[onQuestionExit, questions.length],
); );
const scheduleWheelUnlock = useCallback(() => { 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( const applyDragOffset = useCallback(
(offset: number, animate: boolean) => { (offset: number, animate: boolean) => {
const index = activeIndexRef.current; const index = activeIndexRef.current;
@ -365,13 +363,74 @@ export function QuestionSnapList({
[questions.length, scheduleWheelUnlock, stepQuestion], [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")) { if (document.body.classList.contains("dropdown-open")) {
return; return;
} }
// Always reset click suppression on any new touch start
suppressNextClickRef.current = false; suppressNextClickRef.current = false;
const drag = dragRef.current; const drag = dragRef.current;
@ -403,11 +462,11 @@ export function QuestionSnapList({
drag.didDrag = false; drag.didDrag = false;
drag.height = drag.height =
containerRef.current?.getBoundingClientRect().height ??
window.innerHeight;
container.getBoundingClientRect().height ||
window.innerHeight ||
600;
if (drag.animating && !isOptionCard) { if (drag.animating && !isOptionCard) {
// Grabbed mid-snap on non-option background: freeze panels and continue drag
const activeElement = questionRefs.current[activeIndexRef.current]; const activeElement = questionRefs.current[activeIndexRef.current];
if (activeElement) { if (activeElement) {
drag.baseOffset = -readTranslateY(activeElement); drag.baseOffset = -readTranslateY(activeElement);
@ -429,15 +488,12 @@ export function QuestionSnapList({
drag.startX = touch?.clientX ?? 0; drag.startX = touch?.clientX ?? 0;
drag.startY = touch?.clientY ?? 0; drag.startY = touch?.clientY ?? 0;
drag.lastY = drag.startY; drag.lastY = drag.startY;
drag.startTime = event.timeStamp || performance.now();
drag.startTime = performance.now();
drag.lastMoveTime = drag.startTime; drag.lastMoveTime = drag.startTime;
touchStartYRef.current = drag.startY; touchStartYRef.current = drag.startY;
},
[applyDragOffset, readTranslateY],
);
};
const handleTouchMove = useCallback(
(event: React.TouchEvent<HTMLElement>) => {
const onTouchMove = (event: TouchEvent) => {
if (document.body.classList.contains("dropdown-open")) { if (document.body.classList.contains("dropdown-open")) {
return; return;
} }
@ -457,6 +513,8 @@ export function QuestionSnapList({
const absX = Math.abs(deltaX); const absX = Math.abs(deltaX);
const absY = Math.abs(deltaY); const absY = Math.abs(deltaY);
const now = performance.now();
if (!drag.engaged) { if (!drag.engaged) {
const slop = drag.isOptionCard const slop = drag.isOptionCard
? OPTION_CARD_DRAG_SLOP ? OPTION_CARD_DRAG_SLOP
@ -477,15 +535,14 @@ export function QuestionSnapList({
drag.didDrag = true; drag.didDrag = true;
suppressNextClickRef.current = 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) {
const elapsed = Math.max(1, now - drag.startTime);
drag.velocity = (drag.startY - currentY) / elapsed; drag.velocity = (drag.startY - currentY) / elapsed;
} }
}
// Non-passive preventDefault stops native pan and guarantees gesture ownership
if (event.cancelable) {
event.preventDefault(); event.preventDefault();
}
if (event.touches.length > 1) { if (event.touches.length > 1) {
drag.engaged = false; drag.engaged = false;
@ -494,12 +551,13 @@ export function QuestionSnapList({
return; return;
} }
const now = event.timeStamp || performance.now();
const deltaTime = now - drag.lastMoveTime;
if (deltaTime > 0) {
const deltaTime = Math.max(1, now - drag.lastMoveTime);
const instantVelocity = (drag.lastY - currentY) / deltaTime; const instantVelocity = (drag.lastY - currentY) / deltaTime;
drag.velocity = drag.velocity * 0.72 + instantVelocity * 0.28;
}
drag.velocity =
drag.velocity === 0
? instantVelocity
: drag.velocity * 0.72 + instantVelocity * 0.28;
drag.lastY = currentY; drag.lastY = currentY;
drag.lastMoveTime = now; drag.lastMoveTime = now;
@ -516,67 +574,9 @@ export function QuestionSnapList({
drag.offset = offset; drag.offset = offset;
applyDragOffset(offset, false); 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")) { if (document.body.classList.contains("dropdown-open")) {
return; return;
} }
@ -622,11 +622,9 @@ export function QuestionSnapList({
} }
stepQuestion(distance > 0 ? 1 : -1); stepQuestion(distance > 0 ? 1 : -1);
},
[finishDrag, questions.length, stepQuestion],
);
};
const handleTouchCancel = useCallback(() => {
const onTouchCancel = () => {
const drag = dragRef.current; const drag = dragRef.current;
if (drag.hardIgnored) { if (drag.hardIgnored) {
drag.pointerDown = false; drag.pointerDown = false;
@ -635,7 +633,20 @@ export function QuestionSnapList({
return; return;
} }
finishDrag(true); finishDrag(true);
}, [finishDrag]);
};
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( const handleClickCapture = useCallback(
(event: React.MouseEvent<HTMLElement>) => { (event: React.MouseEvent<HTMLElement>) => {
@ -664,17 +675,13 @@ export function QuestionSnapList({
ref={containerRef} ref={containerRef}
aria-label="Questions" aria-label="Questions"
className={[ 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", "flex-1 min-h-0 pt-4 pb-4",
className, className,
] ]
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
onClickCapture={handleClickCapture} onClickCapture={handleClickCapture}
onTouchCancel={handleTouchCancel}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove}
onTouchStart={handleTouchStart}
onWheel={handleWheel} onWheel={handleWheel}
> >
{questions.map((question, index) => { {questions.map((question, index) => {

Loading…
Cancel
Save