@samitouri / QOS-React-2 / commits / 051513bfa0

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', () => {