Skip to content

Commit 8e91f51

Browse files
committed
revert using portal for render menu and add exenv package for checking prevent crashing when using dom node
1 parent 67057ee commit 8e91f51

File tree

8 files changed

+119
-19
lines changed

8 files changed

+119
-19
lines changed

cypress/integration/context-menu.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ describe('Context menu', () => {
2121
cy.getByDataTest(DATA_TEST.CONTEXT_MENU).should('be.visible');
2222
});
2323

24+
it('Can mount on specified dom node', () => {
25+
cy.getByDataTest(DATA_TEST.CONTEXT_MENU_TRIGGER).rightclick();
26+
cy.getByDataTest(DATA_TEST.CONTEXT_MENU).should('be.visible');
27+
28+
cy.getByDataTest(DATA_TEST.MOUNT_NODE).then(el => {
29+
expect(el.children().length).to.eq(0);
30+
});
31+
32+
cy.getByDataTest(DATA_TEST.TOGGLE_MOUNT_NODE).check();
33+
cy.getByDataTest(DATA_TEST.CONTEXT_MENU_TRIGGER).rightclick();
34+
});
35+
2436
it('Close on Escape', () => {
2537
cy.getByDataTest(DATA_TEST.CONTEXT_MENU_TRIGGER).rightclick();
2638
cy.getByDataTest(DATA_TEST.CONTEXT_MENU).should('be.visible');

example/components/App.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ interface SelectorState {
3535
animation: string | false;
3636
event: string;
3737
hideItems: boolean;
38+
customMountNode: boolean;
3839
customPosition: boolean;
3940
disableEnterAnimation: boolean;
4041
disableExitAnimation: boolean;
@@ -68,6 +69,7 @@ export function App() {
6869
animation: false,
6970
event: selector.events[0],
7071
hideItems: false,
72+
customMountNode: false,
7173
customPosition: false,
7274
disableEnterAnimation: false,
7375
disableExitAnimation: false,
@@ -81,6 +83,9 @@ export function App() {
8183
const { show } = useContextMenu({
8284
id: MENU_ID,
8385
});
86+
const customMountNode = document.querySelector(
87+
`[data-test="${DATA_TEST.MOUNT_NODE}"]`
88+
);
8489

8590
function handleSelector({
8691
target: { name, value },
@@ -153,6 +158,17 @@ export function App() {
153158
/>
154159
</li>
155160
))}
161+
<li>
162+
<label htmlFor="customMountNode">Use custom mount node</label>
163+
<input
164+
type="checkbox"
165+
id="customMountNode"
166+
name="customMountNode"
167+
checked={state.customMountNode}
168+
onChange={handleCheckboxes}
169+
data-test={DATA_TEST.TOGGLE_MOUNT_NODE}
170+
/>
171+
</li>
156172
<li>
157173
<label htmlFor="customPosition">Use custom position</label>
158174
<input
@@ -224,6 +240,7 @@ export function App() {
224240
theme={state.theme}
225241
animation={getAnimation()}
226242
data-test={DATA_TEST.CONTEXT_MENU}
243+
mountNode={state.customMountNode ? customMountNode : null}
227244
>
228245
<Item
229246
onClick={handleItemClick}

example/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export const MENU_ID = '🦄';
22

33
export const enum DATA_TEST {
44
MOUNT_NODE = 'mount-node',
5+
TOGGLE_MOUNT_NODE = 'toggle-mount-node',
56
TOGGLE_CUSTOM_POSITION = 'toggle-custom-position',
67
CONTEXT_MENU_TRIGGER = 'trigger',
78
CONTEXT_MENU = 'context-menu',

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
],
6161
"module": "dist/react-contexify.esm.js",
6262
"devDependencies": {
63+
"@types/exenv": "^1.2.0",
6364
"@types/react": "^16.9.56",
6465
"@types/react-dom": "^16.9.9",
6566
"cssnano": "^4.1.10",
@@ -76,6 +77,7 @@
7677
"typescript": "^4.0.3"
7778
},
7879
"dependencies": {
79-
"clsx": "^1.1.1"
80+
"clsx": "^1.1.1",
81+
"exenv": "^1.2.2"
8082
}
8183
}

src/components/Menu.tsx

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import React, {
88
} from 'react';
99
import cx from 'clsx';
1010

11+
import { Portal, PortalProps } from './Portal';
1112
import { RefTrackerProvider } from './RefTrackerProvider';
1213

1314
import { eventManager } from '../core/eventManager';
@@ -29,7 +30,8 @@ import {
2930
} from './utils';
3031

3132
export interface MenuProps
32-
extends Omit<React.HTMLAttributes<HTMLElement>, 'id'> {
33+
extends PortalProps,
34+
Omit<React.HTMLAttributes<HTMLElement>, 'id'> {
3335
/**
3436
* Unique id to identify the menu. Use to Trigger the corresponding menu
3537
*/
@@ -99,6 +101,7 @@ export const Menu: React.FC<MenuProps> = ({
99101
style,
100102
className,
101103
children,
104+
mountNode,
102105
animation = 'scale',
103106
onHidden = NOOP,
104107
onShown = NOOP,
@@ -310,22 +313,24 @@ export const Menu: React.FC<MenuProps> = ({
310313
};
311314

312315
return (
313-
<RefTrackerProvider refTracker={refTracker}>
314-
{visible && (
315-
<div
316-
{...rest}
317-
className={cssClasses}
318-
onAnimationEnd={handleAnimationEnd}
319-
style={menuStyle}
320-
ref={nodeRef}
321-
role="menu"
322-
>
323-
{cloneItems(children, {
324-
propsFromTrigger,
325-
triggerEvent,
326-
})}
327-
</div>
328-
)}
329-
</RefTrackerProvider>
316+
<Portal mountNode={mountNode}>
317+
<RefTrackerProvider refTracker={refTracker}>
318+
{visible && (
319+
<div
320+
{...rest}
321+
className={cssClasses}
322+
onAnimationEnd={handleAnimationEnd}
323+
style={menuStyle}
324+
ref={nodeRef}
325+
role="menu"
326+
>
327+
{cloneItems(children, {
328+
propsFromTrigger,
329+
triggerEvent,
330+
})}
331+
</div>
332+
)}
333+
</RefTrackerProvider>
334+
</Portal>
330335
);
331336
};

src/components/Portal.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import React, { useEffect, useRef, useState } from 'react';
2+
import { createPortal } from 'react-dom';
3+
4+
import { canUseDOM, isFn } from './utils';
5+
6+
export interface PortalProps {
7+
/**
8+
* HTML node where to mount the context menu.
9+
*
10+
* In SSR mode, prefer the callback form to be sure that document is only
11+
* accessed on the client. `e.g. () => document.querySelector('#element')`
12+
*
13+
* `default: document.body`
14+
*/
15+
mountNode?: Element | (() => Element);
16+
}
17+
18+
export const Portal: React.FC<PortalProps> = ({ children, mountNode }) => {
19+
const [canRender, setCanRender] = useState(false);
20+
const node = useRef<HTMLDivElement>();
21+
22+
useEffect(() => {
23+
let parentNode: Element;
24+
if (canUseDOM) {
25+
parentNode = document.body;
26+
node.current = document.createElement('div');
27+
28+
if (isFn(mountNode)) {
29+
parentNode = mountNode();
30+
} else if (mountNode instanceof Element) {
31+
parentNode = mountNode;
32+
}
33+
34+
parentNode.appendChild(node.current);
35+
36+
setCanRender(true);
37+
}
38+
return () => {
39+
if (canUseDOM) {
40+
parentNode.removeChild(node.current!);
41+
}
42+
};
43+
}, [mountNode]);
44+
45+
if (!canUseDOM) {
46+
return null;
47+
}
48+
49+
return canRender ? createPortal(children, node.current!) : null;
50+
};

src/components/utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Children, cloneElement, ReactNode, ReactElement } from 'react';
2+
import ExecutionEnvironment from 'exenv';
23

34
import {
45
BooleanPredicate,
@@ -64,3 +65,5 @@ export function hasExitAnimation(animation: MenuAnimation) {
6465
(isStr(animation) || ('exit' in animation && animation.exit))
6566
);
6667
}
68+
69+
export const canUseDOM = ExecutionEnvironment.canUseDOM;

yarn.lock

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1241,6 +1241,11 @@
12411241
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
12421242
integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
12431243

1244+
"@types/exenv@^1.2.0":
1245+
version "1.2.0"
1246+
resolved "https://registry.yarnpkg.com/@types/exenv/-/exenv-1.2.0.tgz#84ff936feeafc917c3c66f80b43e917f56eed00b"
1247+
integrity sha512-kSyh9q6bvrOGEnJ9X9Io5gjXaakcSRQTax/Nj2ZKJHuOZ7bH4uvUgLyXA9uV2QBCP7+T8KS0JHbPfP1/79ckKw==
1248+
12441249
"@types/graceful-fs@^4.1.2":
12451250
version "4.1.4"
12461251
resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.4.tgz#4ff9f641a7c6d1a3508ff88bc3141b152772e753"
@@ -3338,6 +3343,11 @@ executable@^4.1.1:
33383343
dependencies:
33393344
pify "^2.2.0"
33403345

3346+
exenv@^1.2.2:
3347+
version "1.2.2"
3348+
resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d"
3349+
integrity sha1-KueOhdmJQVhnCwPUe+wfA72Ru50=
3350+
33413351
exit-hook@^1.0.0:
33423352
version "1.1.1"
33433353
resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"

0 commit comments

Comments
 (0)