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();