[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
+}