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(
-
+