Skip to content

Commit

Permalink
[react-events] Tap: add maximumDistance prop (#16689)
Browse files Browse the repository at this point in the history
A prop for configuring the maximum distance that the active pointer can move before the tap is cancelled.
  • Loading branch information
necolas authored Sep 10, 2019
1 parent 2400400 commit 41a78cd
Show file tree
Hide file tree
Showing 3 changed files with 130 additions and 44 deletions.
88 changes: 46 additions & 42 deletions packages/react-events/src/dom/Tap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
shouldPreventClick: boolean,
};
|};

type TapEventType =
| 'tap-cancel'
Expand Down Expand Up @@ -76,10 +80,10 @@ type TapGestureState = {|
y: number,
|};

type TapEvent = {|
type TapEvent = $ReadOnly<{|
...TapGestureState,
type: TapEventType,
|};
|}>;

/**
* Native event dependencies
Expand Down Expand Up @@ -120,6 +124,7 @@ function createInitialState(): TapState {
buttons: 0,
ignoreEmulatedEvents: false,
isActive: false,
initialPosition: {x: 0, y: 0},
pointerType: '',
responderTarget: null,
rootEvents: null,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
61 changes: 59 additions & 2 deletions packages/react-events/src/dom/__tests__/Tap-test.internal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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} />
Expand Down Expand Up @@ -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', () => {
Expand Down
25 changes: 25 additions & 0 deletions packages/react-events/src/dom/shared/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}

0 comments on commit 41a78cd

Please sign in to comment.