【React / Next.js】createPortalでmodalコンポーネントを作る
やりたいこと
モーダルは画面最前面に表示させることの多い性質上、body
タグ直下にレンダリングしたいことがよくある。
そこで親コンポーネントより外にDOMをレンダリングできるReact Portalを使い、モーダルコンポーネントを実装してみる。
実装方法
1. モーダルの雛形を作る
import { type ReactNode } from "react";
const Modal = ({ children }: { children: ReactNode }) => {
return (
<div>
<div>
<button type="button" aria-label="モーダルを閉じる">
×
</button>
{children}
</div>
</div>
);
};
export default Modal;
まずはprops
で渡されたchildren
と、閉じるボタンを描画する簡素なコンポーネントを作る。
import { type ReactNode } from "react";
import { type ReactNode, useState } from "react";
const Modal = ({
children,
buttonText,
canCloseByClickingBackground = true,
}: {
children: ReactNode;
buttonText: string;
canCloseByClickingBackground?: boolean;
}) => {
const [isOpened, setIsOpened] = useState(false);
const open = () => setIsOpened(true);
const close = () => setIsOpened(false);
if (!isOpened) {
return (
<button type="button" onClick={open}>
{buttonText}
</button>
);
}
return (
<div>
<div>
<button
type="button"
aria-label="モーダルを閉じる"
onClick={close}
>
×
</button>
{children}
</div>
{canCloseByClickingBackground && <div onClick={close} />}
</div>
);
};
export default Modal;
- ボタンをクリックするとモーダルが開く
- モーダル内のxボタンをクリックするとモーダルが閉じる
上記要件を満たすように追記。
canCloseByClickingBackground
がtrue
の時は、背景をクリックしてもモーダルが閉じるようにする。
2. createPortalでレンダリング
import { type ReactNode, useState } from "react";
import { createPortal } from "react-dom";
return (
const elmModal = (
<div>
<div>
<button
type="button"
aria-label="モーダルを閉じる"
onClick={close}
>
×
</button>
{children}
</div>
{canCloseByClickingBackground && <div onClick={close} />}
</div>
);
return createPortal(elmModal, document.body);
};
export default Modal;
createPortal
を使い、body
タグ直下にモーダルをレンダリングさせる。
第1引数にはレンダリングしたい要素を、第2引数にはレンダリング先のノードを指定。
3. スタイルの設定
.wrapper {
position: fixed;
top: 0;
left: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
contain: content;
}
.content {
position: relative;
z-index: 1;
box-sizing: border-box;
width: 70vw;
max-width: 700px;
max-height: 90vh;
padding: 40px;
overflow-y: auto;
background-color: #fff;
border-radius: 5px;
animation: anim-modal 0.5s ease;
will-change: transform, opacity;
}
@keyframes anim-modal {
from {
opacity: 0;
transform: translateY(15px);
}
}
.btnClose {
position: absolute;
top: 5px;
right: 10px;
padding: 0;
font-size: 2.7rem;
font-weight: bold;
line-height: 1;
color: #bbb;
cursor: pointer;
background-color: transparent;
border: none;
}
.background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0 0 0 / 50%);
}
@media screen and (max-width: 768px) {
.content {
width: 96vw;
padding: 30px 25px;
}
}
あとはモーダルっぽいUIにしたいので、modal.module.css
を作成。
import { type ReactNode, useState } from "react";
import { createPortal } from "react-dom";
import styles from "../styles/modal.module.css";
const elmModal = (
<div>
<div className={styles.wrapper}>
<div>
<div className={styles.content}>
<button
className={styles.btnClose}
type="button"
aria-label="モーダルを閉じる"
onClick={close}
>
×
</button>
{children}
</div>
{canCloseByClickingBackground && <div onClick={close} />}
{canCloseByClickingBackground && (
<div className={styles.background} onClick={close} />
)}
</div>
);
return createPortal(elmModal, document.body);
};
export default Modal;
ちなみにCSSは当サイトのCSSコピペツールを利用した。
4. コンポーネントを呼び出す
import Modal from "./modal";
const SomeComponent = () => {
return (
<Modal buttonText="モーダルを開く">
<div>
<p>モーダル内のコンテンツです。</p>
<p>モーダル内のコンテンツです。</p>
<p>モーダル内のコンテンツです。</p>
<p>モーダル内のコンテンツです。</p>
<p>モーダル内のコンテンツです。</p>
<p>モーダル内のコンテンツです。</p>
<p>モーダル内のコンテンツです。</p>
</div>
</Modal>
);
};
export default SomeComponent;
あとは今回実装したコンポーネントを呼び出す。モーダル内に表示したい要素をchildren
として渡せばOK。
無事モーダルが表示され、
body
タグ直下の最後尾にレンダリングされていた 🎉
5. テスト実装
<div className={styles.wrapper}>
<div className={styles.wrapper} data-testid="modal-wrapper">
import Modal from "./modal";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const DUMMY_MODAL_TEXT = "ダミーのテキストです。";
const DUMMY_BUTTON_TEXT = "ダミーのボタンです";
describe("Modalコンポーネント", () => {
test("「開く」ボタンをクリックするとモーダルが開く", async () => {
render(
<Modal buttonText={DUMMY_BUTTON_TEXT}>
<p>{DUMMY_MODAL_TEXT}</p>
</Modal>
);
userEvent.click(screen.getByText(DUMMY_BUTTON_TEXT));
expect(await screen.findByTestId("modal-wrapper")).toBeInTheDocument();
expect(await screen.findByText(DUMMY_MODAL_TEXT)).toBeInTheDocument();
});
test("xボタンをクリックするとモーダルが閉じる", async () => {
render(
<Modal buttonText={DUMMY_BUTTON_TEXT}>
<p>{DUMMY_MODAL_TEXT}</p>
</Modal>
);
userEvent.click(screen.getByText(DUMMY_BUTTON_TEXT));
userEvent.click(await screen.findByText("×"));
expect(await screen.findByTestId("modal-wrapper")).not.toBeInTheDocument();
});
});
最低限のテストも実装し、無事通った。