diff --git a/src/components/Componentes/question-snap-list.test.tsx b/src/components/Componentes/question-snap-list.test.tsx index 6d9fd24..fc25a4a 100644 --- a/src/components/Componentes/question-snap-list.test.tsx +++ b/src/components/Componentes/question-snap-list.test.tsx @@ -511,4 +511,105 @@ describe("QuestionSnapList keyboard interaction", () => { 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( + + + + Option 1 + + + Option 2 + + + Option 3 + + + Option 4 + + + Question 2 + , + ); + + const contentEl = container.querySelector(".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( + + + Dropdown Trigger + + Question 2 + , + ); + + const contentEl = container.querySelector(".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); + }); + }); }); diff --git a/src/components/Componentes/question-snap-list.tsx b/src/components/Componentes/question-snap-list.tsx index 5467931..d2323b7 100644 --- a/src/components/Componentes/question-snap-list.tsx +++ b/src/components/Componentes/question-snap-list.tsx @@ -60,6 +60,8 @@ type SnapDragState = { hardIgnored: boolean; isTextInput: boolean; isOptionCard: boolean; + isScrollableQuestion: boolean; + scrollContainer: HTMLElement | null; engaged: boolean; didDrag: boolean; animating: boolean; @@ -117,6 +119,8 @@ export function QuestionSnapList({ hardIgnored: false, isTextInput: false, isOptionCard: false, + isScrollableQuestion: false, + scrollContainer: null, engaged: false, didDrag: false, animating: false, @@ -144,6 +148,17 @@ export function QuestionSnapList({ return; } + const currentEl = questionRefs.current[activeIndexRef.current]; + const currentContent = currentEl?.querySelector(".question-snap-content"); + if (currentContent) { + currentContent.scrollTop = 0; + } + const nextEl = questionRefs.current[nextIndex]; + const nextContent = nextEl?.querySelector(".question-snap-content"); + if (nextContent) { + nextContent.scrollTop = 0; + } + resetQuestionKeyboardState(); onQuestionExit?.(activeIndexRef.current, nextIndex); setActiveIndex(nextIndex); @@ -424,6 +439,30 @@ export function QuestionSnapList({ return; } + const activeEl = questionRefs.current[activeIndexRef.current]; + const contentEl = activeEl?.querySelector(".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(); if (!wheelLockedRef.current) { @@ -539,6 +578,20 @@ export function QuestionSnapList({ drag.isOptionCard = isOptionCard; drag.didDrag = false; + // 3. Detect overflowing inline-options question + const activeEl = questionRefs.current[activeIndexRef.current]; + const contentEl = activeEl?.querySelector(".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 = container.getBoundingClientRect().height || window.innerHeight || @@ -570,7 +623,7 @@ export function QuestionSnapList({ 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})`); + 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) => { @@ -610,6 +663,25 @@ export function QuestionSnapList({ 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! drag.engaged = true; drag.didDrag = true; @@ -624,8 +696,8 @@ export function QuestionSnapList({ 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(); } @@ -685,8 +757,8 @@ export function QuestionSnapList({ } 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; return; } @@ -813,7 +885,9 @@ export function QuestionSnapList({ containerStyles, ].join(" ")} > - + {question} {isActive && (