logo

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>
.storybook/preview-body.html

※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>
    );
  },
};

参照