[react-events] Tap: add maximumDistance prop (#16689)

A prop for configuring the maximum distance that the active pointer can move before the tap is cancelled.
This commit is contained in:
Nicolas Gallagher
2019-09-10 12:52:43 -07:00
committed by GitHub
parent 2400400788
commit 41a78cd85c
3 changed files with 129 additions and 43 deletions
+45 -41
View File
@@ -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 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 = {
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<string>,
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;
@@ -577,7 +581,7 @@ const responderImpl = {
dispatchChange(context, props, state);
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);
@@ -139,6 +139,60 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
});
});
describe('maximumDistance', () => {
let onTapCancel, onTapUpdate, ref;
function render(props) {
const Component = () => {
const listener = useTap(props);
return <div ref={ref} listeners={listener} />;
};
ReactDOM.render(<Component />, 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;
@@ -512,15 +566,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 (
<div ref={parentRef}>
<div ref={ref} listeners={listener} />
@@ -562,6 +617,8 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
y: 0,
}),
);
target.pointermove({pointerType, x: 5, y: 5});
expect(onTapUpdate).not.toBeCalled();
});
test('long press context menu', () => {
+25
View File
@@ -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
);
}