diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index d5257db270826..4893adfd5319d 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -12,6 +12,11 @@ import type { ReactResponderContext, } from 'shared/ReactTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; +import { + getEventPointerType, + getEventCurrentTarget, + isEventPositionWithinTouchHitTarget, +} from './utils'; const CAPTURE_PHASE = 2; @@ -31,11 +36,11 @@ type HoverState = { hoverTarget: null | Element | Document, isActiveHovered: boolean, isHovered: boolean, - isInHitSlop: boolean, + isOverTouchHitTarget: boolean, isTouched: boolean, hoverStartTimeout: null | Symbol, hoverEndTimeout: null | Symbol, - skipMouseAfterPointer: boolean, + ignoreEmulatedMouseEvents: boolean, }; type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange' | 'hovermove'; @@ -181,9 +186,9 @@ function dispatchHoverEndEvents( dispatchHoverChangeEvent(context, props, state); } - state.isInHitSlop = false; + state.isOverTouchHitTarget = false; state.hoverTarget = null; - state.skipMouseAfterPointer = false; + state.ignoreEmulatedMouseEvents = false; state.isTouched = false; }; @@ -219,17 +224,25 @@ function unmountResponder( } } +function isEmulatedMouseEvent(event, state) { + const {type} = event; + return ( + state.ignoreEmulatedMouseEvents && + (type === 'mousemove' || type === 'mouseover' || type === 'mouseout') + ); +} + const HoverResponder = { targetEventTypes, createInitialState() { return { isActiveHovered: false, isHovered: false, - isInHitSlop: false, + isOverTouchHitTarget: false, isTouched: false, hoverStartTimeout: null, hoverEndTimeout: null, - skipMouseAfterPointer: false, + ignoreEmulatedMouseEvents: false, }; }, onEvent( @@ -238,112 +251,92 @@ const HoverResponder = { props: HoverProps, state: HoverState, ): boolean { - const {type, phase, target} = event; - const nativeEvent: any = event.nativeEvent; + const {type} = event; // Hover doesn't handle capture target events at this point - if (phase === CAPTURE_PHASE) { + if (event.phase === CAPTURE_PHASE) { return false; } - switch (type) { - /** - * Prevent hover events when touch is being used. - */ - case 'touchstart': { - if (!state.isTouched) { - state.isTouched = true; - } - break; - } - case 'touchcancel': - case 'touchend': { - if (state.isTouched) { - state.isTouched = false; - } - break; - } + const pointerType = getEventPointerType(event); + + switch (type) { + // START case 'pointerover': - case 'mouseover': { - if (!state.isHovered && !state.isTouched) { - if (nativeEvent.pointerType === 'touch') { + case 'mouseover': + case 'touchstart': { + if (!state.isHovered) { + // Prevent hover events for touch + if (state.isTouched || pointerType === 'touch') { state.isTouched = true; return false; } - if (type === 'pointerover') { - state.skipMouseAfterPointer = true; + + // Prevent hover events for emulated events + if (isEmulatedMouseEvent(event, state)) { + return false; } - if ( - context.isPositionWithinTouchHitTarget( - target.ownerDocument, - nativeEvent.x, - nativeEvent.y, - ) - ) { - state.isInHitSlop = true; + + if (isEventPositionWithinTouchHitTarget(event, context)) { + state.isOverTouchHitTarget = true; return false; } - state.hoverTarget = target; + state.hoverTarget = getEventCurrentTarget(event, context); + state.ignoreEmulatedMouseEvents = true; dispatchHoverStartEvents(event, context, props, state); } - break; - } - case 'pointerout': - case 'mouseout': { - if (state.isHovered && !state.isTouched) { - dispatchHoverEndEvents(event, context, props, state); - } - break; + return false; } + // MOVE case 'pointermove': case 'mousemove': { - if (type === 'mousemove' && state.skipMouseAfterPointer === true) { - return false; - } - - if (state.isHovered && !state.isTouched) { - if (state.isInHitSlop) { - if ( - !context.isPositionWithinTouchHitTarget( - target.ownerDocument, - nativeEvent.x, - nativeEvent.y, - ) - ) { - dispatchHoverStartEvents(event, context, props, state); - state.isInHitSlop = false; - } - } else if (state.isHovered) { - if ( - context.isPositionWithinTouchHitTarget( - target.ownerDocument, - nativeEvent.x, - nativeEvent.y, - ) - ) { - dispatchHoverEndEvents(event, context, props, state); - state.isInHitSlop = true; + if (state.isHovered && !isEmulatedMouseEvent(event, state)) { + if (state.isHovered) { + if (state.isOverTouchHitTarget) { + // If we were moving over the TouchHitTarget and have now moved + // over the Responder target + if (!isEventPositionWithinTouchHitTarget(event, context)) { + dispatchHoverStartEvents(event, context, props, state); + state.isOverTouchHitTarget = false; + } } else { - if (props.onHoverMove) { - const syntheticEvent = createHoverEvent('hovermove', target); - context.dispatchEvent(syntheticEvent, props.onHoverMove, { - discrete: false, - }); + // If we were moving over the Responder target and have now moved + // over the TouchHitTarget + if (isEventPositionWithinTouchHitTarget(event, context)) { + dispatchHoverEndEvents(event, context, props, state); + state.isOverTouchHitTarget = true; + } else { + if (props.onHoverMove && state.hoverTarget !== null) { + const syntheticEvent = createHoverEvent( + 'hovermove', + state.hoverTarget, + ); + context.dispatchEvent(syntheticEvent, props.onHoverMove, { + discrete: false, + }); + } } } } } - break; + return false; } - case 'pointercancel': { - if (state.isHovered && !state.isTouched) { + // END + case 'pointerout': + case 'pointercancel': + case 'mouseout': + case 'touchcancel': + case 'touchend': { + if (state.isHovered) { dispatchHoverEndEvents(event, context, props, state); - state.hoverTarget = null; + state.ignoreEmulatedMouseEvents = false; + } + if (state.isTouched) { state.isTouched = false; } - break; + return false; } } return false; diff --git a/packages/react-events/src/__tests__/Hover-test.internal.js b/packages/react-events/src/__tests__/Hover-test.internal.js index cdd8ea3532861..6076807575bd6 100644 --- a/packages/react-events/src/__tests__/Hover-test.internal.js +++ b/packages/react-events/src/__tests__/Hover-test.internal.js @@ -65,12 +65,24 @@ describe('Hover event responder', () => { }); it('is not called if "pointerover" pointerType is touch', () => { - const event = createPointerEvent('pointerover'); - event.pointerType = 'touch'; + const event = createPointerEvent('pointerover', {pointerType: 'touch'}); ref.current.dispatchEvent(event); expect(onHoverStart).not.toBeCalled(); }); + it('is called if valid "pointerover" follows touch', () => { + ref.current.dispatchEvent( + createPointerEvent('pointerover', {pointerType: 'touch'}), + ); + ref.current.dispatchEvent( + createPointerEvent('pointerout', {pointerType: 'touch'}), + ); + ref.current.dispatchEvent( + createPointerEvent('pointerover', {pointerType: 'mouse'}), + ); + expect(onHoverStart).toHaveBeenCalledTimes(1); + }); + it('ignores browser emulated "mouseover" event', () => { ref.current.dispatchEvent(createPointerEvent('pointerover')); ref.current.dispatchEvent(createPointerEvent('mouseover'));