diff --git a/packages/react/src/components/Modal/EdgeCases.stories.tsx b/packages/react/src/components/Modal/EdgeCases.stories.tsx
new file mode 100644
index 0000000..75d3db5
--- /dev/null
+++ b/packages/react/src/components/Modal/EdgeCases.stories.tsx
@@ -0,0 +1,105 @@
+import React, { useEffect } from "react";
+import { faker } from "@faker-js/faker";
+import { Meta } from "@storybook/react";
+import { Button } from ":/components/Button";
+import { CunninghamProvider } from ":/components/Provider";
+import { Modal, ModalSize, useModal } from ":/components/Modal/index";
+
+const meta: Meta = {
+ title: "Components/Modal/Edge Cases",
+ parameters: {
+ docs: {
+ story: {
+ height: "350px",
+ },
+ },
+ },
+};
+
+export default meta;
+
+export const StackedModals = {
+ render: () => {
+ const modal1 = useModal();
+ const modal2 = useModal();
+ const modal3 = useModal();
+
+ const openModals = () => {
+ modal1.open();
+ setTimeout(() => {
+ modal2.open();
+ setTimeout(() => {
+ modal3.open();
+ }, 100);
+ }, 100);
+ };
+
+ useEffect(() => {
+ openModals();
+ }, []);
+
+ const array = Array.from({ length: 1000 }, (_, i) => i);
+
+ return (
+
+
+
+
Tertiary}
+ rightActions={
+ <>
+
+
+ >
+ }
+ size={ModalSize.LARGE}
+ {...modal1}
+ >
+ {faker.lorem.lines(400)}
+
+
Tertiary}
+ rightActions={
+ <>
+
+
+ >
+ }
+ size={ModalSize.MEDIUM}
+ {...modal2}
+ >
+ {faker.lorem.lines(400)}
+
+
Tertiary}
+ rightActions={
+ <>
+
+
+ >
+ }
+ size={ModalSize.SMALL}
+ {...modal3}
+ >
+ {faker.lorem.lines(400)}
+
+ {array.map((i) => (
+
{i}
+ ))}
+
+
+ );
+ },
+};
diff --git a/packages/react/src/components/Modal/ModalProvider.tsx b/packages/react/src/components/Modal/ModalProvider.tsx
index 6451db4..5a13a47 100644
--- a/packages/react/src/components/Modal/ModalProvider.tsx
+++ b/packages/react/src/components/Modal/ModalProvider.tsx
@@ -6,6 +6,7 @@ import React, {
PropsWithChildren,
ReactNode,
useContext,
+ useEffect,
useMemo,
useState,
} from "react";
@@ -25,6 +26,8 @@ import {
MessageModalProps,
} from ":/components/Modal/MessageModal";
+export const NOSCROLL_CLASS = "c__noscroll";
+
export type Decision = string | null | undefined;
export type DecisionModalProps = WithOptional & {
onDecide: (decision?: Decision) => void;
@@ -147,6 +150,28 @@ export const ModalProvider = ({ children }: PropsWithChildren) => {
[],
);
+ useEffect(() => {
+ const portalElement = document.getElementById("c__modals-portal")!;
+ // Create an observer instance linked to the callback function
+ const observer = new MutationObserver(() => {
+ const dialogs = portalElement.querySelectorAll("dialog");
+ if (dialogs.length > 0) {
+ document.querySelector("body")!.classList.add(NOSCROLL_CLASS);
+ } else {
+ document.querySelector("body")!.classList.remove(NOSCROLL_CLASS);
+ }
+ });
+
+ // Start observing the target node for configured mutations
+ observer.observe(portalElement, {
+ childList: true,
+ });
+
+ return () => {
+ observer.disconnect();
+ };
+ }, []);
+
return (
{children}
diff --git a/packages/react/src/components/Modal/index.scss b/packages/react/src/components/Modal/index.scss
index b209274..d7e3183 100644
--- a/packages/react/src/components/Modal/index.scss
+++ b/packages/react/src/components/Modal/index.scss
@@ -153,3 +153,7 @@
}
}
}
+
+.c__noscroll {
+ overflow: hidden;
+}
diff --git a/packages/react/src/components/Modal/index.spec.tsx b/packages/react/src/components/Modal/index.spec.tsx
index 62e45f8..35b528f 100644
--- a/packages/react/src/components/Modal/index.spec.tsx
+++ b/packages/react/src/components/Modal/index.spec.tsx
@@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Modal, ModalSize, useModal } from ":/components/Modal/index";
import { CunninghamProvider } from ":/components/Provider";
-import { useModals } from ":/components/Modal/ModalProvider";
+import { NOSCROLL_CLASS, useModals } from ":/components/Modal/ModalProvider";
import { VariantType } from ":/utils/VariantUtils";
describe("", () => {
@@ -432,4 +432,104 @@ describe("", () => {
// Decision is undefined.
expect(decision).toBeUndefined();
});
+
+ it("sets a noscroll class to body when a modal is open and remove it on close", async () => {
+ const Wrapper = () => {
+ const modal = useModal();
+ return (
+
+
+
+ Modal Content
+
+
+ );
+ };
+
+ render();
+ const user = userEvent.setup();
+ const button = screen.getByText("Open Modal");
+
+ expect(document.body.classList.contains(NOSCROLL_CLASS)).toBeFalsy();
+ await user.click(button);
+ expect(document.body.classList.contains(NOSCROLL_CLASS)).toBeTruthy();
+
+ const closeButton = screen.getByRole("button", {
+ name: "close",
+ });
+ await user.click(closeButton);
+ expect(document.body.classList.contains(NOSCROLL_CLASS)).toBeFalsy();
+ });
+
+ it("removes noscroll body class only when last modal is closed when multiple are opened", async () => {
+ const Wrapper = () => {
+ const modal1 = useModal();
+ const modal2 = useModal();
+ const modal3 = useModal();
+ const openModals = () => {
+ modal1.open();
+ modal2.open();
+ modal3.open();
+ };
+ return (
+
+
+
+ Modal 1
+
+
+ Modal 2
+
+
+ Modal 3
+
+
+ );
+ };
+
+ render();
+ const user = userEvent.setup();
+ const button = screen.getByText("Open Modals");
+
+ expect(document.body.classList.contains(NOSCROLL_CLASS)).toBeFalsy();
+ await user.click(button);
+
+ expect(screen.getByText("Modal 1")).toBeInTheDocument();
+ expect(screen.getByText("Modal 2")).toBeInTheDocument();
+ expect(screen.getByText("Modal 3")).toBeInTheDocument();
+
+ expect(document.body.classList.contains(NOSCROLL_CLASS)).toBeTruthy();
+
+ const closeButtons = screen.getAllByRole("button", {
+ name: "close",
+ });
+ expect(closeButtons).toHaveLength(3);
+
+ // Close modal 1.
+ await user.click(closeButtons[0]);
+ expect(screen.queryByText("Modal 1")).not.toBeInTheDocument();
+ expect(screen.getByText("Modal 2")).toBeInTheDocument();
+ expect(screen.getByText("Modal 3")).toBeInTheDocument();
+
+ // class is still on body.
+ expect(document.body.classList.contains(NOSCROLL_CLASS)).toBeTruthy();
+
+ // Close modal 2.
+ await user.click(closeButtons[1]);
+ expect(screen.queryByText("Modal 1")).not.toBeInTheDocument();
+ expect(screen.queryByText("Modal 2")).not.toBeInTheDocument();
+ expect(screen.getByText("Modal 3")).toBeInTheDocument();
+
+ // class is still on body.
+ expect(document.body.classList.contains(NOSCROLL_CLASS)).toBeTruthy();
+
+ // Close modal 3.
+ await user.click(closeButtons[2]);
+ expect(screen.queryByText("Modal 1")).not.toBeInTheDocument();
+ expect(screen.queryByText("Modal 2")).not.toBeInTheDocument();
+ expect(screen.queryByText("Modal 3")).not.toBeInTheDocument();
+
+ // class is removed from body.
+ expect(document.body.classList.contains(NOSCROLL_CLASS)).toBeFalsy();
+ });
});
diff --git a/packages/react/src/hooks/usePrevious.ts b/packages/react/src/hooks/usePrevious.ts
new file mode 100644
index 0000000..aa8026a
--- /dev/null
+++ b/packages/react/src/hooks/usePrevious.ts
@@ -0,0 +1,19 @@
+import { useEffect, useRef } from "react";
+
+/**
+ * Hook which stores the previous value of a component prop or state.
+ * https://usehooks.com/usePrevious/
+ *
+ * @param value
+ */
+const usePrevious = (value: T): T => {
+ const previous = useRef(value);
+
+ useEffect(() => {
+ previous.current = value;
+ }, [value]);
+
+ return previous.current;
+};
+
+export default usePrevious;