React Events: consolidate logic for Press event component (#15451)
Refactor of Press and additional regression coverage. The logic for "start", "move", "end", and "cancel" events is consolidated into a single block to reduce duplication and improve consistency of the UX across input-types. Also reduces code size. The bailout logic for anchor tags is removed since we preventDefault for click by default. We can discuss scenarios where it makes sense to limit functionality around interactions on anchor tags. The logic for ignoring emulated events is simplified and improved. Pointer events can produce emulated touch (immediately after pointer) and mouse events (delayed) which is now accounted for and tested.
Nicolas Gallagher committed
Apr 19, 2019 at 10:05 UTC
051513bfa064c0d5d058482db4cc3a32955b9ec5
2 files changed
+143
-195
packages/react-events/src/Press.js
+69
-188
@@ -13,6 +13,11 @@ import type {
13
ReactResponderDispatchEventOptions,
14
} from 'shared/ReactTypes';
15
import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols';
16
+import {
17
+ getEventPointerType,
18
+ getEventCurrentTarget,
19
+ isEventPositionWithinTouchHitTarget,
20
+} from './utils';
21
22
const CAPTURE_PHASE = 2;
23
@@ -44,7 +49,6 @@ type PointerType = '' | 'mouse' | 'keyboard' | 'pen' | 'touch';
49
type PressState = {
50
isActivePressed: boolean,
51
isActivePressStart: boolean,
47
- isAnchorTouched: boolean,
52
isLongPressed: boolean,
53
isPressed: boolean,
54
isPressWithinResponderRegion: boolean,
@@ -59,7 +63,7 @@ type PressState = {
63
right: number,
64
top: number,
65
|}>,
62
- shouldSkipMouseAfterTouch: boolean,
66
+ ignoreEmulatedMouseEvents: boolean,
67
};
68
69
type PressEventType =
@@ -355,23 +359,6 @@ function calculateResponderRegion(target, props) {
359
};
360
}
361
358
-function getPointerType(nativeEvent: any) {
359
- const {type, pointerType} = nativeEvent;
360
- if (pointerType != null) {
361
- return pointerType;
362
- }
363
- if (type.indexOf('mouse') > -1) {
364
- return 'mouse';
365
- }
366
- if (type.indexOf('touch') > -1) {
367
- return 'touch';
368
- }
369
- if (type.indexOf('key') > -1) {
370
- return 'keyboard';
371
- }
372
- return '';
373
-}
374
-
362
function isPressWithinResponderRegion(
363
nativeEvent: $PropertyType<ReactResponderEvent, 'nativeEvent'>,
364
state: PressState,
@@ -406,7 +393,6 @@ const PressResponder = {
393
didDispatchEvent: false,
394
isActivePressed: false,
395
isActivePressStart: false,
409
- isAnchorTouched: false,
396
isLongPressed: false,
397
isPressed: false,
398
isPressWithinResponderRegion: true,
@@ -416,7 +402,7 @@ const PressResponder = {
402
pressStartTimeout: null,
403
pressTarget: null,
404
responderRegion: null,
419
- shouldSkipMouseAfterTouch: false,
405
+ ignoreEmulatedMouseEvents: false,
406
};
407
},
408
onEvent(
@@ -431,30 +417,40 @@ const PressResponder = {
417
if (phase === CAPTURE_PHASE) {
418
return false;
419
}
420
+
421
const nativeEvent: any = event.nativeEvent;
422
+ const pointerType = getEventPointerType(event);
423
const shouldStopPropagation =
424
props.stopPropagation === undefined ? true : props.stopPropagation;
425
426
switch (type) {
439
- /**
440
- * Respond to pointer events and fall back to mouse.
441
- */
427
+ // START
428
case 'pointerdown':
443
- case 'mousedown': {
444
- if (!state.isPressed && !state.shouldSkipMouseAfterTouch) {
445
- const pointerType = getPointerType(nativeEvent);
446
- state.pointerType = pointerType;
429
+ case 'keydown':
430
+ case 'keypress':
431
+ case 'mousedown':
432
+ case 'touchstart': {
433
+ if (!state.isPressed) {
434
+ if (type === 'pointerdown' || type === 'touchstart') {
435
+ state.ignoreEmulatedMouseEvents = true;
436
+ }
437
+
438
+ // Ignore unrelated key events
439
+ if (pointerType === 'keyboard') {
440
+ if (!isValidKeyPress(nativeEvent.key)) {
441
+ return shouldStopPropagation;
442
+ }
443
+ }
444
448
- // Ignore pressing on hit slop area with mouse
449
- if (
450
- (pointerType === 'mouse' || type === 'mousedown') &&
451
- context.isPositionWithinTouchHitTarget(
452
- target.ownerDocument,
453
- nativeEvent.x,
454
- nativeEvent.y,
455
- )
456
- ) {
457
- return false;
445
+ // Ignore emulated mouse events and mouse pressing on touch hit target
446
+ // area
447
+ if (type === 'mousedown') {
448
+ if (
449
+ state.ignoreEmulatedMouseEvents ||
450
+ isEventPositionWithinTouchHitTarget(event, context)
451
+ ) {
452
+ return shouldStopPropagation;
453
+ }
454
}
455
456
// Ignore any device buttons except left-mouse and touch/pen contact
@@ -462,39 +458,39 @@ const PressResponder = {
458
return shouldStopPropagation;
459
}
460
461
+ state.pointerType = pointerType;
462
state.pressTarget = target;
463
state.isPressWithinResponderRegion = true;
464
dispatchPressStartEvents(context, props, state);
465
context.addRootEventTypes(target.ownerDocument, rootEventTypes);
466
return shouldStopPropagation;
467
+ } else {
468
+ // Prevent spacebar press from scrolling the window
469
+ if (isValidKeyPress(nativeEvent.key) && nativeEvent.key === ' ') {
470
+ nativeEvent.preventDefault();
471
+ return shouldStopPropagation;
472
+ }
473
}
471
- return false;
474
+ return shouldStopPropagation;
475
}
476
+
477
+ // MOVE
478
case 'pointermove':
479
case 'mousemove':
480
case 'touchmove': {
481
if (state.isPressed) {
477
- if (state.shouldSkipMouseAfterTouch) {
482
+ // Ignore emulated events (pointermove will dispatch touch and mouse events)
483
+ // Ignore pointermove events during a keyboard press
484
+ if (state.pointerType !== pointerType) {
485
return shouldStopPropagation;
486
}
487
481
- const pointerType = getPointerType(nativeEvent);
482
- state.pointerType = pointerType;
483
-
488
if (state.responderRegion == null) {
485
- let currentTarget = (target: any);
486
- while (
487
- currentTarget.parentNode &&
488
- context.isTargetWithinEventComponent(currentTarget.parentNode)
489
- ) {
490
- currentTarget = currentTarget.parentNode;
491
- }
489
state.responderRegion = calculateResponderRegion(
493
- currentTarget,
490
+ getEventCurrentTarget(event, context),
491
props,
492
);
493
}
497
-
494
if (isPressWithinResponderRegion(nativeEvent, state)) {
495
state.isPressWithinResponderRegion = true;
496
if (props.onPressMove) {
@@ -510,19 +506,21 @@ const PressResponder = {
506
}
507
return false;
508
}
509
+
510
+ // END
511
case 'pointerup':
514
- case 'mouseup': {
512
+ case 'keyup':
513
+ case 'mouseup':
514
+ case 'touchend': {
515
if (state.isPressed) {
516
- if (state.shouldSkipMouseAfterTouch) {
517
- state.shouldSkipMouseAfterTouch = false;
518
- return shouldStopPropagation;
516
+ // Ignore unrelated keyboard events
517
+ if (pointerType === 'keyboard') {
518
+ if (!isValidKeyPress(nativeEvent.key)) {
519
+ return false;
520
+ }
521
}
522
521
- const pointerType = getPointerType(nativeEvent);
522
- state.pointerType = pointerType;
523
-
523
const wasLongPressed = state.isLongPressed;
525
-
524
dispatchPressEndEvents(context, props, state);
525
526
if (state.pressTarget !== null && props.onPress) {
@@ -540,128 +538,25 @@ const PressResponder = {
538
}
539
context.removeRootEventTypes(rootEventTypes);
540
return shouldStopPropagation;
543
- }
544
- state.isAnchorTouched = false;
545
- state.shouldSkipMouseAfterTouch = false;
546
- return false;
547
- }
548
-
549
- /**
550
- * Touch event implementations are only needed for Safari, which lacks
551
- * support for pointer events.
552
- */
553
- case 'touchstart': {
554
- if (!state.isPressed) {
555
- // We bail out of polyfilling anchor tags, given the same heuristics
556
- // explained above in regards to needing to use click events.
557
- if (isAnchorTagElement(target)) {
558
- state.isAnchorTouched = true;
559
- return shouldStopPropagation;
560
- }
561
- const pointerType = getPointerType(nativeEvent);
562
- state.pointerType = pointerType;
563
- state.pressTarget = target;
564
- state.isPressWithinResponderRegion = true;
565
- dispatchPressStartEvents(context, props, state);
566
- context.addRootEventTypes(target.ownerDocument, rootEventTypes);
567
- return shouldStopPropagation;
568
- }
569
- return false;
570
- }
571
- case 'touchend': {
572
- if (state.isAnchorTouched) {
573
- state.isAnchorTouched = false;
574
- return shouldStopPropagation;
575
- }
576
- if (state.isPressed) {
577
- const pointerType = getPointerType(nativeEvent);
578
- state.pointerType = pointerType;
579
-
580
- const wasLongPressed = state.isLongPressed;
581
-
582
- dispatchPressEndEvents(context, props, state);
583
-
584
- if (type !== 'touchcancel' && props.onPress) {
585
- // Find if the X/Y of the end touch is still that of the original target
586
- const changedTouch = nativeEvent.changedTouches[0];
587
- const doc = (target: any).ownerDocument;
588
- const fromTarget = doc.elementFromPoint(
589
- changedTouch.screenX,
590
- changedTouch.screenY,
591
- );
592
- if (
593
- fromTarget !== null &&
594
- context.isTargetWithinEventComponent(fromTarget)
595
- ) {
596
- if (
597
- !(
598
- wasLongPressed &&
599
- props.onLongPressShouldCancelPress &&
600
- props.onLongPressShouldCancelPress()
601
- )
602
- ) {
603
- dispatchEvent(context, state, 'press', props.onPress);
604
- }
605
- }
606
- }
607
- state.shouldSkipMouseAfterTouch = true;
608
- context.removeRootEventTypes(rootEventTypes);
609
- return shouldStopPropagation;
610
- }
611
- return false;
612
- }
613
-
614
- /**
615
- * Keyboard interaction support
616
- * TODO: determine UX for metaKey + validKeyPress interactions
617
- */
618
- case 'keydown':
619
- case 'keypress': {
620
- if (isValidKeyPress(nativeEvent.key)) {
621
- if (state.isPressed) {
622
- // Prevent spacebar press from scrolling the window
623
- if (nativeEvent.key === ' ') {
624
- nativeEvent.preventDefault();
625
- }
626
- } else {
627
- const pointerType = getPointerType(nativeEvent);
628
- state.pointerType = pointerType;
629
- state.pressTarget = target;
630
- dispatchPressStartEvents(context, props, state);
631
- context.addRootEventTypes(target.ownerDocument, rootEventTypes);
632
- }
633
- return shouldStopPropagation;
634
- }
635
- return false;
636
- }
637
- case 'keyup': {
638
- if (state.isPressed && isValidKeyPress(nativeEvent.key)) {
639
- const wasLongPressed = state.isLongPressed;
640
- dispatchPressEndEvents(context, props, state);
641
- if (state.pressTarget !== null && props.onPress) {
642
- if (
643
- !(
644
- wasLongPressed &&
645
- props.onLongPressShouldCancelPress &&
646
- props.onLongPressShouldCancelPress()
647
- )
648
- ) {
649
- dispatchEvent(context, state, 'press', props.onPress);
650
- }
651
- }
652
- context.removeRootEventTypes(rootEventTypes);
653
- return shouldStopPropagation;
541
+ } else if (type === 'mouseup' && state.ignoreEmulatedMouseEvents) {
542
+ state.ignoreEmulatedMouseEvents = false;
543
}
544
return false;
545
}
546
547
+ // CANCEL
548
+ case 'contextmenu':
549
case 'pointercancel':
550
case 'scroll':
551
case 'touchcancel': {
552
if (state.isPressed) {
662
- state.shouldSkipMouseAfterTouch = false;
663
- dispatchPressEndEvents(context, props, state);
664
- context.removeRootEventTypes(rootEventTypes);
553
+ if (type === 'contextmenu' && props.preventDefault !== false) {
554
+ nativeEvent.preventDefault();
555
+ } else {
556
+ state.ignoreEmulatedMouseEvents = false;
557
+ dispatchPressEndEvents(context, props, state);
558
+ context.removeRootEventTypes(rootEventTypes);
559
+ }
560
return shouldStopPropagation;
561
}
562
return false;
@@ -679,20 +574,6 @@ const PressResponder = {
574
}
575
return false;
576
}
682
-
683
- case 'contextmenu': {
684
- if (state.isPressed) {
685
- if (props.preventDefault !== false) {
686
- nativeEvent.preventDefault();
687
- } else {
688
- state.shouldSkipMouseAfterTouch = false;
689
- dispatchPressEndEvents(context, props, state);
690
- context.removeRootEventTypes(rootEventTypes);
691
- }
692
- return shouldStopPropagation;
693
- }
694
- return false;
695
- }
577
}
578
return false;
579
},
packages/react-events/src/__tests__/Press-test.internal.js
+74
-7
@@ -79,8 +79,9 @@ describe('Event responder: Press', () => {
79
);
80
});
81
82
- it('ignores browser emulated "mousedown" event', () => {
82
+ it('ignores browser emulated events', () => {
83
ref.current.dispatchEvent(createPointerEvent('pointerdown'));
84
+ ref.current.dispatchEvent(createPointerEvent('touchstart'));
85
ref.current.dispatchEvent(createPointerEvent('mousedown'));
86
expect(onPressStart).toHaveBeenCalledTimes(1);
87
});
@@ -215,19 +216,24 @@ describe('Event responder: Press', () => {
216
});
217
218
it('is called after "pointerup" event', () => {
218
- ref.current.dispatchEvent(createPointerEvent('pointerdown'));
219
ref.current.dispatchEvent(
220
- createPointerEvent('pointerup', {pointerType: 'pen'}),
220
+ createPointerEvent('pointerdown', {pointerType: 'pen'}),
221
);
222
+ ref.current.dispatchEvent(createPointerEvent('pointerup'));
223
expect(onPressEnd).toHaveBeenCalledTimes(1);
224
expect(onPressEnd).toHaveBeenCalledWith(
225
expect.objectContaining({pointerType: 'pen', type: 'pressend'}),
226
);
227
});
228
228
- it('ignores browser emulated "mouseup" event', () => {
229
+ it('ignores browser emulated events', () => {
230
+ ref.current.dispatchEvent(
231
+ createPointerEvent('pointerdown', {pointerType: 'touch'}),
232
+ );
233
ref.current.dispatchEvent(createPointerEvent('touchstart'));
234
+ ref.current.dispatchEvent(createPointerEvent('pointerup'));
235
ref.current.dispatchEvent(createPointerEvent('touchend'));
236
+ ref.current.dispatchEvent(createPointerEvent('mousedown'));
237
ref.current.dispatchEvent(createPointerEvent('mouseup'));
238
expect(onPressEnd).toHaveBeenCalledTimes(1);
239
expect(onPressEnd).toHaveBeenCalledWith(
@@ -446,10 +452,10 @@ describe('Event responder: Press', () => {
452
});
453
454
it('is called after "pointerup" event', () => {
449
- ref.current.dispatchEvent(createPointerEvent('pointerdown'));
455
ref.current.dispatchEvent(
451
- createPointerEvent('pointerup', {pointerType: 'pen'}),
456
+ createPointerEvent('pointerdown', {pointerType: 'pen'}),
457
);
458
+ ref.current.dispatchEvent(createPointerEvent('pointerup'));
459
expect(onPress).toHaveBeenCalledTimes(1);
460
expect(onPress).toHaveBeenCalledWith(
461
expect.objectContaining({pointerType: 'pen', type: 'press'}),
@@ -678,7 +684,9 @@ describe('Event responder: Press', () => {
684
bottom: 500,
685
right: 500,
686
});
681
- ref.current.dispatchEvent(createPointerEvent('pointerdown'));
687
+ ref.current.dispatchEvent(
688
+ createPointerEvent('pointerdown', {pointerType: 'touch'}),
689
+ );
690
ref.current.dispatchEvent(
691
createPointerEvent('pointermove', {
692
pointerType: 'touch',
@@ -691,6 +699,65 @@ describe('Event responder: Press', () => {
699
expect.objectContaining({pointerType: 'touch', type: 'pressmove'}),
700
);
701
});
702
+
703
+ it('is not called if "pointermove" occurs during keyboard press', () => {
704
+ const onPressMove = jest.fn();
705
+ const ref = React.createRef();
706
+ const element = (
707
+ <Press onPressMove={onPressMove}>
708
+ <div ref={ref} />
709
+ </Press>
710
+ );
711
+ ReactDOM.render(element, container);
712
+
713
+ ref.current.getBoundingClientRect = () => ({
714
+ top: 50,
715
+ left: 50,
716
+ bottom: 500,
717
+ right: 500,
718
+ });
719
+ ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'}));
720
+ ref.current.dispatchEvent(
721
+ createPointerEvent('pointermove', {
722
+ pointerType: 'mouse',
723
+ pageX: 55,
724
+ pageY: 55,
725
+ }),
726
+ );
727
+ expect(onPressMove).not.toBeCalled();
728
+ });
729
+
730
+ it('ignores browser emulated events', () => {
731
+ const onPressMove = jest.fn();
732
+ const ref = React.createRef();
733
+ const element = (
734
+ <Press onPressMove={onPressMove}>
735
+ <div ref={ref} />
736
+ </Press>
737
+ );
738
+ ReactDOM.render(element, container);
739
+
740
+ ref.current.getBoundingClientRect = () => ({
741
+ top: 50,
742
+ left: 50,
743
+ bottom: 500,
744
+ right: 500,
745
+ });
746
+ ref.current.dispatchEvent(
747
+ createPointerEvent('pointerdown', {pointerType: 'touch'}),
748
+ );
749
+ ref.current.dispatchEvent(createPointerEvent('touchstart'));
750
+ ref.current.dispatchEvent(
751
+ createPointerEvent('pointermove', {
752
+ pointerType: 'touch',
753
+ pageX: 55,
754
+ pageY: 55,
755
+ }),
756
+ );
757
+ ref.current.dispatchEvent(createPointerEvent('touchmove'));
758
+ ref.current.dispatchEvent(createPointerEvent('mousemove'));
759
+ expect(onPressMove).toHaveBeenCalledTimes(1);
760
+ });
761
});
762
763
describe('press with movement', () => {