Vueでawaitできるメッセージダイアログを考えてみる

## 考えるきっかけ

Vueでメッセージを表示するダイアログを実現する際、以下のようなコードを書くことがあります。

```vue
<script setup>
const logic = () => {
  // メッセージダイアログの表示
  visible.value = true
  message.value = "XXXしますか？"
}

const handleOk = () => {
  visible.value = false
  // OKの時の処理
}

const handleCancel = () => {
  visible.value = false
  // Cancelの時の処理
}
</script>

<template>
  <MessageDialog :visible="visible" :message="message" @ok="handleOk" @cancel="handleCancel" />
</template>

```

これで確かに実現はできるのですが、以下の点が気になります。
- もともとのロジックとOK, Cancelのロジックが別れてしまう。
- ページ毎に`MessageDialog` を配置する必要がある。

できるものなら以下のように記述したいです。

```vue
<script setup>
// ダイアログを利用
const dialog = useMessageDialog()

const logic = async () => {

  // メッセージダイアログの表示
  if (await dialog.confirm("XXXしますか？")) {
    // OKの時の処理
  } else {
    // Cancelの時の処理
    await dialog.alert("キャンセルしました。")
  }
}

</script>
```

この書き方が実現できるようにするためにメッセージ表示のダイアログを作ってみます。

:::note info
以下では 要点のみに絞って説明を記述します。
コード全体は最後にリンクを載せていますのでそちらを参照ください。
:::

## コンポーネントの階層

各画面毎にダイアログのコンポーネントを配置することは避けたいので
以下のようにルートの直下に配置します。

![image01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628749/00e88eb7-311f-5fef-73fd-791ea8ed9eea.png)


## ダイアログを表示するための仕組みを作る

### 各画面で利用する側の仕組みを作る

各画面のvueでは `confirm`, `cancel` を実行するだけで実現できるようにしたいため
この機能を持つ `MessageDialogController` というクラスを作成し provide/injectの仕組みで利用できるようにします。


![image02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628749/79555dfb-d77c-4337-f30b-8c8d7b5582e7.png)



``` ts:MessageDialogController.ts

class MessageDialogController {

  /**
   * メッセージの表示
   * @param message メッセージ
   * @returns
   */
  public alert(message: string): Promise<boolean> {
  }

  /**
   * 確認メッセージの表示
   * @param message メッセージ
   * @returns
   */
  public confirm(message: string): Promise<boolean> {
  }

}

export {
  MessageDialogController
}
```

provide/inject用のキー

```ts: MessageDialogKey.ts
/**
 * メッセージダイアログをinjectするためのキー
 */
const messageDialogKey: InjectionKey<MessageDialogController> = Symbol();
export { messageDialogKey };
```

最上位のコンポーネントからprovideする

``` vue:App.vue
<script setup lang="ts">
// import省略

// MessageDialogと各ページで利用できるように最上位でprovideする。
provide(messageDialogKey, new MessageDialogController());

</script>

<template>
  <!-- 省略 -->
  <MessageDialog />
</template>

```

injectして利用する関数を作成する。

``` ts:useMessageDialog.ts
// import省略

/**
 * メッセージダイアログを使用する。
 *
 * @returns メッセージダイアログのコントローラー
 */
function useMessageDialog() {
  const messageDialog = inject(messageDialogKey);
  if (messageDialog == undefined) {
    throw new Error(`messageDialog is not found`);
  }

  return messageDialog;
}

export { useMessageDialog };

```

利用する側のコード
```vue:Sample.vue
<script setup lang="ts">
// import省略

const dialog = useMessageDialog();

async function showDialog() {
  if (await dialog.confirm("ダイアログを表示")) {
    //OK時の処理
    await dialog.alert("OKが選択されました");
  } else {
    //Cancel時の処理
    await dialog.alert("Cancelが選択されました");
  }
}
</script>

<template>
  <main>
    sample

    <div>
      <button @click="showDialog">ダイアログ表示</button>
    </div>
  </main>
</template>
```

### ダイアログを表示する側の仕組みを作る

先の方法では `confirm`, `alert` を実行しても空振りしているだけなので実際にダイアログを表示できるようにしていきます。

これを実現するために `MessageDialogController` を `MessageDialog.vue` でも injectし `confirm`, `alert` が呼ばれたときのコールバックをコントローラーに登録します。

![image03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628749/3172ec63-4b4c-3ca6-0616-72fb22ddd865.png)

型の定義
```ts

/** ダイアログの実体を操作するためのハンドル(コールバック) */
type MessageDialogHandle = {
  show(param: MessageDialogParameter): void;
};

/**
 * メッセージダイアログのパラメーター
 */
type MessageDialogParameter = {
  title: string,
  message: string,
  buttons: string[],
  buttonHandler: MessageDialogButtonHandler,
}

/**
 * メッセージダイアログのボタンを押下したときのハンドラー
 */
type MessageDialogButtonHandler = (index: number) => void;
```

コントローラーには コールバックを登録する処理(`installHandle`)を追加し、`confirm`, `alert` でコールバックを実行する。

```ts
class MessageDialogController {

  // -------------------------------
  // コールバック登録処理を追加

  /**
   * ダイアログの実体(コールバック)
   */
  private handle?: MessageDialogHandle;

  /**
   * ハンドルをインストールします。
   * @param handle ダイアログのハンドル
   */
  public installHandle(handle: MessageDialogHandle): void {
    this.handle = handle;
  }

  // -------------------------------
  // コールバック(handle.show)を呼ぶようにする

  /**
   * ダイアログ表示(共通)
   * @param param パラメーター
   * @returns
   */
  private show(
    param: Omit<MessageDialogParameter, "buttonHandler">
  ): Promise<number> {
    return new Promise<number>((resolve, reject) => {
      if (this.handle == undefined) {
        throw new Error("handle is not installed.");
      }

      const parameter: MessageDialogParameter = {
        ...param,
        buttonHandler: resolve,
      };
      this.handle.show(parameter);
    });
  }

  /**
   * メッセージの表示
   * @param message メッセージ
   * @returns
   */
  public alert(message: string) {
    return this.show({
      title: "情報",
      message,
      buttons: ["OK"],
    }).then(() => true);// ボタン一つしかないので常にtrue
  }

  /**
   * 確認メッセージの表示
   * @param message メッセージ
   * @returns
   */
  public confirm(message: string) {
    return this.show({
      title: "確認",
      message,
      buttons: ["OK", "Cancel"],
    }).then((x) => x === 0); // 0番目のボタン(OK)が押されればtrue
  }
}

export { MessageDialogController };
```

ダイアログのコンポーネントで `onMounted` のタイミングで `installHandle` を実行し
コールバックの登録を行う。

show関数では ダイアログ表示時のパラメータを受け取るようにする。

ダイアログのOK, Cancelなどのボタンをクリックしたときは `handleButtonClick` 内で
`buttonHandler`を実行することで、各画面から表示する際に作成したPromiseをresolveするようにしている。

```vue: MessageDialog.vue
<script setup lang="ts">
//import省略

const messageDialog = useMessageDialog();

// -------------------------------
// 初期値の設定

const visible = ref(false);
const title = ref("");
const message = ref("");
const buttons = ref<string[]>([]);

let buttonHandler: MessageDialogButtonHandler = () => {
  //noop
};

onMounted(() => {
  // 各ページで利用できるように
  // コントローラーのハンドルにこのMessageDialog.vueを設定する。
  messageDialog.installHandle({
    show,
  });
});

//-------------------------------

/**
 * ダイアログの表示
 * @param parameter パラメータ
 */
function show(parameter: MessageDialogParameter) {
  visible.value = true;

  title.value = parameter.title;
  message.value = parameter.message;
  buttons.value = parameter.buttons;
  buttonHandler = parameter.buttonHandler;
}

/**
 * クリーンアップ(閉じるとき)
 */
function cleanup() {
  visible.value = false;

  title.value = "";
  message.value = "";
  buttons.value = [];
  buttonHandler = () => {
    //noop
  };
}

/**
 * ダイアログボタンクリック時
 * @param index 押されたボタンのインデックス
 */
function handleButtonClick(index: number) {
  buttonHandler(index);
  // ボタンクリック時に消す
  cleanup();
}
</script>

<template>
  <div class="messageDialog" v-if="visible">
    <div class="messageDialog-background"></div>
    <div class="messageDialog-content">
      <div class="messageDialog-contentHeader">{{ title }}</div>
      <div class="messageDialog-contentBody">{{ message }}</div>
      <div class="messageDialog-contentButtons">
        <template v-for="(button, index) of buttons">
          <button @click="handleButtonClick(index)">{{ button }}</button>
        </template>
      </div>
    </div>
  </div>
</template>
```

大規模なプロジェクトでの実用性はわからないですが
上記のように 最上位でコントローラーをprovideし、
利用する画面側でinject,
実際のコンポーネントでコールバックを登録
とすることで awaitできるダイアログが実現できるようになります。

ダイアログを表示するときはフォーカスが後ろに移動しないようにするためにinert属性と組み合わせておくとなお良いでしょう

inertについては[こちら](https://qiita.com/epsilonGtmyon/items/34f1e46dfa2b60f7eaf7)を参照してください。

## コード全体

以下に全体のコードと少し改良を加えたものを載せています。

[epsilonGtMyon/vue-awaitable-dialog](https://github.com/epsilonGtMyon/vue-awaitable-dialog)