【Next.js】【モーダル】dialog要素を使った例

2024-10-13 作成

この記事でわかること

  • dialog要素 を使ったモーダル実装の特徴と実装例

特徴

  • キーボード操作、webアクセシビリティ要件を簡単に満たすことができる
  • ios safari 15.4以上で利用可能
  • 以下の機能は自分で実装する必要あり
    • モーダルが開かれた時に背景を固定(背面コンテンツのスクロールを抑止)する
    • 背景要素クリックで、モーダルが閉じる
    • 開閉アニメーション

実装例

src/components/Modal/index.tsx
# ...

const dialog = {
  padding: 0;
  border: 0;
  &::backdrop { // 背景の擬似要素
    background: rgb(0 0 0 / 70%);
  }
}

export const Modal = ({ children, id }: Props) => {
  const [isClient, setIsClient] = useState<boolean>(false);

  useEffect(() => {
    if (typeof window === 'undefined') return;

    setIsClient(true);
  }, []);

  if (!isClient) return <></>; // SSR時にdocumentを参照しないようにしてます

  return createPortal( // createPortal(任意のDOM, document.body) でbodyタグ直下の最後の要素として任意のDOMがレンダリングされます
    <dialog style={dialog} id={id}>
      <div>{children}</div>
    </dialog>,
    document.body
  );
};
src/components/Modal/hooks.tsx
# ...
// モーダルの表示非表示ロジックをhooksにすることで、任意のコンポーネントで表示非表示ロジックを利用できるようにしてます。
export const useModal = (id: string) => {
  const open = useCallback(() => {
    const dialog = document.getElementById(id) as HTMLDialogElement;
    dialog.showModal();
  }, [id]);

  const close = useCallback(() => {
    const dialog = document.getElementById(id) as HTMLDialogElement;
    dialog.close();
  }, [id]);

  return { open, close };
};
src/pages/index.tsx
# ...
import { Modal } from '@/components/Modal';
import { useModal } from '@/components/Modal/hooks';

export const HOME = () => {
  const { open, close } = useModal('modal');

  return (
    <>
      <button onClick={open}>モーダルを開く</button>
      <Modal id="modal">
        <div>
          <p>モーダル</p>
          <button onClick={close}>モーダルを閉じる</button>
        </div>
      </Modal>
    <>
  );
};

完成!

追加実装 モーダルが開かれた時に背景を固定する

src/components/Modal/hooks.tsx
# ...
export const useModal = (id: string) => {
  const open = useCallback(() => {
    const dialog = document.getElementById(id) as HTMLDialogElement;
    const body = document.body // 追加

    dialog.showModal();
    body.style.overflow = 'hidden'; // 追加
  }, [id]);

  const close = useCallback(() => {
    const dialog = document.getElementById(id) as HTMLDialogElement;
    const body = document.body // 追加

    dialog.close();
    body.style.removeProperty('overflow'); // 追加
  }, [id]);

  return { open, close };
};

追加実装 背景要素クリックでモーダルが閉じる

src/components/Modal/index.tsx
...
export const ModalDialog = ({
  children,
  id,
  closeDialog,
}: {
  children: ReactNode;
  id: string;
  closeDialog: () => void;
}) => {
  // イベントリスナーを追加
  useEffect(() => {
    const dialog = document.getElementById(id)
    if (!dialog) return;

    const click = (e: MouseEvent) => {
      e.target === dialog && closeDialog();
    };

    const keydown = (e: KeyboardEvent) => {
      if (e.key === 'Enter' || e.key === ' ') {
        e.target === dialog && closeDialog();
      }
    };

    dialog.addEventListener('click', click);
    dialog.addEventListener('keydown', keydown);

    return () => {
      dialog.removeEventListener('click', click);
      dialog.removeEventListener('keydown', keydown);
    };
  }, [closeDialog, id]);

  return (
    <dialog style={dialog} id={id}>
      <div
        id="dialogInner"
        onClick={(e) => e.stopPropagation()}
        onKeyDown={(e) => e.stopPropagation()}
        role="button"
        tabIndex={-1}
      >
        {children}
      </div>
    </dialog>
  );
};

追加実装 開閉アニメーション

TODO: 実装予定

実装メモ帳