|
|
@ -16,7 +16,7 @@ import { |
|
|
|
|
|
|
|
|
const WHEEL_GESTURE_IDLE_MS = 320; |
|
|
const WHEEL_GESTURE_IDLE_MS = 320; |
|
|
const BACKGROUND_DRAG_SLOP = 10; |
|
|
const BACKGROUND_DRAG_SLOP = 10; |
|
|
const INTERACTIVE_DRAG_SLOP = 16; |
|
|
|
|
|
|
|
|
const OPTION_CARD_DRAG_SLOP = 16; |
|
|
const FAST_FLICK_MIN_DISTANCE = 36; |
|
|
const FAST_FLICK_MIN_DISTANCE = 36; |
|
|
const DRAG_COMMIT_RATIO = 0.3; |
|
|
const DRAG_COMMIT_RATIO = 0.3; |
|
|
const DRAG_FLICK_VELOCITY = 0.5; |
|
|
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 SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; |
|
|
const RUBBER_BAND_RESISTANCE = 0.4; |
|
|
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"]', |
|
|
'input[type="range"]', |
|
|
"[data-snap-drag-ignore]", |
|
|
"[data-snap-drag-ignore]", |
|
|
].join(", "); |
|
|
].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", |
|
|
"label", |
|
|
|
|
|
"button", |
|
|
"a", |
|
|
"a", |
|
|
'[role="button"]', |
|
|
'[role="button"]', |
|
|
'[role="option"]', |
|
|
'[role="option"]', |
|
|
@ -43,13 +53,12 @@ const INTERACTIVE_TAP_SELECTOR = [ |
|
|
'[role="switch"]', |
|
|
'[role="switch"]', |
|
|
'[role="combobox"]', |
|
|
'[role="combobox"]', |
|
|
'[role="listbox"]', |
|
|
'[role="listbox"]', |
|
|
'[contenteditable="true"]', |
|
|
|
|
|
].join(", "); |
|
|
].join(", "); |
|
|
|
|
|
|
|
|
type SnapDragState = { |
|
|
type SnapDragState = { |
|
|
pointerDown: boolean; |
|
|
pointerDown: boolean; |
|
|
hardIgnored: boolean; |
|
|
hardIgnored: boolean; |
|
|
isInteractiveTap: boolean; |
|
|
|
|
|
|
|
|
isOptionCard: boolean; |
|
|
engaged: boolean; |
|
|
engaged: boolean; |
|
|
didDrag: boolean; |
|
|
didDrag: boolean; |
|
|
animating: boolean; |
|
|
animating: boolean; |
|
|
@ -104,7 +113,7 @@ export function QuestionSnapList({ |
|
|
const dragRef = useRef<SnapDragState>({ |
|
|
const dragRef = useRef<SnapDragState>({ |
|
|
pointerDown: false, |
|
|
pointerDown: false, |
|
|
hardIgnored: false, |
|
|
hardIgnored: false, |
|
|
isInteractiveTap: false, |
|
|
|
|
|
|
|
|
isOptionCard: false, |
|
|
engaged: false, |
|
|
engaged: false, |
|
|
didDrag: false, |
|
|
didDrag: false, |
|
|
animating: false, |
|
|
animating: false, |
|
|
@ -362,6 +371,9 @@ export function QuestionSnapList({ |
|
|
return; |
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Always reset click suppression on any new touch start
|
|
|
|
|
|
suppressNextClickRef.current = false; |
|
|
|
|
|
|
|
|
const drag = dragRef.current; |
|
|
const drag = dragRef.current; |
|
|
if (drag.cleanupTimer !== null) { |
|
|
if (drag.cleanupTimer !== null) { |
|
|
window.clearTimeout(drag.cleanupTimer); |
|
|
window.clearTimeout(drag.cleanupTimer); |
|
|
@ -369,32 +381,33 @@ export function QuestionSnapList({ |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const target = event.target as HTMLElement | null; |
|
|
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.pointerDown = false; |
|
|
drag.engaged = false; |
|
|
drag.engaged = false; |
|
|
|
|
|
drag.hardIgnored = true; |
|
|
|
|
|
drag.isOptionCard = false; |
|
|
|
|
|
drag.didDrag = false; |
|
|
drag.baseOffset = 0; |
|
|
drag.baseOffset = 0; |
|
|
drag.offset = 0; |
|
|
drag.offset = 0; |
|
|
touchStartYRef.current = null; |
|
|
touchStartYRef.current = null; |
|
|
return; |
|
|
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 = |
|
|
drag.height = |
|
|
containerRef.current?.getBoundingClientRect().height ?? |
|
|
containerRef.current?.getBoundingClientRect().height ?? |
|
|
window.innerHeight; |
|
|
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]; |
|
|
const activeElement = questionRefs.current[activeIndexRef.current]; |
|
|
if (activeElement) { |
|
|
if (activeElement) { |
|
|
drag.baseOffset = -readTranslateY(activeElement); |
|
|
drag.baseOffset = -readTranslateY(activeElement); |
|
|
@ -445,8 +458,8 @@ export function QuestionSnapList({ |
|
|
const absY = Math.abs(deltaY); |
|
|
const absY = Math.abs(deltaY); |
|
|
|
|
|
|
|
|
if (!drag.engaged) { |
|
|
if (!drag.engaged) { |
|
|
const slop = drag.isInteractiveTap |
|
|
|
|
|
? INTERACTIVE_DRAG_SLOP |
|
|
|
|
|
|
|
|
const slop = drag.isOptionCard |
|
|
|
|
|
? OPTION_CARD_DRAG_SLOP |
|
|
: BACKGROUND_DRAG_SLOP; |
|
|
: BACKGROUND_DRAG_SLOP; |
|
|
|
|
|
|
|
|
// If movement is under slop threshold, do not engage: let native clicks/focus happen
|
|
|
// If movement is under slop threshold, do not engage: let native clicks/focus happen
|
|
|
@ -524,11 +537,13 @@ export function QuestionSnapList({ |
|
|
} |
|
|
} |
|
|
drag.engaged = false; |
|
|
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 index = activeIndexRef.current; |
|
|
const canNext = index < questionsCountRef.current - 1; |
|
|
const canNext = index < questionsCountRef.current - 1; |
|
|
@ -585,13 +600,13 @@ export function QuestionSnapList({ |
|
|
} |
|
|
} |
|
|
drag.pointerDown = false; |
|
|
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; |
|
|
touchStartYRef.current = null; |
|
|
return; |
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// Fallback for fast flicks whose touchmove never engaged continuous drag
|
|
|
|
|
|
|
|
|
// Fast flick fallback on background area
|
|
|
const startY = touchStartYRef.current; |
|
|
const startY = touchStartYRef.current; |
|
|
const endY = event.changedTouches[0]?.clientY; |
|
|
const endY = event.changedTouches[0]?.clientY; |
|
|
touchStartYRef.current = null; |
|
|
touchStartYRef.current = null; |
|
|
@ -624,6 +639,13 @@ export function QuestionSnapList({ |
|
|
|
|
|
|
|
|
const handleClickCapture = useCallback( |
|
|
const handleClickCapture = useCallback( |
|
|
(event: React.MouseEvent<HTMLElement>) => { |
|
|
(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) { |
|
|
if (suppressNextClickRef.current) { |
|
|
event.preventDefault(); |
|
|
event.preventDefault(); |
|
|
event.stopPropagation(); |
|
|
event.stopPropagation(); |
|
|
|