diff --git a/packages/react-events/src/dom/Tap.js b/packages/react-events/src/dom/Tap.js index fe5ce0d91e65..32b0f4715e9c 100644 --- a/packages/react-events/src/dom/Tap.js +++ b/packages/react-events/src/dom/Tap.js @@ -21,29 +21,33 @@ import { isMac, dispatchDiscreteEvent, dispatchUserBlockingEvent, + getTouchById, + hasModifierKey, } from './shared'; -type TapProps = {| - disabled: boolean, - preventDefault: boolean, - onTapCancel: (e: TapEvent) => void, - onTapChange: boolean => void, - onTapEnd: (e: TapEvent) => void, - onTapStart: (e: TapEvent) => void, - onTapUpdate: (e: TapEvent) => void, -|}; - -type TapState = { +type TapProps = $ReadOnly<{| + disabled?: boolean, + maximumDistance?: number, + preventDefault?: boolean, + onTapCancel?: (e: TapEvent) => void, + onTapChange?: boolean => void, + onTapEnd?: (e: TapEvent) => void, + onTapStart?: (e: TapEvent) => void, + onTapUpdate?: (e: TapEvent) => void, +|}>; + +type TapState = {| activePointerId: null | number, buttons: 0 | 1 | 4, gestureState: TapGestureState, ignoreEmulatedEvents: boolean, + initialPosition: {|x: number, y: number|}, isActive: boolean, pointerType: PointerType, responderTarget: null | Element, rootEvents: null | Array, shouldPreventClick: boolean, -}; +|}; type TapEventType = | 'tap-cancel' @@ -76,10 +80,10 @@ type TapGestureState = {| y: number, |}; -type TapEvent = {| +type TapEvent = $ReadOnly<{| ...TapGestureState, type: TapEventType, -|}; +|}>; /** * Native event dependencies @@ -120,6 +124,7 @@ function createInitialState(): TapState { buttons: 0, ignoreEmulatedEvents: false, isActive: false, + initialPosition: {x: 0, y: 0}, pointerType: '', responderTarget: null, rootEvents: null, @@ -299,23 +304,6 @@ function removeRootEventTypes( * Managing pointers */ -function getTouchById( - nativeEvent: TouchEvent, - pointerId: null | number, -): null | Touch { - if (pointerId != null) { - const changedTouches = nativeEvent.changedTouches; - for (let i = 0; i < changedTouches.length; i++) { - const touch = changedTouches[i]; - if (touch.identifier === pointerId) { - return touch; - } - } - return null; - } - return null; -} - function getHitTarget( event: ReactDOMResponderEvent, context: ReactDOMResponderContext, @@ -362,14 +350,6 @@ function isActivePointer( } } -function isModifiedTap(event: ReactDOMResponderEvent): boolean { - const nativeEvent: any = event.nativeEvent; - const {altKey, ctrlKey, metaKey, shiftKey} = nativeEvent; - return ( - altKey === true || ctrlKey === true || metaKey === true || shiftKey === true - ); -} - function shouldActivate(event: ReactDOMResponderEvent): boolean { const nativeEvent: any = event.nativeEvent; const pointerType = event.pointerType; @@ -511,7 +491,12 @@ const responderImpl = { state.pointerType = event.pointerType; state.responderTarget = context.getResponderNode(); state.shouldPreventClick = props.preventDefault !== false; - state.gestureState = createGestureState(context, props, state, event); + + const gestureState = createGestureState(context, props, state, event); + state.gestureState = gestureState; + state.initialPosition.x = gestureState.x; + state.initialPosition.y = gestureState.y; + dispatchStart(context, props, state); dispatchChange(context, props, state); addRootEventTypes(rootEventTypes, context, state); @@ -549,7 +534,26 @@ const responderImpl = { if (state.isActive && isActivePointer(event, state)) { state.gestureState = createGestureState(context, props, state, event); - if (context.isTargetWithinResponder(hitTarget)) { + let shouldUpdate = true; + + if (!context.isTargetWithinResponder(hitTarget)) { + shouldUpdate = false; + } else if ( + props.maximumDistance != null && + props.maximumDistance >= 10 + ) { + const maxDistance = props.maximumDistance; + const initialPosition = state.initialPosition; + const currentPosition = state.gestureState; + const moveX = initialPosition.x - currentPosition.x; + const moveY = initialPosition.y - currentPosition.y; + const moveDistance = Math.sqrt(moveX * moveX + moveY * moveY); + if (moveDistance > maxDistance) { + shouldUpdate = false; + } + } + + if (shouldUpdate) { dispatchUpdate(context, props, state); } else { state.isActive = false; @@ -575,7 +579,7 @@ const responderImpl = { if (context.isTargetWithinResponder(hitTarget)) { // Determine whether to call preventDefault on subsequent native events. - if (isModifiedTap(event)) { + if (hasModifierKey(event)) { state.shouldPreventClick = false; } dispatchEnd(context, props, state); diff --git a/packages/react-events/src/dom/__tests__/Tap-test.internal.js b/packages/react-events/src/dom/__tests__/Tap-test.internal.js index bd8c051fe52f..26a32d7f19b8 100644 --- a/packages/react-events/src/dom/__tests__/Tap-test.internal.js +++ b/packages/react-events/src/dom/__tests__/Tap-test.internal.js @@ -139,6 +139,60 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { }); }); + describe('maximumDistance', () => { + let onTapCancel, onTapUpdate, ref; + + function render(props) { + const Component = () => { + const listener = useTap(props); + return
; + }; + ReactDOM.render(, container); + document.elementFromPoint = () => ref.current; + } + + beforeEach(() => { + onTapCancel = jest.fn(); + onTapUpdate = jest.fn(); + ref = React.createRef(); + render({ + maximumDistance: 20, + onTapCancel, + onTapUpdate, + }); + }); + + test('ignores values less than 10', () => { + render({ + maximumDistance: 5, + onTapCancel, + onTapUpdate, + }); + const target = createEventTarget(ref.current); + const pointerType = 'mouse'; + target.pointerdown({pointerType, x: 0, y: 0}); + target.pointermove({pointerType, x: 10, y: 10}); + expect(onTapUpdate).toHaveBeenCalledTimes(1); + expect(onTapCancel).toHaveBeenCalledTimes(0); + }); + + testWithPointerType('below threshold', pointerType => { + const target = createEventTarget(ref.current); + target.pointerdown({pointerType, x: 0, y: 0}); + target.pointermove({pointerType, x: 10, y: 10}); + expect(onTapUpdate).toHaveBeenCalledTimes(1); + expect(onTapCancel).toHaveBeenCalledTimes(0); + }); + + testWithPointerType('above threshold', pointerType => { + const target = createEventTarget(ref.current); + target.pointerdown({pointerType, x: 0, y: 0}); + target.pointermove({pointerType, x: 15, y: 14}); + expect(onTapUpdate).toHaveBeenCalledTimes(0); + expect(onTapCancel).toHaveBeenCalledTimes(1); + }); + }); + describe('onTapStart', () => { let onTapStart, ref; @@ -496,15 +550,16 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { }); describe('onTapCancel', () => { - let onTapCancel, parentRef, ref, siblingRef; + let onTapCancel, onTapUpdate, parentRef, ref, siblingRef; beforeEach(() => { onTapCancel = jest.fn(); + onTapUpdate = jest.fn(); parentRef = React.createRef(); ref = React.createRef(); siblingRef = React.createRef(); const Component = () => { - const listener = useTap({onTapCancel}); + const listener = useTap({onTapCancel, onTapUpdate}); return (
@@ -546,6 +601,8 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { y: 0, }), ); + target.pointermove({pointerType, x: 5, y: 5}); + expect(onTapUpdate).not.toBeCalled(); }); test('long press context menu', () => { diff --git a/packages/react-events/src/dom/shared/index.js b/packages/react-events/src/dom/shared/index.js index 9eb53f660a82..3e56457a7eb2 100644 --- a/packages/react-events/src/dom/shared/index.js +++ b/packages/react-events/src/dom/shared/index.js @@ -45,3 +45,28 @@ export function dispatchUserBlockingEvent( ) { context.dispatchEvent(payload, callback, UserBlockingEvent); } + +export function getTouchById( + nativeEvent: TouchEvent, + pointerId: null | number, +): null | Touch { + if (pointerId != null) { + const changedTouches = nativeEvent.changedTouches; + for (let i = 0; i < changedTouches.length; i++) { + const touch = changedTouches[i]; + if (touch.identifier === pointerId) { + return touch; + } + } + return null; + } + return null; +} + +export function hasModifierKey(event: ReactDOMResponderEvent): boolean { + const nativeEvent: any = event.nativeEvent; + const {altKey, ctrlKey, metaKey, shiftKey} = nativeEvent; + return ( + altKey === true || ctrlKey === true || metaKey === true || shiftKey === true + ); +}