Vueで使用しているModalの開閉を管理するオブジェクト

Vueで使用しているModalの開閉を管理するオブジェクト（備忘録）

# 目的
* モーダルの開閉制御
* モーダル開閉処理の前後に処理を挟む場合
* モーダル開閉時に特定の条件での強制中止機能の実装
* ただ・・・ただ・それだけ。


# どんなの？
## modal-control.ts

```typescript:modal-control.ts
export interface ModalControl<T = any> {
  /**
   * showBefre,showAfter がasync付きならasync付き関数になります。
   */
  show: (() => void) | (() => Promise<void>);
  /**
   * showBefore モーダルが開く前に実行される関数
   * - true をreturnするとモーダルを開く処理は中止される
   */
  showBefore: OptionalFunction<T>;
  showAfter: OptionalFunction<T>;
  /**
   * closeBefore,closeAfter がasync付きならasync付き関数になります。
   */
  close: () => void;
  /**
   * closeBefore モーダルが閉じる前に実行される関数
   * - true をreturnするとモーダルを閉じる処理は中止される
   */
  closeBefore: OptionalFunction<T>;
  closeAfter: OptionalFunction<T>;
  //状態管理
  isShow: boolean;
  //データ保持
  state: T;
}

type OptionalFunction<T> = ((state: T) => void | boolean) | ((state: T) => Promise<void | boolean>) | null;

export const InitModalControl = <T = any>(arg?: {
  state?: T;
  closeBefore?: OptionalFunction<T>;
  closeAfter?: OptionalFunction<T>;
  showBefore?: OptionalFunction<T>;
  showAfter?: OptionalFunction<T>;
}): ModalControl<T> => {
  // if (arg === undefined) arg = {};
  const noneInitMessage = 'が初期化されていません';
  return {
    show: () => console.log('[show]' + noneInitMessage),
    showBefore: null,
    showAfter: null,
    close: () => console.log('[close]' + noneInitMessage),
    closeBefore: null,
    closeAfter: null,
    isShow: false,
    state: null,
    ...arg,
  } as ModalControl;
};

const isAsync = (func: any) => {
  try {
    if (func === null) return false;
    if (func === undefined) return false;
    if (!('constructor' in func)) return false;
    return func.constructor.name === 'AsyncFunction';
  } catch {
    return false;
  }
};

/**
 * modalオブジェクトの初期化
 * @param modal ModalControlが含まれるReactiveオブジェクト
 * @param nextTick Vue の nextTick関数
 */
export const InitModals = (modal: any, nextTick: any) => {
  Object.keys(modal).forEach((key) => {
    const m: ModalControl = (modal as any)[key];
    m.show = async () => {
      if (m.showBefore) {
        if (isAsync(m.showBefore)) {
          if ((await m.showBefore(m.state)) === true) {
            console.info('モーダルのShow動作はキャンセルされました');
            return;
          }
        } else {
          if (m.showBefore(m.state) === true) {
            console.info('モーダルのShow動作はキャンセルされました');
            return;
          }
        }
      }
      m.isShow = true;
      if (!m.showAfter) return;
      await nextTick();
      if (isAsync(m.showAfter)) {
        await m.showAfter(m.state);
      } else {
        m.showAfter(m.state);
      }
    };
    m.close = async () => {
      if (m.closeBefore) {
        if (isAsync(m.closeBefore)) {
          if ((await m.closeBefore(m.state)) === true) {
            console.info('モーダルのClose動作はキャンセルされました');
            return;
          }
        } else {
          if (m.closeBefore(m.state) === true) {
            console.info('モーダルのClose動作はキャンセルされました');
            return;
          }
        }
      }
      m.isShow = false;
      await nextTick();
      if (!m.closeAfter) return;
      if (isAsync(m.closeAfter)) {
        await m.closeAfter(m.state);
      } else {
        m.closeAfter(m.state);
      }
    };
  });
};

```


## 使い方
### モーダル開閉を管理するReactiveオブジェクトの型定義

```typescript:root-view.vue
import { ModalControl, InitModalControl, InitModals } from './modal-control';
interface Modal {
  test1: ModalControl<{
    isLock: boolean;
    id: number;
  }>;
  //test2子コンポーネント側で閉じる部分の制御する
  test2: ModalControl;
}
```

### モーダル開閉を管理するReactiveオブジェクト

```typescript:root-view.vue
const modal = reactive<Modal>({
  test1: InitModalControl<Modal['test1']['state']>({
    state: { isLock: false, id: 1 },
    //asyncもOK
    showBefore: (state) => {
      console.log('showBefore', state);
      //ここに制御を入れてtrueを返せば中断できる
      return state.isLock;
    },
    showAfter: (state) => {
      console.log('showAfter', state);
    },
    //asyncもOK
    closeBefore: (state) => {
      console.log('closeBefore', state);
      //ここに制御を入れてtrueを返せば中断できる
      return state.isLock;
    },
    closeAfter: (state) => {
      console.log('closeAfter', state);
    },
  }),
  test2: InitModalControl(),
});
```


### コンポーネントがマウントされたらModalの関数登録用処理を実行

```typescript:root-view.vue
onMounted(() => {
  InitModals(modal, nextTick);
});
```
>Elementを捕捉して開く直前にモーダル要素側のスクロール制御を入れることがあるので
onMountedで実行するように統一して使っている。けどrefの組み合わせで気にしなくても良いかも




### テンプレート側のModal部分

```html:root-view.vue
<!-- 省略 -->
 <teleport to="#teleport">
    <div class="modal-container" :class="{ isShow: modal.test1.isShow }" @click="modal.test1.close()">
      <div class="card" @click.stop>
        <div class="card-header">モーダル</div>
        <div class="card-body">
          <div class="text-center mb-2">id={{ modal.test1.state.id }}</div>
          <div class="">
            <div class="btn btn-danger me-1" @click="modal.test1.state.isLock = !modal.test1.state.isLock">change</div>
            <span class="ms-1">isLock={{ modal.test1.state.isLock }}</span>
          </div>
        </div>
        <div class="card-footer">
          <div class="btn btn-outline-secondary w-100" @click="modal.test1.close()">modal閉じる</div>
        </div>
      </div>
    </div>
    <div class="modal-container" :class="{ isShow: modal.test2.isShow }" @click="modal.test2.close()">
      <VcModal
        :isShow="modal.test2.isShow"
        @close-before-func="(func) => (modal.test2.closeBefore = func)"
        @close="modal.test2.close()"
      >
      </VcModal>
    </div>
  </teleport>
```


>@close-before-func="(func) => (modal.test2.closeBefore = func)"


ちなみにtest2は子コンポーネント側でcloseBefore関数を定義して制御している
※emitで関数を子コンポーネントから受け取ってModalの管理オブジェクトに設定している


念のため子コンポーネント側SFC
```vue:vc-modal.vue
<script setup lang="ts">
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap';
import { ref, watch } from 'vue';
type Props = {
  isShow: boolean;
};
const props = defineProps<Props>();
type Emits = {
  (e: 'close'): void;
  (e: 'close-before-func', func: () => void): void;
};
const emit = defineEmits<Emits>();
const target = ref();
const { activate, deactivate } = useFocusTrap(target);

watch(
  () => props.isShow,
  () => {
    if (props.isShow) {
      message.value.length = 0;
      activate();
    } else {
      deactivate();
    }
  }
);
const closeBefore = () => {
  if (isLock.value) {
    message.value.push('isLock=trueで閉じません');
  }
  return isLock.value;
};
emit('close-before-func', closeBefore);
const message = ref<string[]>([]);
const isLock = ref(false);
</script>
<template>
  <div class="card" @click.stop>
    <div class="card-header">モーダル</div>
    <div class="card-body py-1">
      <div class="d-flex justify-content-between fw-bold fs-5 w-100">
        <div class="">←</div>
        <div class="">領域外クリックで閉じれる</div>
        <div class="">→</div>
      </div>
      <div class="text-center mb-2">でもisLock=trueだと閉じれない</div>
      <div class="">
        <div class="btn btn-danger me-1" @click="isLock = !isLock">change</div>
        <span class="ms-1">isLock={{ isLock }}</span>
      </div>
      <div class="message">
        {{ message.join('\n') }}
      </div>
    </div>
    <div class="card-footer">
      <div class="btn btn-outline-secondary w-100" @click="emit('close')">modal閉じる</div>
    </div>
  </div>
</template>
<style lang="scss" scoped>
//省略
</style>
```

ちなみに[Vue.jsとVueUseでフォーカスの逃げないModalを作るメモ](https://qiita.com/harenokuni/items/cc2dbf6fbaecfdc5f730)を使ってフォーカスロック付き




#GitHubにサンプル上げてます

https://github.com/hare-systems-ryo/vue-sample-2

#ついでにGitHubにデモページも・・・

https://hare-systems-ryo.github.io/vue-sample-2/