📃

ユーザーの入力を待つ Vue.js ダイアログコンポーネント

2024/02/15に公開

はじめに

カスタマイズしたダイアログを使うことは多いと思うのですが、confirm()prompt()のようにユーザーの入力を待つコンポーネントを作ろうと思うとちょっと工夫が必要になります。
以前作った仕組みを再度使おうと思った時に思い出すのに時間がかかったので、記事としてまとめておこうと思います。

プリプロセッサ

コード内では、以下のプリプロセッサを使っています。

  • pug
  • TypeScript
  • sass

コンポーネントのコード

コンポーネントの機能実現のため、ダイアログ本体の他にコンポーザブルのファイルを作成します。

dialog-type.ts
// Type
export const DialogType = {
  Alert: 'Alert',
  Confirm: 'Confirm'
} as const
export type DialogType = (typeof DialogType)[keyof typeof DialogType]

// Property
interface BaseDialogProperty {
  type: DialogType
}
export interface AlertDialogProperty extends BaseDialogProperty {
  type: 'Alert'
  message: string
}
export interface ConfirmDialogProperty extends BaseDialogProperty {
  type: 'Confirm'
  message: string
}
export type DialogProperty = AlertDialogProperty | ConfirmDialogProperty

// Result
export type AlertDialogResult = void
export type ConfirmDialogResult = boolean
export type DialogResult<T> = T extends AlertDialogProperty
  ? AlertDialogResult
  : T extends ConfirmDialogProperty
    ? ConfirmDialogResult
    : void

コンポーネントで使用する型を用意しておきます。
以降のファイルで使用するのですが、ダイアログのタイププロパティ戻り値を定義しています。

タイプ

unionを使用してダイアログのタイプを定義しています。

プロパティ

ダイアログのタイトルやメッセージなど、タイプに応じてプロパティが変わってくるはずなので、型として定義しています。
BaseDialogPropertytypeを持つ型である事を定義し、それをextendsする事で各プロパティ型にもtypeを実装する事を強制しています。

戻り値

条件型を使って、特定の型に対する戻り値の型を定義しています。
AlertDialogPropertyがジェネリック型に渡されれば、返ってくる値はAlertDialogResultとなります。

コンポーザブル

dialog-data.ts
import { ref, type InjectionKey, readonly } from 'vue'
import type { DialogProperty, DialogResult } from './dialog-type'

type DialogResolver = (
  value: DialogResult<DialogProperty> | PromiseLike<DialogResult<DialogProperty>>
) => void

export class DialogData {
  public static readonly Key: InjectionKey<DialogData> = Symbol('DialogData')

  private readonly _shown = ref(false)
  private readonly _property = ref<DialogProperty>()
  private resolver: DialogResolver | null = null

  public readonly dialogShown = readonly(this._shown)
  public readonly dialogProperty = readonly(this._property)

  public readonly showDialog = async <T extends DialogProperty>(
    props: T
  ): Promise<DialogResult<T>> => {
    this._shown.value = true
    this._property.value = props
    return new Promise<DialogResult<T>>((res) => {
      this.resolver = res as DialogResolver
    })
  }

  public readonly hideDialog = <T extends DialogProperty>(result?: DialogResult<T>) => {
    this._shown.value = false
    if (this.resolver) this.resolver(result)
  }
}

主にダイアログの状態管理を司るコンポーザブルです。
前項で定義した型は主にここで使われており、これによって利用時に型を使った推論ができるようになります。

ポイントは、showDialog内で作成したPromiseを完了させるための関数をresolverとして外部化し、後から呼んでいる事ですね。
これによってhideDialogが呼ばれるまでawaitし続けるという仕組みができます。

ここでのコンポーザブルの書き方は以前記事にしましたので、興味のある方はご覧ください。

https://zenn.dev/soumi/articles/06fb206aafacdc

ダイアログ

modal-dialog.vue
<script setup lang="ts">
import { inject } from 'vue'
import { DialogData } from './dialog-data'
import { DialogType } from './dialog-type'

const { dialogShown, dialogProperty, hideDialog } = inject(DialogData.Key) as DialogData

const hide = () => {
  if (dialogProperty.value?.type === DialogType.Alert) {
    hideDialog()
  } else if (dialogProperty.value?.type === DialogType.Confirm) {
    hideDialog(true)
  }
}
</script>

<template lang="pug">
transition
  .modal-dialog(
    v-if="dialogShown"
  )
    .popup(
      @click="hide"
    )
      template(
        v-if="dialogProperty?.type === DialogType.Alert"
      ) Alert
      template(
        v-else-if="dialogProperty?.type === DialogType.Confirm"
      ) Confirm
      .msg {{ dialogProperty?.message }}
</template>

<style lang="sass" scoped>
.v-enter-active,
.v-leave-active
  transition: opacity 0.25s ease
  .popup
    transition: transform 0.25s ease

.v-enter-from,
.v-leave-to
  opacity: 0
  .popup
    transform: translateY(-20px)

.modal-dialog
  position: fixed
  top: 0
  left: 0
  right: 0
  bottom: 0
  z-index: 1000
  display: flex
  align-items: center
  justify-content: center
  background-color: rgba(0, 0, 0, 0.3)
  .popup
    background-color: white
    padding: 30px
    border-radius: 10px
    box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2)
</style>

実際にダイアログを表示するコンポーネントになります。
コンポーザブルから受け取った値に応じて中身を書き換えているだけですが、DialogTypeが増えて処理が複雑になってくるとポップアップの中身も色々なパターンが出てきます。
今回はシンプルに済ますためにベタ書きしていますが、本来ならコンポーネント化して処理を分けたほうがいいと思います。
また、同様にシンプルに済ますため、今回はポップアップ内に特に選択肢を設けず、ポップアップをクリックしたら固定の値を返して閉じるようにしています。

表示/非表示のアニメーションはvueの組み込みコンポーネントであるtransitionを使って実装します。
アニメーションのクラスをネストさせて子要素には違う動きをさせているのがミソです。
(フェードイン/アウトしつつ、ポップアップは上下の移動をする)

使い方

app.vue
<script setup lang="ts">
import { provide } from 'vue'
import ModalDialog from './components/core/dialog/modal-dialog.vue'
import { DialogData } from './components/core/dialog/dialog-data'
import { DialogType } from './components/core/dialog/dialog-type'

const data = new DialogData()
provide(DialogData.Key, data)

const openAlert = async () => {
  const res = await data.showDialog({
    type: DialogType.Alert,
    message: 'Alert Dialog'
  })
  console.log(res)
}

const openConfirm = async () => {
  const res = await data.showDialog({
    type: DialogType.Confirm,
    message: 'Confirm Dialog'
  })
  console.log(res)
}
</script>

<template lang="pug">
.app
  button(
    @click="openAlert"
  ) Alert
  button(
    @click="openConfirm"
  ) Confirm
  ModalDialog
</template>

modal-dialog.vueの中でinjectしているので、上位階層でprovideが必要です。
今回はapp.vueの中に全て定義しているので恩恵はあまり感じませんが、ここで定義しておけば下階層ではinjectするだけで使用できるようになります。

各ボタンを押して実行すると、以下のことが確認できるかと思います。

  • ユーザー入力が終わるまでawaitが待ってくれる
  • 戻り値の型が推論され、エディタが適切な補完を行なってくれる

おわりに

記事内ではシンプルなダイアログの実装しかしていませんが、中身は普通のコンポーネントですので、ダイアログ内でajaxするような複雑なものも実装可能です。
将来の自分含め、困っている誰かの役に立てば幸いです。

Discussion