[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.
Nicolas Gallagher committed
Aug 6, 2019 at 09:16 UTC
23405c9c4c30b07892e0721de9e5c59620b34bc4
5 files changed
+414
-17
packages/react-events/context-menu.js
new
+12
@@ -0,0 +1,12 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+'use strict';
11
+
12
+module.exports = require('./src/dom/ContextMenu');
packages/react-events/package.json
+6
-5
@@ -12,14 +12,15 @@
12
"files": [
13
"LICENSE",
14
"README.md",
15
- "press.js",
16
- "hover.js",
17
- "focus.js",
18
- "swipe.js",
15
+ "context-menu.js",
16
"drag.js",
20
- "scroll.js",
17
+ "focus.js",
18
+ "hover.js",
19
"input.js",
20
"keyboard.js",
21
+ "press.js",
22
+ "scroll.js",
23
+ "swipe.js",
24
"build-info.json",
25
"cjs/",
26
"umd/"
packages/react-events/src/dom/ContextMenu.js
new
+128
@@ -0,0 +1,128 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+import type {
11
+ ReactDOMResponderEvent,
12
+ ReactDOMResponderContext,
13
+ PointerType,
14
+} from 'shared/ReactDOMTypes';
15
+import type {ReactEventResponderListener} from 'shared/ReactTypes';
16
+
17
+import React from 'react';
18
+import {DiscreteEvent} from 'shared/ReactTypes';
19
+
20
+type ContextMenuProps = {|
21
+ disabled: boolean,
22
+ onContextMenu: (e: ContextMenuEvent) => void,
23
+ preventDefault: boolean,
24
+|};
25
+
26
+type ContextMenuState = {
27
+ pointerType: PointerType,
28
+};
29
+
30
+type ContextMenuEvent = {|
31
+ target: Element | Document,
32
+ type: 'contextmenu',
33
+ pointerType: PointerType,
34
+ timeStamp: number,
35
+ clientX: null | number,
36
+ clientY: null | number,
37
+ pageX: null | number,
38
+ pageY: null | number,
39
+ x: null | number,
40
+ y: null | number,
41
+ altKey: boolean,
42
+ ctrlKey: boolean,
43
+ metaKey: boolean,
44
+ shiftKey: boolean,
45
+|};
46
+
47
+const hasPointerEvents =
48
+ typeof window !== 'undefined' && window.PointerEvent != null;
49
+
50
+function dispatchContextMenuEvent(
51
+ event: ReactDOMResponderEvent,
52
+ context: ReactDOMResponderContext,
53
+ props: ContextMenuProps,
54
+ state: ContextMenuState,
55
+): void {
56
+ const nativeEvent: any = event.nativeEvent;
57
+ const target = event.target;
58
+ const timeStamp = context.getTimeStamp();
59
+ const pointerType = state.pointerType;
60
+
61
+ const gestureState = {
62
+ altKey: nativeEvent.altKey,
63
+ button: nativeEvent.button === 0 ? 'primary' : 'auxillary',
64
+ ctrlKey: nativeEvent.altKey,
65
+ metaKey: nativeEvent.altKey,
66
+ pageX: nativeEvent.altKey,
67
+ pageY: nativeEvent.altKey,
68
+ pointerType,
69
+ shiftKey: nativeEvent.altKey,
70
+ target,
71
+ timeStamp,
72
+ type: 'contextmenu',
73
+ x: nativeEvent.clientX,
74
+ y: nativeEvent.clientY,
75
+ };
76
+
77
+ context.dispatchEvent(gestureState, props.onContextMenu, DiscreteEvent);
78
+}
79
+
80
+const contextMenuImpl = {
81
+ targetEventTypes: hasPointerEvents
82
+ ? ['contextmenu_active', 'pointerdown']
83
+ : ['contextmenu_active', 'touchstart', 'mousedown'],
84
+ getInitialState(): ContextMenuState {
85
+ return {
86
+ pointerType: '',
87
+ };
88
+ },
89
+ onEvent(
90
+ event: ReactDOMResponderEvent,
91
+ context: ReactDOMResponderContext,
92
+ props: ContextMenuProps,
93
+ state: ContextMenuState,
94
+ ): void {
95
+ const nativeEvent: any = event.nativeEvent;
96
+ const pointerType = event.pointerType;
97
+ const type = event.type;
98
+
99
+ if (props.disabled) {
100
+ return;
101
+ }
102
+
103
+ if (type === 'contextmenu') {
104
+ const onContextMenu = props.onContextMenu;
105
+ const preventDefault = props.preventDefault;
106
+ if (preventDefault !== false && !nativeEvent.defaultPrevented) {
107
+ nativeEvent.preventDefault();
108
+ }
109
+ if (typeof onContextMenu === 'function') {
110
+ dispatchContextMenuEvent(event, context, props, state);
111
+ }
112
+ state.pointerType = '';
113
+ } else {
114
+ state.pointerType = pointerType;
115
+ }
116
+ },
117
+};
118
+
119
+export const ContextMenuResponder = React.unstable_createResponder(
120
+ 'ContextMenu',
121
+ contextMenuImpl,
122
+);
123
+
124
+export function useContextMenuResponder(
125
+ props: ContextMenuProps,
126
+): ReactEventResponderListener<any, any> {
127
+ return React.unstable_useResponder(ContextMenuResponder, props);
128
+}
packages/react-events/src/dom/__tests__/ContextMenu-test.internal.js
new
+240
@@ -0,0 +1,240 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @emails react-core
8
+ */
9
+
10
+'use strict';
11
+
12
+let React;
13
+let ReactFeatureFlags;
14
+let ReactDOM;
15
+let useContextMenuResponder;
16
+
17
+function createEvent(type, data) {
18
+ const event = document.createEvent('CustomEvent');
19
+ event.initCustomEvent(type, true, true);
20
+ if (data != null) {
21
+ Object.entries(data).forEach(([key, value]) => {
22
+ event[key] = value;
23
+ });
24
+ }
25
+ return event;
26
+}
27
+
28
+function init(hasPointerEvents) {
29
+ global.PointerEvents = hasPointerEvents ? function() {} : undefined;
30
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
31
+ ReactFeatureFlags.enableFlareAPI = true;
32
+ React = require('react');
33
+ ReactDOM = require('react-dom');
34
+ useContextMenuResponder = require('react-events/context-menu')
35
+ .useContextMenuResponder;
36
+}
37
+
38
+const platformGetter = jest.spyOn(global.navigator, 'platform', 'get');
39
+function setPlatform(platform: 'mac' | 'windows') {
40
+ jest.resetModules();
41
+ switch (platform) {
42
+ case 'mac': {
43
+ platformGetter.mockReturnValue('MacIntel');
44
+ break;
45
+ }
46
+ case 'windows': {
47
+ platformGetter.mockReturnValue('Win32');
48
+ break;
49
+ }
50
+ default: {
51
+ break;
52
+ }
53
+ }
54
+ init();
55
+}
56
+
57
+function clearPlatform() {
58
+ platformGetter.mockClear();
59
+}
60
+
61
+function dispatchContextMenuEvents(ref, options) {
62
+ const preventDefault = options.preventDefault || function() {};
63
+ const variant = (options.variant: 'mouse' | 'touch' | 'modified');
64
+ const dispatchEvent = arg => ref.current.dispatchEvent(arg);
65
+
66
+ if (variant === 'mouse') {
67
+ // right-click
68
+ dispatchEvent(
69
+ createEvent('pointerdown', {pointerType: 'mouse', button: 2}),
70
+ );
71
+ dispatchEvent(createEvent('mousedown', {button: 2}));
72
+ dispatchEvent(createEvent('contextmenu', {button: 2, preventDefault}));
73
+ } else if (variant === 'modified') {
74
+ // left-click + ctrl
75
+ dispatchEvent(
76
+ createEvent('pointerdown', {pointerType: 'mouse', button: 0}),
77
+ );
78
+ dispatchEvent(createEvent('mousedown', {button: 0}));
79
+ if (global.navigator.platform === 'MacIntel') {
80
+ dispatchEvent(
81
+ createEvent('contextmenu', {button: 0, ctrlKey: true, preventDefault}),
82
+ );
83
+ }
84
+ } else if (variant === 'touch') {
85
+ // long-press
86
+ dispatchEvent(
87
+ createEvent('pointerdown', {pointerType: 'touch', button: 0}),
88
+ );
89
+ dispatchEvent(
90
+ createEvent('touchstart', {
91
+ changedTouches: [],
92
+ targetTouches: [],
93
+ }),
94
+ );
95
+ dispatchEvent(createEvent('contextmenu', {button: 0, preventDefault}));
96
+ }
97
+}
98
+
99
+const forcePointerEvents = true;
100
+
101
+describe.each([[forcePointerEvents], [!forcePointerEvents]])(
102
+ 'ContextMenu responder',
103
+ hasPointerEvents => {
104
+ let container;
105
+
106
+ beforeEach(() => {
107
+ jest.resetModules();
108
+ init(hasPointerEvents);
109
+ container = document.createElement('div');
110
+ document.body.appendChild(container);
111
+ });
112
+
113
+ afterEach(() => {
114
+ ReactDOM.render(null, container);
115
+ document.body.removeChild(container);
116
+ container = null;
117
+ });
118
+
119
+ describe('all platforms', () => {
120
+ it('mouse right-click', () => {
121
+ const onContextMenu = jest.fn();
122
+ const preventDefault = jest.fn();
123
+ const ref = React.createRef();
124
+ const Component = () => {
125
+ const listener = useContextMenuResponder({onContextMenu});
126
+ return <div ref={ref} listeners={listener} />;
127
+ };
128
+ ReactDOM.render(<Component />, container);
129
+
130
+ dispatchContextMenuEvents(ref, {variant: 'mouse', preventDefault});
131
+ expect(preventDefault).toHaveBeenCalledTimes(1);
132
+ expect(onContextMenu).toHaveBeenCalledTimes(1);
133
+ expect(onContextMenu).toHaveBeenCalledWith(
134
+ expect.objectContaining({pointerType: 'mouse', type: 'contextmenu'}),
135
+ );
136
+ });
137
+
138
+ it('touch long-press', () => {
139
+ const onContextMenu = jest.fn();
140
+ const preventDefault = jest.fn();
141
+ const ref = React.createRef();
142
+ const Component = () => {
143
+ const listener = useContextMenuResponder({onContextMenu});
144
+ return <div ref={ref} listeners={listener} />;
145
+ };
146
+ ReactDOM.render(<Component />, container);
147
+
148
+ dispatchContextMenuEvents(ref, {variant: 'touch', preventDefault});
149
+ expect(preventDefault).toHaveBeenCalledTimes(1);
150
+ expect(onContextMenu).toHaveBeenCalledTimes(1);
151
+ expect(onContextMenu).toHaveBeenCalledWith(
152
+ expect.objectContaining({pointerType: 'touch', type: 'contextmenu'}),
153
+ );
154
+ });
155
+
156
+ it('"disabled" is true', () => {
157
+ const onContextMenu = jest.fn();
158
+ const ref = React.createRef();
159
+ const Component = () => {
160
+ const listener = useContextMenuResponder({
161
+ onContextMenu,
162
+ disabled: true,
163
+ });
164
+ return <div ref={ref} listeners={listener} />;
165
+ };
166
+ ReactDOM.render(<Component />, container);
167
+
168
+ dispatchContextMenuEvents(ref, 'mouse');
169
+ expect(onContextMenu).toHaveBeenCalledTimes(0);
170
+ });
171
+
172
+ it('"preventDefault" is false', () => {
173
+ const preventDefault = jest.fn();
174
+ const onContextMenu = jest.fn();
175
+ const ref = React.createRef();
176
+ const Component = () => {
177
+ const listener = useContextMenuResponder({
178
+ onContextMenu,
179
+ preventDefault: false,
180
+ });
181
+ return <div ref={ref} listeners={listener} />;
182
+ };
183
+ ReactDOM.render(<Component />, container);
184
+
185
+ dispatchContextMenuEvents(ref, {variant: 'mouse', preventDefault});
186
+ expect(preventDefault).toHaveBeenCalledTimes(0);
187
+ expect(onContextMenu).toHaveBeenCalledTimes(1);
188
+ });
189
+ });
190
+
191
+ describe('mac platform', () => {
192
+ beforeEach(() => {
193
+ setPlatform('mac');
194
+ });
195
+
196
+ afterEach(() => {
197
+ clearPlatform();
198
+ });
199
+
200
+ it('mouse modified left-click', () => {
201
+ const onContextMenu = jest.fn();
202
+ const ref = React.createRef();
203
+ const Component = () => {
204
+ const listener = useContextMenuResponder({onContextMenu});
205
+ return <div ref={ref} listeners={listener} />;
206
+ };
207
+ ReactDOM.render(<Component />, container);
208
+
209
+ dispatchContextMenuEvents(ref, {variant: 'modified'});
210
+ expect(onContextMenu).toHaveBeenCalledTimes(1);
211
+ expect(onContextMenu).toHaveBeenCalledWith(
212
+ expect.objectContaining({pointerType: 'mouse', type: 'contextmenu'}),
213
+ );
214
+ });
215
+ });
216
+
217
+ describe('windows platform', () => {
218
+ beforeEach(() => {
219
+ setPlatform('windows');
220
+ });
221
+
222
+ afterEach(() => {
223
+ clearPlatform();
224
+ });
225
+
226
+ it('mouse modified left-click', () => {
227
+ const onContextMenu = jest.fn();
228
+ const ref = React.createRef();
229
+ const Component = () => {
230
+ const listener = useContextMenuResponder({onContextMenu});
231
+ return <div ref={ref} listeners={listener} />;
232
+ };
233
+ ReactDOM.render(<Component />, container);
234
+
235
+ dispatchContextMenuEvents(ref, {variant: 'modified'});
236
+ expect(onContextMenu).toHaveBeenCalledTimes(0);
237
+ });
238
+ });
239
+ },
240
+);
scripts/rollup/bundles.js
+28
-12
@@ -419,6 +419,7 @@ const bundles = [
419
},
420
421
/******* React Events (experimental) *******/
422
+
423
{
424
bundleTypes: [
425
UMD_DEV,
@@ -429,8 +430,8 @@ const bundles = [
430
FB_WWW_PROD,
431
],
432
moduleType: NON_FIBER_RENDERER,
432
- entry: 'react-events/press',
433
- global: 'ReactEventsPress',
433
+ entry: 'react-events/context-menu',
434
+ global: 'ReactEventsContextMenu',
435
externals: ['react'],
436
},
437
@@ -444,8 +445,8 @@ const bundles = [
445
FB_WWW_PROD,
446
],
447
moduleType: NON_FIBER_RENDERER,
447
- entry: 'react-events/hover',
448
- global: 'ReactEventsHover',
448
+ entry: 'react-events/drag',
449
+ global: 'ReactEventsDrag',
450
externals: ['react'],
451
},
452
@@ -474,8 +475,8 @@ const bundles = [
475
FB_WWW_PROD,
476
],
477
moduleType: NON_FIBER_RENDERER,
477
- entry: 'react-events/swipe',
478
- global: 'ReactEventsSwipe',
478
+ entry: 'react-events/hover',
479
+ global: 'ReactEventsHover',
480
externals: ['react'],
481
},
482
@@ -489,8 +490,8 @@ const bundles = [
490
FB_WWW_PROD,
491
],
492
moduleType: NON_FIBER_RENDERER,
492
- entry: 'react-events/drag',
493
- global: 'ReactEventsDrag',
493
+ entry: 'react-events/input',
494
+ global: 'ReactEventsInput',
495
externals: ['react'],
496
},
497
@@ -504,8 +505,23 @@ const bundles = [
505
FB_WWW_PROD,
506
],
507
moduleType: NON_FIBER_RENDERER,
507
- entry: 'react-events/input',
508
- global: 'ReactEventsInput',
508
+ entry: 'react-events/keyboard',
509
+ global: 'ReactEventsKeyboard',
510
+ externals: ['react'],
511
+ },
512
+
513
+ {
514
+ bundleTypes: [
515
+ UMD_DEV,
516
+ UMD_PROD,
517
+ NODE_DEV,
518
+ NODE_PROD,
519
+ FB_WWW_DEV,
520
+ FB_WWW_PROD,
521
+ ],
522
+ moduleType: NON_FIBER_RENDERER,
523
+ entry: 'react-events/press',
524
+ global: 'ReactEventsPress',
525
externals: ['react'],
526
},
527
@@ -534,8 +550,8 @@ const bundles = [
550
FB_WWW_PROD,
551
],
552
moduleType: NON_FIBER_RENDERER,
537
- entry: 'react-events/keyboard',
538
- global: 'ReactEventsKeyboard',
553
+ entry: 'react-events/swipe',
554
+ global: 'ReactEventsSwipe',
555
externals: ['react'],
556
},
557
];