【Vue.js】モーダルウィンドウを作成する方法|スロット・クリック外で閉じる処理付き

【Vue.js】モーダルウィンドウを作成する方法|スロット・クリック外で閉じる処理付き Vue.js

モーダルウィンドウは、ページの上に重ねて表示し、確認や入力を求めるUIです。見た目は簡単に作れますが、実用にするには、表示位置が崩れない工夫、キーボードや読み上げソフトへの対応、そしてスロットの落とし穴を知っておく必要があります。この記事では、Vue 3(<script setup>)で、再利用できるモーダルコンポーネントを作ります。

スポンサーリンク

完成コード:Modal.vue

最初に、完成したコンポーネントを示します。ポイントは次のとおりです。

  • <Teleport to="body">で、body直下に描画する
  • role="dialog"、aria-modal="true"、aria-labelledbyで、モーダルであることと見出しを伝える
  • 開いたらフォーカスをモーダルの中へ移し、閉じたら元の場所へ戻す
  • Escキーで閉じ、Tabキーのフォーカスがモーダルの外へ出ないようにする
  • 開いている間は、背景のスクロールを止める
  • オーバーレイ(背景)のクリックで閉じる
Vue:Modal.vue
<!-- Modal.vue -->
<script>
// 通常の <script>:全インスタンスで共有されるカウンター(見出しの id を重複させないため)
let uid = 0
</script>

<script setup>
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'

const props = defineProps({
  open: { type: Boolean, default: false },
  title: { type: String, required: true }
})
const emit = defineEmits(['close'])

const titleId = `modal-title-${uid++}`
const dialogRef = ref(null)
let lastFocused = null          // 開く前にフォーカスがあった要素

function close() {
  emit('close')
}

watch(() => props.open, async (isOpen) => {
  if (isOpen) {
    lastFocused = document.activeElement
    document.body.style.overflow = 'hidden'   // 背景のスクロールを止める
    await nextTick()
    dialogRef.value?.focus()                  // フォーカスをモーダルの中へ移す
  } else {
    document.body.style.overflow = ''
    lastFocused?.focus()                      // 閉じたら、元の場所へ戻す
  }
})

onBeforeUnmount(() => {
  document.body.style.overflow = ''
})

// Esc で閉じる/Tab のフォーカスがモーダルの外へ出ないようにする
function onKeydown(event) {
  if (event.key === 'Escape') {
    close()
    return
  }
  if (event.key !== 'Tab') return

  const focusables = dialogRef.value.querySelectorAll(
    'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
  )
  if (focusables.length === 0) {
    event.preventDefault()
    return
  }
  const first = focusables[0]
  const last = focusables[focusables.length - 1]
  const active = document.activeElement

  if (event.shiftKey && (active === first || active === dialogRef.value)) {
    event.preventDefault()
    last.focus()
  } else if (!event.shiftKey && active === last) {
    event.preventDefault()
    first.focus()
  }
}
</script>

<template>
  <!-- body 直下に描画する -->
  <Teleport to="body">
    <div v-if="open" class="modal-overlay" @click.self="close">
      <div
        ref="dialogRef"
        class="modal-content"
        role="dialog"
        aria-modal="true"
        :aria-labelledby="titleId"
        tabindex="-1"
        @keydown="onKeydown"
      >
        <h2 :id="titleId">{{ title }}</h2>

        <slot />

        <div class="modal-footer">
          <!-- footer スロットを渡さなければ、「閉じる」ボタンを出す -->
          <slot name="footer">
            <button type="button" @click="close">閉じる</button>
          </slot>
        </div>
      </div>
    </div>
  </Teleport>
</template>

<style scoped>
.modal-overlay {
  position: fixed;
  inset: 0;
  display: flex;
  justify-content: center;
  align-items: center;
  background: rgba(0, 0, 0, 0.5);
  z-index: 1000;
}
.modal-content {
  min-width: 300px;
  max-width: 90vw;
  padding: 2rem;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.modal-footer {
  margin-top: 1.5rem;
  text-align: right;
}
</style>

使う側は、次のとおりです。

Vue:モーダルを使う
<script setup>
import { ref } from 'vue'
import Modal from './Modal.vue'

const isOpen = ref(false)
</script>

<template>
  <button type="button" @click="isOpen = true">モーダルを開く</button>

  <Modal :open="isOpen" title="お知らせ" @close="isOpen = false">
    <p>これはスロットで渡された内容です。</p>
  </Modal>
</template>

コードのポイント

Teleportでbody直下に描画する

モーダルが、コンポーネントの深い階層の中に描画されると、表示が崩れることがあります。公式のTeleportのガイドによると、position: fixedは、祖先にtransform、perspective、filterがあると、ビューポートではなくその祖先を基準にしてしまいます。z-indexも、親の要素に制約されます。<Teleport to="body">で囲むと、見た目はbody直下に描画されつつ、論理的には、元のコンポーネントの子のままです。propsやイベントは、これまでどおり動きます。

スロットの中の $emit は動かない

スロットの中身は、モーダルではなく、親のスコープで評価されます。そのため、スロットに渡したボタンに@click="$emit('close')"と書いても、それは親コンポーネントの$emitです。Modalのcloseイベントではないので、何も起きません。親の状態を直接変えてください。

Vue:スロット内のボタン
<Modal :open="isOpen" title="お知らせ" @close="isOpen = false">
  <p>スロットの中身</p>

  <!-- NG:スロットの中身は「親」のスコープ。$emit は親コンポーネントのもので、
       Modal の close ではないため、押しても何も起きない -->
  <button type="button" @click="$emit('close')">閉じる</button>

  <!-- OK:親の状態を、直接変える -->
  <button type="button" @click="isOpen = false">閉じる</button>
</Modal>

フォーカスの管理

モーダルを開いたら、フォーカスをモーダルの中へ移します。そうしないと、キーボードの利用者は、背景のページを操作したままになります。閉じるときは、開く前にフォーカスがあった要素に戻します。上のコードでは、Tabキーで、先頭と末尾の要素の間を循環させて、フォーカスが外へ出ないようにしています。

defineEmitsでイベントを宣言する

子が発行するイベント(close)は、defineEmitsで宣言します。宣言がないと、親のリスナーが、ネイティブのイベントとして扱われます。

確認ダイアログとして使う

footerスロットを使うと、ボタンを差し替えられます。タイトルをpropsで渡し、キャンセルと実行を別のボタンにすれば、削除の確認のようなダイアログとして、同じコンポーネントを使い回せます。

Vue:確認ダイアログ
<script setup>
import { ref } from 'vue'
import Modal from './Modal.vue'

const isOpen = ref(false)

function onDelete() {
  // 削除の処理...
  isOpen.value = false
}
</script>

<template>
  <button type="button" @click="isOpen = true">削除</button>

  <Modal :open="isOpen" title="削除の確認" @close="isOpen = false">
    <p>本当に削除しますか?この操作は元に戻せません。</p>

    <!-- footer スロットで、ボタンを差し替える -->
    <template #footer>
      <button type="button" @click="isOpen = false">キャンセル</button>
      <button type="button" @click="onDelete">削除する</button>
    </template>
  </Modal>
</template>

HTML標準の dialog 要素を使う

ここまで作ってきたことの多くは、HTML標準の<dialog>要素が、ブラウザの機能として提供しています。MDNによると、showModal()で開くと、次の動きが標準で得られます。

  • 背景の内容が操作できなくなる(inert)
  • Escキーで閉じる
  • フォーカスが、モーダルの中に移る
  • ::backdropで、背景を装飾できる
  • モーダルには、暗黙のaria-modal="true"が付く

主要なブラウザで、2022年3月から広く使える状態です。Vueで使うときは、refで要素を取り、props.openの変化に合わせてshowModal()とclose()を呼びます。

Vue:DialogModal.vue
<!-- DialogModal.vue:HTML 標準の <dialog> を使う版 -->
<script setup>
import { ref, watch } from 'vue'

const props = defineProps({
  open: { type: Boolean, default: false },
  title: { type: String, required: true }
})
const emit = defineEmits(['close'])

const dialogRef = ref(null)

// props.open に合わせて、showModal() / close() を呼ぶ
watch(() => props.open, (isOpen) => {
  const dialog = dialogRef.value
  if (isOpen && !dialog.open) dialog.showModal()
  else if (!isOpen && dialog.open) dialog.close()
})
</script>

<template>
  <!-- Esc で閉じたときも close イベントが発生するので、親の状態を同期できる -->
  <dialog
    ref="dialogRef"
    :aria-label="title"
    @close="emit('close')"
    @click.self="emit('close')"
  >
    <div class="dialog-body">
      <h2>{{ title }}</h2>
      <slot />
      <button type="button" @click="emit('close')">閉じる</button>
    </div>
  </dialog>
</template>

<style scoped>
/* dialog 自体に余白があると、余白部分のクリックが「外側のクリック」と判定されるため、
   余白は内側の div に持たせる */
dialog { padding: 0; border: none; border-radius: 8px; }
.dialog-body { padding: 2rem; }
dialog::backdrop { background: rgba(0, 0, 0, 0.5); }
</style>

外側のクリックで閉じる処理は、MDNによると、属性closedby="any"でも指定できます。ただし、対応状況は、対象のブラウザで確認してください。

自作と標準の使い分け

  • dialog要素:フォーカスの管理や、Escキー、背景の無効化をブラウザに任せられる。まずはこちらを検討する。
  • 自作のモーダル:トランジションなど、見た目を細かく制御したい場合や、既存のUIライブラリの流儀に合わせたい場合。アクセシビリティは、自分で担保する必要がある。

アニメーションを付ける

開閉にアニメーションを付けるには、<Transition>が使えます。基礎はtransitionコンポーネントを使ってアニメーションを実装する方法、高さの変化を伴う例はアニメーション付きアコーディオンを実装する方法をご覧ください。

関連記事

よくある質問(FAQ)

Qスロットに入れた「閉じる」ボタンが動きません。
Aスロットの中身は親のスコープで評価されるため、$emit('close')は親のものです。親の状態を、@click="isOpen = false"のように直接変えるか、スロットのprops(スコープ付きスロット)で閉じる関数を受け取ってください。
Qモーダルの表示位置が崩れたり、ほかの要素の後ろに隠れたりします。
A祖先の要素にtransformやfilter、z-indexの制約があるのが原因です。<Teleport to="body">で、body直下に描画してください。
Q自作のモーダルとdialog要素は、どちらを使うべきですか?
Aまずは、フォーカスの管理やEscキーをブラウザに任せられるdialog要素を検討してください。細かい見た目の制御や、UIライブラリとの統一が必要な場合に、自作を選びます。自作では、アクセシビリティを自分で担保する必要があります。
Qモーダルを開いている間、背景がスクロールしてしまいます。
A自作のモーダルでは、開いている間にdocument.body.style.overflow = 'hidden'を設定し、閉じるときと破棄されるときに戻します。dialog要素のshowModal()は、背景を操作不能にします。

まとめ

モーダルは、<Teleport>でbody直下に描画し、role="dialog"とフォーカスの管理、Escキーで閉じる動作を備えると、実用的になります。スロットの中身は親のスコープで評価されるため、$emitの落とし穴に注意してください。多くの機能は、HTML標準の<dialog>要素にも備わっているので、まずは標準を検討し、必要に応じて自作を選びましょう。