57
50

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【React】コンポーネントの外がクリックされたら閉じるポップアップメニューを実装しよう

Last updated at Posted at 2019-11-30

ポップアップのカラーピッカーを React で作っているときに、いくつかの問題に直面したのでまとめておこうと思います。
作ったものは下のGIFのようなカラーピッカーです。
Image from Gyazo

この記事では、カラーピッカー機能を取り除いた、下のポップアップメニューの作り方を紹介します。
完成品の 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 のイベントリスナーよりも後で呼ばれることが確定しているため、トグルボタンをクリックした時に isShowndocument.body に設定したイベントリスナーによって false になった後、トグルボタンのイベントリスナーが true にしてしまうのです。
また、isShowntrue にした後、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をフォローしていただけると幸いです!

最後までご覧いただきありがとうございました!

参考文献

57
50
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
57
50

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?