vue3でvue-confirm-dialogが使えなかったので確認画面を使えるようにしてみた

vue3では[Teleport](https://v3.ja.vuejs.org/guide/teleport.html)を使ったモーダルを作成できます。  
vue2では[vue-confirm-dialog](https://github.com/aslanon/vue-confirm-dialog)の[API](https://github.com/aslanon/vue-confirm-dialog#api)機能を使ってモーダルを作成していました（関数内からモーダルを呼べて便利）。  
ですが、vue3に対応していなかったので[API](https://github.com/aslanon/vue-confirm-dialog#api)機能を使いたかったので一部機能を自作してみました。

## ディレクトリ構成

`ディレクトリ内`  
※ 使用していないものは省略しています
```
├─ public/
│   └── index.html
└─ src/
    ├── components/
    │        └ ConfirmDialog.vue
    ├── mixins/
    │        └ index.js
    │
    ├── App.vue
    └── main.js
```

### 確認画面の動作の流れ

1. $confirm関数を実行
2. index.htmlの`<div id="mount-point"></div>`に`ConfirmDialog.vue`をマウント
3. モーダルの`ok`または`cancel`のコマンドを押すと渡されたコマンドを実行
4. 表示中のモーダルをアンマウント

## 

```diff:public/index.html
<!DOCTYPE html>
<html lang="">
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
  <link rel="icon" href="<%= BASE_URL %>favicon.ico">
  <title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
  <noscript>
      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
  <div id="app"></div>
+  <div id="mount-point"></div>
  <!-- built files will be auto injected -->
</body>
</html>
```

### main.jsにmixinを追加

```diff_javascript:src/main.js
import { createApp } from 'vue'
import App from './App.vue'

import Mixin from '@/mixins'

- createApp(App).mount('#app')
+ createApp(App).mixin(Mixin).mount('#app')
```

### Mixinsに$confirm関数を作成

デフォルトで`okAction`と`cancelAction`には`Boolean`を返すように設定してます。  
[createApp()](https://ja.vuejs.org/api/application.html#createapp)の第一引数にコンポーネント、第二引数にpropsを渡して`#mount-point`にマウントしています。

```javascript:src/mixins/index.js
import * as Vue from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog'

export default {
  methods: {
    async $confirm(payload) {
      return new Promise((resolve) => {
        const defaultOption = {
          okAction: () => resolve(true),
          cancelAction: () => resolve(false),
        }
        const Option = Object.assign(defaultOption, payload)
        Vue.createApp(ConfirmDialog, Option).mount('#mount-point')

      }).catch((err) => {
        throw err;
      })
    },
  }
}
```


### プロパティ

| Attribute    |   Type   |          Default           | Description              |
| :----------- | :------: | :------------------------: | :----------------------- |
| message      |  String  |        `メッセージ`        | メッセージ表示内容       |
| title        |  String  |         `タイトル`         | タイトルの表示内容       |
| button       |  Object  | `{ yes: 'Yes', no: 'No' }` | OKの時とNGの時の表示内容 |
| okAction     | Function |          `()=>{}`          | OK時の処理               |
| cancelAction | Function |          `()=>{}`          | キャンセル時の処理       |

ボタンを押して、処理が終わった後に`this.$.appContext.app.unmount()`を実行すると[アンマウント](https://ja.vuejs.org/api/application.html#app-unmount)することができます。  
参考サイト：[How to destroy/unmount vue.js 3 components?](https://stackoverflow.com/questions/65163775/how-to-destroy-unmount-vue-js-3-components)

> [vue-confirm-dialog](https://github.com/aslanon/vue-confirm-dialog/blob/master/src/vue-confirm-dialog.vue)からhtmlとcssを拝借してます。  
```vue:src/omponents/ConfirmDialog.vue
<template>
  <div id="vueConfirm" class="vc-overlay">
    <div class="vc-container">
      <span class="vc-text-grid">
        <h4 class="vc-title">{{ message }}</h4>
        <p class="vc-text">{{ title }}</p>
      </span>
      <div class="vc-btn-grid">
        <button class="vc-btn left" @click="ok" v-if="button.yes">{{ button.yes }}</button>
        <button class="vc-btn" @click="cancel" v-if="button.no">{{ button.no }}</button>
      </div>
    </div>
  </div>
</template>
<script>
export default {
  name: 'ConfirmDialog',
  props: {
    message: {
      type: String,
      required: true,
      default: 'メッセージ',
    },
    title: {
      type: String,
      required: true,
      default: 'タイトル',
    },
    button: {
      type: Object,
      required: true,
      default: function () {
        return { yes: 'Yes', no: 'No' }
      },
    },
    okAction: {
      type: Function,
      required: true,
      default: () => {},
    },
    cancelAction: {
      type: Function,
      required: true,
      default: () => {},
    },
  },
  methods: {
    ok() {
      this.okAction()
      this.$.appContext.app.unmount()
    },
    cancel() {
      this.cancelAction()
      this.$.appContext.app.unmount()
    },
  },
}
</script>
<style>
:root {
  --title-color: #000;
  --message-color: #000;
  --overlay-background-color: #0000004a;
  --container-box-shadow: #0000004a 0px 3px 8px 0px;
  --base-background-color: #fff;
  --button-color: #4083ff;
  --button-background-color: #fff;
  --button-border-color: #e0e0e0;
  --button-background-color-disabled: #f5f5f5;
  --button-background-color-hover: #f5f5f5;
  --button-box-shadow-active: inset 0 2px 0px 0px #00000014;
  --input-background-color: #ebebeb;
  --input-background-color-hover: #dfdfdf;
  --font-size-m: 16px;
  --font-size-s: 14px;
  --font-weight-black: 900;
  --font-weight-bold: 700;
  --font-weight-medium: 500;
  --font-weight-normal: 400;
  --font-weight-light: 300;
}

.vc-overlay *,
.vc-overlay *:before,
.vc-overlay *:after {
  -webkit-box-sizing: border-box;
  box-sizing: border-box;
  text-decoration: none;
  -webkit-touch-callout: none;
  -moz-osx-font-smoothing: grayscale;
  margin: 0;
  padding: 0;
}

.vc-title {
  color: var(--title-color);
  padding: 0 1rem;
  width: 100%;
  font-weight: var(--font-weight-black);
  text-align: center;
  font-size: var(--font-size-m);
  line-height: initial;
  margin-bottom: 5px;
}
.vc-text {
  color: var(--message-color);
  padding: 0 1rem;
  width: 100%;
  font-weight: var(--font-weight-medium);
  text-align: center;
  font-size: var(--font-size-s);
  line-height: initial;
}
.vc-overlay {
  background-color: var(--overlay-background-color);
  width: 100%;
  height: 100%;
  transition: all 0.1s ease-in;
  left: 0;
  top: 0;
  z-index: 999999999999;
  position: fixed;
  display: flex;
  justify-content: center;
  align-items: center;
  align-content: baseline;
}
.vc-container {
  background-color: var(--base-background-color);
  border-radius: 1rem;
  width: 286px;
  height: auto;
  display: grid;
  grid-template-rows: 1fr max-content;
  box-shadow: var(--container-box-shadow);
}
.vc-text-grid {
  padding: 1rem;
}
.vc-btn-grid {
  width: 100%;
  display: grid;
  grid-template-columns: 1fr 1fr;
  border-radius: 0 0 1rem 1rem;
  overflow: hidden;
}
.vc-btn-grid.isMono {
  grid-template-columns: 1fr;
}
.vc-btn {
  border-radius: 0 0 1rem 0;
  color: var(--button-color);
  background-color: var(--button-background-color);
  border: 0;
  font-size: 1rem;
  border-top: 1px solid var(--button-border-color);
  cursor: pointer;
  font-weight: var(--font-weight-bold);
  outline: none;
  min-height: 50px;
}
.vc-btn:hover {
  background-color: var(--button-background-color-hover);
}
.vc-btn:disabled {
  background-color: var(--button-background-color-disabled);
}
.vc-btn:active {
  box-shadow: var(--button-box-shadow-active);
}
.vc-btn.left {
  border-radius: 0;
  border-right: 1px solid var(--button-border-color);
}
.vc-input[type='password'] {
  width: 100%;
  outline: none;
  border-radius: 8px;
  height: 35px;
  border: 0;
  margin: 5px 0;
  background-color: var(--input-background-color);
  padding: 0 0.5rem;
  font-size: var(--font-size-m);
  transition: 0.21s ease;
}
.vc-input[type='password']:hover,
.vc-input[type='password']:focus {
  background-color: var(--input-background-color-hover);
}

/**
* Transition
*/
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.21s;
}

.fade-enter,
.fade-leave-to {
  opacity: 0;
}

.zoom-enter-active,
.zoom-leave-active {
  animation-duration: 0.21s;
  animation-fill-mode: both;
  animation-name: zoom;
}

.zoom-leave-active {
  animation-direction: reverse;
}

@keyframes zoom {
  from {
    opacity: 0;
    transform: scale3d(1.1, 1.1, 1.1);
  }

  100% {
    opacity: 1;
    transform: scale3d(1, 1, 1);
  }
}
</style>

```

### 確認画面から削除する処理をApp.vueに記述

```vue:App.vue
<template>
  <table>
    <tr>
      <th>タイトル</th>
      <th>操作</th>
    </tr>
    <tr v-for="(item, index) in this.data" :key="index">
      <td>{{ item.name }}</td>
      <td><button @click="DelData(index)">削除</button></td>
    </tr>
  </table>
</template>

<script>
export default {
  name: 'App',
  data() {
    return {
      data: [],
    }
  },
  mounted() {
    for (let i = 0; 30 > this.data.length; i++) {
      this.data.push({
        name: 'test' + i,
      })
    }
  },
  methods: {
    async DelData(index) {
      this.$confirm({
        message: 'メッセージが入ります：' + index,
        title: 'タイトルが入ります：' + index,
        button: {
          yes: '削除する',
          no: 'キャンセル',
        },
        okAction: () => {
          // データ削除
          this.data.splice(index, 1)
          console.log('ok')
        },
        cancelAction: () => {
          console.log('ng')
        },
      })
    },
  },
}
</script>

<style>
table {
  border-collapse: collapse;
  margin: 0 auto;
}
td,
th {
  padding: 10px;
}
th {
  color: #fff;
  background: #005ab3;
}
table tr:nth-child(odd) {
  background: #e6f2ff;
}
</style>
```
## 実際の動き
![CPT2301201913-1018x1035.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3112590/32427d83-a77a-37d0-2cab-5ec9382fb366.gif)

trueの時しか処理しない場合はthen()関数でブール値を判別できます。
```javascript
async hoge() {
  await this.$confirm().then((value) => {
    if (value) console.log('YESが押されました！')
  })
}
```

## おわり

vue3系で便利になったこともあれば、プラグインが対応しきっていないことがあり思わぬところで時間を使ってしまいます。
vue-confirm-dialogがvue3に対応されたら、すぐ使うんですけどね・・・

[vue3-confirm-dialog](https://github.com/rowathy/vue3-confirm-dialog)ができたみたいです