GitHub アドレス:
はじめに
この記事では、Vue 3 と Vuetify 3 を使用して、Pinia を利用した動的なモーダルコンポーネントをどのように封装するかについて説明します。プロジェクトで再利用可能なモーダルコンポーネントを作成する際の設計と実装の詳細を共有します。
連絡先メール:
Demo:
使用技術
- Vue 3
- Vuetify 3
- Pinia
- TypeScript
目的
モーダルの管理を効率化し、プロジェクト全体で統一されたモーダルコンポーネントを提供することを目的とします。具体的には、以下の機能を実現します。
- Pinia を利用してモーダルの状態(開閉)を管理
- モーダルの動的作成および DOM への追加
- ユーザーの要件に応じたモーダルの外観と挙動のカスタマイズ
コンポーネントの設計
CustomModal.vue
モーダルの見た目と基本機能を持つコンポーネントです。外部から v-bind
を用いてプロパティを渡し、表示内容やボタンの動作をカスタマイズできます。
<template>
<v-dialog
v-model="isVisible"
:style="{ zIndex: modal?.zIndex }"
v-bind="modal?.props"
:fullscreen="isFullscreen"
>
<v-card v-if="modal">
<v-card-title>
<template v-if="modal.slots?.titleSlot">
<component :is="modal.slots.titleSlot" />
</template>
<div class="tw-flex tw-justify-between tw-items-center" v-else>
<div class="tw-flex-shrink-0">{{ modal?.props.title }}</div>
<div class="tw-flex tw-space-x-2">
<v-icon @click="toggleFullscreen" v-if="modal?.props.fullscreen">{{
isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'
}}</v-icon>
<v-icon @click="closeModal(modal.id)" class="tw-place-items-end">{{
'mdi-window-close'
}}</v-icon>
</div>
</div>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="tw-overflow-auto tw-max-h-90">
<component :is="modal.component" v-bind="modal.props" />
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<template v-if="modal.slots?.footSlot">
<component :is="modal.slots.footSlot" />
</template>
<template v-else>
<v-btn @click="handleCloseClick">{{
$t('views.modal.cancel')
}}</v-btn>
<v-btn @click="handleConfirmClick">{{
$t('views.modal.confirm')
}}</v-btn>
</template>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="tsx">
import { computed, ref } from 'vue';
import { useModalStore } from '@/store/modalStore';
const modalStore = useModalStore();
const isVisible = ref(true);
const props = defineProps<{ modalId: string }>();
const modal = computed(() =>
modalStore.modals.find((m) => m.id === props.modalId)
);
const closeModal = (id: string) => {
// isVisible を false に設定し、トランジション終了時に DOM 要素を削除
isVisible.value = false;
// トランジションが終了するまで待機してから modalStore.closeModal を実行
setTimeout(() => {
modalStore.closeModal(id);
}, 300); // 300ms は Vue のトランジションのデフォルト時間で、必要に応じて調整可能
};
const isFullscreen = ref(modal.value?.props?.fullscreen);
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value;
};
const handleCloseClick = async () => {
let res = true;
if (modal.value?.callbackMethod.onCloseCallback) {
res = await modal.value?.callbackMethod.onCloseCallback();
if (res) {
closeModal(modal.value?.id);
}
} else {
closeModal(modal.value?.id);
}
};
const handleConfirmClick = async () => {
let res = true;
if (modal.value?.callbackMethod.onConfirmCallback) {
res = await modal.value?.callbackMethod.onConfirmCallback();
if (res) {
closeModal(modal.value?.id);
}
} else {
closeModal(modal.value?.id);
}
};
</script>
<style scoped lang="scss">
.v-dialog {
transition: none;
}
</style>
modalUtils.ts
モーダルを動的に作成し、DOM に追加するためのユーティリティです。
import { useModalStore } from '@/store/modalStore';
import { markRaw, render, h } from 'vue';
import CustomModal from '@/components/JpModal/index.vue';
import { app } from '../main.js';
// Vue アプリケーションのコンテキストを取得する関数
export function getAppContext() {
return app._context; // _context は Vue アプリケーションインスタンスのコンテキストです
}
// モーダルを開くための関数
export function openModal({
component = null,
props = {},
slots = {},
callbackMethod = {},
}: {
component: any; // モーダルに表示するコンポーネント
props?: Record<string, any>; // コンポーネントに渡すプロパティ
slots?: Record<string, any>; // コンポーネントのスロット
callbackMethod?: Record<string, any>; // コールバックメソッド
}) {
const modalStore = useModalStore();
const componentRef = markRaw(component); // コンポーネントをマークして再レンダリングを防ぐ
// モーダルストアを使ってモーダルを開く
const id = modalStore.openModal(
componentRef,
{ ...props },
slots,
callbackMethod
);
// 新しい div 要素を作成して body に追加する
const modalElement = document.createElement('div');
document.body.appendChild(modalElement);
// CustomModal コンポーネントのインスタンスを作成する
const modalInstance = h(CustomModal, { modalId: id });
// Vuetify のエラーを回避するため、アプリケーションコンテキストを設定する
modalInstance.appContext = getAppContext();
// モーダルインスタンスを作成した div 要素にレンダリングする
render(modalInstance, modalElement);
return id; // モーダルの ID を返す
}
// 指定された ID のモーダルを閉じる関数
export function closeModal(id: string) {
const modalStore = useModalStore();
modalStore.closeModal(id);
}
// すべてのモーダルを閉じる関数
export function closeAllModals() {
const modalStore = useModalStore();
modalStore.closeAllModals();
}
useModalStore.ts
モーダルの状態を管理するための Pinia ストアです。
// store/modalStore.ts
import { defineStore } from 'pinia';
interface Modal {
id: string;
component: any;
props?: Record<string, any>;
slots?: Record<string, any>;
zIndex: number;
callbackMethod?: Record<string, Function>;
}
export const useModalStore = defineStore('modalStore', {
state: () => ({
modals: [] as Modal[],
zIndexCounter: 9999,
}),
actions: {
openModal(
component: any,
props?: Record<string, any>,
slots?: Record<string, any>,
callbackMethod?: Record<string, Function>
) {
const id = `modal-${Date.now()}`;
this.zIndexCounter++;
this.modals.push({
id,
component,
props,
zIndex: this.zIndexCounter,
slots,
callbackMethod,
});
return id;
},
closeModal(id: string) {
this.modals = this.modals.filter((modal) => modal.id !== id);
},
closeAllModals() {
this.modals = [];
},
},
});
使用方法
以下のようにモーダルを動的に作成し、管理することができます。
// モーダルを開く
openModal({
component: (
<div class="tw-flex-col">
<div onClick={newModal}>
<VBtn class="tw-mb-2">
{$t('views.modal.nestingModal.buttonText')}
</VBtn>
</div>
<HighlightCode code={nestingCode} />
</div>
),
props: {
title: $t('views.modal.nestingModal.title'),
width: '600',
},
callbackMethod: {
onCloseCallback: () => {
return true;
},
onConfirmCallback: () => {
return false;
},
},
});
終わりに
このモーダルコンポーネントの封装により、プロジェクト全体で再利用可能なモーダルを作成し、管理の効率化が図れます。動的に作成されるモーダルは、柔軟性と拡張性を持ちながら、Vue 3 と Vuetify 3 の機能を最大限に活用しています。
皆さんもぜひ試してみてください。そして、フィードバックや質問があれば、コメント欄でお知らせください!