Vue.jsのVuexでモーダルを作る

# このモーダルはHTMLで作ってください

削除ボタン押したときに**「削除してよろしいですか？」**ってモーダル[^1]をHTMLでデザインして作ってください…なんて言われたことはありませんか？

[^1]: 厳密にはダイアログですが、便宜上モーダルと言う名称で統一しています

正直、モーダル回りでは`window.alert()`や`window.confirm()`の方が勝手が良いことが多いのが作る側の印象です。
これらのWEB APIはブラウザ側で完全に同期として動いてくれるので効率が良いです（特にconfirm）。

```javascript
if (window.confirm('削除してよろしいですか？')){
  // OKを選択した時のロジック
}
```

これで削除確認のモーダルが書けます。
見た目もスマホのブラウザならシステムとの親和性も高い見た目で、ユーザーの誤認も防ぐことが出来ると思います。

しかしながらデザイナーサイドの声が強い場合、作らざるを得ないのですがモーダルをコンポーネント化すると、いたるところにモーダルコンポーネントが潜む形になり、管理コストが上がってしまう事が多いです。

さらに、DOM階層の深いところからコンポーネントとして表示すると、重なり合わせの条件が複雑になりz-indexでのコントロールが辛くなります。
最終的に、異常に大きい数値のz-indexが指定されるという…。

自分としては、あまりモーダルをコンポーネント化して、各所で読み込ませるような作りはお勧めできないのではないのかと思っています。

# DOM階層側からの考察

モーダル等を作る際に、DOM構造から最も良い場所を考えます。

```vuejs
<template>
  <div id="app" class="page">
    <section class="content">
      <router-view />
    </section>
    <Modal class="modal" />
  </div>
</template>

<style>
.content {
 position: fixed;
 top: 0;
 left: 0;
 right: 0;
 bottom: 0;
 overflow: auto;
}
.modal {
 position: fixed;
 top: 0;
 left: 0;
 right: 0;
 bottom: 0;
}
</style>
```

メインコンテンツとモーダルのDOMを完全に並列の位置関係にします。
cssで`body`要素のスクロールはしない様にし、`.content`でスクロールをするようにします。

こうすることで、`.modal`領域のスクロールイベントに`.content`領域が引っ張られるようなことはなくなります。
つまり、モーダル出現時にスクロールをロックするようなscriptを書く必要がなくなります。
（ただスマホの場合はURLバーが消えなかったりするようになるので、ケースバイケースで行います。）

また、`.content`よりも`.modal`が後に来ているので、表示順としては最終的に`.modal`側が優先されるようになります。

# 使い勝手の良いモーダルにするには

自分の見解としては`window.confirm()`などの使い方が一番好きなので、それに近い使用感を持った構造にします。

仕組みとしては以下のような仕立てを考えました。

- Vuexでモーダル全体の表示と文字列をコントロールする
- if分の代わりに`promise`で制御するようにする。

この計画に合わせて実装していきます。

# 実際に動く状態のコードに落とし込んでみる

Vuexで実装した場合mutationの種類は、

- モーダル開く
- モーダル閉じる→OKボタンなどの「ポジティブ要素」
- モーダル閉じる→キャンセルなどの「ネガティブ要素」

大体3種類くらは必要になります。

promiseでこの仕組みを組むには少し難しかったので、jQueryなどでおなじみの`Deferred`を使います。
jQueryと言うとjQuery殺すマンが押し寄せてきますので、MDNにあるヘルパー関数を利用します。

参考： https://developer.mozilla.org/en-US/docs/Mozilla/JavaScript_code_modules/Promise.jsm/Deferred

こんな感じのjsファイルを作ります。

```javascript
export default function Deferred() {
  if (typeof Promise != 'undefined' && Promise.defer) {
    return Promise.defer()
  } else if (typeof PromiseUtils != 'undefined' && PromiseUtils.defer) {
    return PromiseUtils.defer() 
  } else {
    this.resolve = null
    this.reject = null
    this.promise = new Promise(
      function(resolve, reject) {
        this.resolve = resolve
        this.reject = reject
      }.bind(this)
    )
    Object.freeze(this)
  }
}
```

store.jsはこんな感じ

```javascript
import Vue from 'vue'
import Vuex from 'vuex'
import Deferred from '@/deferred' // Deferredのヘルパー

Vue.use(Vuex)

let deferred = null

export default new Vuex.Store({
  state: {
    modal: {
      isOpen: false,
      head: '',
      body: '',
      data: {}
    }
  },
  mutations: {
    changeModalState(state, object) {
      state.modal = object
    },
    resetModalState(state) {
      state.modal = {
        isOpen: false,
        name: '',
        message: '',
        data: {}
      }
    }
  },
  actions: {
    modalOpen(context, options) {
      deferred = new Deferred()
      const option = {
        isOpen: true,
        head: '',
        body: '',
        data: {},
        ...options
      }
      context.commit('changeModalState', option)
      deferred.promise.then(
        () => { deferred = null },
        () => { deferred = null }
      )
      return deferred.promise
    },
    modalResolve(context, result = '') {
      deferred.resolve(result)
      context.commit('resetModalState')
    },
    modalReject(context, result = '') {
      deferred.reject(result)
      context.commit('resetModalState')
    }
  },
})
```

モーダル部分の構造はこうなります。（万が一のdataは使ってないです）

```vuejs
<template>
  <section v-if="modal.isOpen" class="modal">
    <div class="background">
      <div class="outline">
        <div class="head">{{ modal.head }}</div>
        <div class="body">{{ modal.body }}</div>
        <div class="button-area">
          <button @click="method.negativeAction()">CANCEL</button>
          <button @click="method.positiveAction()">OK</button>
        </div>
      </div>
    </div>
  </section>
</template>

<script>
import { defineComponent, computed } from '@vue/composition-api'
import store from '@/store/index'

const method = {
  positiveAction() {
    store.dispatch('modalResolve', 'OKボタンを選択')
  },
  negativeAction() {
    store.dispatch('modalReject', 'CANCELボタンを選択')
  }
}

export default defineComponent({
  setup() {
    // templateでは$store.state.modalでも呼べるけどcomputedを使って省略する
    const modal = computed(() => store.state.modal) 
    return {
      modal,
      method
    }
  }
})
</script>
```

モーダルを呼び出す場合は下記のようになります。

```javascript
  store.dispatch('modalOpen', {
    head: 'モーダルテスト',
    body: 'ボタンを選択後、選択結果をcosole.logで確認できます。'
  }).then((result) => {
    console.log(result)
  }).catch((result) => {
    console.log(result)
  })
```

`dispatch`に`Deferred`を使って`Promise`を受け取ることが出来ます。
ifほどでは無いですがOKとキャンセルの様な要素を受け取れるので、（そこまで）分岐も複雑にはなりません。
様々なモーダルパターンが必要な場合は、制御用のIDなどを設定し、IDもdispatchで引き渡して合致するものだけ表示するようにすれば良いかと思います。

個人的には、モーダルのボタンを押した後の処理が、`then`と`catch`にかけるので、モーダル表示した瞬間に削除APIが呼ばれてしまう…なんて事は起きにくいのかなぁと思って使っています。