diff --git a/.changeset/real-donuts-check.md b/.changeset/real-donuts-check.md new file mode 100644 index 0000000000..419ba192a5 --- /dev/null +++ b/.changeset/real-donuts-check.md @@ -0,0 +1,5 @@ +--- +"@navikt/ds-react": minor +--- + +:sparkles: Modal: mulighet for å rendre i portal diff --git a/@navikt/core/react/src/modal/Modal.test.tsx b/@navikt/core/react/src/modal/Modal.test.tsx index 27e247752c..a8d919163f 100644 --- a/@navikt/core/react/src/modal/Modal.test.tsx +++ b/@navikt/core/react/src/modal/Modal.test.tsx @@ -37,4 +37,18 @@ describe("Modal", () => { expect(document.body.classList).not.toContain(BODY_CLASS) ); }); + + test("should toggle body class when using portal", async () => { + render( + + + + ); + expect(document.body.classList).toContain(BODY_CLASS); + + fireEvent.click(screen.getByRole("button")); + await waitFor(() => + expect(document.body.classList).not.toContain(BODY_CLASS) + ); + }); }); diff --git a/@navikt/core/react/src/modal/Modal.tsx b/@navikt/core/react/src/modal/Modal.tsx index 3002e1bf0a..da56701841 100644 --- a/@navikt/core/react/src/modal/Modal.tsx +++ b/@navikt/core/react/src/modal/Modal.tsx @@ -5,9 +5,11 @@ import React, { useMemo, useRef, } from "react"; +import { createPortal } from "react-dom"; +import { useFloatingPortalNode } from "@floating-ui/react"; import cl from "clsx"; import dialogPolyfill, { needPolyfill } from "./dialog-polyfill"; -import { Detail, Heading, mergeRefs, useId } from ".."; +import { Detail, Heading, mergeRefs, useId, useProvider } from ".."; import ModalBody from "./ModalBody"; import ModalHeader from "./ModalHeader"; import ModalFooter from "./ModalFooter"; @@ -62,6 +64,11 @@ export interface ModalProps * @default fit-content (up to 700px) * */ width?: "medium" | "small" | number | `${number}${string}`; + /** + * Lets you render the modal into a different part of the DOM. + * Will use `rootElement` from `Provider` if defined, otherwise `document.body`. + */ + portal?: boolean; /** * User defined classname for modal */ @@ -138,6 +145,7 @@ export const Modal = forwardRef( onBeforeClose, onCancel, width, + portal, className, "aria-labelledby": ariaLabelledby, style, @@ -148,35 +156,41 @@ export const Modal = forwardRef( const modalRef = useRef(null); const mergedRef = useMemo(() => mergeRefs([modalRef, ref]), [ref]); const ariaLabelId = useId(); + const rootElement = useProvider()?.rootElement; + const portalNode = useFloatingPortalNode({ root: rootElement }); if (useContext(ModalContext)) { console.error("Modals should not be nested"); } useEffect(() => { - if (needPolyfill && modalRef.current) { + // If using portal, modalRef.current will not be set before portalNode is set. + // If not using portal, modalRef.current is available first. + // We check both to avoid activating polyfill twice when not using portal. + if (needPolyfill && modalRef.current && portalNode) { dialogPolyfill.registerDialog(modalRef.current); } - }, [modalRef]); + }, [modalRef, portalNode]); useEffect(() => { // We need to have this in a useEffect so that the content renders before the modal is displayed, // and in case `open` is true initially. - if (modalRef.current && open !== undefined) { + // We need to check both modalRef.current and portalNode to make sure the polyfill has been activated. + if (modalRef.current && portalNode && open !== undefined) { if (open && !modalRef.current.open) { modalRef.current.showModal(); } else if (!open && modalRef.current.open) { modalRef.current.close(); } } - }, [modalRef, open]); + }, [modalRef, portalNode, open]); - useBodyScrollLock(modalRef); + useBodyScrollLock(modalRef, portalNode); const isWidthPreset = typeof width === "string" && ["small", "medium"].includes(width); - return ( + const component = ( ( ); + + if (portal) { + if (portalNode) return createPortal(component, portalNode); + return null; + } + return component; } ) as ModalComponent; diff --git a/@navikt/core/react/src/modal/ModalUtils.ts b/@navikt/core/react/src/modal/ModalUtils.ts index 48210c014d..136951cf64 100644 --- a/@navikt/core/react/src/modal/ModalUtils.ts +++ b/@navikt/core/react/src/modal/ModalUtils.ts @@ -16,10 +16,11 @@ export function getCloseHandler( export const BODY_CLASS = "navds-modal__document-body"; export function useBodyScrollLock( - modalRef: React.RefObject + modalRef: React.RefObject, + portalNode: HTMLElement | null ) { React.useEffect(() => { - if (!modalRef.current) return; + if (!modalRef.current || !portalNode) return; // We check both to avoid running this twice when not using portal if (modalRef.current.open) document.body.classList.add(BODY_CLASS); // In case `open` is true initially const observer = new MutationObserver(() => { @@ -34,5 +35,5 @@ export function useBodyScrollLock( observer.disconnect(); document.body.classList.remove(BODY_CLASS); // In case modal is unmounted before it's closed }; - }, [modalRef]); + }, [modalRef, portalNode]); } diff --git a/@navikt/core/react/src/modal/modal.stories.tsx b/@navikt/core/react/src/modal/modal.stories.tsx index 86673b678c..7abd1cd301 100644 --- a/@navikt/core/react/src/modal/modal.stories.tsx +++ b/@navikt/core/react/src/modal/modal.stories.tsx @@ -160,8 +160,8 @@ export const Small = () => ( ); -export const Medium = () => ( - +export const MediumWithPortal = () => ( + Lorem ipsum dolor sit amet. ); diff --git a/examples/shadow-dom/main.tsx b/examples/shadow-dom/main.tsx index d87ad45dd7..e6d88524ec 100644 --- a/examples/shadow-dom/main.tsx +++ b/examples/shadow-dom/main.tsx @@ -18,7 +18,7 @@ class CustomComponent extends HTMLElement { this.attachShadow({ mode: "closed" }).appendChild(rootElement); ReactDOM.createRoot(appElement).render( - + @@ -41,7 +41,7 @@ const ModalWrapper = () => { return ( <> - setIsOpen(false)}> + setIsOpen(false)}> modal content diff --git a/examples/shadow-dom/open.tsx b/examples/shadow-dom/open.tsx index 08ce60f29f..954a039023 100644 --- a/examples/shadow-dom/open.tsx +++ b/examples/shadow-dom/open.tsx @@ -11,7 +11,7 @@ class CustomComponent extends HTMLElement { this.attachShadow({ mode: "open" }).appendChild(rootElement); ReactDOM.createRoot(appElement).render( - +