From 23405c9c4c30b07892e0721de9e5c59620b34bc4 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Tue, 6 Aug 2019 09:16:05 -0700 Subject: [PATCH] [react-events] Add ContextMenu responder (#16296) A module for responding to contextmenu events. This functionality will be removed from the Press responder in the future. --- packages/react-events/context-menu.js | 12 + packages/react-events/package.json | 11 +- packages/react-events/src/dom/ContextMenu.js | 128 ++++++++++ .../__tests__/ContextMenu-test.internal.js | 240 ++++++++++++++++++ scripts/rollup/bundles.js | 40 ++- 5 files changed, 414 insertions(+), 17 deletions(-) create mode 100644 packages/react-events/context-menu.js create mode 100644 packages/react-events/src/dom/ContextMenu.js create mode 100644 packages/react-events/src/dom/__tests__/ContextMenu-test.internal.js diff --git a/packages/react-events/context-menu.js b/packages/react-events/context-menu.js new file mode 100644 index 0000000000000..3844d285ffc2e --- /dev/null +++ b/packages/react-events/context-menu.js @@ -0,0 +1,12 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +module.exports = require('./src/dom/ContextMenu'); diff --git a/packages/react-events/package.json b/packages/react-events/package.json index 7135dc53b4ce6..c16956dd84771 100644 --- a/packages/react-events/package.json +++ b/packages/react-events/package.json @@ -12,14 +12,15 @@ "files": [ "LICENSE", "README.md", - "press.js", - "hover.js", - "focus.js", - "swipe.js", + "context-menu.js", "drag.js", - "scroll.js", + "focus.js", + "hover.js", "input.js", "keyboard.js", + "press.js", + "scroll.js", + "swipe.js", "build-info.json", "cjs/", "umd/" diff --git a/packages/react-events/src/dom/ContextMenu.js b/packages/react-events/src/dom/ContextMenu.js new file mode 100644 index 0000000000000..b8094b71d8b46 --- /dev/null +++ b/packages/react-events/src/dom/ContextMenu.js @@ -0,0 +1,128 @@ +/** + * 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. + * + * @flow + */ + +import type { + ReactDOMResponderEvent, + ReactDOMResponderContext, + PointerType, +} from 'shared/ReactDOMTypes'; +import type {ReactEventResponderListener} from 'shared/ReactTypes'; + +import React from 'react'; +import {DiscreteEvent} from 'shared/ReactTypes'; + +type ContextMenuProps = {| + disabled: boolean, + onContextMenu: (e: ContextMenuEvent) => void, + preventDefault: boolean, +|}; + +type ContextMenuState = { + pointerType: PointerType, +}; + +type ContextMenuEvent = {| + target: Element | Document, + type: 'contextmenu', + pointerType: PointerType, + timeStamp: number, + clientX: null | number, + clientY: null | number, + pageX: null | number, + pageY: null | number, + x: null | number, + y: null | number, + altKey: boolean, + ctrlKey: boolean, + metaKey: boolean, + shiftKey: boolean, +|}; + +const hasPointerEvents = + typeof window !== 'undefined' && window.PointerEvent != null; + +function dispatchContextMenuEvent( + event: ReactDOMResponderEvent, + context: ReactDOMResponderContext, + props: ContextMenuProps, + state: ContextMenuState, +): void { + const nativeEvent: any = event.nativeEvent; + const target = event.target; + const timeStamp = context.getTimeStamp(); + const pointerType = state.pointerType; + + const gestureState = { + altKey: nativeEvent.altKey, + button: nativeEvent.button === 0 ? 'primary' : 'auxillary', + ctrlKey: nativeEvent.altKey, + metaKey: nativeEvent.altKey, + pageX: nativeEvent.altKey, + pageY: nativeEvent.altKey, + pointerType, + shiftKey: nativeEvent.altKey, + target, + timeStamp, + type: 'contextmenu', + x: nativeEvent.clientX, + y: nativeEvent.clientY, + }; + + context.dispatchEvent(gestureState, props.onContextMenu, DiscreteEvent); +} + +const contextMenuImpl = { + targetEventTypes: hasPointerEvents + ? ['contextmenu_active', 'pointerdown'] + : ['contextmenu_active', 'touchstart', 'mousedown'], + getInitialState(): ContextMenuState { + return { + pointerType: '', + }; + }, + onEvent( + event: ReactDOMResponderEvent, + context: ReactDOMResponderContext, + props: ContextMenuProps, + state: ContextMenuState, + ): void { + const nativeEvent: any = event.nativeEvent; + const pointerType = event.pointerType; + const type = event.type; + + if (props.disabled) { + return; + } + + if (type === 'contextmenu') { + const onContextMenu = props.onContextMenu; + const preventDefault = props.preventDefault; + if (preventDefault !== false && !nativeEvent.defaultPrevented) { + nativeEvent.preventDefault(); + } + if (typeof onContextMenu === 'function') { + dispatchContextMenuEvent(event, context, props, state); + } + state.pointerType = ''; + } else { + state.pointerType = pointerType; + } + }, +}; + +export const ContextMenuResponder = React.unstable_createResponder( + 'ContextMenu', + contextMenuImpl, +); + +export function useContextMenuResponder( + props: ContextMenuProps, +): ReactEventResponderListener { + return React.unstable_useResponder(ContextMenuResponder, props); +} diff --git a/packages/react-events/src/dom/__tests__/ContextMenu-test.internal.js b/packages/react-events/src/dom/__tests__/ContextMenu-test.internal.js new file mode 100644 index 0000000000000..546378f98f16d --- /dev/null +++ b/packages/react-events/src/dom/__tests__/ContextMenu-test.internal.js @@ -0,0 +1,240 @@ +/** + * 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'; + +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; + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableFlareAPI = true; + React = require('react'); + ReactDOM = require('react-dom'); + useContextMenuResponder = require('react-events/context-menu') + .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'); + const dispatchEvent = arg => ref.current.dispatchEvent(arg); + + if (variant === 'mouse') { + // right-click + dispatchEvent( + createEvent('pointerdown', {pointerType: 'mouse', button: 2}), + ); + dispatchEvent(createEvent('mousedown', {button: 2})); + dispatchEvent(createEvent('contextmenu', {button: 2, preventDefault})); + } else if (variant === 'modified') { + // left-click + ctrl + dispatchEvent( + createEvent('pointerdown', {pointerType: 'mouse', button: 0}), + ); + dispatchEvent(createEvent('mousedown', {button: 0})); + if (global.navigator.platform === 'MacIntel') { + dispatchEvent( + createEvent('contextmenu', {button: 0, ctrlKey: true, preventDefault}), + ); + } + } else if (variant === 'touch') { + // long-press + dispatchEvent( + createEvent('pointerdown', {pointerType: 'touch', button: 0}), + ); + dispatchEvent( + createEvent('touchstart', { + changedTouches: [], + targetTouches: [], + }), + ); + dispatchEvent(createEvent('contextmenu', {button: 0, preventDefault})); + } +} + +const forcePointerEvents = true; + +describe.each([[forcePointerEvents], [!forcePointerEvents]])( + 'ContextMenu responder', + hasPointerEvents => { + let container; + + beforeEach(() => { + jest.resetModules(); + init(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); + }); + + 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(() => { + 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(() => { + 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); + }); + }); + }, +); diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index ca0f79af7cd83..e94bf3da6f398 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -419,6 +419,7 @@ const bundles = [ }, /******* React Events (experimental) *******/ + { bundleTypes: [ UMD_DEV, @@ -429,8 +430,8 @@ const bundles = [ FB_WWW_PROD, ], moduleType: NON_FIBER_RENDERER, - entry: 'react-events/press', - global: 'ReactEventsPress', + entry: 'react-events/context-menu', + global: 'ReactEventsContextMenu', externals: ['react'], }, @@ -444,8 +445,8 @@ const bundles = [ FB_WWW_PROD, ], moduleType: NON_FIBER_RENDERER, - entry: 'react-events/hover', - global: 'ReactEventsHover', + entry: 'react-events/drag', + global: 'ReactEventsDrag', externals: ['react'], }, @@ -474,8 +475,8 @@ const bundles = [ FB_WWW_PROD, ], moduleType: NON_FIBER_RENDERER, - entry: 'react-events/swipe', - global: 'ReactEventsSwipe', + entry: 'react-events/hover', + global: 'ReactEventsHover', externals: ['react'], }, @@ -489,8 +490,8 @@ const bundles = [ FB_WWW_PROD, ], moduleType: NON_FIBER_RENDERER, - entry: 'react-events/drag', - global: 'ReactEventsDrag', + entry: 'react-events/input', + global: 'ReactEventsInput', externals: ['react'], }, @@ -504,8 +505,23 @@ const bundles = [ FB_WWW_PROD, ], moduleType: NON_FIBER_RENDERER, - entry: 'react-events/input', - global: 'ReactEventsInput', + entry: 'react-events/keyboard', + global: 'ReactEventsKeyboard', + externals: ['react'], + }, + + { + bundleTypes: [ + UMD_DEV, + UMD_PROD, + NODE_DEV, + NODE_PROD, + FB_WWW_DEV, + FB_WWW_PROD, + ], + moduleType: NON_FIBER_RENDERER, + entry: 'react-events/press', + global: 'ReactEventsPress', externals: ['react'], }, @@ -534,8 +550,8 @@ const bundles = [ FB_WWW_PROD, ], moduleType: NON_FIBER_RENDERER, - entry: 'react-events/keyboard', - global: 'ReactEventsKeyboard', + entry: 'react-events/swipe', + global: 'ReactEventsSwipe', externals: ['react'], }, ];