ポップアップのカラーピッカーを React で作っているときに、いくつかの問題に直面したのでまとめておこうと思います。
作ったものは下のGIFのようなカラーピッカーです。
この記事では、カラーピッカー機能を取り除いた、下のポップアップメニューの作り方を紹介します。
完成品の CodePen を先に置いておくので、コピペしたい人はこちらをお使いください。
See the Pen React Popup Menu by ジフォ (@G4RDS) on CodePen.
ポップアップメニューの機能
今回作るポップアップメニューには以下のような機能があります。
- ボタンがクリックされるまでポップアップメニューを表示しない。
- ポップアップメニュー範囲外をクリックすると、ポップアップメニューを閉じる。
- ポップアップメニューの内側をクリックされても、ポップアップメニューは閉じない。
- ポップアップメニューの内側に、ポップアップメニューを閉じるボタンを用意する。
ポップアップメニューを作ろう
Functional Component で作っていきます。
Hooks を使うので、基礎を理解している必要があります。
まずはベースを作ろう
メニューの表示を切り替えるボタンと、メニューを持つコンポーネントを作ります。
// PopupMenu.js
import './PopupMenu.scss'
const PopupMenu = () => {
return (
<div className="popup-menu-container">
<button>
Toggle Menu
</button>
<div className="popoup-menu">
<div>menu</div>
<button>
Close Menu
</button>
</div>
</div>
)
}
適当なスタイルを当てておきます。
.popup-menu
がレイアウトに影響しないよう、position: absolute
を適用します。
// PopupMenu.scss
.popup-menu-container {
position: relative;
}
.popup-menu {
position: absolute;
z-index: 2;
width: 10rem;
margin-top: 0.5rem;
padding: 1rem;
background: #fff;
border-radius: 0.5rem;
}
メニューの表示を切り替えよう
React Hook の useState
を使って、メニューを表示したり非表示にしたりできるようにします。
// PopupMenu.js
import React, { useState } from 'react'
const PopupMenu = () => {
const [isShown, setIsShown] = useState(false)
const handleToggleButtonClick = () => {
setIsShown(true)
}
const handleCloseButtonClick = () => {
setIsShown(false)
}
return (
<div className="popup-menu-container">
<button onClick={handleToggleButtonClick}>
Toggle Menu
</button>
<div className={`popup-menu ${isShown ? 'shown' : ''}`}>
<div>menu</div>
<button onClick={handleCloseButtonClick}>
Close Menu
</button>
</div>
</div>
)
}
// PopupMenu.scss
.popup-menu {
transform: scale(0);
transform-origin: top left;
transition: transform 0.2s;
&.shown {
transform: scale(1);
}
}
ポップアップメニュー範囲外がクリックされたら閉じるようにしよう
click イベントは通常、クリックされた場所に一番近い要素から遠い要素へ順番にイベントリスナーを実行していきます。
つまり、HTML DOM の一番外側である body
に設定されたイベントリスナーは、そこに伝播するまでの要素に設定されたイベントリスナーが伝播を止めない限り、どこがクリックされても必ず実行されます。
とりあえず、どこをクリックしても閉じるようにしよう
この機能を実装するには、document.body
にポップアップメニューを閉じるためのイベントリスナーを設定すれば良さそうです。
const handleToggleButtonClick = () => {
setIsShown(true)
document.body.addEventListener('click', e => {
setIsShown(false)
document.body.removeEventListener('click', documentClickHandler)
})
}
しかし、このコードには問題があります。
React DOM の onClick などのイベントリスナーは、HTML DOM のイベントリスナーよりも後で呼ばれることが確定しているため、トグルボタンをクリックした時に isShown
が document.body
に設定したイベントリスナーによって false
になった後、トグルボタンのイベントリスナーが true
にしてしまうのです。
また、isShown
を true
にした後、document.body
にイベントリスナーを再設定してしまいます。
これを解決するには、範囲外イベントリスナーを React DOM イベントリスナーよりも後に実行される場所に設定する必要があります。
それは、document
です。
React DOM は document
にクリックイベントリスナーを設定することで、どの React DOM 要素がクリックされたのかを判断しています。
同じ要素に複数のリスナーが設定されている場合、先に設定されたリスナーから順番に実行されます。
つまり、React DOM と同じ document
にイベントリスナーを設定することで、React DOM のイベントリスナーが処理された後に実行されるようにできるのです。
document.addEventListener('click', e => {
setIsShown(false)
document.removeEventListener('click', documentClickHandler)
})
ポップアップメニューの範囲がクリックされたのなら閉じないようにしよう
現状だとメニューを利用することができないので、ポップアップメニューの範囲内かどうかを判定し、範囲内なら閉じる処理を行わないようにしましょう。
これには、二つの実装方法が考えられます。
- ポップアップメニュー
.popup-menu
に、これ以上イベントが伝播しないようにするイベントリスナーを設定する。 -
document
に設定したイベントリスナーで、クリック対象の要素がポップアップメニュー要素もしくはその子要素であるかどうかを判定する。
簡単なのは前者ですが、React DOM のイベントリスナーで e.stopPropagation()
を実行しても、HTML DOM のイベント伝播は止まらないため、機能しません。
面倒ですが、後者の方法で実装します。
ポップアップメニュー要素にアクセスできるようにする
まず、ポップアップメニュー要素を取得するため、useRef
フックを使います。
const popupRef = useRef()
// ...
<div
className={`popup-menu ${isShown ? 'shown' : ''}`}
ref={popupRef}
>
...
</div>
これで、popupRef.current
でポップアップメニュー要素にアクセスできるようになりました。
クリックされた要素がポップアップメニュー要素以下であるかを判定する
popupRef.current
で取得できる Element
のスーパークラスである Node
には、引数に指定したノードがこのノードの子孫ノード(自分自身を含む)であるかどうかを判定する Node.contains メソッドがあります。
これを使うことで、クリックされた要素がポップアップメニュー要素以下であるかどうかを判定できます。
const documentClickHandler = e => {
if (popupRef.current.contains(e.target)) return
setIsShown(false)
document.body.removeEventListener('click', documentClickHandler)
}
ポップアップメニュー内に閉じるボタンを用意しよう
一番初めに紹介したカラーピッカーでは、カラーが選択されたらポップアップメニューを閉じるようにしています。
この機能を実装してみましょう。
閉じるときに、document
に設定したイベントリスナーを削除するのを忘れないようにしましょう!
const documentClickHandler = e => {
if (popupRef.current.contains(e.target)) return
setIsShown(false)
removeDocumentClickHandler()
}
const removeDocumentClickHandler = () => {
document.removeEventListener('click', documentClickHandler)
}
const handleCloseButtonClick = () => {
setIsShown(false)
removeDocumentClickHandler()
}
しかし、このコードは正しく動作しません。
removeEventListner
は第二引数が同じ参照を指すイベントリスナーを除去するという働きをする関数ですが、documentClickHandler
がReact がレンダリングするたびに新しく生成されてしまうため、イベントリスナーを除去できずに残り続けてしまいます。
これを解決するために、useRef を使った documentClickHandler
インスタンス変数を用意し、useEffect
で最初のレンダリング後に関数をセットするようにします。
const PopupMenu = () => {
// ...
const documentClickHandler = useRef()
useEffect(() => {
documentClickHandler.current = e => {
// ...
}
}, [])
const removeDocumentClickHandler = () => {
document.removeEventListener('click', documentClickHandler.current)
}
const handleToggleButtonClick = () => {
// ...
document.addEventListener('click', documentClickHandler.current)
}
正しく動作するようになりましたね!
🎉 完成! お疲れ様でした
React DOM は HTML DOM の要素にセットしているイベントリスナーはダミーで、実際は document
にセットしたイベントリスナーが処理していたとは、まったく気づきませんでした。
今回はメニューの内部も含めて一つのコンポーネントにしましたが、実際に運用する React プロジェクトでは機能だけを提供するコンポーネントを用意して、再利用できるようにすると良いのかなと思います。
最後に宣伝をさせてください!
今年の春ごろに、毎日やりたいことを習慣づけるアプリ「コツコツ忍者」を作りました!
毎日開発する!とか、毎週土日はランニングする!とかの目標を作り、記録することでモチベーションを保つアプリです!
Nuxt.js で作ったもので、何を使っているのかなどを書いた記事もありますので、ぜひそちらもご覧ください。
また、英語リスニング学習アプリを Flutter で開発中です。
ある程度までできたら、事前登録サイトを用意しようと思っていますので、Twitterをフォローしていただけると幸いです!
最後までご覧いただきありがとうございました!
参考文献
-
DOMイベントのキャプチャ/バブリングを整理する 〜 JSおくのほそ道 #017
https://qiita.com/hosomichi/items/49500fea5fdf43f59c58 -
Event propagation with React bubbling out of order
https://stackoverflow.com/questions/45212840/event-propagation-with-react-bubbling-out-of-order