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"