main
js 127 lines 3.41 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and 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 * as React from 'react';
11 import {useLayoutEffect} from 'react';
12 import {createPortal} from 'react-dom';
13
14 import ContextMenuItem from './ContextMenuItem';
15
16 import type {
17 ContextMenuItem as ContextMenuItemType,
18 ContextMenuPosition,
19 } from './types';
20
21 import styles from './ContextMenu.css';
22
23 function repositionToFit(element: HTMLElement, x: number, y: number) {
24 const ownerWindow = element.ownerDocument.defaultView;
25 if (y + element.offsetHeight >= ownerWindow.innerHeight) {
26 if (y - element.offsetHeight > 0) {
27 element.style.top = `${y - element.offsetHeight}px`;
28 } else {
29 element.style.top = '0px';
30 }
31 } else {
32 element.style.top = `${y}px`;
33 }
34
35 if (x + element.offsetWidth >= ownerWindow.innerWidth) {
36 if (x - element.offsetWidth > 0) {
37 element.style.left = `${x - element.offsetWidth}px`;
38 } else {
39 element.style.left = '0px';
40 }
41 } else {
42 element.style.left = `${x}px`;
43 }
44 }
45
46 type Props = {
47 anchorElementRef: {current: React.ElementRef<any> | null},
48 items: ContextMenuItemType[],
49 position: ContextMenuPosition,
50 hide: () => void,
51 };
52
53 export default function ContextMenu({
54 anchorElementRef,
55 position,
56 items,
57 hide,
58 }: Props): React.Node {
59 // This works on the assumption that ContextMenu component is only rendered when it should be shown
60 const anchor = anchorElementRef.current;
61
62 if (anchor == null) {
63 throw new Error(
64 'Attempted to open a context menu for an element, which is not mounted',
65 );
66 }
67
68 const ownerDocument = anchor.ownerDocument;
69 const portalContainer = ownerDocument.querySelector(
70 '[data-react-devtools-portal-root]',
71 );
72
73 const hideMenu = portalContainer == null || items.length === 0;
74 const menuRef = React.useRef<HTMLDivElement | null>(null);
75
76 useLayoutEffect(() => {
77 // Match the early-return condition below.
78 if (hideMenu) {
79 return;
80 }
81 const maybeMenu = menuRef.current;
82 if (maybeMenu === null) {
83 throw new Error(
84 "Can't access context menu element. This is a bug in React DevTools.",
85 );
86 }
87 const menu = maybeMenu as HTMLDivElement;
88
89 function hideUnlessContains(event: Event) {
90 if (!menu.contains(event.target as any as Node)) {
91 hide();
92 }
93 }
94
95 ownerDocument.addEventListener('mousedown', hideUnlessContains);
96 ownerDocument.addEventListener('touchstart', hideUnlessContains);
97 ownerDocument.addEventListener('keydown', hideUnlessContains);
98
99 const ownerWindow = ownerDocument.defaultView;
100 ownerWindow.addEventListener('resize', hide);
101
102 repositionToFit(menu, position.x, position.y);
103
104 return () => {
105 ownerDocument.removeEventListener('mousedown', hideUnlessContains);
106 ownerDocument.removeEventListener('touchstart', hideUnlessContains);
107 ownerDocument.removeEventListener('keydown', hideUnlessContains);
108
109 ownerWindow.removeEventListener('resize', hide);
110 };
111 }, [hideMenu]);
112
113 if (hideMenu) {
114 return null;
115 }
116
117 return createPortal(
118 <div className={styles.ContextMenu} ref={menuRef}>
119 {items.map(({onClick, content}, index) => (
120 <ContextMenuItem key={index} onClick={onClick} hide={hide}>
121 {content}
122 </ContextMenuItem>
123 ))}
124 </div>,
125 portalContainer,
126 );
127 }