30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

React アプリのモーダルを dialog 要素で実装する

モーダルは、ウェブアプリケーションでよく使われる機能であり、実装する事が多い。
便利だし、利用者にとっても見慣れた UI なので導入しやすい。

だが、ブラウザや HTML には、モーダルという要素は用意されていない。
似て非なるものとしてalertがあるが、これはモーダルとして開発者が求めているものとは、多くの面で異なる。

そのためなのか、モーダルを実現するためのライブラリは多数公開されている。
だが個人的な方針として、依存ライブラリはあまり増やしたくない。
かといって自分で開発するのもそれなりに手間がかかる。

そこで、HTML 5.2 で標準化されたdialog要素を使うことにした。
標準化された機能なら、知識やノウハウが陳腐化しにくく、第三者が作ったライブラリより採用しやすい。

そう考え、React アプリのモーダルをdialog要素で実装することにした。
だが、DOM を操作するためにref属性を利用する必要があったり、そもそも対応しているブラウザが少ないため Polyfill が必要だったりと、思っていた以上に手間がかかった。

最終的には、主要なブラウザで動作する以下のモーダルが完成したので、手順をまとめておく。

f:id:numb_86:20200229144534p:plain

使用しているライブラリのバージョンは以下の通り。

  • react@16.13.0
  • typescript@3.8.2
  • dialog-polyfill@0.5.0

動作確認したブラウザとそのバージョンは以下の通り。

  • Google Chrome 80.0.3987.122
  • Firefox 73.0.1
  • Safari 13.0.5

最終的なコードは記事の最後に掲載している。

dialog 要素の概要

React で実装していく前にまず、dialog要素そのものの挙動を見ていく。
クロスブラウザ対応は後回しにして、ひとまず Chrome で動作確認していくことにする。

dialog要素は通常は表示されず、open属性を付与すると表示される。

<dialog open>
  dialog text
</dialog>

f:id:numb_86:20200229143704p:plain

そのため、以下の HTML ファイルを Chrome で開くと、ボタンのクリックによってdialogの表示非表示を切り替えることができる。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  <a href="https://example.com">https://example.com/</a>
  <br>
  <button id="open" type="button">open dialog</button>
  <dialog>
    dialog text
    <br>
    <button id="close" type="button">close dialog</button>
  </dialog>

  <script>
    const dialogElement = document.querySelector('dialog');
    const openButtonElement = document.querySelector('#open');
    const closeButtonElement = document.querySelector('#close');

    openButtonElement.addEventListener('click', () => {
      dialogElement.setAttribute('open', true);
    });

    closeButtonElement.addEventListener('click', () => {
      dialogElement.removeAttribute('open');
    });

  </script>
</body>
</html>

f:id:numb_86:20200229143729g:plain

だがこの動作には問題があり、dialog要素を表示しても、依然としてdialog要素の外にある要素を操作できてしまう。
そのため、https://example.comというリンクをクリックできてしまう。

f:id:numb_86:20200229143758g:plain

これは、ユーザーが一般的に想像する挙動とは、乖離している。
dialog要素を表示させたときは、dialog要素の外にある要素は操作できないようにしたい。
その挙動は予め用意されており、dialog要素が持っているshowModalメソッドを実行すると、dialog要素を表示させた上で、dialog要素の外にある要素を操作できないようになる。
そして、showModalと対になる、dialog要素を閉じるためのメソッドが、close

そのため、先程の HTML ファイルを以下のように書き換えると、意図した通りの挙動になる。
なぜかdialogの表示位置も変わるので、スタイルで調整している。

@@ -8,7 +8,7 @@
   <a href="https://example.com">https://example.com/</a>
   <br>
   <button id="open" type="button">open dialog</button>
-  <dialog>
+  <dialog style="top: 30px;">
     dialog text
     <br>
     <button id="close" type="button">close dialog</button>
@@ -20,11 +20,11 @@
     const closeButtonElement = document.querySelector('#close');

     openButtonElement.addEventListener('click', () => {
-      dialogElement.setAttribute('open', true);
+      dialogElement.showModal();
     });

     closeButtonElement.addEventListener('click', () => {
-      dialogElement.removeAttribute('open');
+      dialogElement.close();
     });

   </script>

f:id:numb_86:20200229143852g:plain

あとは CSS でデザインの調整さえ行えば、取り敢えず完成である。
このようにかなり手軽にモーダルを実装できるのだが、「dialog要素が持っているメソッドを実行しないといけない」という性質上、これを React で実装するためには、どうしてもref属性を使うことになる。

React アプリで実装する

早速実装を開始していく。
まずは、ボタンを押下してdialog要素を表示できるようにする。

showModalを実行するためにdialog要素を取得しなければならないので、useRefRefオブジェクトを作ってdialog要素のref属性に渡す。

import React, {useRef, useEffect} from 'react';

export const App = () => {
  const ref: React.MutableRefObject<HTMLDialogElement | null> = useRef(null);

  console.log('will mount', ref.current);

  useEffect(() => {
    console.log('mounted', ref.current);
  }, []);

  return (
    <>
      <dialog ref={ref}>dialog text</dialog>
    </>
  );
};

上記のコードを実行すると以下のログが流れるので、ref.currentの初期値はnullだが、マウントされるとdialog要素が入っているのが分かる。

will mount null
mounted <dialog>​dialog text​</dialog>​

次に、ボタンを用意してそのonClickの属性のなかでshowModalを実行すると、dialogが表示される。
dialogを閉じるのも、同じ要領でcloseを実行すればよい。

import React, {useRef, useCallback} from 'react';

export const App = () => {
  const ref: React.MutableRefObject<HTMLDialogElement | null> = useRef(null);

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

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

  return (
    <>
      <button type="button" onClick={showModal}>
        open
      </button>
      <dialog ref={ref} style={{top: '30px'}}>
        dialog text
        <br />
        <button type="button" onClick={closeModal}>
          close
        </button>
      </dialog>
    </>
  );
};

これで React アプリでモーダルを実装できた。
しかし欲を言えば、モーダルの外をクリックしたときに、モーダルが閉じて欲しい。少なくとも、そのような挙動にしたいケースは存在する。
デフォルトだと、dialog要素の外側をクリックしても、閉じない。

f:id:numb_86:20200229143934g:plain

dialog要素のonClickcloseModalを設定すれば解決する。
dialog要素の外側をクリックすると、dialog要素に設定されたクリックイベントが実行されるからだ。

       <button type="button" onClick={showModal}>
         open
       </button>
-      <dialog ref={ref} style={{top: '30px'}}>
+      <dialog onClick={closeModal} ref={ref} style={{top: '30px'}}>
         dialog text
         <br />
         <button type="button" onClick={closeModal}>

しかしそうすると今度は、dialog要素をクリックした際もモーダルが閉じてしまう。
dialogonClickイベントにcloseModalを設定したのだから当然だが、この挙動は止めたい。

f:id:numb_86:20200229144145g:plain

どうしたものかと考えていたが、以下の記事に答えが書いてあった。

qiita.com

dialog直下の子要素を用意して、それがクリックされたときはイベントの伝播が行われないようにする。
そうすれば、dialog要素自体をクリックしても、モーダルは閉じない。

stopPropagation関数を作って、それをdialog直下のdivに渡した。
引き続きコンポーネントにスタイルを書いてしまっているが、これはあとで修正する。

+  const stopPropagation = useCallback(
+    (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
+      e.stopPropagation();
+    },
+    []
+  );
+
   return (
     <>
       <button type="button" onClick={showModal}>
         open
       </button>
-      <dialog onClick={closeModal} ref={ref} style={{top: '30px'}}>
-        dialog text
-        <br />
-        <button type="button" onClick={closeModal}>
-          close
-        </button>
+      <dialog
+        onClick={closeModal}
+        ref={ref}
+        style={{top: '30px', padding: '0px'}}
+      >
+        <div onClick={stopPropagation} style={{padding: '16px'}}>
+          dialog text
+          <br />
+          <button type="button" onClick={closeModal}>
+            close
+          </button>
+        </div>
       </dialog>

f:id:numb_86:20200229144225g:plain

カスタムフックとして処理を切り出す

次に、モーダルの表示に関するロジックをカスタムフックとして切り出す。
動作自体が変わるわけではないので、必須ではない。
だがカスタムフックにすることで、コンポーネントの記述がシンプルになるし、2 つ以上のモーダルを使い分けたいという場合にも簡単に対応できるようになる。

以下の内容のuseModal.tsというファイルを作る。

import React, {useRef, useCallback} from 'react';

export const useModal = () => {
  const ref: React.MutableRefObject<HTMLDialogElement | null> = useRef(null);
  const showModal = useCallback(() => {
    if (ref.current) {
      ref.current.showModal();
    }
  }, []);
  const closeModal = useCallback(() => {
    if (ref.current) {
      ref.current.close();
    }
  }, []);
  return {ref, showModal, closeModal};
};

そして、コンポーネント側で、useModalを利用する。

-import React, {useRef, useCallback} from 'react';
+import React, {useCallback} from 'react';
+
+import {useModal} from './useModal';

 export const App = () => {
-  const ref: React.MutableRefObject<HTMLDialogElement | null> = useRef(null);
-
-  const showModal = useCallback(() => {
-    if (ref.current) {
-      ref.current.showModal();
-    }
-  }, []);
-
-  const closeModal = useCallback(() => {
-    if (ref.current) {
-      ref.current.close();
-    }
-  }, []);
+  const {ref, showModal, closeModal} = useModal();

   const stopPropagation = useCallback(
     (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {

これで、可読性や再利用性が高まった。

スタイルを調整する

あとは、スタイルを調整していけばよい。
今日現在では CSS in JS の利用などが主流だと思うが、説明を簡略化するために、CSS ファイルを用意してそこに既述していく形にした。

まず、コンポーネントに直接書いてしまっていた部分を移植する。

       <button type="button" onClick={showModal}>
         open
       </button>
-      <dialog
-        onClick={closeModal}
-        ref={ref}
-        style={{top: '30px', padding: '0px'}}
-      >
-        <div onClick={stopPropagation} style={{padding: '16px'}}>
+      <dialog onClick={closeModal} ref={ref}>
+        <div onClick={stopPropagation} className="dialog-body">
           dialog text
           <br />
           <button type="button" onClick={closeModal}>
dialog {
  top: 30px;
  padding: 0;
}

.dialog-body {
  padding: 16px;
}

ここから先はデザインや UI の話になってしまうが、今回は以下のような構造とスタイルにした。

<dialog onClick={closeModal} ref={ref}>
  <div onClick={stopPropagation} className="dialog-body">
    <header>Header</header>
    <main>
      dialog text
      <br />
      <button type="button" onClick={closeModal}>
        close
      </button>
    </main>
  </div>
</dialog>
dialog {
  top: 30px;
  padding: 0;
  width: 400px;
  top: 70px;
  border: none;
  padding: 0;
  background: transparent;
  color: #000;
}

.dialog-body {
  padding: 16px;
}

header {
  background-color: #eaeff9;
  border-bottom: 2px solid #a3bce2;
  height: 40px;
  padding: 10px 0 0 30px;
  border-top-left-radius: 30px;
  border-top-right-radius: 30px;
  font-size: 23px;
}

main {
  background-color: #fff;
  padding: 30px;
  border-bottom-left-radius: 30px;
  border-bottom-right-radius: 30px;
}

dialog要素の外側に対しては、dialog::backdropで指定できる。
今回はデフォルトより暗くしたかったので、以下のようにした。

    dialog::backdrop {
      background-color: #000;
      opacity: 0.3;
    }

出来上がったのが、以下のモーダル。

f:id:numb_86:20200229143647p:plain

クロスブラウザ対応

これで完成、と言いたいところだが、まだクロスブラウザ対応が残っている。

冒頭で少し触れたが、dialogは標準化された機能ではあるのだが、ブラウザの対応はまだあまり進んでいない。

https://caniuse.com/#search=dialog

Firefox で動作確認してみると、ボタンを押下してもモーダルが表示されない。
Safari に至っては、ボタンを押す前から表示されてしまっている。

f:id:numb_86:20200229144320p:plain

幸い、Google が Polyfill を用意しているので、それを使って対応する。

github.com

$ npm i dialog-polyfill
$ npm i -D @types/dialog-polyfill

以下のように使うので、今回のケースだとregisterDialogメソッドの引数としてref.currentを渡せばよい。

import dialogPolyfill from 'dialog-polyfill';

dialogPolyfill.registerDialog(dialog 要素);

useEffectのなかで実行するが、dialog要素に対応しているブラウザ環境では不要な処理なので、次のようになる。

  useEffect(() => {
    if (ref.current && !ref.current.showModal) {
      dialogPolyfill.registerDialog(ref.current);
    }
  }, [ref]);

これで Polyfill の適用はできたが、この Polyfill は本来の挙動を完全に再現するものではない。
例えば、dialog要素の外側は、本来は::backdropという疑似要素のだが、Polyfill では.backdropというdialogに隣接する要素になる。
そのため、dialog::backdropで指定したスタイルが反映されない。

以下は Firefox のスクリーンショットだが、.backdropが高さ0の要素になってしまっている。そのため、dialog要素の外側をクリックしても、モーダルが閉じない。

f:id:numb_86:20200229144338p:plain

dialog + .backdropにスタイルを指定して、対応する。

dialog + .backdrop {
  background-color: #000;
  opacity: 0.3;
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

これで、Firefox でも問題なくなった。

では Safari はどうかというと、相変わらずボタンを押す前からdialogが表示されてしまっている。
これを修正するには、さらに CSS を追加する必要がある。

先程の Polyfill は CSS も提供しているので、それを参考にする。

dist/dialog-polyfill.css

具体的には、以下の差分を追加した。

@@ -7,6 +7,11 @@
   padding: 0;
   background: transparent;
   color: #000;
+  height: fit-content;
+  position: absolute;
+  left: 0;
+  right: 0;
+  margin: auto;
 }

 dialog::backdrop {
@@ -23,3 +28,6 @@
   width: 100%;
   height: 100%;
 }
+dialog:not([open]) {
+  display: none;
+}

これで、Safari でも動くようになる。

参考資料

最終的なコード

// App.tsx
import React, {useCallback, useEffect} from 'react';
import dialogPolyfill from 'dialog-polyfill';

import {useModal} from './useModal';

export const App = () => {
  const {ref, showModal, closeModal} = useModal();

  useEffect(() => {
    if (ref.current && !ref.current.showModal) {
      dialogPolyfill.registerDialog(ref.current);
    }
  }, [ref]);

  const stopPropagation = useCallback(
    (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
      e.stopPropagation();
    },
    []
  );

  return (
    <>
      <button type="button" onClick={showModal}>
        open
      </button>
      <dialog onClick={closeModal} ref={ref}>
        <div onClick={stopPropagation} className="dialog-body">
          <header>Header</header>
          <main>
            dialog text
            <br />
            <button type="button" onClick={closeModal}>
              close
            </button>
          </main>
        </div>
      </dialog>
    </>
  );
};
// useModal.ts
import React, {useRef, useCallback} from 'react';

export const useModal = () => {
  const ref: React.MutableRefObject<HTMLDialogElement | null> = useRef(null);
  const showModal = useCallback(() => {
    if (ref.current) {
      ref.current.showModal();
    }
  }, []);
  const closeModal = useCallback(() => {
    if (ref.current) {
      ref.current.close();
    }
  }, []);
  return {ref, showModal, closeModal};
};
dialog {
  top: 30px;
  padding: 0;
  width: 400px;
  top: 70px;
  border: none;
  padding: 0;
  background: transparent;
  color: #000;

  /* For Polyfill */
  height: fit-content;
  position: absolute;
  left: 0;
  right: 0;
  margin: auto;
}

/* For Native */
dialog::backdrop {
  background-color: #000;
  opacity: 0.3;
}

/* For Polyfill */
dialog + .backdrop {
  background-color: #000;
  opacity: 0.3;
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

/* For Polyfill */
dialog:not([open]) {
  display: none;
}

.dialog-body {
  padding: 16px;
}

header {
  background-color: #eaeff9;
  border-bottom: 2px solid #a3bce2;
  height: 40px;
  padding: 10px 0 0 30px;
  border-top-left-radius: 30px;
  border-top-right-radius: 30px;
  font-size: 23px;
}

main {
  background-color: #fff;
  padding: 30px;
  border-bottom-left-radius: 30px;
  border-bottom-right-radius: 30px;
}