(react) add custom modal portal

In some apps this is mostly needed, for instance: when the
CunninghamProvider is nested in the DOM, we want the modal to still
be displayed on top of anything else, then in those cases we will be
able to define a node directly in the body and tell cunningham to
render modals inside it.
This commit is contained in:
Nathan Vasse
2024-04-04 16:59:12 +02:00
committed by NathanVss
parent 6ebeb116d3
commit 2916dd2af9
5 changed files with 75 additions and 5 deletions

View File

@@ -44,6 +44,7 @@ interface ModalContextType {
props?: Partial<ConfirmationModalProps>,
) => Promise<Decision>;
messageModal: (props?: Partial<MessageModalProps>) => Promise<Decision>;
modalParentSelector?: () => HTMLElement;
}
const ModalContext = createContext<undefined | ModalContextType>(undefined);
@@ -102,7 +103,14 @@ const ModalContainer = ({
type ModalMap = Map<string, ReactNode>;
export const ModalProvider = ({ children }: PropsWithChildren) => {
interface ModalProviderProps extends PropsWithChildren {
modalParentSelector?: () => HTMLElement;
}
export const ModalProvider = ({
children,
modalParentSelector,
}: ModalProviderProps) => {
const [modals, setModals] = useState<ModalMap>({} as ModalMap);
useEffect(() => {
@@ -151,6 +159,12 @@ export const ModalProvider = ({ children }: PropsWithChildren) => {
deleteConfirmationModal: ModalHelper(DeleteConfirmationModal),
confirmationModal: ModalHelper(ConfirmationModal),
messageModal: ModalHelper(MessageModal),
modalParentSelector: () => {
if (modalParentSelector) {
return modalParentSelector();
}
return document.getElementById("c__modals-portal")!;
},
}),
[],
);

View File

@@ -69,6 +69,43 @@ describe("<Modal/>", () => {
expect(screen.getByText("Modal Content")).toBeInTheDocument();
expect(app).toHaveAttribute("aria-hidden", "true");
});
it("use modalParentSelector to change the modal portal", async () => {
const Wrapper = () => {
const modal = useModal();
return (
<>
<CunninghamProvider
modalParentSelector={() =>
document.querySelector("#my-custom-portal")!
}
>
<button onClick={modal.open}>Open Modal</button>
<Modal size={ModalSize.SMALL} {...modal}>
<div>Modal Content</div>
</Modal>
</CunninghamProvider>
<div id="my-custom-portal" />
</>
);
};
render(<Wrapper />);
const user = userEvent.setup();
const button = screen.getByText("Open Modal");
const portal = document.querySelector("#my-custom-portal")!;
expect(portal.children.length).toEqual(0);
expect(screen.queryByText("Modal Content")).not.toBeInTheDocument();
await user.click(button);
const content = screen.getByText("Modal Content");
expect(content).toBeInTheDocument();
expect(portal.children.length).toEqual(1);
expect(portal.children[0].className).toEqual("ReactModalPortal");
expect(portal.children[0].children.length).toEqual(1);
expect(portal).toContain(content);
});
it("closes the modal when clicking on the close button", async () => {
const Wrapper = () => {
const modal = useModal();

View File

@@ -21,10 +21,10 @@ const meta: Meta<typeof Modal> = {
}, []);
return (
<CunninghamProvider>
<>
<Button onClick={() => modal.open()}>Open Modal</Button>
<Story args={{ ...context.args, ...modal }} />
</CunninghamProvider>
</>
);
},
],
@@ -197,3 +197,20 @@ export const FullWithContent: Story = {
children: longLorem.text,
},
};
export const CustomParentSelect: Story = {
render: () => {
return (
<CunninghamProvider
modalParentSelector={() =>
document.querySelector("#my-custom-modal-parent")!
}
>
<Modal isOpen={true} onClose={() => {}} size={ModalSize.MEDIUM}>
I am rendered inside #my-custom-modal-parent
</Modal>
<div id="my-custom-modal-parent" />
</CunninghamProvider>
);
},
};

View File

@@ -2,7 +2,7 @@ import React, { PropsWithChildren, ReactNode, useEffect } from "react";
import classNames from "classnames";
import ReactModal from "react-modal";
import { Button } from ":/components/Button";
import { NOSCROLL_CLASS } from ":/components/Modal/ModalProvider";
import { NOSCROLL_CLASS, useModals } from ":/components/Modal/ModalProvider";
export type ModalHandle = {};

View File

@@ -34,6 +34,7 @@ interface Props extends PropsWithChildren {
customLocales?: Record<string, TranslationSet>;
currentLocale?: string;
theme?: string;
modalParentSelector?: () => HTMLElement;
}
export const DEFAULT_LOCALE = Locales.enUS;
@@ -53,6 +54,7 @@ export const CunninghamProvider = ({
currentLocale = DEFAULT_LOCALE,
customLocales,
theme = DEFAULT_THEME,
modalParentSelector,
children,
}: Props) => {
const locales: Record<string, TranslationSet> = useMemo(
@@ -102,7 +104,7 @@ export const CunninghamProvider = ({
return (
<CunninghamContext.Provider value={context}>
<ModalProvider>
<ModalProvider modalParentSelector={modalParentSelector}>
<div className="c__app">
<ToastProvider>{children}</ToastProvider>
</div>