モーダルは、ウェブアプリケーションでよく使われる機能であり、実装する事が多い。
便利だし、利用者にとっても見慣れた UI なので導入しやすい。
だが、ブラウザや HTML には、モーダルという要素は用意されていない。
似て非なるものとしてalert
があるが、これはモーダルとして開発者が求めているものとは、多くの面で異なる。
そのためなのか、モーダルを実現するためのライブラリは多数公開されている。
だが個人的な方針として、依存ライブラリはあまり増やしたくない。
かといって自分で開発するのもそれなりに手間がかかる。
そこで、HTML 5.2 で標準化されたdialog
要素を使うことにした。
標準化された機能なら、知識やノウハウが陳腐化しにくく、第三者が作ったライブラリより採用しやすい。
そう考え、React アプリのモーダルをdialog
要素で実装することにした。
だが、DOM を操作するためにref
属性を利用する必要があったり、そもそも対応しているブラウザが少ないため Polyfill が必要だったりと、思っていた以上に手間がかかった。
最終的には、主要なブラウザで動作する以下のモーダルが完成したので、手順をまとめておく。
使用しているライブラリのバージョンは以下の通り。
- 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>
そのため、以下の 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>
だがこの動作には問題があり、dialog
要素を表示しても、依然としてdialog
要素の外にある要素を操作できてしまう。
そのため、https://example.com
というリンクをクリックできてしまう。
これは、ユーザーが一般的に想像する挙動とは、乖離している。
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>
あとは CSS でデザインの調整さえ行えば、取り敢えず完成である。
このようにかなり手軽にモーダルを実装できるのだが、「dialog
要素が持っているメソッドを実行しないといけない」という性質上、これを React で実装するためには、どうしてもref
属性を使うことになる。
React アプリで実装する
早速実装を開始していく。
まずは、ボタンを押下してdialog
要素を表示できるようにする。
showModal
を実行するためにdialog
要素を取得しなければならないので、useRef
でRef
オブジェクトを作って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
要素の外側をクリックしても、閉じない。
dialog
要素のonClick
にcloseModal
を設定すれば解決する。
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
要素をクリックした際もモーダルが閉じてしまう。
dialog
のonClick
イベントにcloseModal
を設定したのだから当然だが、この挙動は止めたい。
どうしたものかと考えていたが、以下の記事に答えが書いてあった。
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>
カスタムフックとして処理を切り出す
次に、モーダルの表示に関するロジックをカスタムフックとして切り出す。
動作自体が変わるわけではないので、必須ではない。
だがカスタムフックにすることで、コンポーネントの記述がシンプルになるし、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; }
出来上がったのが、以下のモーダル。
クロスブラウザ対応
これで完成、と言いたいところだが、まだクロスブラウザ対応が残っている。
冒頭で少し触れたが、dialog
は標準化された機能ではあるのだが、ブラウザの対応はまだあまり進んでいない。
https://caniuse.com/#search=dialog
Firefox で動作確認してみると、ボタンを押下してもモーダルが表示されない。
Safari に至っては、ボタンを押す前から表示されてしまっている。
幸い、Google が Polyfill を用意しているので、それを使って対応する。
$ 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
要素の外側をクリックしても、モーダルが閉じない。
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 も提供しているので、それを参考にする。
具体的には、以下の差分を追加した。
@@ -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; }