ユーザーの入力を待つ Vue.js ダイアログコンポーネント
はじめに
カスタマイズしたダイアログを使うことは多いと思うのですが、confirm()
やprompt()
のようにユーザーの入力を待つコンポーネントを作ろうと思うとちょっと工夫が必要になります。
以前作った仕組みを再度使おうと思った時に思い出すのに時間がかかったので、記事としてまとめておこうと思います。
プリプロセッサ
コード内では、以下のプリプロセッサを使っています。
- pug
- TypeScript
- sass
コンポーネントのコード
コンポーネントの機能実現のため、ダイアログ本体の他に型とコンポーザブルのファイルを作成します。
型
// 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
を使用してダイアログのタイプを定義しています。
プロパティ
ダイアログのタイトルやメッセージなど、タイプに応じてプロパティが変わってくるはずなので、型として定義しています。
BaseDialogProperty
でtype
を持つ型である事を定義し、それをextends
する事で各プロパティ型にもtype
を実装する事を強制しています。
戻り値
条件型を使って、特定の型に対する戻り値の型を定義しています。
AlertDialogProperty
がジェネリック型に渡されれば、返ってくる値はAlertDialogResult
となります。
コンポーザブル
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
し続けるという仕組みができます。
ここでのコンポーザブルの書き方は以前記事にしましたので、興味のある方はご覧ください。
ダイアログ
<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
を使って実装します。
アニメーションのクラスをネストさせて子要素には違う動きをさせているのがミソです。
(フェードイン/アウトしつつ、ポップアップは上下の移動をする)
使い方
<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