🌝

HTMLのdialog要素でDialogComponentを作る(React)

2022/12/21に公開2

2022年3月から主要ブラウザ(Chrome, Edge, Firefox, Safari)で dialog要素が利用できるようになったそうなのでそれを使ってDialogすなわりModalのコンポーネントを作ったらどんな感じになるかを試してみました.説明少なめですが記事にします.

また,今回はできるだけ最小限のコードで作成することを意識しています.

dialog要素ってなに?

https://developer.mozilla.org/ja/docs/Web/HTML/Element/dialog

  • eventで表示/非表示を切り替えることができる
  • backdropという疑似要素があり,すでにOverlayが作成されている(これ結構嬉しい)

メリットはこの2点かなと思います.

わかりやすく作ってみる

Reactで最小限に作るとこんな感じですね.

const Sample = () => {
  const dialogRef = useRef<HTMLDialogElement>(null);

  const openDialog = () => {
    if (dialogRef.current) {
      dialogRef.current.showModal();
    }
  };

  const closeDialog = () => {
    if (dialogRef.current) {
      dialogRef.current.close();
    }
  };

  return (
    <div>
      <button onClick={openDialog}>openModal</button>

      <dialog ref={dialogRef}>
        <h2>Dialog title</h2>
        <p>Dialog content</p>
        <button onClick={closeDialog}>close</button>
      </dialog>
    </div>
  );
};

const dialogRef = useRef<HTMLDialogElement>(null);

イベントを操作するためにDOMにアクセスできるようにrefを用意します.

これで,dialogRef.current.showModal()を呼ぶことでdialogがOverlay付きで表示されます.(Modalなんだ😇)

同様にdialogRef.current.close()で閉じることができます.

OverlayをクリックしたときにDialogを閉じたい

dialog要素にはonClick属性があり,

<dialog ref={dialogRef} onClick={closeDialog}>とすれば,どこをクリックしてもDialogを閉じることができます.

しかし,Dialogの中をクリックした場合は閉じないほうが実用的です.

これを実現するには以下のようにします.

<dialog ref={dialogRef} onClick={closeDialog}>
  <div onClick={(e) => e.stopPropagation()}>
    <h2>Dialog title</h2>
    <p>Dialog content</p>
    <button onClick={closeDialog}>close</button>
  </div>
</dialog>

dialogの中をdivなどで囲むようにし,そのクリックイベントをstopPropagation()で中断します.

ここまで,かなり短いコードで実用的なDialogができました.

再利用性の高いコンポーネントにする

このDialogの仕組みを再利用性の高いものにします.

いろいろ考えたのですが,そのためにはちょっと複雑ですが以下のようになりそうです.

  • useDialogを作成し,ref,openDialog,closeDialogの再利用可能なものにする
  • refを渡せるようにforwordRefでWrapしたDialogコンポーネントを作成する
  • DialogのUIを追加する際は上記の2つを毎度組み合わせて作るようにする

実際のコードはこちら

useDialog.ts

import { useRef, useCallback } from 'react';

export const useDialog = () => {
  const ref = useRef<HTMLDialogElement>(null);

  const showModal = useCallback(() => {
    if (ref.current) {
      ref.current.showModal();
    }
  }, []);

  const closeModal = useCallback(() => {
    if (ref.current) {
      ref.current.close();
    }
  }, []);

  return { ref, showModal, closeModal };
};

Dialog.ts

import { FC, forwardRef, ReactNode, Ref } from 'react';

type Props = {
  ref: Ref<HTMLDialogElement>;
  onClose: () => void;
  children: ReactNode;
};

export const Dialog: FC<Props> = forwardRef<HTMLDialogElement, Props>(
  ({ onClose, children }, ref) => {
    return (
      <dialog
        ref={ref}
        onClick={onClose}
      >
        <div onClick={(e) => e.stopPropagation()}>
          {children}
        </div>
      </dialog>
    );
  },
);

Dialog.displayName = 'Dialog'; // ESLint: react/display-nameの回避のため

使用箇所

const Sample = () => {
  const { ref, showModal, closeModal } = useDialog();

  return (
    <div>
      <h1>Sample dialog element</h1>
      <button onClick={showModal}>showModal</button>

      <Dialog ref={ref} onClose={closeModal}>
        <h2>Dialog title</h2>
        <p>Dialog content. Dialog content. Dialog content.</p>
        <button onClick={closeModal}>
          close
        </button>
      </Dialog>
    </div>
  );
};

Dialog.tsにおいてforwardRefを使った型の書き方が結構微妙なのですが,これが最も簡潔だと思っています.

一応要点をまとめると

  • refを渡すためにforwardRefを使用する必要がある.
  • refはpropsから受け取ることができない
  • Propsの型にrefを(書く必要はないのだが)書かないと渡す箇所でエラーになる

こんな感じです.

OverlayのCSSのあて方

疑似要素なので

dialog::backdrop {
  backgroud-color: orange;
  opacty: 0.5;
}

とかするだけです.これが楽で最高!!!!!

感想

useDialog.tsDialog.tsの二重管理になるのがちょっと微妙って感じですが,Overlayを自分で作らなくていいのはかなり嬉しいです.

あとはHTMLをちゃんと勉強してる感があっていいですね(謎)

今回作ったサンプルのリポジトリも置いときます.

https://github.com/nbr41to/sample-dialog-element

Discussion

あるぱかあるぱか

dialog::backdropはFirefoxだけ2022年9月末時点で対応してなかったんですが、全モダンブラウザ対応するようになったんですね!