[Vue.js]外側をクリックすると閉じるドロップダウンメニュー(複数の場合)



# はじめに
- メニューボタン押すと開く
- メニューボタンもう一度押すと閉じる
- メニューの外側押しても閉じる

この操作はこちら↓の記事を読んでもらえればできます。
[[Vue.js]外側をクリックすると閉じるドロップダウンメニュー](https://qiita.com/huigo/items/a888dcee8f46d62bb9e3)

ですが、複数ボタンがある場合、この処理だけだと操作性の問題が生じたため、いろいろ改善しました。それを紹介します。

注意 ： 本記事は上で紹介した記事の技術がわかった上でお話をします。

# [問題 1] メニューが閉じない場合がある
**複数のボタンがある時、メニューを一度開き、別のボタンを押すと先に開いていたメニューが閉じない。**
[デモ (JSFiddle)](https://jsfiddle.net/Chige12/k0to647q/44/)

以下の画像をご覧ください。
`Info 1` から順番にボタンを押していくと、`Info 2`を押した時に`Info 1`のメニューが閉じない。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180775/e21c5120-6587-481a-5fa4-f246055587b5.png)

### 構造
親コンポーネント↓ ： `<msg-comp>` を複数呼び出して並べる。

```vuejs
<div id="app">
  <div class="containter">
    <msg-comp
      v-for="msg in msgList"
      :msg="msg"
    ></msg-comp>
  </div>
</div>
```
`<msg-comp>`コンポーネント↓ ： ボタンを押すと `<info-menu>` が開く。

```vuejs
<div class="msg">
  <button v-on:click.stop="showInfo = !showInfo">Info {{msg}}</button>
  <info-menu 
      v-if="showInfo"
      v-on:close="showInfo = false">
  </info-menu>
</div>
```
`<info-menu>`コンポーネント↓ : クリックイベントが発生した際、クリックした要素がこのコンポーネント内の要素ではない場合に閉じる。

```vuejs
<div class="info">
    <div>AAA</div>
    <div>BBB</div>
    <div>CCC</div>
</div>
```

## 問題1 の原因
```vuejs
<!--  <msg-comp> コンポーネント内  -->
<button v-on:click.stop="showInfo = !showInfo">Info {{msg}}</button>
```
`.stop` modifierでバブリングの停止を行っているのが原因。
`Info 2` を押してメニューを開ける時に、自分自身のメニューを閉じるイベントが発火しないようになっているのはいいんだが、 `Info 1`のメニューを閉じるイベントも発火しない。

## 問題1 解決策
- `.stop` modifierをやめて自前で実装。
- クリックした要素が開けるボタンだった時に、閉じる処理を発火させないようにする。

[問題1 の解決策 デモ (JSFiddle)](https://jsfiddle.net/Chige12/mz4p09jy/20/)

まず、`<msg-comp>`コンポーネント内のbuttonから`.stop`を削除。
`ref="msgBtn"`でボタンのDOMを取得。取得したDOM要素を`<info-menu>`コンポーネントへpropsで投げる。

```vuejs
<!--  <msg-comp> コンポーネント内  -->
  <div class="msg">
    <button 
      ref="msgBtn" 
      v-on:click="showInfo = !showInfo"
    >
      Info {{msg}}
    </button>
    <info-menu 
      v-if="showInfo"
      v-on:close="showInfo = false"
      :msgBtn="$refs.msgBtn"
    >
    </info-menu>
  </div>
```

`<info-menu>`コンポーネントで、ボタンのDOM（msgBtn）を受け取る。
イベントリスナーの発火条件を追加。
　これで判定できる→ `!this.msgBtn.contains(e.target)`
　→ 意味：クリックしたDOMが、ボタンのDOM内にあればfalseになる

```js
Vue.component('info-menu', {
  mixins:[listener],
  template: '#info',
  props: {
    msgBtn: {
      type: HTMLButtonElement
    }
  },
  created:function(){
    this.listen(window, 'click', function(e){
      if (!this.$el.contains(e.target) && !this.msgBtn.contains(e.target)){
        this.$emit('close');
      }
    }.bind(this));
  }
}
```

注意点：propsにtype指定する際、msgBtnのDOMの型(type)を`HTMLButtonElement`としている。a要素とかを使うなら変わる。

以上です。
`Info 1`を押して開いた状態で`Info 2`を押すと`Info 1`のmenuが閉じる！　解決できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180775/adf55b50-9e21-3477-7773-bb23435978b2.png)


# [問題 2] Tab移動でInfo内部から抜けた時に勝手に閉じて欲しい

これは問題というより、そういう設計にしたかったという願望です。
Infoのサイズが大きい場合にはUX的にもそうしたいですよね。

## 問題2 解決策

[問題2 の解決策 デモ (JSFiddle)](https://jsfiddle.net/Chige12/tjm1pw5e/3/)

これはめちゃ簡単。新しくイベントリスナー追加するだけ。

```js:
created:function(){
  // ~~~
  this.listen(window, 'keyup', function(e){
    if (!this.$el.contains(e.target) && e.key === 'Tab'){
      this.$emit('close');
    }
  }.bind(this));
}
```

注意点としては`keydown`ではなく`keyup`にするところ。
`keydown`にしてしまうと、まだbuttonにいて、Infoに移ろうとする時に発火してしまい、すぐ閉じてしまう。
`keyup`にしておくと、Tabの`keydown`時にInfoに移るので、`keyup`が発火する時にはInfoにいる。よって閉じない。

# [おまけ] Nuxt.jsでやりたい！

Nuxt.jsでやる際に参考になりそうな話をします。

## mixinをNuxtでやる
Nuxt.jsの場合、mixinは `plugins`フォルダに`mixin-common-methods.js` 的なものを作り、
`nuxt-config.js` 内に `plugins: ['@/plugins/mixin-common-methods']` とすれば良い。
各コンポーネントからわざわざ呼び出しとかもなし、これだけで使える。

参考 : [Nuxt.jsで異なるコンポーネントから共通で利用できる関数を定義する（mixin編）](https://qiita.com/itouuuuuuuuu/items/67afb038e2af5956148e)



```js:plugins/mixin-common-methods.js
import Vue from 'vue'

Vue.mixin({
  destroyed() {
    if (this._eventRemovers) {
      this._eventRemovers.forEach(function(eventRemover) {
        eventRemover.remove()
      })
    }
  },
  methods: {
    listen(target, eventType, callback) {
      if (!this._eventRemovers) {
        this._eventRemovers = []
      }
      target.addEventListener(eventType, callback)
      this._eventRemovers.push({
        remove() {
          target.removeEventListener(eventType, callback)
        }
      })
    }
  }
})
```

```js:~/components/info-menu.vue
export default {
  props: {
    msgBtn: {
      type: HTMLButtonElement
    }
  },
  //~~~
  created:function(){
    this.listen(window, 'click', function(e){
      if (!this.$el.contains(e.target) && !this.msgBtn.contains(e.target)){
        this.$emit('close');
      }
    }.bind(this));
  },
  //~~~
}
```