diff --git a/packages/react-events/src/dom/Hover.js b/packages/react-events/src/dom/Hover.js index 3d83313100b3e..df8469c1c86b4 100644 --- a/packages/react-events/src/dom/Hover.js +++ b/packages/react-events/src/dom/Hover.js @@ -10,6 +10,7 @@ import type { ReactDOMResponderEvent, ReactDOMResponderContext, + PointerType, } from 'shared/ReactDOMTypes'; import type {ReactEventResponderListener} from 'shared/ReactTypes'; @@ -29,15 +30,14 @@ type HoverState = { hoverTarget: null | Element | Document, isActiveHovered: boolean, isHovered: boolean, - isTouched: boolean, - hoverStartTimeout: null | number, - hoverEndTimeout: null | number, - ignoreEmulatedMouseEvents: boolean, + isTouched?: boolean, + ignoreEmulatedMouseEvents?: boolean, }; type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange' | 'hovermove'; type HoverEvent = {| + pointerType: PointerType, target: Element | Document, type: HoverEventType, timeStamp: number, @@ -51,17 +51,8 @@ type HoverEvent = {| y: null | number, |}; -const targetEventTypes = [ - 'pointerover', - 'pointermove', - 'pointerout', - 'pointercancel', -]; - -// If PointerEvents is not supported (e.g., Safari), also listen to touch and mouse events. -if (typeof window !== 'undefined' && window.PointerEvent === undefined) { - targetEventTypes.push('touchstart', 'mouseover', 'mousemove', 'mouseout'); -} +const hasPointerEvents = + typeof window !== 'undefined' && window.PointerEvent != null; function isFunction(obj): boolean { return typeof obj === 'function'; @@ -79,13 +70,16 @@ function createHoverEvent( let pageY = null; let screenX = null; let screenY = null; + let pointerType = ''; if (event) { const nativeEvent = (event.nativeEvent: any); + pointerType = event.pointerType; ({clientX, clientY, pageX, pageY, screenX, screenY} = nativeEvent); } return { + pointerType, target, type, timeStamp: context.getTimeStamp(), @@ -131,11 +125,6 @@ function dispatchHoverStartEvents( state.isHovered = true; - if (state.hoverEndTimeout !== null) { - context.clearTimeout(state.hoverEndTimeout); - state.hoverEndTimeout = null; - } - if (!state.isActiveHovered) { state.isActiveHovered = true; const onHoverStart = props.onHoverStart; @@ -152,6 +141,20 @@ function dispatchHoverStartEvents( } } +function dispatchHoverMoveEvent(event, context, props, state) { + const target = state.hoverTarget; + const onHoverMove = props.onHoverMove; + if (isFunction(onHoverMove)) { + const syntheticEvent = createHoverEvent( + event, + context, + 'hovermove', + ((target: any): Element | Document), + ); + context.dispatchEvent(syntheticEvent, onHoverMove, UserBlockingEvent); + } +} + function dispatchHoverEndEvents( event: null | ReactDOMResponderEvent, context: ReactDOMResponderContext, @@ -170,11 +173,6 @@ function dispatchHoverEndEvents( state.isHovered = false; - if (state.hoverStartTimeout !== null) { - context.clearTimeout(state.hoverStartTimeout); - state.hoverStartTimeout = null; - } - if (state.isActiveHovered) { state.isActiveHovered = false; const onHoverEnd = props.onHoverEnd; @@ -189,7 +187,6 @@ function dispatchHoverEndEvents( } dispatchHoverChangeEvent(event, context, props, state); state.hoverTarget = null; - state.ignoreEmulatedMouseEvents = false; state.isTouched = false; } } @@ -204,24 +201,17 @@ function unmountResponder( } } -function isEmulatedMouseEvent(event, state) { - const {type} = event; - return ( - state.ignoreEmulatedMouseEvents && - (type === 'mousemove' || type === 'mouseover' || type === 'mouseout') - ); -} - const hoverResponderImpl = { - targetEventTypes, + targetEventTypes: [ + 'pointerover', + 'pointermove', + 'pointerout', + 'pointercancel', + ], getInitialState() { return { isActiveHovered: false, isHovered: false, - isTouched: false, - hoverStartTimeout: null, - hoverEndTimeout: null, - ignoreEmulatedMouseEvents: false, }; }, allowMultipleHostChildren: false, @@ -237,95 +227,120 @@ const hoverResponderImpl = { if (props.disabled) { if (state.isHovered) { dispatchHoverEndEvents(event, context, props, state); - state.ignoreEmulatedMouseEvents = false; - } - if (state.isTouched) { - state.isTouched = false; } return; } switch (type) { // START - case 'pointerover': - case 'mouseover': - case 'touchstart': { - if (!state.isHovered) { - // Prevent hover events for touch - if (state.isTouched || pointerType === 'touch') { - state.isTouched = true; - return; - } - - // Prevent hover events for emulated events - if (isEmulatedMouseEvent(event, state)) { - return; - } + case 'pointerover': { + if (!state.isHovered && pointerType !== 'touch') { state.hoverTarget = event.responderTarget; - state.ignoreEmulatedMouseEvents = true; dispatchHoverStartEvents(event, context, props, state); } - return; + break; } // MOVE - case 'pointermove': - case 'mousemove': { - if (state.isHovered && !isEmulatedMouseEvent(event, state)) { - const onHoverMove = props.onHoverMove; - if (state.hoverTarget !== null && isFunction(onHoverMove)) { - const syntheticEvent = createHoverEvent( - event, - context, - 'hovermove', - state.hoverTarget, - ); - context.dispatchEvent( - syntheticEvent, - onHoverMove, - UserBlockingEvent, - ); - } + case 'pointermove': { + if (state.isHovered && state.hoverTarget !== null) { + dispatchHoverMoveEvent(event, context, props, state); } - return; + break; } // END case 'pointerout': - case 'pointercancel': - case 'mouseout': - case 'touchcancel': - case 'touchend': { + case 'pointercancel': { if (state.isHovered) { dispatchHoverEndEvents(event, context, props, state); - state.ignoreEmulatedMouseEvents = false; } - if (state.isTouched) { - state.isTouched = false; - } - return; + break; } } }, - onUnmount( - context: ReactDOMResponderContext, - props: HoverProps, - state: HoverState, - ) { - unmountResponder(context, props, state); + onUnmount: unmountResponder, + onOwnershipChange: unmountResponder, +}; + +const hoverResponderFallbackImpl = { + targetEventTypes: ['mouseover', 'mousemove', 'mouseout', 'touchstart'], + getInitialState() { + return { + isActiveHovered: false, + isHovered: false, + isTouched: false, + ignoreEmulatedMouseEvents: false, + }; }, - onOwnershipChange( + allowMultipleHostChildren: false, + allowEventHooks: true, + onEvent( + event: ReactDOMResponderEvent, context: ReactDOMResponderContext, props: HoverProps, state: HoverState, - ) { - unmountResponder(context, props, state); + ): void { + const {type} = event; + + if (props.disabled) { + if (state.isHovered) { + dispatchHoverEndEvents(event, context, props, state); + state.ignoreEmulatedMouseEvents = false; + } + state.isTouched = false; + return; + } + + switch (type) { + // START + case 'mouseover': { + if (!state.isHovered && !state.ignoreEmulatedMouseEvents) { + state.hoverTarget = event.responderTarget; + dispatchHoverStartEvents(event, context, props, state); + } + break; + } + + // MOVE + case 'mousemove': { + if ( + state.isHovered && + state.hoverTarget !== null && + !state.ignoreEmulatedMouseEvents + ) { + dispatchHoverMoveEvent(event, context, props, state); + } else if (!state.isHovered && type === 'mousemove') { + state.ignoreEmulatedMouseEvents = false; + state.isTouched = false; + } + break; + } + + // END + case 'mouseout': { + if (state.isHovered) { + dispatchHoverEndEvents(event, context, props, state); + } + break; + } + + case 'touchstart': { + if (!state.isHovered) { + state.isTouched = true; + state.ignoreEmulatedMouseEvents = true; + } + break; + } + } }, + onUnmount: unmountResponder, + onOwnershipChange: unmountResponder, }; export const HoverResponder = React.unstable_createResponder( 'Hover', - hoverResponderImpl, + hasPointerEvents ? hoverResponderImpl : hoverResponderFallbackImpl, ); export function useHoverResponder( diff --git a/packages/react-events/src/dom/__tests__/ContextMenu-test.internal.js b/packages/react-events/src/dom/__tests__/ContextMenu-test.internal.js index 546378f98f16d..4e03d897bf82d 100644 --- a/packages/react-events/src/dom/__tests__/ContextMenu-test.internal.js +++ b/packages/react-events/src/dom/__tests__/ContextMenu-test.internal.js @@ -9,24 +9,16 @@ 'use strict'; +import {createEvent, platform, setPointerEvent} from '../test-utils'; + let React; let ReactFeatureFlags; let ReactDOM; let useContextMenuResponder; -function createEvent(type, data) { - const event = document.createEvent('CustomEvent'); - event.initCustomEvent(type, true, true); - if (data != null) { - Object.entries(data).forEach(([key, value]) => { - event[key] = value; - }); - } - return event; -} - -function init(hasPointerEvents) { - global.PointerEvents = hasPointerEvents ? function() {} : undefined; +function initializeModules(hasPointerEvents) { + setPointerEvent(hasPointerEvents); + jest.resetModules(); ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.enableFlareAPI = true; React = require('react'); @@ -35,29 +27,6 @@ function init(hasPointerEvents) { .useContextMenuResponder; } -const platformGetter = jest.spyOn(global.navigator, 'platform', 'get'); -function setPlatform(platform: 'mac' | 'windows') { - jest.resetModules(); - switch (platform) { - case 'mac': { - platformGetter.mockReturnValue('MacIntel'); - break; - } - case 'windows': { - platformGetter.mockReturnValue('Win32'); - break; - } - default: { - break; - } - } - init(); -} - -function clearPlatform() { - platformGetter.mockClear(); -} - function dispatchContextMenuEvents(ref, options) { const preventDefault = options.preventDefault || function() {}; const variant = (options.variant: 'mouse' | 'touch' | 'modified'); @@ -76,7 +45,7 @@ function dispatchContextMenuEvents(ref, options) { createEvent('pointerdown', {pointerType: 'mouse', button: 0}), ); dispatchEvent(createEvent('mousedown', {button: 0})); - if (global.navigator.platform === 'MacIntel') { + if (platform.get() === 'mac') { dispatchEvent( createEvent('contextmenu', {button: 0, ctrlKey: true, preventDefault}), ); @@ -97,144 +66,143 @@ function dispatchContextMenuEvents(ref, options) { } const forcePointerEvents = true; +const table = [[forcePointerEvents], [!forcePointerEvents]]; + +describe.each(table)('ContextMenu responder', hasPointerEvents => { + let container; + + beforeEach(() => { + initializeModules(hasPointerEvents); + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + ReactDOM.render(null, container); + document.body.removeChild(container); + container = null; + }); + + describe('all platforms', () => { + it('mouse right-click', () => { + const onContextMenu = jest.fn(); + const preventDefault = jest.fn(); + const ref = React.createRef(); + const Component = () => { + const listener = useContextMenuResponder({onContextMenu}); + return
; + }; + ReactDOM.render(, container); + + dispatchContextMenuEvents(ref, {variant: 'mouse', preventDefault}); + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(onContextMenu).toHaveBeenCalledTimes(1); + expect(onContextMenu).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'mouse', type: 'contextmenu'}), + ); + }); + + it('touch long-press', () => { + const onContextMenu = jest.fn(); + const preventDefault = jest.fn(); + const ref = React.createRef(); + const Component = () => { + const listener = useContextMenuResponder({onContextMenu}); + return
; + }; + ReactDOM.render(, container); + + dispatchContextMenuEvents(ref, {variant: 'touch', preventDefault}); + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(onContextMenu).toHaveBeenCalledTimes(1); + expect(onContextMenu).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'touch', type: 'contextmenu'}), + ); + }); + + it('"disabled" is true', () => { + const onContextMenu = jest.fn(); + const ref = React.createRef(); + const Component = () => { + const listener = useContextMenuResponder({ + onContextMenu, + disabled: true, + }); + return
; + }; + ReactDOM.render(, container); + + dispatchContextMenuEvents(ref, 'mouse'); + expect(onContextMenu).toHaveBeenCalledTimes(0); + }); -describe.each([[forcePointerEvents], [!forcePointerEvents]])( - 'ContextMenu responder', - hasPointerEvents => { - let container; + it('"preventDefault" is false', () => { + const preventDefault = jest.fn(); + const onContextMenu = jest.fn(); + const ref = React.createRef(); + const Component = () => { + const listener = useContextMenuResponder({ + onContextMenu, + preventDefault: false, + }); + return
; + }; + ReactDOM.render(, container); + + dispatchContextMenuEvents(ref, {variant: 'mouse', preventDefault}); + expect(preventDefault).toHaveBeenCalledTimes(0); + expect(onContextMenu).toHaveBeenCalledTimes(1); + }); + }); + describe('mac platform', () => { beforeEach(() => { + platform.set('mac'); jest.resetModules(); - init(hasPointerEvents); - container = document.createElement('div'); - document.body.appendChild(container); }); afterEach(() => { - ReactDOM.render(null, container); - document.body.removeChild(container); - container = null; + platform.clear(); }); - describe('all platforms', () => { - it('mouse right-click', () => { - const onContextMenu = jest.fn(); - const preventDefault = jest.fn(); - const ref = React.createRef(); - const Component = () => { - const listener = useContextMenuResponder({onContextMenu}); - return
; - }; - ReactDOM.render(, container); - - dispatchContextMenuEvents(ref, {variant: 'mouse', preventDefault}); - expect(preventDefault).toHaveBeenCalledTimes(1); - expect(onContextMenu).toHaveBeenCalledTimes(1); - expect(onContextMenu).toHaveBeenCalledWith( - expect.objectContaining({pointerType: 'mouse', type: 'contextmenu'}), - ); - }); - - it('touch long-press', () => { - const onContextMenu = jest.fn(); - const preventDefault = jest.fn(); - const ref = React.createRef(); - const Component = () => { - const listener = useContextMenuResponder({onContextMenu}); - return
; - }; - ReactDOM.render(, container); - - dispatchContextMenuEvents(ref, {variant: 'touch', preventDefault}); - expect(preventDefault).toHaveBeenCalledTimes(1); - expect(onContextMenu).toHaveBeenCalledTimes(1); - expect(onContextMenu).toHaveBeenCalledWith( - expect.objectContaining({pointerType: 'touch', type: 'contextmenu'}), - ); - }); - - it('"disabled" is true', () => { - const onContextMenu = jest.fn(); - const ref = React.createRef(); - const Component = () => { - const listener = useContextMenuResponder({ - onContextMenu, - disabled: true, - }); - return
; - }; - ReactDOM.render(, container); - - dispatchContextMenuEvents(ref, 'mouse'); - expect(onContextMenu).toHaveBeenCalledTimes(0); - }); - - it('"preventDefault" is false', () => { - const preventDefault = jest.fn(); - const onContextMenu = jest.fn(); - const ref = React.createRef(); - const Component = () => { - const listener = useContextMenuResponder({ - onContextMenu, - preventDefault: false, - }); - return
; - }; - ReactDOM.render(, container); - - dispatchContextMenuEvents(ref, {variant: 'mouse', preventDefault}); - expect(preventDefault).toHaveBeenCalledTimes(0); - expect(onContextMenu).toHaveBeenCalledTimes(1); - }); + it('mouse modified left-click', () => { + const onContextMenu = jest.fn(); + const ref = React.createRef(); + const Component = () => { + const listener = useContextMenuResponder({onContextMenu}); + return
; + }; + ReactDOM.render(, container); + + dispatchContextMenuEvents(ref, {variant: 'modified'}); + expect(onContextMenu).toHaveBeenCalledTimes(1); + expect(onContextMenu).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'mouse', type: 'contextmenu'}), + ); }); + }); - describe('mac platform', () => { - beforeEach(() => { - setPlatform('mac'); - }); - - afterEach(() => { - clearPlatform(); - }); - - it('mouse modified left-click', () => { - const onContextMenu = jest.fn(); - const ref = React.createRef(); - const Component = () => { - const listener = useContextMenuResponder({onContextMenu}); - return
; - }; - ReactDOM.render(, container); - - dispatchContextMenuEvents(ref, {variant: 'modified'}); - expect(onContextMenu).toHaveBeenCalledTimes(1); - expect(onContextMenu).toHaveBeenCalledWith( - expect.objectContaining({pointerType: 'mouse', type: 'contextmenu'}), - ); - }); + describe('windows platform', () => { + beforeEach(() => { + platform.set('windows'); + jest.resetModules(); + }); + + afterEach(() => { + platform.clear(); }); - describe('windows platform', () => { - beforeEach(() => { - setPlatform('windows'); - }); - - afterEach(() => { - clearPlatform(); - }); - - it('mouse modified left-click', () => { - const onContextMenu = jest.fn(); - const ref = React.createRef(); - const Component = () => { - const listener = useContextMenuResponder({onContextMenu}); - return
; - }; - ReactDOM.render(, container); - - dispatchContextMenuEvents(ref, {variant: 'modified'}); - expect(onContextMenu).toHaveBeenCalledTimes(0); - }); + it('mouse modified left-click', () => { + const onContextMenu = jest.fn(); + const ref = React.createRef(); + const Component = () => { + const listener = useContextMenuResponder({onContextMenu}); + return
; + }; + ReactDOM.render(, container); + + dispatchContextMenuEvents(ref, {variant: 'modified'}); + expect(onContextMenu).toHaveBeenCalledTimes(0); }); - }, -); + }); +}); diff --git a/packages/react-events/src/dom/__tests__/Hover-test.internal.js b/packages/react-events/src/dom/__tests__/Hover-test.internal.js index 895a8236046e6..73d5106e6fbab 100644 --- a/packages/react-events/src/dom/__tests__/Hover-test.internal.js +++ b/packages/react-events/src/dom/__tests__/Hover-test.internal.js @@ -9,50 +9,41 @@ 'use strict'; +import { + dispatchPointerCancel, + dispatchPointerHoverEnter, + dispatchPointerHoverExit, + dispatchPointerHoverMove, + dispatchTouchTap, + setPointerEvent, +} from '../test-utils'; + let React; let ReactFeatureFlags; let ReactDOM; -let TestUtils; -let Scheduler; let HoverResponder; let useHoverResponder; -const createEvent = (type, data) => { - const event = document.createEvent('CustomEvent'); - event.initCustomEvent(type, true, true); - if (data != null) { - Object.entries(data).forEach(([key, value]) => { - event[key] = value; - }); - } - return event; -}; - -function createTouchEvent(type, id, data) { - return createEvent(type, { - changedTouches: [ - { - ...data, - identifier: id, - }, - ], - }); +function initializeModules(hasPointerEvents) { + jest.resetModules(); + setPointerEvent(hasPointerEvents); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableFlareAPI = true; + ReactFeatureFlags.enableUserBlockingEvents = true; + React = require('react'); + ReactDOM = require('react-dom'); + HoverResponder = require('react-events/hover').HoverResponder; + useHoverResponder = require('react-events/hover').useHoverResponder; } -describe('Hover event responder', () => { +const forcePointerEvents = true; +const table = [[forcePointerEvents], [!forcePointerEvents]]; + +describe.each(table)('Hover responder', hasPointerEvents => { let container; beforeEach(() => { - jest.resetModules(); - ReactFeatureFlags = require('shared/ReactFeatureFlags'); - ReactFeatureFlags.enableFlareAPI = true; - ReactFeatureFlags.enableUserBlockingEvents = true; - React = require('react'); - ReactDOM = require('react-dom'); - TestUtils = require('react-dom/test-utils'); - Scheduler = require('scheduler'); - HoverResponder = require('react-events/hover').HoverResponder; - useHoverResponder = require('react-events/hover').useHoverResponder; + initializeModules(hasPointerEvents); container = document.createElement('div'); document.body.appendChild(container); }); @@ -64,17 +55,21 @@ describe('Hover event responder', () => { }); describe('disabled', () => { - let onHoverStart, onHoverEnd, ref; + let onHoverChange, onHoverStart, onHoverMove, onHoverEnd, ref; beforeEach(() => { + onHoverChange = jest.fn(); onHoverStart = jest.fn(); + onHoverMove = jest.fn(); onHoverEnd = jest.fn(); ref = React.createRef(); const Component = () => { const listener = useHoverResponder({ disabled: true, - onHoverStart: onHoverStart, - onHoverEnd: onHoverEnd, + onHoverChange, + onHoverStart, + onHoverMove, + onHoverEnd, }); return
; }; @@ -82,9 +77,11 @@ describe('Hover event responder', () => { }); it('prevents custom events being dispatched', () => { - ref.current.dispatchEvent(createEvent('pointerover')); - ref.current.dispatchEvent(createEvent('pointerout')); + dispatchPointerHoverEnter(ref); + dispatchPointerHoverExit(ref); + expect(onHoverChange).not.toBeCalled(); expect(onHoverStart).not.toBeCalled(); + expect(onHoverMove).not.toBeCalled(); expect(onHoverEnd).not.toBeCalled(); }); }); @@ -104,68 +101,21 @@ describe('Hover event responder', () => { ReactDOM.render(, container); }); - it('is called after "pointerover" event', () => { - ref.current.dispatchEvent(createEvent('pointerover')); + it('is called for mouse pointers', () => { + dispatchPointerHoverEnter(ref); expect(onHoverStart).toHaveBeenCalledTimes(1); }); - it('is not called if "pointerover" pointerType is touch', () => { - const event = createEvent('pointerover', {pointerType: 'touch'}); - ref.current.dispatchEvent(event); + it('is not called for touch pointers', () => { + dispatchTouchTap(ref); expect(onHoverStart).not.toBeCalled(); }); - it('is called if valid "pointerover" follows touch', () => { - ref.current.dispatchEvent( - createEvent('pointerover', {pointerType: 'touch'}), - ); - ref.current.dispatchEvent( - createEvent('pointerout', {pointerType: 'touch'}), - ); - ref.current.dispatchEvent( - createEvent('pointerover', {pointerType: 'mouse'}), - ); - expect(onHoverStart).toHaveBeenCalledTimes(1); - }); - - it('ignores browser emulated "mouseover" event', () => { - ref.current.dispatchEvent(createEvent('pointerover')); - ref.current.dispatchEvent( - createEvent('mouseover', { - button: 0, - }), - ); + it('is called if a mouse pointer is used after a touch pointer', () => { + dispatchTouchTap(ref); + dispatchPointerHoverEnter(ref); expect(onHoverStart).toHaveBeenCalledTimes(1); }); - - // No PointerEvent fallbacks - it('is called after "mouseover" event', () => { - ref.current.dispatchEvent( - createEvent('mouseover', { - button: 0, - }), - ); - expect(onHoverStart).toHaveBeenCalledTimes(1); - }); - - it('is not called after "touchstart"', () => { - ref.current.dispatchEvent( - createTouchEvent('touchstart', 0, { - target: ref.current, - }), - ); - ref.current.dispatchEvent( - createTouchEvent('touchend', 0, { - target: ref.current, - }), - ); - ref.current.dispatchEvent( - createEvent('mouseover', { - button: 0, - }), - ); - expect(onHoverStart).not.toBeCalled(); - }); }); describe('onHoverChange', () => { @@ -183,58 +133,18 @@ describe('Hover event responder', () => { ReactDOM.render(, container); }); - it('is called after "pointerover" and "pointerout" events', () => { - ref.current.dispatchEvent(createEvent('pointerover')); - expect(onHoverChange).toHaveBeenCalledTimes(1); - expect(onHoverChange).toHaveBeenCalledWith(true); - ref.current.dispatchEvent(createEvent('pointerout')); - expect(onHoverChange).toHaveBeenCalledTimes(2); - expect(onHoverChange).toHaveBeenCalledWith(false); - }); - - // No PointerEvent fallbacks - it('is called after "mouseover" and "mouseout" events', () => { - ref.current.dispatchEvent(createEvent('mouseover')); + it('is called for mouse pointers', () => { + dispatchPointerHoverEnter(ref); expect(onHoverChange).toHaveBeenCalledTimes(1); expect(onHoverChange).toHaveBeenCalledWith(true); - ref.current.dispatchEvent(createEvent('mouseout')); + dispatchPointerHoverExit(ref); expect(onHoverChange).toHaveBeenCalledTimes(2); expect(onHoverChange).toHaveBeenCalledWith(false); }); - it('should be user-blocking but not discrete', async () => { - const {act} = TestUtils; - const {useState} = React; - - const newContainer = document.createElement('div'); - document.body.appendChild(newContainer); - const root = ReactDOM.unstable_createRoot(newContainer); - - const target = React.createRef(null); - function Foo() { - const [isHover, setHover] = useState(false); - const listener = useHoverResponder({ - onHoverChange: setHover, - }); - return ( -
- {isHover ? 'hovered' : 'not hovered'} -
- ); - } - - await act(async () => { - root.render(); - }); - expect(newContainer.textContent).toEqual('not hovered'); - - await act(async () => { - target.current.dispatchEvent(createEvent('mouseover')); - - // 3s should be enough to expire the updates - Scheduler.unstable_advanceTime(3000); - expect(newContainer.textContent).toEqual('hovered'); - }); + it('is not called for touch pointers', () => { + dispatchTouchTap(ref); + expect(onHoverChange).not.toBeCalled(); }); }); @@ -253,64 +163,35 @@ describe('Hover event responder', () => { ReactDOM.render(, container); }); - it('is called after "pointerout" event', () => { - ref.current.dispatchEvent(createEvent('pointerover')); - ref.current.dispatchEvent(createEvent('pointerout')); + it('is called for mouse pointers', () => { + dispatchPointerHoverEnter(ref); + dispatchPointerHoverExit(ref); expect(onHoverEnd).toHaveBeenCalledTimes(1); }); - it('is not called if "pointerover" pointerType is touch', () => { - const event = createEvent('pointerover'); - event.pointerType = 'touch'; - ref.current.dispatchEvent(event); - ref.current.dispatchEvent(createEvent('pointerout')); - expect(onHoverEnd).not.toBeCalled(); - }); - - it('ignores browser emulated "mouseout" event', () => { - ref.current.dispatchEvent(createEvent('pointerover')); - ref.current.dispatchEvent(createEvent('pointerout')); - ref.current.dispatchEvent(createEvent('mouseout')); - expect(onHoverEnd).toHaveBeenCalledTimes(1); - }); - - it('is called after "pointercancel" event', () => { - ref.current.dispatchEvent(createEvent('pointerover')); - ref.current.dispatchEvent(createEvent('pointercancel')); - expect(onHoverEnd).toHaveBeenCalledTimes(1); - }); - - it('is not called again after "pointercancel" event if it follows "pointerout"', () => { - ref.current.dispatchEvent(createEvent('pointerover')); - ref.current.dispatchEvent(createEvent('pointerout')); - ref.current.dispatchEvent(createEvent('pointercancel')); - expect(onHoverEnd).toHaveBeenCalledTimes(1); - }); + if (hasPointerEvents) { + it('is called once for cancelled mouse pointers', () => { + dispatchPointerHoverEnter(ref); + dispatchPointerCancel(ref); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + + // only called once if cancel follows exit + onHoverEnd.mockReset(); + dispatchPointerHoverEnter(ref); + dispatchPointerHoverExit(ref); + dispatchPointerCancel(ref); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); + } - // No PointerEvent fallbacks - it('is called after "mouseout" event', () => { - ref.current.dispatchEvent(createEvent('mouseover')); - ref.current.dispatchEvent(createEvent('mouseout')); - expect(onHoverEnd).toHaveBeenCalledTimes(1); - }); - it('is not called after "touchend"', () => { - ref.current.dispatchEvent( - createTouchEvent('touchstart', 0, { - target: ref.current, - }), - ); - ref.current.dispatchEvent( - createTouchEvent('touchend', 0, { - target: ref.current, - }), - ); - ref.current.dispatchEvent(createEvent('mouseout')); + it('is not called for touch pointers', () => { + dispatchTouchTap(ref); expect(onHoverEnd).not.toBeCalled(); }); }); describe('onHoverMove', () => { - it('is called after "pointermove"', () => { + it('is called after the active pointer moves"', () => { const onHoverMove = jest.fn(); const ref = React.createRef(); const Component = () => { @@ -320,20 +201,9 @@ describe('Hover event responder', () => { return
; }; ReactDOM.render(, container); - - ref.current.getBoundingClientRect = () => ({ - top: 50, - left: 50, - bottom: 500, - right: 500, - }); - ref.current.dispatchEvent(createEvent('pointerover')); - ref.current.dispatchEvent( - createEvent('pointermove', {pointerType: 'mouse'}), - ); - ref.current.dispatchEvent(createEvent('touchmove')); - ref.current.dispatchEvent(createEvent('mousemove')); - expect(onHoverMove).toHaveBeenCalledTimes(1); + dispatchPointerHoverEnter(ref); + dispatchPointerHoverMove(ref, {from: {x: 0, y: 0}, to: {x: 1, y: 1}}); + expect(onHoverMove).toHaveBeenCalledTimes(2); expect(onHoverMove).toHaveBeenCalledWith( expect.objectContaining({type: 'hovermove'}), ); @@ -372,18 +242,13 @@ describe('Hover event responder', () => { }; ReactDOM.render(, container); - outerRef.current.dispatchEvent(createEvent('pointerover')); - outerRef.current.dispatchEvent( - createEvent('pointerout', {relatedTarget: innerRef.current}), - ); - innerRef.current.dispatchEvent(createEvent('pointerover')); - innerRef.current.dispatchEvent( - createEvent('pointerout', {relatedTarget: outerRef.current}), - ); - outerRef.current.dispatchEvent( - createEvent('pointerover', {relatedTarget: innerRef.current}), - ); - outerRef.current.dispatchEvent(createEvent('pointerout')); + dispatchPointerHoverEnter(outerRef, {relatedTarget: container}); + dispatchPointerHoverExit(outerRef, {relatedTarget: innerRef.current}); + dispatchPointerHoverEnter(innerRef, {relatedTarget: outerRef.current}); + dispatchPointerHoverExit(innerRef, {relatedTarget: outerRef.current}); + dispatchPointerHoverEnter(outerRef, {relatedTarget: innerRef.current}); + dispatchPointerHoverExit(outerRef, {relatedTarget: container}); + expect(events).toEqual([ 'outer: onHoverStart', 'outer: onHoverChange', @@ -411,10 +276,10 @@ describe('Hover event responder', () => { const eventLog = []; const logEvent = event => { const propertiesWeCareAbout = { + x: event.x, + y: event.y, pageX: event.pageX, pageY: event.pageY, - screenX: event.screenX, - screenY: event.screenY, clientX: event.clientX, clientY: event.clientY, pointerType: event.pointerType, @@ -435,79 +300,58 @@ describe('Hover event responder', () => { }; ReactDOM.render(, container); - ref.current.getBoundingClientRect = () => ({ - top: 10, - left: 10, - bottom: 20, - right: 20, - }); + dispatchPointerHoverEnter(ref, {x: 10, y: 10}); + dispatchPointerHoverMove(ref, {from: {x: 10, y: 10}, to: {x: 20, y: 20}}); + dispatchPointerHoverExit(ref, {x: 20, y: 20}); - ref.current.dispatchEvent( - createEvent('pointerover', { - pointerType: 'mouse', - pageX: 15, - pageY: 16, - screenX: 20, - screenY: 21, - clientX: 30, - clientY: 31, - }), - ); - ref.current.dispatchEvent( - createEvent('pointermove', { - pointerType: 'mouse', - pageX: 16, - pageY: 17, - screenX: 21, - screenY: 22, - clientX: 31, - clientY: 32, - }), - ); - ref.current.dispatchEvent( - createEvent('pointerout', { - pointerType: 'mouse', - pageX: 17, - pageY: 18, - screenX: 22, - screenY: 23, - clientX: 32, - clientY: 33, - }), - ); expect(eventLog).toEqual([ { - pageX: 15, - pageY: 16, - screenX: 20, - screenY: 21, - clientX: 30, - clientY: 31, + x: 10, + y: 10, + pageX: 10, + pageY: 10, + clientX: 10, + clientY: 10, target: ref.current, timeStamp: timeStamps[0], type: 'hoverstart', + pointerType: 'mouse', }, { - pageX: 16, - pageY: 17, - screenX: 21, - screenY: 22, - clientX: 31, - clientY: 32, + x: 10, + y: 10, + pageX: 10, + pageY: 10, + clientX: 10, + clientY: 10, target: ref.current, timeStamp: timeStamps[1], type: 'hovermove', + pointerType: 'mouse', }, { - pageX: 17, - pageY: 18, - screenX: 22, - screenY: 23, - clientX: 32, - clientY: 33, + x: 20, + y: 20, + pageX: 20, + pageY: 20, + clientX: 20, + clientY: 20, target: ref.current, timeStamp: timeStamps[2], + type: 'hovermove', + pointerType: 'mouse', + }, + { + x: 20, + y: 20, + pageX: 20, + pageY: 20, + clientX: 20, + clientY: 20, + target: ref.current, + timeStamp: timeStamps[3], type: 'hoverend', + pointerType: 'mouse', }, ]); }); diff --git a/packages/react-events/src/dom/test-utils.js b/packages/react-events/src/dom/test-utils.js new file mode 100644 index 0000000000000..ddf6bf6773a1f --- /dev/null +++ b/packages/react-events/src/dom/test-utils.js @@ -0,0 +1,311 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +/* eslint-disable no-unused-vars */ + +/** + * Change environment support for PointerEvent. + */ + +function hasPointerEvent(bool) { + return global != null && global.PointerEvent != null; +} + +function setPointerEvent(bool) { + global.PointerEvent = bool ? function() {} : undefined; +} + +/** + * Change environment host platform. + */ + +const platformGetter = jest.spyOn(global.navigator, 'platform', 'get'); + +const platform = { + clear() { + platformGetter.mockClear(); + }, + get() { + return global.navigator.platform === 'MacIntel' ? 'mac' : 'windows'; + }, + set(name: 'mac' | 'windows') { + switch (name) { + case 'mac': { + platformGetter.mockReturnValue('MacIntel'); + break; + } + case 'windows': { + platformGetter.mockReturnValue('Win32'); + break; + } + default: { + break; + } + } + }, +}; + +/** + * Mock native events + */ + +function createEvent(type, data) { + const event = document.createEvent('CustomEvent'); + event.initCustomEvent(type, true, true); + if (data != null) { + Object.entries(data).forEach(([key, value]) => { + event[key] = value; + }); + } + return event; +} + +function createTouchEvent(type, data, id) { + return createEvent(type, { + changedTouches: [ + { + ...data, + identifier: id, + }, + ], + }); +} + +const createKeyboardEvent = (type, data) => { + return new KeyboardEvent(type, { + bubbles: true, + cancelable: true, + ...data, + }); +}; + +function blur(data) { + return createEvent('blur', data); +} + +function click(data) { + return createEvent('click', data); +} + +function dragstart(data) { + return createEvent('dragstart', data); +} + +function focus(data) { + return createEvent('focus', data); +} + +function gotpointercapture(data) { + return createEvent('gotpointercapture', data); +} + +function lostpointercapture(data) { + return createEvent('lostpointercapture', data); +} + +function pointercancel(data) { + return createEvent('pointercancel', data); +} + +function pointerdown(data) { + return createEvent('pointerdown', data); +} + +function pointerenter(data) { + return createEvent('pointerenter', data); +} + +function pointerleave(data) { + return createEvent('pointerleave', data); +} + +function pointermove(data) { + return createEvent('pointermove', data); +} + +function pointerout(data) { + return createEvent('pointerout', data); +} + +function pointerover(data) { + return createEvent('pointerover', data); +} + +function pointerup(data) { + return createEvent('pointerup', data); +} + +function mousedown(data) { + return createEvent('mousedown', data); +} + +function mouseenter(data) { + return createEvent('mouseenter', data); +} + +function mouseleave(data) { + return createEvent('mouseleave', data); +} + +function mousemove(data) { + return createEvent('mousemove', data); +} + +function mouseout(data) { + return createEvent('mouseout', data); +} + +function mouseover(data) { + return createEvent('mouseover', data); +} + +function mouseup(data) { + return createEvent('mouseup', data); +} + +function touchcancel(data, id) { + return createTouchEvent('touchcancel', data, id); +} + +function touchend(data, id) { + return createTouchEvent('touchend', data, id); +} + +function touchmove(data, id) { + return createTouchEvent('touchmove', data, id); +} + +function touchstart(data, id) { + return createTouchEvent('touchstart', data, id); +} + +/** + * Dispatch high-level event sequences + */ + +function dispatchPointerHoverEnter(ref, {relatedTarget, x, y} = {}) { + const dispatch = arg => ref.current.dispatchEvent(arg); + const button = -1; + const pointerType = 'mouse'; + const event = { + button, + relatedTarget, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + }; + if (hasPointerEvent()) { + dispatch(pointerover({pointerType, ...event})); + dispatch(pointerenter({pointerType, ...event})); + } + dispatch(mouseover(event)); + dispatch(mouseover(event)); +} + +function dispatchPointerHoverMove(ref, {from, to} = {}) { + const dispatch = arg => ref.current.dispatchEvent(arg); + const button = -1; + const pointerId = 1; + const pointerType = 'mouse'; + function dispatchMove({x, y}) { + const event = { + button, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + }; + if (hasPointerEvent()) { + dispatch(pointermove({pointerId, pointerType, ...event})); + } + dispatch(mousemove(event)); + } + dispatchMove({x: from.x, y: from.y}); + dispatchMove({x: to.x, y: to.y}); +} + +function dispatchPointerHoverExit(ref, {relatedTarget, x, y} = {}) { + const dispatch = arg => ref.current.dispatchEvent(arg); + const button = -1; + const pointerType = 'mouse'; + const event = { + button, + relatedTarget, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + }; + if (hasPointerEvent()) { + dispatch(pointerout({pointerType, ...event})); + dispatch(pointerleave({pointerType, ...event})); + } + dispatch(mouseout(event)); + dispatch(mouseleave(event)); +} + +function dispatchPointerCancel(ref, options) { + const dispatchEvent = arg => ref.current.dispatchEvent(arg); + dispatchEvent(pointercancel({pointerType: 'mouse'})); + dispatchEvent(dragstart({pointerType: 'mouse'})); +} + +function dispatchPointerPressDown(ref, {button = 0, pointerType = 'mouse'}) { + const dispatch = arg => ref.current.dispatchEvent(arg); + const pointerId = 1; + if (hasPointerEvent()) { + dispatch(pointerover({pointerId, pointerType, button})); + dispatch(pointerenter({pointerId, pointerType, button})); + dispatch(pointerdown({pointerId, pointerType, button})); + } + dispatch(touchstart(null, pointerId)); + if (hasPointerEvent()) { + dispatch(gotpointercapture({pointerId, pointerType, button})); + } +} + +function dispatchPointerPressRelease(ref, {button = 0, pointerType = 'mouse'}) { + const dispatch = arg => ref.current.dispatchEvent(arg); + const pointerId = 1; + if (hasPointerEvent()) { + dispatch(pointerup({pointerId, pointerType, button})); + dispatch(lostpointercapture({pointerId, pointerType, button})); + dispatch(pointerout({pointerId, pointerType, button})); + dispatch(pointerleave({pointerId, pointerType, button})); + } + dispatch(touchend(null, pointerId)); + dispatch(mouseover({button})); + dispatch(mousemove({button})); + dispatch(mousedown({button})); + dispatch(focus({button})); + dispatch(mouseup({button})); + dispatch(click({button})); +} + +function dispatchTouchTap(ref) { + dispatchPointerPressDown(ref, {pointerType: 'touch'}); + dispatchPointerPressRelease(ref, {pointerType: 'touch'}); +} + +module.exports = { + createEvent, + dispatchPointerCancel, + dispatchPointerHoverEnter, + dispatchPointerHoverExit, + dispatchPointerHoverMove, + dispatchPointerPressDown, + dispatchPointerPressRelease, + dispatchTouchTap, + platform, + hasPointerEvent, + setPointerEvent, +};