Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Modal: mulighet for å rendre i portal #2209

Merged
merged 4 commits into from
Aug 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/real-donuts-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@navikt/ds-react": minor
---

:sparkles: Modal: mulighet for å rendre i portal
14 changes: 14 additions & 0 deletions @navikt/core/react/src/modal/Modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,18 @@ describe("Modal", () => {
expect(document.body.classList).not.toContain(BODY_CLASS)
);
});

test("should toggle body class when using portal", async () => {
render(
<Modal portal open>
<Modal.Header />
</Modal>
);
expect(document.body.classList).toContain(BODY_CLASS);

fireEvent.click(screen.getByRole("button"));
await waitFor(() =>
expect(document.body.classList).not.toContain(BODY_CLASS)
);
});
});
34 changes: 27 additions & 7 deletions @navikt/core/react/src/modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -138,6 +145,7 @@ export const Modal = forwardRef<HTMLDialogElement, ModalProps>(
onBeforeClose,
onCancel,
width,
portal,
className,
"aria-labelledby": ariaLabelledby,
style,
Expand All @@ -148,35 +156,41 @@ export const Modal = forwardRef<HTMLDialogElement, ModalProps>(
const modalRef = useRef<HTMLDialogElement>(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 = (
<dialog
ref={mergedRef}
className={cl("navds-modal", className, {
Expand Down Expand Up @@ -226,6 +240,12 @@ export const Modal = forwardRef<HTMLDialogElement, ModalProps>(
</ModalContext.Provider>
</dialog>
);

if (portal) {
if (portalNode) return createPortal(component, portalNode);
return null;
}
return component;
}
) as ModalComponent;

Expand Down
7 changes: 4 additions & 3 deletions @navikt/core/react/src/modal/ModalUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ export function getCloseHandler(
export const BODY_CLASS = "navds-modal__document-body";

export function useBodyScrollLock(
modalRef: React.RefObject<HTMLDialogElement>
modalRef: React.RefObject<HTMLDialogElement>,
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(() => {
Expand All @@ -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]);
}
4 changes: 2 additions & 2 deletions @navikt/core/react/src/modal/modal.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,8 @@ export const Small = () => (
</Modal>
);

export const Medium = () => (
<Modal open width="medium" header={{ heading: "Simple header" }}>
export const MediumWithPortal = () => (
<Modal open portal width="medium" header={{ heading: "Simple header" }}>
<Modal.Body>Lorem ipsum dolor sit amet.</Modal.Body>
</Modal>
);
4 changes: 2 additions & 2 deletions examples/shadow-dom/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class CustomComponent extends HTMLElement {
this.attachShadow({ mode: "closed" }).appendChild(rootElement);

ReactDOM.createRoot(appElement).render(
<Provider rootElement={rootElement} appElement={appElement}>
<Provider rootElement={rootElement}>
<style>{styles}</style>
<Button>Click me!</Button>
<CheckboxGroup legend="Legend" defaultValue={["tekst2"]}>
Expand All @@ -41,7 +41,7 @@ const ModalWrapper = () => {
return (
<>
<Button onClick={() => setIsOpen(true)}>Open modal</Button>
<Modal open={isOpen} onClose={() => setIsOpen(false)}>
<Modal portal open={isOpen} onClose={() => setIsOpen(false)}>
<Modal.Body>modal content</Modal.Body>
</Modal>
</>
Expand Down
2 changes: 1 addition & 1 deletion examples/shadow-dom/open.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class CustomComponent extends HTMLElement {
this.attachShadow({ mode: "open" }).appendChild(rootElement);

ReactDOM.createRoot(appElement).render(
<Provider rootElement={rootElement} appElement={appElement}>
<Provider rootElement={rootElement}>
<style>{styles}</style>
<DateWrapper />
</Provider>
Expand Down