ReactのcreatePortalで実装したマウント時に自動的に開くモーダルがStorybookで表示されない
2024-05-14
7 months ago
開発環境
- react 18
- next 14.2.3
- storybook 8.0.10
前提
モーダルの実装はcreatePortalを利用しています。
また、モーダルはマウント時に自動的に開く想定です。
※状態などで管理する、ボタン押下で開閉するモーダルではございません。
本題
ReactのcreatePortalで実装した、マウント時に自動的に描画されるモーダルがStorybookで表示されない場合、どのように対応するか紹介します。
まず、モーダルの実装は以下のようなものを想定しています。
'use client';
import { type ElementRef, useEffect, useRef, type ReactNode } from 'react';
import { useRouter } from 'next/navigation';
import { createPortal } from 'react-dom';
import { Button } from './button';
import { Icon } from './icon';
const Modal = ({ children }: { children: ReactNode }) => {
const router = useRouter();
const dialogRef = useRef<ElementRef<'dialog'>>(null);
useEffect(() => {
if (!dialogRef.current?.open) {
dialogRef.current?.showModal();
}
}, []);
const onDismiss = () => {
router.back();
};
return createPortal(
<div className="absolute inset-0 bg-black/60 z-50">
<dialog
ref={dialogRef}
className="relative w-[80%] max-w-lg h-auto max-h-[500px] border-border border rounded-lg bg-background p-5 flex items-center justify-center"
onClose={onDismiss}
>
{children}
<Button
variant="ghost"
size="icon"
className="absolute top-2.5 right-2.5 rounded-full"
onClick={onDismiss}
>
<Icon name="X" />
</Button>
</dialog>
</div>,
document.getElementById('modal-root')!,
);
};
export { Modal };
そして、modal-rootの要素は上位のコンポーネントに存在している想定です。
import type { Metadata } from 'next';
import '@/styles/globals.css';
import { Inter as FontSans } from 'next/font/google';
import { cn } from '@/lib/utils';
const fontSans = FontSans({
subsets: ['latin'],
variable: '--font-sans',
});
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ja">
<body
className={cn(
'min-h-screen bg-background font-sans antialiased text-foreground',
fontSans.variable,
)}
>
{children}
<div id="modal-root" />
</body>
</html>
);
}
このような実装の場合、Storyファイルを以下のように実装するとエラーが出ます。
import type { Meta, StoryObj } from '@storybook/react';
import { Modal } from './modal';
const meta = {
title: 'components/ui/modal',
component: Modal,
tags: ['autodocs'],
} satisfies Meta<typeof Modal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
parameters: {
nextjs: {
appDirectory: true,
},
},
args: {
children: 'Modal Content',
},
render: ({ children }) => {
return (
<div>
<Modal>
<div className="flex flex-col gap-4">
<div>{children}</div>
</div>
</Modal>
<div id="modal-root" /> // ここに要素を追加する
</div>
);
},
};
また、previewファイルでdecoratorsに、同じように<div id="modal-root" />を挿入しても同様にエラーが発生します。
今回のような、事前に要素が存在して欲しい場合、preview-body.htmlで要素を追加することができます。
<div id="modal-root"></div>
※Storyファイルのrenderに記述していた<div id="modal-root" />は必要ないので削除します。
これでStorybook上のコンポーネントを開くとエラーなくモーダルを表示することができます。
さいごに
今回は少しニッチな用途のご紹介でしたが、よくあるボタン押下で開閉するモーダルであればpreview-body.htmlを利用する必要はないことが多いです。
以下のような、初期表示はモーダル非表示、状態で開閉を管理する実装ではrender内に<div id="modal-root" />を挿入しても問題なく表示できます。
import type { Meta, StoryObj } from '@storybook/react';
import { Modal } from './modal';
import { Button } from './button';
import { useState } from 'react';
const meta = {
title: 'components/ui/modal',
component: Modal,
tags: ['autodocs'],
} satisfies Meta<typeof Modal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
parameters: {
nextjs: {
appDirectory: true,
},
},
args: {
children: 'Modal Content',
},
render: ({ children }) => {
const [open, setOpen] = useState(false);
return (
<div>
<Button onClick={() => setOpen(true)}>Open</Button>
{open && (
<Modal>
<div className="flex flex-col gap-4">
<div>{children}</div>
<Button onClick={() => setOpen(false)}>Close</Button>
</div>
</Modal>
)}
<div id="modal-root" />
</div>
);
},
};