[vue3-jp-admin]Vue 3 と Vuetify 3 を使用した動的モーダルコンポーネントの封装

## GitHub アドレス:
https://github.com/yangrongwe/vue3-jp-admin

## はじめに

この記事では、Vue 3 と Vuetify 3 を使用して、Pinia を利用した動的なモーダルコンポーネントをどのように封装するかについて説明します。プロジェクトで再利用可能なモーダルコンポーネントを作成する際の設計と実装の詳細を共有します。

### 連絡先メール:
yangrongwei1996@gmail.com
### Demo:
https://wxcorer.com/components/modal

## 使用技術

- Vue 3
- Vuetify 3
- Pinia
- TypeScript

## 目的

モーダルの管理を効率化し、プロジェクト全体で統一されたモーダルコンポーネントを提供することを目的とします。具体的には、以下の機能を実現します。

- Pinia を利用してモーダルの状態（開閉）を管理
- モーダルの動的作成および DOM への追加
- ユーザーの要件に応じたモーダルの外観と挙動のカスタマイズ

## コンポーネントの設計

### `CustomModal.vue`

モーダルの見た目と基本機能を持つコンポーネントです。外部から `v-bind` を用いてプロパティを渡し、表示内容やボタンの動作をカスタマイズできます。

```vue
<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 に追加するためのユーティリティです。
```ts
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 ストアです。
```ts
// 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 = [];
    },
  },
});

```
## 使用方法
以下のようにモーダルを動的に作成し、管理することができます。
```tsx
// モーダルを開く
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 の機能を最大限に活用しています。

皆さんもぜひ試してみてください。そして、フィードバックや質問があれば、コメント欄でお知らせください！