diff --git a/src/components/Componentes/question-snap-list.test.tsx b/src/components/Componentes/question-snap-list.test.tsx index 019bd9c..a3b5d09 100644 --- a/src/components/Componentes/question-snap-list.test.tsx +++ b/src/components/Componentes/question-snap-list.test.tsx @@ -380,6 +380,40 @@ describe("QuestionSnapList keyboard interaction", () => { expect(onActiveIndexChange).toHaveBeenCalledWith(1); }); + it("does not swipe or blur when touching and dragging inside a text input", () => { + const onActiveIndexChange = vi.fn(); + render( + +
+ +
+
+ +
+
, + ); + + const input = screen.getByRole("textbox", { name: "First Name" }); + input.focus(); + + // Large drag on input (e.g. user selecting text or scrolling within text input) + fireEvent.touchStart(input, { + touches: [{ clientX: 100, clientY: 400 }], + target: input, + }); + fireEvent.touchMove(input, { + touches: [{ clientX: 100, clientY: 200 }], + target: input, + }); + fireEvent.touchEnd(input, { + changedTouches: [{ clientX: 100, clientY: 200 }], + target: input, + }); + + expect(input).toHaveFocus(); + expect(onActiveIndexChange).not.toHaveBeenCalledWith(1); + }); + it("does not swipe on hard-ignored controls like range sliders", () => { const onActiveIndexChange = vi.fn(); render( diff --git a/src/components/Componentes/question-snap-list.tsx b/src/components/Componentes/question-snap-list.tsx index b9907b4..2e45ff8 100644 --- a/src/components/Componentes/question-snap-list.tsx +++ b/src/components/Componentes/question-snap-list.tsx @@ -16,7 +16,7 @@ import { const WHEEL_GESTURE_IDLE_MS = 320; const BACKGROUND_DRAG_SLOP = 10; -const INTERACTIVE_DRAG_SLOP = 16; +const OPTION_CARD_DRAG_SLOP = 16; const FAST_FLICK_MIN_DISTANCE = 36; const DRAG_COMMIT_RATIO = 0.3; const DRAG_FLICK_VELOCITY = 0.5; @@ -24,17 +24,27 @@ const SNAP_ANIMATION_MS = 340; const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; const RUBBER_BAND_RESISTANCE = 0.4; -const HARD_DRAG_IGNORE_SELECTOR = [ +/** + * Text inputs and direct embedded controls that MUST be completely isolated + * from the snap/drag gesture recognizer so first-tap focus, virtual keyboard, + * and caret manipulation work 100% reliably. + */ +const TEXT_INPUT_SELECTOR = [ + 'input:not([type="radio"]):not([type="checkbox"])', + "textarea", + "select", + '[contenteditable="true"]', 'input[type="range"]', "[data-snap-drag-ignore]", ].join(", "); -const INTERACTIVE_TAP_SELECTOR = [ - "input", - "textarea", - "select", - "button", +/** + * 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"]', @@ -43,13 +53,12 @@ const INTERACTIVE_TAP_SELECTOR = [ '[role="switch"]', '[role="combobox"]', '[role="listbox"]', - '[contenteditable="true"]', ].join(", "); type SnapDragState = { pointerDown: boolean; hardIgnored: boolean; - isInteractiveTap: boolean; + isOptionCard: boolean; engaged: boolean; didDrag: boolean; animating: boolean; @@ -104,7 +113,7 @@ export function QuestionSnapList({ const dragRef = useRef({ pointerDown: false, hardIgnored: false, - isInteractiveTap: false, + isOptionCard: false, engaged: false, didDrag: false, animating: false, @@ -362,6 +371,9 @@ export function QuestionSnapList({ return; } + // Always reset click suppression on any new touch start + suppressNextClickRef.current = false; + const drag = dragRef.current; if (drag.cleanupTimer !== null) { window.clearTimeout(drag.cleanupTimer); @@ -369,32 +381,33 @@ export function QuestionSnapList({ } const target = event.target as HTMLElement | null; - const isHardIgnored = Boolean( - target?.closest?.(HARD_DRAG_IGNORE_SELECTOR), - ); - const isInteractive = Boolean( - target?.closest?.(INTERACTIVE_TAP_SELECTOR), - ); - drag.hardIgnored = isHardIgnored; - drag.isInteractiveTap = isInteractive; - drag.didDrag = false; - - if (isHardIgnored) { + // 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 = containerRef.current?.getBoundingClientRect().height ?? window.innerHeight; - if (drag.animating && !isInteractive) { - // Grabbed mid-snap on background area: freeze panels and continue drag + 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); @@ -445,8 +458,8 @@ export function QuestionSnapList({ const absY = Math.abs(deltaY); if (!drag.engaged) { - const slop = drag.isInteractiveTap - ? INTERACTIVE_DRAG_SLOP + 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 @@ -524,11 +537,13 @@ export function QuestionSnapList({ } drag.engaged = false; - // Keep suppressNextClickRef active so trailing click is suppressed - suppressNextClickRef.current = true; - window.setTimeout(() => { - suppressNextClickRef.current = false; - }, 300); + // 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; @@ -585,13 +600,13 @@ export function QuestionSnapList({ } drag.pointerDown = false; - // If it was an interactive tap target with micro-movement, leave it to native focus/click! - if (drag.isInteractiveTap) { + // If it was a clean tap on an option card / button, leave it to native click! + if (drag.isOptionCard) { touchStartYRef.current = null; return; } - // Fallback for fast flicks whose touchmove never engaged continuous drag + // Fast flick fallback on background area const startY = touchStartYRef.current; const endY = event.changedTouches[0]?.clientY; touchStartYRef.current = null; @@ -624,6 +639,13 @@ export function QuestionSnapList({ const handleClickCapture = useCallback( (event: React.MouseEvent) => { + 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();