From e04aeebf54d94783c4e673fb434e1513ea420882 Mon Sep 17 00:00:00 2001 From: Halvor Haugan Date: Wed, 23 Aug 2023 15:18:20 +0200 Subject: [PATCH 1/3] =?UTF-8?q?:sparkles:=20Modal:=20mulighet=20for=20?= =?UTF-8?q?=C3=A5=20rendre=20i=20portal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/real-donuts-check.md | 5 ++++ @navikt/core/react/src/modal/Modal.tsx | 20 +++++++++++-- @navikt/core/react/src/modal/ModalPortal.tsx | 28 +++++++++++++++++++ @navikt/core/react/src/modal/ModalUtils.ts | 6 ++-- .../core/react/src/modal/modal.stories.tsx | 4 +-- 5 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 .changeset/real-donuts-check.md create mode 100644 @navikt/core/react/src/modal/ModalPortal.tsx 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.tsx b/@navikt/core/react/src/modal/Modal.tsx index 59675bc6d1..22abfcc4c9 100644 --- a/@navikt/core/react/src/modal/Modal.tsx +++ b/@navikt/core/react/src/modal/Modal.tsx @@ -13,6 +13,7 @@ import ModalHeader from "./ModalHeader"; import ModalFooter from "./ModalFooter"; import { getCloseHandler, useBodyScrollLock } from "./ModalUtils"; import { ModalContext } from "./ModalContext"; +import ModalPortal from "./ModalPortal"; const needPolyfill = typeof window !== "undefined" && window.HTMLDialogElement === undefined; @@ -65,6 +66,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 */ @@ -141,6 +147,7 @@ export const Modal = forwardRef( onBeforeClose, onCancel, width, + portal, className, "aria-labelledby": ariaLabelledby, style, @@ -174,12 +181,12 @@ export const Modal = forwardRef( } }, [modalRef, open]); - useBodyScrollLock(modalRef, "navds-modal__document-body"); + useBodyScrollLock(modalRef, "navds-modal__document-body", open); const isWidthPreset = typeof width === "string" && ["small", "medium"].includes(width); - return ( + const component = ( ( ); + + if (portal) { + return ( + + {component} + + ); + } + return component; } ) as ModalComponent; diff --git a/@navikt/core/react/src/modal/ModalPortal.tsx b/@navikt/core/react/src/modal/ModalPortal.tsx new file mode 100644 index 0000000000..1243d12b9f --- /dev/null +++ b/@navikt/core/react/src/modal/ModalPortal.tsx @@ -0,0 +1,28 @@ +import React, { useEffect } from "react"; +import { createPortal } from "react-dom"; +import { useFloatingPortalNode } from "@floating-ui/react"; +import { useProvider } from ".."; + +interface Props { + children: React.ReactNode; + modalRef: React.RefObject; + open?: boolean; +} + +export default function ModalPortal({ children, modalRef, open }: Props) { + const rootElement = useProvider()?.rootElement; + const portalNode = useFloatingPortalNode({ root: rootElement }); + + useEffect(() => { + // In case `open` is true initially + if (modalRef.current && open && !modalRef.current.open) { + modalRef.current.showModal(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [portalNode]); + + if (portalNode) { + return createPortal(children, portalNode); + } + return null; +} diff --git a/@navikt/core/react/src/modal/ModalUtils.ts b/@navikt/core/react/src/modal/ModalUtils.ts index 7a00ecaac3..c7898e5c97 100644 --- a/@navikt/core/react/src/modal/ModalUtils.ts +++ b/@navikt/core/react/src/modal/ModalUtils.ts @@ -15,11 +15,12 @@ export function getCloseHandler( export function useBodyScrollLock( modalRef: React.RefObject, - bodyClass: string + bodyClass: string, + open?: ModalProps["open"] ) { React.useEffect(() => { + if (open) document.body.classList.add(bodyClass); // In case `open` is true initially if (!modalRef.current) return; - if (modalRef.current.open) document.body.classList.add(bodyClass); // In case `open` is true initially const observer = new MutationObserver(() => { if (modalRef.current?.open) document.body.classList.add(bodyClass); @@ -33,5 +34,6 @@ export function useBodyScrollLock( observer.disconnect(); document.body.classList.remove(bodyClass); // In case modal is unmounted before it's closed }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [modalRef, bodyClass]); } 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. ); From 884c84cf606369a32c9a2f8e5fc10b0a15991548 Mon Sep 17 00:00:00 2001 From: Halvor Haugan Date: Thu, 24 Aug 2023 13:03:00 +0200 Subject: [PATCH 2/3] test --- @navikt/core/react/src/modal/Modal.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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) + ); + }); }); From d4750a3b230f632363281a9bb6e8b574c80e8cf6 Mon Sep 17 00:00:00 2001 From: Halvor Haugan Date: Thu, 24 Aug 2023 13:05:33 +0200 Subject: [PATCH 3/3] =?UTF-8?q?m=C3=A5=20bli=20slik=20i=20s=C3=A5=20fall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- @navikt/core/react/src/modal/Modal.tsx | 28 +++++++++++--------- @navikt/core/react/src/modal/ModalPortal.tsx | 28 -------------------- @navikt/core/react/src/modal/ModalUtils.ts | 9 +++---- examples/shadow-dom/main.tsx | 4 +-- examples/shadow-dom/open.tsx | 2 +- 5 files changed, 23 insertions(+), 48 deletions(-) delete mode 100644 @navikt/core/react/src/modal/ModalPortal.tsx diff --git a/@navikt/core/react/src/modal/Modal.tsx b/@navikt/core/react/src/modal/Modal.tsx index 0d83c2a552..da56701841 100644 --- a/@navikt/core/react/src/modal/Modal.tsx +++ b/@navikt/core/react/src/modal/Modal.tsx @@ -5,15 +5,16 @@ 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"; import { getCloseHandler, useBodyScrollLock } from "./ModalUtils"; import { ModalContext } from "./ModalContext"; -import ModalPortal from "./ModalPortal"; export interface ModalProps extends React.DialogHTMLAttributes { @@ -155,30 +156,36 @@ 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, open); + useBodyScrollLock(modalRef, portalNode); const isWidthPreset = typeof width === "string" && ["small", "medium"].includes(width); @@ -235,11 +242,8 @@ export const Modal = forwardRef( ); if (portal) { - return ( - - {component} - - ); + if (portalNode) return createPortal(component, portalNode); + return null; } return component; } diff --git a/@navikt/core/react/src/modal/ModalPortal.tsx b/@navikt/core/react/src/modal/ModalPortal.tsx deleted file mode 100644 index 1243d12b9f..0000000000 --- a/@navikt/core/react/src/modal/ModalPortal.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { useEffect } from "react"; -import { createPortal } from "react-dom"; -import { useFloatingPortalNode } from "@floating-ui/react"; -import { useProvider } from ".."; - -interface Props { - children: React.ReactNode; - modalRef: React.RefObject; - open?: boolean; -} - -export default function ModalPortal({ children, modalRef, open }: Props) { - const rootElement = useProvider()?.rootElement; - const portalNode = useFloatingPortalNode({ root: rootElement }); - - useEffect(() => { - // In case `open` is true initially - if (modalRef.current && open && !modalRef.current.open) { - modalRef.current.showModal(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [portalNode]); - - if (portalNode) { - return createPortal(children, portalNode); - } - return null; -} diff --git a/@navikt/core/react/src/modal/ModalUtils.ts b/@navikt/core/react/src/modal/ModalUtils.ts index 0eea2ce09a..136951cf64 100644 --- a/@navikt/core/react/src/modal/ModalUtils.ts +++ b/@navikt/core/react/src/modal/ModalUtils.ts @@ -17,11 +17,11 @@ export const BODY_CLASS = "navds-modal__document-body"; export function useBodyScrollLock( modalRef: React.RefObject, - open?: ModalProps["open"] + portalNode: HTMLElement | null ) { React.useEffect(() => { - if (open) document.body.classList.add(BODY_CLASS); // In case `open` is true initially - 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(() => { if (modalRef.current?.open) document.body.classList.add(BODY_CLASS); @@ -35,6 +35,5 @@ export function useBodyScrollLock( observer.disconnect(); document.body.classList.remove(BODY_CLASS); // In case modal is unmounted before it's closed }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [modalRef]); + }, [modalRef, portalNode]); } 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( - +