[Vue ２系]自前でモーダルを実装したTips

# [Vue ２系]自前でモーダルを実装したTips

## 完成系だけ見れたらいい:weary:という人へ

Nuxt.js、TypeScriptを利用しています。

```json:package.json
"nuxt": "2.15.6"
```

長くなるので１ファイルを３分割してます。

```vue:HTML部分
<template>
  <div v-if="isOpen" class="modal-container">
    <portal to="modal">
      <div ref="modal" class="overlay" @click.self="closeModal">
        <div :class="['modal-box', { full: full !== undefined || full !== false }]">
          <span class="close-btn-container">
            <icon-button name="cross" color="#000000" :on-click="closeModal" />
          </span>
          <p class="title">{{ title }}</p>
          <div class="content-box">
            <!-- ここにメインコンテンツ -->
            <slot name="main" />
          </div>
          <div class="btn-box">
            <!-- ここにボタンコンテンツ -->
            <slot name="btns" />
          </div>
        </div>
      </div>
    </portal>
  </div>
</template>
```

```vue:Script部分
<script lang="ts">
import Vue from 'vue'
import { ThisTypedComponentOptionsWithRecordProps } from 'vue/types/options'
import { Data, Methods, Computed, Props } from '~/components/molecules/Modal/type'
import IconButton from '~/components/atoms/IconButton/index.vue'

export default Vue.extend({
  components: { IconButton },
  props: {
    isOpen: {},
    onClose: {},
    title: {},
  },
  data: () => {
    return {
      timeoutId: null,
    }
  },
  methods: {
    closeModal() {
      ;(this.$refs.modal as Element).classList.add('delete')
      this.timeoutId = setTimeout(() => {
        this.onClose()
      }, 900)
    },
    clear() {
      if (this.timeoutId) {
        clearTimeout(this.timeoutId)
      }
    },
  },
  destroyed() {
    this.clear()
  },
  watch: {
    // スクロール許可の切り替え
    isOpen() {
      if (this.isOpen) {
        document.documentElement.style.overflow = 'hidden'
      } else {
        document.documentElement.style.overflow = 'auto'
      }
    },
  },
} as ThisTypedComponentOptionsWithRecordProps<Vue, Data, Methods, Computed, Props>)
</script>
```

```vue:Style部分(細部は省略)
<style lang="scss" scoped>
.overlay {
  background: transparent;
  backdrop-filter: blur(10px);
  --webkit-backdrop-filter: blur(10px);
  background-color: rgba(0, 0, 0, 0.15);
  animation: fadeInBg 0.5s;
  .modal-box {
    transform: translateY(80vh);
    animation: fadeInModal 0.5s;
    animation-fill-mode: forwards;
    -webkit-animation-fill-mode: forwards;
  }
  &.delete {
    animation: fadeOutBg 0.5s;
    animation-delay: 0.4s;
    animation-fill-mode: forwards;
    -webkit-animation-fill-mode: forwards;
    .modal-box {
      animation: fadeOutModal 0.6s;
      animation-delay: 0.2s;
      animation-fill-mode: both;
      -webkit-animation-fill-mode: both;
    }
  }
}
</style>

<style>
@keyframes fadeInBg {
  0% {
    /* Note: currently only Safari supports backdrop-filter */
    backdrop-filter: blur(0px);
    --webkit-backdrop-filter: blur(0px);
    background-color: rgba(0, 0, 0, 0);
  }
  100% {
    /* Note: currently only Safari supports backdrop-filter */
    backdrop-filter: blur(10px);
    --webkit-backdrop-filter: blur(10px);
    background-color: rgba(0, 0, 0, 0.15);
  }
}
@keyframes fadeOutBg {
  0% {
    /* Note: currently only Safari supports backdrop-filter */
    backdrop-filter: blur(10px);
    --webkit-backdrop-filter: blur(10px);
    background-color: rgba(0, 0, 0, 0.15);
  }
  100% {
    /* Note: currently only Safari supports backdrop-filter */
    backdrop-filter: blur(0px);
    --webkit-backdrop-filter: blur(0px);
    background-color: rgba(0, 0, 0, 0);
  }
}
@keyframes fadeInModal {
  100% {
    transform: translateY(0);
  }
}
@keyframes fadeOutModal {
  0% {
    transform: translateY(0);
  }
  100% {
    transform: translateY(100vh);
  }
}
</style>

```
以上がモーダルのコンポーネントの中身です。完成イメージはこんな感じです。
![ezgif-4-8892ae0a379c.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405522/75406570-bb99-77bb-09e2-cefbe1f88e0c.gif)

modalを呼び出すときは以下のように呼び出します。

```vue:page
<template>
  <div>
    <div style="max-width: 500px; margin: auto; margin-top: 40px">
      <button @click="() => (show = true)">click</button>
    </div>
    <modal title="Title" :is-open="show" :on-close="() => (show = false)">
      <template v-slot:main>
        <p>Here is modal description.</p>
      </template>
      <template v-slot:btns>
        <div style="margin-bottom: 16px">
          <button label="click" @click="() => (show = true)">click</button>
        </div>
        <button label="click" @click="() => (show = true)">click</button>
      </template>
    </modal>
  </div>
</template>

<script lang="ts">
import Vue from 'vue'
import Modal from '~/components/molecules/Modal/index.vue'

export default Vue.extend({
  components: { Modal },
  data: () => {
    return {
      show: false,
    }
  },
})
</script>
```

# モーダルが満たすべき条件
1. 重なり順は必ず一番上にくる
1. モーダルが開いている間は後ろがスクロールできない
1. モーダルの表示 / 非表示にアニメーションをつけてUXを向上させる（大事！）

## 1. 重なり順は必ず一番上にくる

vue-portalというライブラリを用います。Weekly Downloadsは437,265でstar数3.3kなんで人気はありそうですね。

参考URL：
https://github.com/LinusBorg/portal-vue
https://www.npmjs.com/package/portal-vue

```json:package.json
"portal-vue": "^2.1.7"
```

vue-portalは、vue.jsに記述したHTMLの要素を記述した場所に表示させるのではなく全く別の場所に表示させることができるプラグインです。ドキュメント内の任意の場所で、記述したコンポーネントの外にDOMをレンダリングすることができます。

これによって、レンダリングされたモーダルを、常にDOMツリーの下に描画させることができます。

Vue3だったら純正で`teleport`という機能があるみたいですね、、もうちょっとしたらこっちに移行ですね

実際の利用法は以下になります。

```js:plugins/portal-vue.ts
import Vue from 'vue'
import PortalVue from 'portal-vue'

Vue.use(PortalVue)
```

```js:nuxt.config.js
plugins: [{ src: '~/plugins/portal-vue', ssr: false }],
```

```vue:modalのコンポーネント
<template>
  <div v-if="isOpen" class="modal-container">
    <portal to="modal">
      <!-- modalの中身 -->
    </portal>
  </div>
</template>
```

```vue:rootファイル
<template>
  <div>
    <Nuxt />
    <portal-target name="modal" />
  </div>
</template>
```
これで、`portal to="modal"`の中身はDOM上では同じnameを持つ`<portal-target />`の場所で描画されます。

## 2. モーダルが開いている間は後ろがスクロールできない

モーダルが開いているときに後ろがスクロールできてしまうモーダルがありますが、ユーザーからしたらそれは意図しない挙動です。そのため、後ろの部分（つまり、メインコンテンツ部分）はスクロールできないようにする必要があります。


```vue
<script lang="ts">
export default {
  props: {
    isOpen: {}
  },
  watch: {
    // スクロール許可の切り替え
    isOpen() {
      if (this.isOpen) {
        document.documentElement.style.overflow = 'hidden'
      } else {
        document.documentElement.style.overflow = 'auto'
      }
    }
  }
}
</script>
```

`isOpen`はpropsで渡ってくる変数で、モーダルの表示 / 非表示を切り替える真偽値になります。この変数をウォッチャーで監視して、変数が変わったかどうかをチェックします。`document.documentElement`は`html`タグを取得します。`html`タグに対して、`overflow: hidden`を指定すると、画面のスクロールができなくなります。`overflow: auto`を指定すると、画面はスクロールできるようになります。

つまり、モーダルが表示されるタイミングで、`html`タグに対してCSSを追加し画面のスクロールを禁止し、モーダルを閉じる際に`html`タグのCSSをさらに変更しスクロールできる状態に戻します。

## 3. モーダルの表示 / 非表示にアニメーションをつけてUXを向上させる（大事！）

ここはもうどこまでこだわるかです。シンプルに実装したいなら`transition`タグを利用してアニメーションを実装した方がいいです。今回は背景部分とモーダルの部分のアニメーションを完全に別々に動かしたいという思いで実装しました。

```vue
<template>
  <div v-if="isOpen">
    <portal to="modal">
      <div ref="modal" class="overlay" @click.self="closeModal">
        <!-- モーダル部分 -->
      </div>
    </portal>
  </div>
</template>

<script lang="ts">
  // 抜粋
  props: {
    onClose: {}
  },
  data: () => {
    return {
      timeoutId: null,
    }
  },
  methods: {
    closeModal() {
      ;(this.$refs.modal as Element).classList.add('delete')
      this.timeoutId = setTimeout(() => {
        this.onClose()
      }, 900)
    }
  },
</script>
```
実際にモーダルを非表示にするのはpropsで親から渡ってきている`onClose`メソッドとなります。`onClose`メソッドを実行してしまうとモーダルが即座に非表示になるので、モーダルを閉じるアニメーションが終了してからDOMから消します。`setTimeout`で指定している900というのはアニメーションが終わるのにかかる時間です。（マジックナンバー、、最後には修正しないと:fearful:）
SCSSのアニメーションの部分は一番上のソースをご参考ください。

## おまけ：Jestでテストしたときのつまづきポイント
Jestで作成したモーダルのコンポーネントのテストコードを書いているときに詰まったところです。

### [Vue warn]: Unknown custom element: <portal> - did you register the component correctly?

`<portal>`なんて要素は知らないよ、と言われてしまいました。なんででしょう。とりあえずどういうDOMが生成されるか確認してみます。

```ts:modal.test.ts
wrapper = shallowMount(Modal, options)
console.log(wrapper.html())
```

```bash:bash
<div class="modal-container">
  <portal to="modal">
    ~~省略~~
  </portal>
</div>
```

おぉー...portalと出てしまってますね。今回のケースではあくまでもコンポーネントのみをレンダリングしているのでこうなっているんですね。テストが通るようにスタブを作成します。

```vue:~/tests/mocks/Portal.ts
<template>
  <div><slot /></div>
</template>
```

```ts:modal.test.ts
import Portal from '~/tests/mocks/Portal.vue'

const options = {
  propsData: {} as Props,
  stubs: {
    Portal: Portal,
  },
}

...

wrapper = shallowMount(Modal, options)
console.log(wrapper.html())
```

```bash:bash
<div class="modal-container">
  <div to="modal">
    ~~省略~~
  </div>
</div>
```
portalがdivに変わってますね、これでエラーがなくなります。