Browse Source

refactor: improve touch-to-focus reliability by isolating interactive elements from the drag gesture and adjusting snap thresholds.

master
mortezaei 6 days ago
parent
commit
872abf8b79
  1. 65
      src/components/Componentes/dev-tap-instrumentation.tsx
  2. 2
      src/components/Componentes/question-section-flow.tsx
  3. 126
      src/components/Componentes/question-snap-list.test.tsx
  4. 51
      src/components/Componentes/question-snap-list.tsx

65
src/components/Componentes/dev-tap-instrumentation.tsx

@ -4,38 +4,79 @@ import { useEffect } from "react";
/**
* Development-only capture-phase instrumentation for debugging section-card
* tap responsiveness. Records pointerdown pointerup click timing and
* whether the event reaches the DOM at all (vs being swallowed by a native
* overlay such as Flutter's loading cover).
* and question flow tap/focus responsiveness. Records pointerdown pointerup click,
* touchstart touchmove touchend, and focusin/focusout lifecycle events.
*
* Mount this inside the questions-list page during development. Remove or
* gate behind process.env.NODE_ENV check for production.
* Mount this inside the questions-list and question detail flows during development.
* Automatically inactive outside of development.
*/
export default function DevTapInstrumentation() {
useEffect(() => {
if (process.env.NODE_ENV !== "development") return;
const events = ["pointerdown", "pointerup", "pointercancel", "click"] as const;
const events = [
"pointerdown",
"pointerup",
"pointercancel",
"click",
"touchstart",
"touchmove",
"touchend",
"touchcancel",
"focusin",
"focusout",
] as const;
const startTime = performance.now();
let sequenceId = 0;
let touchStartY: number | null = null;
const handler = (event: Event) => {
const e = event as PointerEvent;
const target = e.target as HTMLElement | null;
const target = event.target as HTMLElement | null;
const anchor = target?.closest?.("a");
const elapsed = (performance.now() - startTime).toFixed(1);
sequenceId += 1;
let deltaY: number | null = null;
if (event.type === "touchstart") {
touchStartY = (event as TouchEvent).touches?.[0]?.clientY ?? null;
} else if (event.type === "touchmove" || event.type === "touchend") {
const currentY =
(event as TouchEvent).touches?.[0]?.clientY ??
(event as TouchEvent).changedTouches?.[0]?.clientY ??
null;
if (touchStartY !== null && currentY !== null) {
deltaY = Math.round(touchStartY - currentY);
}
}
const isInteractive = Boolean(
target?.closest?.(
'input, textarea, select, button, label, a, [role="button"], [role="option"], [role="checkbox"], [role="radio"], [role="switch"], [role="combobox"], [role="listbox"], [contenteditable="true"]',
),
);
const color = event.type.startsWith("focus")
? "#0EB13C"
: event.type.startsWith("touch")
? "#3B82F6"
: "#E03950";
console.debug(
`[tap-debug #${sequenceId}] %c${e.type}%c @ ${elapsed}ms`,
"color: #E03950; font-weight: bold",
`[tap-debug #${sequenceId}] %c${event.type}%c @ ${elapsed}ms`,
`color: ${color}; font-weight: bold`,
"color: inherit",
{
pointerType: (e as PointerEvent).pointerType || "n/a",
type: event.type,
target: target?.tagName,
targetId: target?.id,
targetClass: target?.className,
activeElement: document.activeElement?.tagName,
activeElementId: document.activeElement?.id,
isInteractive,
deltaY,
closestAnchorHref: anchor?.getAttribute("href") || null,
defaultPrevented: e.defaultPrevented,
defaultPrevented: event.defaultPrevented,
pathname: window.location.pathname,
timestamp: performance.now(),
},

2
src/components/Componentes/question-section-flow.tsx

@ -17,6 +17,7 @@ import AnswerPaceSheet from "@/app/questions-list/[slug]/answer-pace-sheet";
import { FixToTheEnd } from "./fix-to-the-end";
import Button from "./button";
import { markFirstEntryCompleted } from "@/lib/first-entry-helper";
import DevTapInstrumentation from "./dev-tap-instrumentation";
type QuestionSectionFlowProps = {
children: ReactNode;
@ -133,6 +134,7 @@ function SectionFlowContent({
{children}
</QuestionSnapList>
{process.env.NODE_ENV === "development" ? <DevTapInstrumentation /> : null}
<FixToTheEnd>
<Button
disabled={!isCompleted || isSubmitting}

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

@ -183,4 +183,130 @@ describe("QuestionSnapList keyboard interaction", () => {
expect(input).toHaveFocus();
});
});
describe("touch isolation and first-tap focus reliability", () => {
it("does not step question or blur active input when touch has jitter on input", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<label htmlFor="name-input">Full Name</label>
<input id="name-input" aria-label="Full Name" type="text" />
</div>
<div>
<input aria-label="Second Question" type="text" />
</div>
</QuestionSnapList>,
);
const input = screen.getByRole("textbox", { name: "Full Name" });
input.focus();
expect(input).toHaveFocus();
// Finger touches input with 12px jitter/drift (typical coarse touch contact area shift)
fireEvent.touchStart(input, {
touches: [{ clientY: 300 }],
target: input,
});
fireEvent.touchMove(input, {
touches: [{ clientY: 288 }],
target: input,
});
fireEvent.touchEnd(input, {
changedTouches: [{ clientY: 288 }],
target: input,
});
// Input should remain focused, and active question must remain index 0
expect(input).toHaveFocus();
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
});
it("does not engage drag or blur input when touched while snap animation is settling", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<textarea aria-label="Bio description" />
</div>
<div>
<textarea aria-label="Second Bio" />
</div>
</QuestionSnapList>,
);
const textarea = screen.getByRole("textbox", {
name: "Bio description",
});
textarea.focus();
// Simulate touching during animation or right as question is rendered
fireEvent.touchStart(textarea, {
touches: [{ clientY: 400 }],
target: textarea,
});
fireEvent.touchEnd(textarea, {
changedTouches: [{ clientY: 390 }],
target: textarea,
});
expect(textarea).toHaveFocus();
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
});
it("does not step question on buttons or labels", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<button type="button">Select Option</button>
</div>
<div>
<button type="button">Next Step</button>
</div>
</QuestionSnapList>,
);
const button = screen.getByRole("button", { name: "Select Option" });
fireEvent.touchStart(button, {
touches: [{ clientY: 250 }],
target: button,
});
fireEvent.touchMove(button, {
touches: [{ clientY: 235 }],
target: button,
});
fireEvent.touchEnd(button, {
changedTouches: [{ clientY: 235 }],
target: button,
});
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
});
it("allows intentional vertical swipe on non-interactive question background", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div data-testid="slide-1">Slide 1</div>
<div data-testid="slide-2">Slide 2</div>
</QuestionSnapList>,
);
const region = screen.getByRole("region", { name: "Questions" });
// Fast upward flick on background area (startY = 400, endY = 320 -> delta = 80 > TOUCH_MIN_DISTANCE)
fireEvent.touchStart(region, {
touches: [{ clientY: 400 }],
target: region,
});
fireEvent.touchEnd(region, {
changedTouches: [{ clientY: 320 }],
target: region,
});
expect(onActiveIndexChange).toHaveBeenCalledWith(1);
});
});
});

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

@ -15,8 +15,8 @@ import {
} from "./question-viewport-coordinator";
const WHEEL_GESTURE_IDLE_MS = 320;
const TOUCH_MIN_DISTANCE = 8;
const DRAG_ENGAGE_DISTANCE = 10;
const TOUCH_MIN_DISTANCE = 40;
const DRAG_ENGAGE_DISTANCE = 16;
const DRAG_COMMIT_RATIO = 0.3;
const DRAG_FLICK_VELOCITY = 0.55;
const SNAP_ANIMATION_MS = 340;
@ -33,6 +33,9 @@ const DRAG_IGNORE_SELECTOR = [
'[role="option"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="switch"]',
'[role="combobox"]',
'[role="listbox"]',
'[contenteditable="true"]',
"[data-snap-drag-ignore]",
].join(", ");
@ -353,7 +356,19 @@ export function QuestionSnapList({
}
const target = event.target as HTMLElement | null;
drag.ignored = Boolean(target?.closest?.(DRAG_IGNORE_SELECTOR));
const isIgnored = Boolean(target?.closest?.(DRAG_IGNORE_SELECTOR));
drag.ignored = isIgnored;
if (isIgnored) {
// When touching an interactive element (input, textarea, button, etc.),
// completely isolate it from the snap/drag gesture engine so native focus & clicks work on first tap.
drag.pointerDown = false;
drag.engaged = false;
drag.baseOffset = 0;
drag.offset = 0;
touchStartYRef.current = null;
return;
}
drag.height =
containerRef.current?.getBoundingClientRect().height ??
@ -395,11 +410,7 @@ export function QuestionSnapList({
const drag = dragRef.current;
if (!drag.pointerDown) {
return;
}
if (drag.ignored) {
if (drag.ignored || !drag.pointerDown) {
return;
}
@ -451,7 +462,10 @@ export function QuestionSnapList({
const finishDrag = useCallback(
(cancelled: boolean) => {
const drag = dragRef.current;
if (!drag.pointerDown) {
if (drag.ignored || !drag.pointerDown) {
drag.pointerDown = false;
drag.engaged = false;
touchStartYRef.current = null;
return;
}
drag.pointerDown = false;
@ -501,6 +515,13 @@ export function QuestionSnapList({
const drag = dragRef.current;
if (drag.ignored) {
drag.pointerDown = false;
drag.engaged = false;
touchStartYRef.current = null;
return;
}
if (drag.engaged) {
finishDrag(false);
return;
@ -511,8 +532,7 @@ export function QuestionSnapList({
}
drag.pointerDown = false;
// Fallback for very fast flicks whose touchmove never engaged the
// drag layer: fall back to the distance-based step.
// Fallback for fast flicks whose touchmove never engaged continuous drag
const startY = touchStartYRef.current;
const endY = event.changedTouches[0]?.clientY;
touchStartYRef.current = null;
@ -533,6 +553,13 @@ export function QuestionSnapList({
);
const handleTouchCancel = useCallback(() => {
const drag = dragRef.current;
if (drag.ignored) {
drag.pointerDown = false;
drag.engaged = false;
touchStartYRef.current = null;
return;
}
finishDrag(true);
}, [finishDrag]);
@ -545,7 +572,7 @@ export function QuestionSnapList({
ref={containerRef}
aria-label="Questions"
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",
className,
]

Loading…
Cancel
Save