@samitouri / QOS-React-2 / commits / 41a78cd85c

[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.

Nicolas Gallagher committed Sep 10, 2019 at 12:52 UTC 41a78cd85c461d2f029c1863614c73a28faabade
3 files changed +130 -44
packages/react-events/src/dom/Tap.js
+46 -42
@@ -21,29 +21,33 @@ import {
21 isMac,
22 dispatchDiscreteEvent,
23 dispatchUserBlockingEvent,
24 + getTouchById,
25 + hasModifierKey,
26 } from './shared';
27
26 -type TapProps = {|
27 - disabled: boolean,
28 - preventDefault: boolean,
29 - onTapCancel: (e: TapEvent) => void,
30 - onTapChange: boolean => void,
31 - onTapEnd: (e: TapEvent) => void,
32 - onTapStart: (e: TapEvent) => void,
33 - onTapUpdate: (e: TapEvent) => void,
34 -|};
35 -
36 -type TapState = {
28 +type TapProps = $ReadOnly<{|
29 + disabled?: boolean,
30 + maximumDistance?: number,
31 + preventDefault?: boolean,
32 + onTapCancel?: (e: TapEvent) => void,
33 + onTapChange?: boolean => void,
34 + onTapEnd?: (e: TapEvent) => void,
35 + onTapStart?: (e: TapEvent) => void,
36 + onTapUpdate?: (e: TapEvent) => void,
37 +|}>;
38 +
39 +type TapState = {|
40 activePointerId: null | number,
41 buttons: 0 | 1 | 4,
42 gestureState: TapGestureState,
43 ignoreEmulatedEvents: boolean,
44 + initialPosition: {|x: number, y: number|},
45 isActive: boolean,
46 pointerType: PointerType,
47 responderTarget: null | Element,
48 rootEvents: null | Array<string>,
49 shouldPreventClick: boolean,
46 -};
50 +|};
51
52 type TapEventType =
53 | 'tap-cancel'
@@ -76,10 +80,10 @@ type TapGestureState = {|
80 y: number,
81 |};
82
79 -type TapEvent = {|
83 +type TapEvent = $ReadOnly<{|
84 ...TapGestureState,
85 type: TapEventType,
82 -|};
86 +|}>;
87
88 /**
89 * Native event dependencies
@@ -120,6 +124,7 @@ function createInitialState(): TapState {
124 buttons: 0,
125 ignoreEmulatedEvents: false,
126 isActive: false,
127 + initialPosition: {x: 0, y: 0},
128 pointerType: '',
129 responderTarget: null,
130 rootEvents: null,
@@ -299,23 +304,6 @@ function removeRootEventTypes(
304 * Managing pointers
305 */
306
302 -function getTouchById(
303 - nativeEvent: TouchEvent,
304 - pointerId: null | number,
305 -): null | Touch {
306 - if (pointerId != null) {
307 - const changedTouches = nativeEvent.changedTouches;
308 - for (let i = 0; i < changedTouches.length; i++) {
309 - const touch = changedTouches[i];
310 - if (touch.identifier === pointerId) {
311 - return touch;
312 - }
313 - }
314 - return null;
315 - }
316 - return null;
317 -}
318 -
307 function getHitTarget(
308 event: ReactDOMResponderEvent,
309 context: ReactDOMResponderContext,
@@ -362,14 +350,6 @@ function isActivePointer(
350 }
351 }
352
365 -function isModifiedTap(event: ReactDOMResponderEvent): boolean {
366 - const nativeEvent: any = event.nativeEvent;
367 - const {altKey, ctrlKey, metaKey, shiftKey} = nativeEvent;
368 - return (
369 - altKey === true || ctrlKey === true || metaKey === true || shiftKey === true
370 - );
371 -}
372 -
353 function shouldActivate(event: ReactDOMResponderEvent): boolean {
354 const nativeEvent: any = event.nativeEvent;
355 const pointerType = event.pointerType;
@@ -511,7 +491,12 @@ const responderImpl = {
491 state.pointerType = event.pointerType;
492 state.responderTarget = context.getResponderNode();
493 state.shouldPreventClick = props.preventDefault !== false;
514 - state.gestureState = createGestureState(context, props, state, event);
494 +
495 + const gestureState = createGestureState(context, props, state, event);
496 + state.gestureState = gestureState;
497 + state.initialPosition.x = gestureState.x;
498 + state.initialPosition.y = gestureState.y;
499 +
500 dispatchStart(context, props, state);
501 dispatchChange(context, props, state);
502 addRootEventTypes(rootEventTypes, context, state);
@@ -549,7 +534,26 @@ const responderImpl = {
534
535 if (state.isActive && isActivePointer(event, state)) {
536 state.gestureState = createGestureState(context, props, state, event);
552 - if (context.isTargetWithinResponder(hitTarget)) {
537 + let shouldUpdate = true;
538 +
539 + if (!context.isTargetWithinResponder(hitTarget)) {
540 + shouldUpdate = false;
541 + } else if (
542 + props.maximumDistance != null &&
543 + props.maximumDistance >= 10
544 + ) {
545 + const maxDistance = props.maximumDistance;
546 + const initialPosition = state.initialPosition;
547 + const currentPosition = state.gestureState;
548 + const moveX = initialPosition.x - currentPosition.x;
549 + const moveY = initialPosition.y - currentPosition.y;
550 + const moveDistance = Math.sqrt(moveX * moveX + moveY * moveY);
551 + if (moveDistance > maxDistance) {
552 + shouldUpdate = false;
553 + }
554 + }
555 +
556 + if (shouldUpdate) {
557 dispatchUpdate(context, props, state);
558 } else {
559 state.isActive = false;
@@ -577,7 +581,7 @@ const responderImpl = {
581 dispatchChange(context, props, state);
582 if (context.isTargetWithinResponder(hitTarget)) {
583 // Determine whether to call preventDefault on subsequent native events.
580 - if (isModifiedTap(event)) {
584 + if (hasModifierKey(event)) {
585 state.shouldPreventClick = false;
586 }
587 dispatchEnd(context, props, state);
packages/react-events/src/dom/__tests__/Tap-test.internal.js
+59 -2
@@ -139,6 +139,60 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
139 });
140 });
141
142 + describe('maximumDistance', () => {
143 + let onTapCancel, onTapUpdate, ref;
144 +
145 + function render(props) {
146 + const Component = () => {
147 + const listener = useTap(props);
148 + return <div ref={ref} listeners={listener} />;
149 + };
150 + ReactDOM.render(<Component />, container);
151 + document.elementFromPoint = () => ref.current;
152 + }
153 +
154 + beforeEach(() => {
155 + onTapCancel = jest.fn();
156 + onTapUpdate = jest.fn();
157 + ref = React.createRef();
158 + render({
159 + maximumDistance: 20,
160 + onTapCancel,
161 + onTapUpdate,
162 + });
163 + });
164 +
165 + test('ignores values less than 10', () => {
166 + render({
167 + maximumDistance: 5,
168 + onTapCancel,
169 + onTapUpdate,
170 + });
171 + const target = createEventTarget(ref.current);
172 + const pointerType = 'mouse';
173 + target.pointerdown({pointerType, x: 0, y: 0});
174 + target.pointermove({pointerType, x: 10, y: 10});
175 + expect(onTapUpdate).toHaveBeenCalledTimes(1);
176 + expect(onTapCancel).toHaveBeenCalledTimes(0);
177 + });
178 +
179 + testWithPointerType('below threshold', pointerType => {
180 + const target = createEventTarget(ref.current);
181 + target.pointerdown({pointerType, x: 0, y: 0});
182 + target.pointermove({pointerType, x: 10, y: 10});
183 + expect(onTapUpdate).toHaveBeenCalledTimes(1);
184 + expect(onTapCancel).toHaveBeenCalledTimes(0);
185 + });
186 +
187 + testWithPointerType('above threshold', pointerType => {
188 + const target = createEventTarget(ref.current);
189 + target.pointerdown({pointerType, x: 0, y: 0});
190 + target.pointermove({pointerType, x: 15, y: 14});
191 + expect(onTapUpdate).toHaveBeenCalledTimes(0);
192 + expect(onTapCancel).toHaveBeenCalledTimes(1);
193 + });
194 + });
195 +
196 describe('onTapStart', () => {
197 let onTapStart, ref;
198
@@ -512,15 +566,16 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
566 });
567
568 describe('onTapCancel', () => {
515 - let onTapCancel, parentRef, ref, siblingRef;
569 + let onTapCancel, onTapUpdate, parentRef, ref, siblingRef;
570
571 beforeEach(() => {
572 onTapCancel = jest.fn();
573 + onTapUpdate = jest.fn();
574 parentRef = React.createRef();
575 ref = React.createRef();
576 siblingRef = React.createRef();
577 const Component = () => {
523 - const listener = useTap({onTapCancel});
578 + const listener = useTap({onTapCancel, onTapUpdate});
579 return (
580 <div ref={parentRef}>
581 <div ref={ref} listeners={listener} />
@@ -562,6 +617,8 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
617 y: 0,
618 }),
619 );
620 + target.pointermove({pointerType, x: 5, y: 5});
621 + expect(onTapUpdate).not.toBeCalled();
622 });
623
624 test('long press context menu', () => {
packages/react-events/src/dom/shared/index.js
+25
@@ -45,3 +45,28 @@ export function dispatchUserBlockingEvent(
45 ) {
46 context.dispatchEvent(payload, callback, UserBlockingEvent);
47 }
48 +
49 +export function getTouchById(
50 + nativeEvent: TouchEvent,
51 + pointerId: null | number,
52 +): null | Touch {
53 + if (pointerId != null) {
54 + const changedTouches = nativeEvent.changedTouches;
55 + for (let i = 0; i < changedTouches.length; i++) {
56 + const touch = changedTouches[i];
57 + if (touch.identifier === pointerId) {
58 + return touch;
59 + }
60 + }
61 + return null;
62 + }
63 + return null;
64 +}
65 +
66 +export function hasModifierKey(event: ReactDOMResponderEvent): boolean {
67 + const nativeEvent: any = event.nativeEvent;
68 + const {altKey, ctrlKey, metaKey, shiftKey} = nativeEvent;
69 + return (
70 + altKey === true || ctrlKey === true || metaKey === true || shiftKey === true
71 + );
72 +}