React Events: fix cancel events for Press (#15563)
* Fixes a bug in the cancellation logic. The cancel events are now correctly listened to on the root. * Fixes cancellation in Safari by using the dragstart event as a proxy for cancellation (i.e., the event dispatched when move-during-press occurs on an anchor tag)
Nicolas Gallagher committed
May 3, 2019 at 15:33 UTC
2e5d1a8b9e2c29418a27b24306e4c8d5f8681f4f
3 files changed
+93
-32
packages/react-events/docs/Press.md
+8
-6
@@ -100,10 +100,10 @@ Called when the element changes press state (i.e., after `onPressStart` and
100
101
### onPressEnd: (e: PressEvent) => void
102
103
-Called once the element is no longer pressed (because it was released, or moved
104
-beyond the hit bounds). If the press starts again before the `delayPressEnd`
105
-threshold is exceeded then the delay is reset to prevent `onPressEnd` being
106
-called during a press.
103
+Called once the element is no longer pressed (because the press was released,
104
+cancelled, or moved beyond the hit bounds). If the press starts again before the
105
+`delayPressEnd` threshold is exceeded then the delay is reset to prevent
106
+`onPressEnd` being called during a press.
107
108
### onPressMove: (e: PressEvent) => void
109
@@ -120,8 +120,10 @@ Called once the element is pressed down. If the press is released before the
120
### pressRetentionOffset: PressOffset
121
122
Defines how far the pointer (while held down) may move outside the bounds of the
123
-element before it is deactivated. Ensure you pass in a constant to reduce memory
124
-allocations. Default is `20` for each offset.
123
+element before it is deactivated. Once deactivated, the pointer (still held
124
+down) can be moved back within the bounds of the element to reactivate it.
125
+Ensure you pass in a constant to reduce memory allocations. Default is `20` for
126
+each offset.
127
128
### preventDefault: boolean = true
129
packages/react-events/src/Press.js
+35
-26
@@ -108,18 +108,26 @@ const targetEventTypes = [
108
// We need to preventDefault on pointerdown for mouse/pen events
109
// that are in hit target area but not the element area.
110
{name: 'pointerdown', passive: false},
111
+];
112
+const rootEventTypes = [
113
+ 'keyup',
114
+ 'pointerup',
115
+ 'pointermove',
116
+ 'scroll',
117
'pointercancel',
118
];
113
-const rootEventTypes = ['keyup', 'pointerup', 'pointermove', 'scroll'];
119
120
// If PointerEvents is not supported (e.g., Safari), also listen to touch and mouse events.
121
if (typeof window !== 'undefined' && window.PointerEvent === undefined) {
117
- targetEventTypes.push('touchstart', 'touchcancel', 'mousedown');
122
+ targetEventTypes.push('touchstart', 'mousedown');
123
rootEventTypes.push(
124
{name: 'mouseup', passive: false},
125
+ 'mousemove',
126
'touchmove',
127
'touchend',
122
- 'mousemove',
128
+ 'touchcancel',
129
+ // Used as a 'cancel' signal for mouse interactions
130
+ 'dragstart',
131
);
132
}
133
@@ -319,6 +327,28 @@ function dispatchPressEndEvents(
327
}
328
}
329
330
+function dispatchCancel(
331
+ event: ReactResponderEvent,
332
+ context: ReactResponderContext,
333
+ props: PressProps,
334
+ state: PressState,
335
+): void {
336
+ const nativeEvent: any = event.nativeEvent;
337
+ const type = event.type;
338
+
339
+ if (state.isPressed) {
340
+ if (type === 'contextmenu' && props.preventDefault !== false) {
341
+ nativeEvent.preventDefault();
342
+ } else {
343
+ state.ignoreEmulatedMouseEvents = false;
344
+ removeRootEventTypes(context, state);
345
+ dispatchPressEndEvents(event, context, props, state);
346
+ }
347
+ } else if (state.allowPressReentry) {
348
+ removeRootEventTypes(context, state);
349
+ }
350
+}
351
+
352
function isAnchorTagElement(eventTarget: EventTarget): boolean {
353
return (eventTarget: any).nodeName === 'A';
354
}
@@ -415,28 +445,6 @@ function unmountResponder(
445
}
446
}
447
418
-function dispatchCancel(
419
- event: ReactResponderEvent,
420
- context: ReactResponderContext,
421
- props: PressProps,
422
- state: PressState,
423
-): void {
424
- const nativeEvent: any = event.nativeEvent;
425
- const type = event.type;
426
-
427
- if (state.isPressed) {
428
- if (type === 'contextmenu' && props.preventDefault !== false) {
429
- nativeEvent.preventDefault();
430
- } else {
431
- state.ignoreEmulatedMouseEvents = false;
432
- removeRootEventTypes(context, state);
433
- dispatchPressEndEvents(event, context, props, state);
434
- }
435
- } else if (state.allowPressReentry) {
436
- removeRootEventTypes(context, state);
437
- }
438
-}
439
-
448
function addRootEventTypes(
449
context: ReactResponderContext,
450
state: PressState,
@@ -710,7 +718,8 @@ const PressResponder = {
718
// CANCEL
719
case 'pointercancel':
720
case 'scroll':
713
- case 'touchcancel': {
721
+ case 'touchcancel':
722
+ case 'dragstart': {
723
dispatchCancel(event, context, props, state);
724
}
725
}
packages/react-events/src/__tests__/Press-test.internal.js
+50
@@ -1629,6 +1629,56 @@ describe('Event responder: Press', () => {
1629
});
1630
});
1631
1632
+ describe('responder cancellation', () => {
1633
+ it('ends on "pointercancel", "touchcancel", "scroll", and "dragstart"', () => {
1634
+ const onLongPress = jest.fn();
1635
+ const onPressEnd = jest.fn();
1636
+ const ref = React.createRef();
1637
+ const element = (
1638
+ <Press onLongPress={onLongPress} onPressEnd={onPressEnd}>
1639
+ <a href="#" ref={ref} />
1640
+ </Press>
1641
+ );
1642
+ ReactDOM.render(element, container);
1643
+
1644
+ ref.current.dispatchEvent(createPointerEvent('pointerdown'));
1645
+ ref.current.dispatchEvent(createPointerEvent('scroll'));
1646
+ expect(onPressEnd).toHaveBeenCalledTimes(1);
1647
+ jest.runAllTimers();
1648
+ expect(onLongPress).not.toBeCalled();
1649
+
1650
+ onLongPress.mockReset();
1651
+ onPressEnd.mockReset();
1652
+
1653
+ // When pointer events are supported
1654
+ ref.current.dispatchEvent(createPointerEvent('pointerdown'));
1655
+ ref.current.dispatchEvent(createPointerEvent('pointercancel'));
1656
+ expect(onPressEnd).toHaveBeenCalledTimes(1);
1657
+ jest.runAllTimers();
1658
+ expect(onLongPress).not.toBeCalled();
1659
+
1660
+ onLongPress.mockReset();
1661
+ onPressEnd.mockReset();
1662
+
1663
+ // Touch fallback
1664
+ ref.current.dispatchEvent(createPointerEvent('touchstart'));
1665
+ ref.current.dispatchEvent(createPointerEvent('touchcancel'));
1666
+ expect(onPressEnd).toHaveBeenCalledTimes(1);
1667
+ jest.runAllTimers();
1668
+ expect(onLongPress).not.toBeCalled();
1669
+
1670
+ onLongPress.mockReset();
1671
+ onPressEnd.mockReset();
1672
+
1673
+ // Mouse fallback
1674
+ ref.current.dispatchEvent(createPointerEvent('mousedown'));
1675
+ ref.current.dispatchEvent(createPointerEvent('dragstart'));
1676
+ expect(onPressEnd).toHaveBeenCalledTimes(1);
1677
+ jest.runAllTimers();
1678
+ expect(onLongPress).not.toBeCalled();
1679
+ });
1680
+ });
1681
+
1682
it('expect displayName to show up for event component', () => {
1683
expect(Press.displayName).toBe('Press');
1684
});