diff --git a/cypress/integration/context-menu.spec.ts b/cypress/integration/context-menu.spec.ts
index cf35ed20..6d523438 100644
--- a/cypress/integration/context-menu.spec.ts
+++ b/cypress/integration/context-menu.spec.ts
@@ -21,6 +21,18 @@ describe('Context menu', () => {
cy.getByDataTest(DATA_TEST.CONTEXT_MENU).should('be.visible');
});
+ it('Can mount on specified dom node', () => {
+ cy.getByDataTest(DATA_TEST.CONTEXT_MENU_TRIGGER).rightclick();
+ cy.getByDataTest(DATA_TEST.CONTEXT_MENU).should('be.visible');
+
+ cy.getByDataTest(DATA_TEST.MOUNT_NODE).then(el => {
+ expect(el.children().length).to.eq(0);
+ });
+
+ cy.getByDataTest(DATA_TEST.TOGGLE_MOUNT_NODE).check();
+ cy.getByDataTest(DATA_TEST.CONTEXT_MENU_TRIGGER).rightclick();
+ });
+
it('Close on Escape', () => {
cy.getByDataTest(DATA_TEST.CONTEXT_MENU_TRIGGER).rightclick();
cy.getByDataTest(DATA_TEST.CONTEXT_MENU).should('be.visible');
diff --git a/example/components/App.tsx b/example/components/App.tsx
index f55f789b..6e74e0c8 100644
--- a/example/components/App.tsx
+++ b/example/components/App.tsx
@@ -35,6 +35,7 @@ interface SelectorState {
animation: string | false;
event: string;
hideItems: boolean;
+ customMountNode: boolean;
customPosition: boolean;
disableEnterAnimation: boolean;
disableExitAnimation: boolean;
@@ -68,6 +69,7 @@ export function App() {
animation: false,
event: selector.events[0],
hideItems: false,
+ customMountNode: false,
customPosition: false,
disableEnterAnimation: false,
disableExitAnimation: false,
@@ -81,6 +83,9 @@ export function App() {
const { show } = useContextMenu({
id: MENU_ID,
});
+ const customMountNode = document.querySelector(
+ `[data-test="${DATA_TEST.MOUNT_NODE}"]`
+ );
function handleSelector({
target: { name, value },
@@ -153,6 +158,17 @@ export function App() {
/>
))}
+
+
+
+
- , 'id'> {
+ extends PortalProps,
+ Omit, 'id'> {
/**
* Unique id to identify the menu. Use to Trigger the corresponding menu
*/
@@ -99,6 +101,7 @@ export const Menu: React.FC = ({
style,
className,
children,
+ mountNode,
animation = 'scale',
onHidden = NOOP,
onShown = NOOP,
@@ -310,22 +313,24 @@ export const Menu: React.FC = ({
};
return (
-
- {visible && (
-
- {cloneItems(children, {
- propsFromTrigger,
- triggerEvent,
- })}
-
- )}
-
+
+
+ {visible && (
+
+ {cloneItems(children, {
+ propsFromTrigger,
+ triggerEvent,
+ })}
+
+ )}
+
+
);
};
diff --git a/src/components/Portal.tsx b/src/components/Portal.tsx
new file mode 100644
index 00000000..219040f0
--- /dev/null
+++ b/src/components/Portal.tsx
@@ -0,0 +1,50 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+
+import { canUseDOM, isFn } from './utils';
+
+export interface PortalProps {
+ /**
+ * HTML node where to mount the context menu.
+ *
+ * In SSR mode, prefer the callback form to be sure that document is only
+ * accessed on the client. `e.g. () => document.querySelector('#element')`
+ *
+ * `default: document.body`
+ */
+ mountNode?: Element | (() => Element);
+}
+
+export const Portal: React.FC = ({ children, mountNode }) => {
+ const [canRender, setCanRender] = useState(false);
+ const node = useRef();
+
+ useEffect(() => {
+ let parentNode: Element;
+ if (canUseDOM) {
+ parentNode = document.body;
+ node.current = document.createElement('div');
+
+ if (isFn(mountNode)) {
+ parentNode = mountNode();
+ } else if (mountNode instanceof Element) {
+ parentNode = mountNode;
+ }
+
+ parentNode.appendChild(node.current);
+
+ setCanRender(true);
+ }
+ return () => {
+ if (canUseDOM) {
+ parentNode.removeChild(node.current!);
+ }
+ };
+ }, [mountNode]);
+
+ if (!canUseDOM) {
+ return null;
+ }
+
+ return canRender ? createPortal(children, node.current!) : null;
+};
diff --git a/src/components/utils.ts b/src/components/utils.ts
index c995349a..572ad6c7 100644
--- a/src/components/utils.ts
+++ b/src/components/utils.ts
@@ -1,4 +1,5 @@
import { Children, cloneElement, ReactNode, ReactElement } from 'react';
+import ExecutionEnvironment from 'exenv';
import {
BooleanPredicate,
@@ -64,3 +65,5 @@ export function hasExitAnimation(animation: MenuAnimation) {
(isStr(animation) || ('exit' in animation && animation.exit))
);
}
+
+export const canUseDOM = ExecutionEnvironment.canUseDOM;
diff --git a/yarn.lock b/yarn.lock
index 5d9a047c..f20d62ee 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1241,6 +1241,11 @@
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
+"@types/exenv@^1.2.0":
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/@types/exenv/-/exenv-1.2.0.tgz#84ff936feeafc917c3c66f80b43e917f56eed00b"
+ integrity sha512-kSyh9q6bvrOGEnJ9X9Io5gjXaakcSRQTax/Nj2ZKJHuOZ7bH4uvUgLyXA9uV2QBCP7+T8KS0JHbPfP1/79ckKw==
+
"@types/graceful-fs@^4.1.2":
version "4.1.4"
resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.4.tgz#4ff9f641a7c6d1a3508ff88bc3141b152772e753"
@@ -3338,6 +3343,11 @@ executable@^4.1.1:
dependencies:
pify "^2.2.0"
+exenv@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d"
+ integrity sha1-KueOhdmJQVhnCwPUe+wfA72Ru50=
+
exit-hook@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"