Vue3 + Vuetify3 で再利用できてどこでも呼び出せるモーダルを作成する
はじめに
みなさんはモーダルに悩まされていませんか?
ポップで簡単そうに見えるからか、何かと要件が増えてしまうモーダル。
業務で本当に悩まされたので、複数画面で利用することを想定としたモーダルコンポーネントを作成してみました。
動作環境・使用するツールや言語
- Vue: v3.5.13
- Vuetify: v3.7.16
作成したリポジトリ
コード全体を知りたい方はこちらをどうぞ。
基盤コンポーネント
Vuetify のv-dialogを使用してモーダルコンポーネントを作成します。
以下の要件を満たせるようにしました。プロパティを追加すれば他の調整も可能です。
- 横幅をモーダルごとに調整可能に
- タイトルやアクションボタンがある場合はそれらを表示
まずは全体のコードから。
<script setup lang="ts">
const { width = '400' } = defineProps<{
width?: string
}>()
const status = defineModel<boolean>({ default: false })
</script>
<template>
<v-dialog v-model="status" width="auto">
<v-card :width="width">
<v-card-title v-if="$slots.title">
<slot name="title"></slot>
</v-card-title>
<v-card-text>
<slot name="text"></slot>
</v-card-text>
<v-card-actions v-if="$slots.actions">
<slot name="actions"></slot>
</v-card-actions>
</v-card>
</v-dialog>
</template>
条件付きスロット
Vue 公式に記載がありました。
スロットの name と同名を v-if に定義します。
今回はタイトルとアクションボタンを制御しております。
<v-card-title v-if="$slots.title">
<slot name="title"></slot>
</v-card-title>
<v-card-actions v-if="$slots.actions">
<slot name="actions"></slot>
</v-card-actions>
非表示
画像のようにタイトルとアクションボタン部分の高さが非表示になります。
useModalManagerStore
については後ほど解説します。
コード
<script setup lang="ts">
import AppModal from '@/components/modals/AppModal.vue'
import { useModalManagerStore } from '@/stores/modalManager'
const manager = useModalManagerStore()
</script>
<template>
<AppModal v-model="manager.modals['featureSimple']">
<template #text>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
labore et dolore magna aliqua.
</p>
</template>
</AppModal>
</template>
表示あり
コード
<script setup lang="ts">
import AppModal from '@/components/modals/AppModal.vue'
import { useModalManagerStore } from '@/stores/modalManager'
const manager = useModalManagerStore()
</script>
<template>
<AppModal v-model="manager.modals['featureSettings']" width="500">
<template #title>
<div class="d-flex justify-space-between align-center">
<div class="text-h5 text-medium-emphasis ps-2">Invite John to connect</div>
<v-btn icon="mdi-close" variant="text" @click="manager.hide('featureSettings')"></v-btn>
</div>
</template>
<template #text>
<div class="text-medium-emphasis mb-4">
Invite collaborators to your network and grow your connections.
</div>
<div class="mb-2">Message (optional)</div>
<v-textarea
:counter="300"
class="mb-2"
rows="2"
variant="outlined"
persistent-counter
></v-textarea>
<div class="text-overline mb-2">💎 PREMIUM</div>
<div class="text-medium-emphasis mb-1">
Share with unlimited people and get more insights about your network. Try Premium Free for
30 days.
</div>
<v-btn
class="text-none font-weight-bold ms-n4"
color="primary"
text="Retry Premium Free"
variant="text"
></v-btn>
</template>
<template #actions>
<v-btn
class="text-none"
rounded="xl"
text="Cancel"
@click="manager.hide('featureSettings')"
></v-btn>
<v-btn
class="text-none"
color="primary"
rounded="xl"
text="Send"
variant="flat"
@click="manager.hide('featureSettings')"
></v-btn>
</template>
</AppModal>
</template>
本題(複数画面での利用を想定)
上記コードで何回か出ていますが、複数画面での利用を想定して、モーダルの開閉をPiniaを使用して状態管理を行いました。
次のことを満たせるように作成しました。
- モーダルを増やしても、Pinia の状態管理ファイルは増やさないように
- 型補完が効くように
- 型が合っていない場合はリントエラーを出すように
タイプファイル
型補完を効かせるためにas constを指定して、リテラル型にしています。
モーダルが増えても、このファイルに追加していくだけで大丈夫です。
MODAL_IDS の値をコンポーネントでは使用します。
export const MODAL_IDS = {
FEATURE_SIMPLE: 'featureSimple',
FEATURE_SETTINGS: 'featureSettings',
} as const
export type ModalId = (typeof MODAL_IDS)[keyof typeof MODAL_IDS]
typeofやkeyofを使用して、次のようにキーに対する値を取得できます。
// typeof MODAL_IDS
{
FEATURE_SIMPLE: 'featureSimple',
FEATURE_SETTINGS: 'featureSettings'
}
// keyof typeof MODAL_IDS
'FEATURE_SIMPLE' | 'FEATURE_SETTINGS'
// (typeof MODAL_IDS)[keyof typeof MODAL_IDS]
MODAL_IDS['FEATURE_SIMPLE'] | MODAL_IDS['FEATURE_SETTINGS']
= 'featureSimple' | 'featureSettings'
ストアファイル
上記の型を使用して状態管理を行います。
Vuetify の v-dialog に備え付けの開閉を生かすために modals を外部でも使用できるようにしていますが、状況に合わせて変更してください。
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { MODAL_IDS, type ModalId } from '@/types/modalIds'
const getInitialState = (): Record<ModalId, boolean> =>
Object.fromEntries(Object.entries(MODAL_IDS).map((id) => [id, false]))
export const useModalManagerStore = defineStore('modalManager', () => {
const modals = ref(getInitialState())
const show = (id: ModalId): void => {
modals.value[id] = true
}
const hide = (id: ModalId): void => {
modals.value[id] = false
}
return { modals, show, hide }
})
Recordを使用して、モーダルの初期状態をオブジェクトにします。
const getInitialState = (): Record<ModalId, boolean> =>
Object.fromEntries(Object.entries(MODAL_IDS).map((id) => [id, false]));
const modals = ref(getInitialState());
コンポーネントでの使用例
<script setup lang="ts">
import AppModal from '@/components/modals/AppModal.vue'
import { useModalManagerStore } from '@/stores/modalManager'
const manager = useModalManagerStore()
</script>
<template>
<AppModal v-model="manager.modals['featureSettings']" width="500">
<template #title>
<div class="d-flex justify-space-between align-center">
<div class="text-h5 text-medium-emphasis ps-2">Invite John to connect</div>
<v-btn icon="mdi-close" variant="text" @click="manager.hide('featureSettings')"></v-btn>
</div>
</template>
<!--略-->
<template #actions>
<v-btn
class="text-none"
rounded="xl"
text="Cancel"
@click="manager.hide('featureSettings')"
></v-btn>
<v-btn
class="text-none"
color="primary"
rounded="xl"
text="Send"
variant="flat"
@click="manager.hide('featureSettings')"
></v-btn>
</template>
</AppModal>
</template>
おわりに
業務では最初の実装がプロップスリレーで行なわれていて「本当に世のエンジニアはこれをやっているんだろうか?」と疑問に思って、Pinia の状態管理に変更しました。
ただ、モーダルを作成する度に状態管理ファイルを増やす必要があり、どうにかならないかと思って今回の記事を書きました。
ですので、業務に逆輸入する予定です笑
ここまで読んでいただき、ありがとうございました。
Discussion