Skip to content

Commit 41a78cd

Browse files
authored
[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.
1 parent 2400400 commit 41a78cd

File tree

3 files changed

+130
-44
lines changed

3 files changed

+130
-44
lines changed

packages/react-events/src/dom/Tap.js

Lines changed: 46 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -21,29 +21,33 @@ import {
2121
isMac,
2222
dispatchDiscreteEvent,
2323
dispatchUserBlockingEvent,
24+
getTouchById,
25+
hasModifierKey,
2426
} from './shared';
2527

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 = {|
3740
activePointerId: null | number,
3841
buttons: 0 | 1 | 4,
3942
gestureState: TapGestureState,
4043
ignoreEmulatedEvents: boolean,
44+
initialPosition: {|x: number, y: number|},
4145
isActive: boolean,
4246
pointerType: PointerType,
4347
responderTarget: null | Element,
4448
rootEvents: null | Array<string>,
4549
shouldPreventClick: boolean,
46-
};
50+
|};
4751

4852
type TapEventType =
4953
| 'tap-cancel'
@@ -76,10 +80,10 @@ type TapGestureState = {|
7680
y: number,
7781
|};
7882

79-
type TapEvent = {|
83+
type TapEvent = $ReadOnly<{|
8084
...TapGestureState,
8185
type: TapEventType,
82-
|};
86+
|}>;
8387

8488
/**
8589
* Native event dependencies
@@ -120,6 +124,7 @@ function createInitialState(): TapState {
120124
buttons: 0,
121125
ignoreEmulatedEvents: false,
122126
isActive: false,
127+
initialPosition: {x: 0, y: 0},
123128
pointerType: '',
124129
responderTarget: null,
125130
rootEvents: null,
@@ -299,23 +304,6 @@ function removeRootEventTypes(
299304
* Managing pointers
300305
*/
301306

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-
319307
function getHitTarget(
320308
event: ReactDOMResponderEvent,
321309
context: ReactDOMResponderContext,
@@ -362,14 +350,6 @@ function isActivePointer(
362350
}
363351
}
364352

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-
373353
function shouldActivate(event: ReactDOMResponderEvent): boolean {
374354
const nativeEvent: any = event.nativeEvent;
375355
const pointerType = event.pointerType;
@@ -511,7 +491,12 @@ const responderImpl = {
511491
state.pointerType = event.pointerType;
512492
state.responderTarget = context.getResponderNode();
513493
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+
515500
dispatchStart(context, props, state);
516501
dispatchChange(context, props, state);
517502
addRootEventTypes(rootEventTypes, context, state);
@@ -549,7 +534,26 @@ const responderImpl = {
549534

550535
if (state.isActive && isActivePointer(event, state)) {
551536
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) {
553557
dispatchUpdate(context, props, state);
554558
} else {
555559
state.isActive = false;
@@ -577,7 +581,7 @@ const responderImpl = {
577581
dispatchChange(context, props, state);
578582
if (context.isTargetWithinResponder(hitTarget)) {
579583
// Determine whether to call preventDefault on subsequent native events.
580-
if (isModifiedTap(event)) {
584+
if (hasModifierKey(event)) {
581585
state.shouldPreventClick = false;
582586
}
583587
dispatchEnd(context, props, state);

packages/react-events/src/dom/__tests__/Tap-test.internal.js

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,60 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
139139
});
140140
});
141141

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+
142196
describe('onTapStart', () => {
143197
let onTapStart, ref;
144198

@@ -512,15 +566,16 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
512566
});
513567

514568
describe('onTapCancel', () => {
515-
let onTapCancel, parentRef, ref, siblingRef;
569+
let onTapCancel, onTapUpdate, parentRef, ref, siblingRef;
516570

517571
beforeEach(() => {
518572
onTapCancel = jest.fn();
573+
onTapUpdate = jest.fn();
519574
parentRef = React.createRef();
520575
ref = React.createRef();
521576
siblingRef = React.createRef();
522577
const Component = () => {
523-
const listener = useTap({onTapCancel});
578+
const listener = useTap({onTapCancel, onTapUpdate});
524579
return (
525580
<div ref={parentRef}>
526581
<div ref={ref} listeners={listener} />
@@ -562,6 +617,8 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
562617
y: 0,
563618
}),
564619
);
620+
target.pointermove({pointerType, x: 5, y: 5});
621+
expect(onTapUpdate).not.toBeCalled();
565622
});
566623

567624
test('long press context menu', () => {

packages/react-events/src/dom/shared/index.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,28 @@ export function dispatchUserBlockingEvent(
4545
) {
4646
context.dispatchEvent(payload, callback, UserBlockingEvent);
4747
}
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+
}

0 commit comments

Comments
 (0)