♻️(react) migrate Modals to react modal

We encoutered an issue where stacked modal backdrop were not rendered
above the modal below. It was caused by the dialog element that is
natively rendered on the top layer regardless where it is create in
the DOM. So we decided to use react modal that provides hand crafted
dialog, and implementing a11y features.

Closes #314
This commit is contained in:
Nathan Vasse
2024-04-04 15:01:29 +02:00
committed by NathanVss
parent c61b2ac43d
commit 6ebeb116d3
8 changed files with 128 additions and 115 deletions

View File

@@ -52,6 +52,7 @@
"@react-stately/calendar": "3.4.4",
"@react-stately/datepicker": "3.9.2",
"@tanstack/react-table": "8.13.2",
"@types/react-modal": "3.16.3",
"chromatic": "11.2.0",
"classnames": "2.5.1",
"downshift": "8.4.0",
@@ -59,6 +60,7 @@
"react-aria": "3.32.1",
"react-aria-components": "1.1.1",
"react-dom": "18.2.0",
"react-modal": "3.16.1",
"react-stately": "3.30.1"
},
"engines": {

View File

@@ -10,6 +10,7 @@ import React, {
useMemo,
useState,
} from "react";
import ReactModal from "react-modal";
import {
DeleteConfirmationModal,
DeleteConfirmationModalProps,
@@ -104,6 +105,10 @@ type ModalMap = Map<string, ReactNode>;
export const ModalProvider = ({ children }: PropsWithChildren) => {
const [modals, setModals] = useState<ModalMap>({} as ModalMap);
useEffect(() => {
ReactModal.setAppElement(".c__app");
}, []);
const addModal = (
component: FunctionComponent<DecisionModalProps>,
props: Partial<DecisionModalProps>,
@@ -150,28 +155,6 @@ 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 (
<ModalContext.Provider value={context}>
{children}

View File

@@ -8,6 +8,17 @@
box-shadow: var(--c--components--modal--box-shadow);
color: var(--c--components--modal--color);
box-sizing: border-box;
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
max-width: calc(100% - 2em - 6px);
max-height: calc(100% - 2em - 6px);
overflow: auto;
&:focus-visible {
outline: none;
}
&::backdrop {
// ::backdrop does not inherit from its element so CSS vars does not work.
@@ -181,6 +192,7 @@
border: none;
display: flex;
flex-direction: column;
transform: none;
.c__modal__content {
flex-grow: 1;

View File

@@ -22,7 +22,6 @@ describe("<Modal/>", () => {
render(<Wrapper />);
expect(await screen.findByText("Modal Content")).toBeInTheDocument();
});
it("shows a modal when clicking on the button", async () => {
const Wrapper = () => {
const modal = useModal();
@@ -44,6 +43,32 @@ describe("<Modal/>", () => {
await user.click(button);
expect(screen.getByText("Modal Content")).toBeInTheDocument();
});
it("sets aria-hidden on app when a modal is opened", async () => {
const Wrapper = () => {
const modal = useModal();
return (
<CunninghamProvider>
<button onClick={modal.open}>Open Modal</button>
<Modal size={ModalSize.SMALL} {...modal}>
<div>Modal Content</div>
</Modal>
</CunninghamProvider>
);
};
render(<Wrapper />);
const user = userEvent.setup();
const button = screen.getByText("Open Modal");
const app = document.querySelector(".c__app");
expect(screen.queryByText("Modal Content")).not.toBeInTheDocument();
expect(app).not.toHaveAttribute("aria-hidden", "true");
await user.click(button);
expect(screen.getByText("Modal Content")).toBeInTheDocument();
expect(app).toHaveAttribute("aria-hidden", "true");
});
it("closes the modal when clicking on the close button", async () => {
const Wrapper = () => {
const modal = useModal();
@@ -118,16 +143,7 @@ describe("<Modal/>", () => {
await user.click(button);
expect(screen.getByText("Modal Content")).toBeInTheDocument();
const modal = screen.getByRole("dialog");
// We move the pointer on the edge of the screen in order to simulate the click outside.
await user.pointer({
coords: {
x: 1,
y: 1,
},
});
await user.click(modal);
await user.click(document.querySelector(".c__modal__backdrop")!);
expect(screen.queryByText("Modal Content")).not.toBeInTheDocument();
});
@@ -292,7 +308,6 @@ describe("<Modal/>", () => {
// Display the modal.
await user.click(button);
// Modal is open.
expect(screen.getByText("Are you sure?")).toBeInTheDocument();
expect(
@@ -452,6 +467,7 @@ describe("<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", {

View File

@@ -1,10 +1,13 @@
import React, { PropsWithChildren, ReactNode, useEffect } from "react";
import classNames from "classnames";
import { createPortal } from "react-dom";
import ReactModal from "react-modal";
import { Button } from ":/components/Button";
import { NOSCROLL_CLASS } from ":/components/Modal/ModalProvider";
export type ModalHandle = {};
export const MODAL_CLASS = "c__modal";
export enum ModalSize {
SMALL = "small",
MEDIUM = "medium",
@@ -69,91 +72,47 @@ export const Modal = (props: ModalProps) => {
};
export const ModalInner = (props: ModalProps) => {
const ref = React.useRef<HTMLDialogElement>(null);
useEffect(() => {
if (props.isOpen) {
ref.current?.showModal();
} else {
ref.current?.close();
}
}, [props.isOpen]);
useEffect(() => {
ref.current?.addEventListener("close", () => props.onClose(), {
once: true,
});
const onClick = (event: MouseEvent) => {
const rect = ref.current!.getBoundingClientRect();
const isInDialog =
rect.top <= event.clientY &&
event.clientY <= rect.top + rect.height &&
rect.left <= event.clientX &&
event.clientX <= rect.left + rect.width;
if (!isInDialog) {
props.onClose();
}
};
if (props.closeOnClickOutside) {
ref.current?.addEventListener("click", onClick);
}
const preventClose = (event: Event) => {
event.preventDefault();
};
if (props.preventClose) {
ref.current?.addEventListener("cancel", preventClose);
}
return () => {
ref.current?.removeEventListener("click", onClick);
ref.current?.removeEventListener("click", preventClose);
};
}, [props.isOpen]);
const { modalParentSelector } = useModals();
if (!props.isOpen) {
return null;
}
return (
<>
{createPortal(
<>
<div aria-hidden={true} className="c__modal__backdrop" />
<dialog
ref={ref}
className={classNames("c__modal", "c__modal--" + props.size)}
>
{!props.hideCloseButton && !props.preventClose && (
<div className="c__modal__close">
<Button
icon={<span className="material-icons">close</span>}
color="tertiary-text"
size="small"
onClick={() => props.onClose()}
/>
</div>
)}
{props.titleIcon && (
<div className="c__modal__title-icon">{props.titleIcon}</div>
)}
{props.title && (
<div className="c__modal__title">{props.title}</div>
)}
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
<div className="c__modal__content" tabIndex={0}>
{props.children}
</div>
<ModalFooter {...props} />
</dialog>
</>,
document.getElementById("c__modals-portal")!,
<ReactModal
isOpen={props.isOpen}
onRequestClose={() => {
if (!props.preventClose) {
props.onClose();
}
}}
parentSelector={modalParentSelector}
overlayClassName="c__modal__backdrop"
className={classNames(MODAL_CLASS, `${MODAL_CLASS}--${props.size}`)}
shouldCloseOnOverlayClick={!!props.closeOnClickOutside}
bodyOpenClassName={classNames("c__modals--opened", NOSCROLL_CLASS)}
>
{!props.hideCloseButton && !props.preventClose && (
<div className="c__modal__close">
<Button
icon={<span className="material-icons">close</span>}
color="tertiary-text"
size="small"
onClick={() => props.onClose()}
/>
</div>
)}
</>
{props.titleIcon && (
<div className="c__modal__title-icon">{props.titleIcon}</div>
)}
{props.title && <div className="c__modal__title">{props.title}</div>}
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
<div className="c__modal__content" tabIndex={0}>
{props.children}
</div>
<ModalFooter {...props} />
</ReactModal>
);
};

View File

@@ -103,7 +103,9 @@ export const CunninghamProvider = ({
return (
<CunninghamContext.Provider value={context}>
<ModalProvider>
<ToastProvider>{children}</ToastProvider>
<div className="c__app">
<ToastProvider>{children}</ToastProvider>
</div>
</ModalProvider>
</CunninghamContext.Provider>
);