Browse Source

feat(question-snap-list): enable vertical scroll for overflowing inline options questions before page snap

master
mortezaei 2 days ago
parent
commit
3317ed3f06
  1. 101
      src/components/Componentes/question-snap-list.test.tsx
  2. 86
      src/components/Componentes/question-snap-list.tsx

101
src/components/Componentes/question-snap-list.test.tsx

@ -511,4 +511,105 @@ describe("QuestionSnapList keyboard interaction", () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
}); });
describe("Overflowing inline-options vs non-overflowing/sheet questions", () => {
it("allows inner scrolling for overflowing radio questions before snapping to next question", () => {
const onActiveIndexChange = vi.fn();
const { container } = render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<label>
<input type="radio" name="opt" value="1" /> Option 1
</label>
<label>
<input type="radio" name="opt" value="2" /> Option 2
</label>
<label>
<input type="radio" name="opt" value="3" /> Option 3
</label>
<label>
<input type="radio" name="opt" value="4" /> Option 4
</label>
</div>
<div>Question 2</div>
</QuestionSnapList>,
);
const contentEl = container.querySelector<HTMLElement>(".question-snap-content");
expect(contentEl).not.toBeNull();
if (contentEl) {
// Mock overflowing height: scrollHeight 600px > clientHeight 400px (maxScroll = 200px)
Object.defineProperty(contentEl, "scrollHeight", { value: 600, configurable: true });
Object.defineProperty(contentEl, "clientHeight", { value: 400, configurable: true });
contentEl.scrollTop = 0;
}
const region = screen.getByRole("region", { name: "Questions" });
// Drag up while at top (scrollTop = 0 < maxScroll = 200): should NOT flip question
fireEvent.touchStart(region, {
touches: [{ clientX: 100, clientY: 300 }],
});
fireEvent.touchMove(region, {
touches: [{ clientX: 100, clientY: 200 }],
});
fireEvent.touchEnd(region, {
changedTouches: [{ clientX: 100, clientY: 200 }],
});
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
// Now simulate user having scrolled to bottom of options (scrollTop = 200)
if (contentEl) {
contentEl.scrollTop = 200;
}
// Drag up at the bottom: now it SHOULD snap to next question
fireEvent.touchStart(region, {
touches: [{ clientX: 100, clientY: 300 }],
});
fireEvent.touchMove(region, {
touches: [{ clientX: 100, clientY: 150 }],
});
fireEvent.touchEnd(region, {
changedTouches: [{ clientX: 100, clientY: 150 }],
});
expect(onActiveIndexChange).toHaveBeenCalledWith(1);
});
it("immediately snaps for non-overflowing questions (e.g. dropdown or short question)", () => {
const onActiveIndexChange = vi.fn();
const { container } = render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<button>Dropdown Trigger</button>
</div>
<div>Question 2</div>
</QuestionSnapList>,
);
const contentEl = container.querySelector<HTMLElement>(".question-snap-content");
if (contentEl) {
// Fits in screen: scrollHeight 300 <= clientHeight 400
Object.defineProperty(contentEl, "scrollHeight", { value: 300, configurable: true });
Object.defineProperty(contentEl, "clientHeight", { value: 400, configurable: true });
}
const region = screen.getByRole("region", { name: "Questions" });
fireEvent.touchStart(region, {
touches: [{ clientX: 100, clientY: 300 }],
});
fireEvent.touchMove(region, {
touches: [{ clientX: 100, clientY: 150 }],
});
fireEvent.touchEnd(region, {
changedTouches: [{ clientX: 100, clientY: 150 }],
});
expect(onActiveIndexChange).toHaveBeenCalledWith(1);
});
});
}); });

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

@ -60,6 +60,8 @@ type SnapDragState = {
hardIgnored: boolean; hardIgnored: boolean;
isTextInput: boolean; isTextInput: boolean;
isOptionCard: boolean; isOptionCard: boolean;
isScrollableQuestion: boolean;
scrollContainer: HTMLElement | null;
engaged: boolean; engaged: boolean;
didDrag: boolean; didDrag: boolean;
animating: boolean; animating: boolean;
@ -117,6 +119,8 @@ export function QuestionSnapList({
hardIgnored: false, hardIgnored: false,
isTextInput: false, isTextInput: false,
isOptionCard: false, isOptionCard: false,
isScrollableQuestion: false,
scrollContainer: null,
engaged: false, engaged: false,
didDrag: false, didDrag: false,
animating: false, animating: false,
@ -144,6 +148,17 @@ export function QuestionSnapList({
return; return;
} }
const currentEl = questionRefs.current[activeIndexRef.current];
const currentContent = currentEl?.querySelector<HTMLElement>(".question-snap-content");
if (currentContent) {
currentContent.scrollTop = 0;
}
const nextEl = questionRefs.current[nextIndex];
const nextContent = nextEl?.querySelector<HTMLElement>(".question-snap-content");
if (nextContent) {
nextContent.scrollTop = 0;
}
resetQuestionKeyboardState(); resetQuestionKeyboardState();
onQuestionExit?.(activeIndexRef.current, nextIndex); onQuestionExit?.(activeIndexRef.current, nextIndex);
setActiveIndex(nextIndex); setActiveIndex(nextIndex);
@ -424,6 +439,30 @@ export function QuestionSnapList({
return; return;
} }
const activeEl = questionRefs.current[activeIndexRef.current];
const contentEl = activeEl?.querySelector<HTMLElement>(".question-snap-content");
const hasInlineOptions = Boolean(
activeEl?.querySelector('input[type="radio"], input[type="checkbox"]')
);
const isOverflowing =
hasInlineOptions &&
contentEl != null &&
contentEl.scrollHeight > contentEl.clientHeight + 6;
if (isOverflowing && contentEl) {
const currentScroll = contentEl.scrollTop;
const maxScroll = contentEl.scrollHeight - contentEl.clientHeight;
const isScrollingDown = delta > 0;
const isScrollingUp = delta < 0;
if (isScrollingDown && currentScroll < maxScroll - 4) {
return;
}
if (isScrollingUp && currentScroll > 4) {
return;
}
}
event.preventDefault(); event.preventDefault();
if (!wheelLockedRef.current) { if (!wheelLockedRef.current) {
@ -539,6 +578,20 @@ export function QuestionSnapList({
drag.isOptionCard = isOptionCard; drag.isOptionCard = isOptionCard;
drag.didDrag = false; drag.didDrag = false;
// 3. Detect overflowing inline-options question
const activeEl = questionRefs.current[activeIndexRef.current];
const contentEl = activeEl?.querySelector<HTMLElement>(".question-snap-content");
const hasInlineOptions = Boolean(
activeEl?.querySelector('input[type="radio"], input[type="checkbox"]')
);
const isOverflowing =
hasInlineOptions &&
contentEl != null &&
contentEl.scrollHeight > contentEl.clientHeight + 6;
drag.isScrollableQuestion = Boolean(isOverflowing);
drag.scrollContainer = isOverflowing ? contentEl : null;
drag.height = drag.height =
container.getBoundingClientRect().height || container.getBoundingClientRect().height ||
window.innerHeight || window.innerHeight ||
@ -570,7 +623,7 @@ export function QuestionSnapList({
drag.lastMoveTime = drag.startTime; drag.lastMoveTime = drag.startTime;
touchStartYRef.current = drag.startY; touchStartYRef.current = drag.startY;
console.log(`[Snap] TouchStart at (${drag.startX.toFixed(0)}, ${drag.startY.toFixed(0)}) on <${target?.tagName?.toLowerCase()}> (isOption: ${isOptionCard})`);
console.log(`[Snap] TouchStart at (${drag.startX.toFixed(0)}, ${drag.startY.toFixed(0)}) on <${target?.tagName?.toLowerCase()}> (isOption: ${isOptionCard}, isScrollable: ${drag.isScrollableQuestion})`);
}; };
const onTouchMove = (event: TouchEvent) => { const onTouchMove = (event: TouchEvent) => {
@ -610,6 +663,25 @@ export function QuestionSnapList({
return; return;
} }
// If this question has inline options that overflow the screen:
if (drag.isScrollableQuestion && drag.scrollContainer) {
const scrollEl = drag.scrollContainer;
const currentScroll = scrollEl.scrollTop;
const maxScroll = scrollEl.scrollHeight - scrollEl.clientHeight;
const isDraggingUp = deltaY < 0; // Finger moving up -> scrolling down to view lower options
const isDraggingDown = deltaY > 0; // Finger moving down -> scrolling up towards top
if (isDraggingUp && currentScroll < maxScroll - 4) {
// Not at the bottom yet! Allow internal scroll of options to continue without snapping page.
return;
}
if (isDraggingDown && currentScroll > 4) {
// Not at the top yet! Allow internal scroll back to top without snapping page.
return;
}
}
// Pager claims the gesture! // Pager claims the gesture!
drag.engaged = true; drag.engaged = true;
drag.didDrag = true; drag.didDrag = true;
@ -624,8 +696,8 @@ export function QuestionSnapList({
console.log(`[Snap] Drag Engaged: deltaY=${deltaY.toFixed(0)}px, slop=${slop}px`); 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) {
// Non-passive preventDefault stops native pan only when pager has claimed the gesture
if (drag.engaged && event.cancelable) {
event.preventDefault(); event.preventDefault();
} }
@ -685,8 +757,8 @@ export function QuestionSnapList({
} }
drag.pointerDown = false; drag.pointerDown = false;
// If it was a clean tap on an option card / button, leave it to native click!
if (drag.isOptionCard) {
// If it was a clean tap on an option card / button or an internal scroll on overflowing question, leave it!
if (drag.isOptionCard || drag.isScrollableQuestion) {
touchStartYRef.current = null; touchStartYRef.current = null;
return; return;
} }
@ -813,7 +885,9 @@ export function QuestionSnapList({
containerStyles, containerStyles,
].join(" ")} ].join(" ")}
> >
<div className={`question-snap-content ${wrapperStyles}`}>
<div
className={`question-snap-content ${wrapperStyles} overflow-y-auto max-h-full overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden`}
>
{question} {question}
</div> </div>
{isActive && ( {isActive && (

Loading…
Cancel
Save