Browse Source

fox list question

master
mortezaei 6 days ago
parent
commit
d2c603e1b7
  1. 34
      src/components/Componentes/question-snap-list.test.tsx
  2. 88
      src/components/Componentes/question-snap-list.tsx

34
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(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<input aria-label="First Name" type="text" />
</div>
<div>
<input aria-label="Last Name" type="text" />
</div>
</QuestionSnapList>,
);
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(

88
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<SnapDragState>({
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<HTMLElement>) => {
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();

Loading…
Cancel
Save