APG Patterns
English GitHub
English GitHub

Dialog (Modal)

プライマリウィンドウの上に重ねて表示され、その下のコンテンツを非アクティブにするウィンドウです。

🤖 AI Implementation Guide

デモ

基本的なダイアログ

タイトル、説明文、クローズ機能を備えたシンプルなモーダルダイアログです。

説明なし

タイトルとコンテンツのみのダイアログです。

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
dialog ダイアログコンテナ その要素がダイアログウィンドウであることを示す

WAI-ARIA dialog role (opens in new tab)

WAI-ARIA プロパティ

属性 対象 必須 説明
aria-modal dialog true はい これがモーダルダイアログであることを示す
aria-labelledby dialog タイトル要素への ID 参照 はい ダイアログのタイトルを参照する
aria-describedby dialog 説明への ID 参照 いいえ オプションの説明テキストを参照する

フォーカス管理

イベント 動作
ダイアログが開く ダイアログ内の最初のフォーカス可能な要素にフォーカスが移動する
ダイアログが閉じる ダイアログを開いた要素にフォーカスが戻る
フォーカストラップ Tab/Shift+Tab はダイアログ内のフォーカス可能な要素間のみをサイクルする
背景 ダイアログ外のコンテンツは不活性化される(フォーカス不可・操作不可)

キーボードサポート

キー アクション
Tab ダイアログ内の次のフォーカス可能な要素にフォーカスを移動する。最後の要素にフォーカスがある場合は最初の要素に移動する。
Shift + Tab ダイアログ内の前のフォーカス可能な要素にフォーカスを移動する。最初の要素にフォーカスがある場合は最後の要素に移動する。
Escape ダイアログを閉じて、開いた要素にフォーカスを戻す

補足事項

  • アクセシビリティのためにダイアログのタイトルは必須であり、ダイアログの目的を明確に説明する必要があります
  • ダイアログが開いている間はページのスクロールが無効になります
  • オーバーレイ(背景)をクリックするとデフォルトでダイアログが閉じます
  • 閉じるボタンにはスクリーンリーダー向けのアクセシブルなラベルが付いています

ソースコード

Dialog.vue
<template>
  <slot name="trigger" :open="openDialog" />

  <Teleport to="body">
    <dialog
      ref="dialogRef"
      :class="`apg-dialog ${className}`.trim()"
      :aria-labelledby="titleId"
      :aria-describedby="description ? descriptionId : undefined"
      @click="handleDialogClick"
      @close="handleClose"
    >
      <div class="apg-dialog-header">
        <h2 :id="titleId" class="apg-dialog-title">
          {{ title }}
        </h2>
        <button
          type="button"
          class="apg-dialog-close"
          @click="closeDialog"
          aria-label="Close dialog"
        >
          <svg
            xmlns="http://www.w3.org/2000/svg"
            width="24"
            height="24"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
            aria-hidden="true"
          >
            <line x1="18" y1="6" x2="6" y2="18" />
            <line x1="6" y1="6" x2="18" y2="18" />
          </svg>
        </button>
      </div>
      <p v-if="description" :id="descriptionId" class="apg-dialog-description">
        {{ description }}
      </p>
      <div class="apg-dialog-body">
        <slot />
      </div>
    </dialog>
  </Teleport>
</template>

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';

export interface DialogProps {
  /** Dialog title (required for accessibility) */
  title: string;
  /** Optional description text */
  description?: string;
  /** Default open state */
  defaultOpen?: boolean;
  /** Close on overlay click */
  closeOnOverlayClick?: boolean;
  /** Additional CSS class */
  className?: string;
}

const props = withDefaults(defineProps<DialogProps>(), {
  description: undefined,
  defaultOpen: false,
  closeOnOverlayClick: true,
  className: '',
});

const emit = defineEmits<{
  openChange: [open: boolean];
}>();

const dialogRef = ref<HTMLDialogElement>();
const previousActiveElement = ref<HTMLElement | null>(null);
const instanceId = ref('');

onMounted(() => {
  instanceId.value = `dialog-${Math.random().toString(36).substr(2, 9)}`;

  // Open on mount if defaultOpen
  if (props.defaultOpen && dialogRef.value) {
    dialogRef.value.showModal();
    emit('openChange', true);
  }
});

const titleId = computed(() => `${instanceId.value}-title`);
const descriptionId = computed(() => `${instanceId.value}-description`);

const openDialog = () => {
  if (dialogRef.value) {
    previousActiveElement.value = document.activeElement as HTMLElement;
    dialogRef.value.showModal();
    emit('openChange', true);
  }
};

const closeDialog = () => {
  dialogRef.value?.close();
};

const handleClose = () => {
  emit('openChange', false);
  // Return focus to trigger
  if (previousActiveElement.value) {
    previousActiveElement.value.focus();
  }
};

const handleDialogClick = (event: MouseEvent) => {
  // Close on backdrop click
  if (props.closeOnOverlayClick && event.target === dialogRef.value) {
    closeDialog();
  }
};

// Expose methods for external control
defineExpose({
  open: openDialog,
  close: closeDialog,
});
</script>

使い方

Example
<template>
  <Dialog
    title="Dialog Title"
    description="Optional description text"
    @open-change="handleOpenChange"
  >
    <template #trigger="{ open }">
      <button @click="open" class="btn-primary">Open Dialog</button>
    </template>
    <p>Dialog content goes here.</p>
  </Dialog>
</template>

<script setup>
import Dialog from './Dialog.vue';

function handleOpenChange(open) {
  console.log('Dialog:', open);
}
</script>

API

Props

プロパティ デフォルト 説明
title string 必須 ダイアログのタイトル(アクセシビリティ用)
description string - オプションの説明文
defaultOpen boolean false 初期の開閉状態
closeOnOverlayClick boolean true オーバーレイクリックで閉じる

Events

イベント ペイロード 説明
openChange boolean 開閉状態が変更されたときに発火

Slots

スロット Props 説明
trigger { open: () => void } ダイアログを開くトリガー要素
default - ダイアログのコンテンツ

テスト

テストは、キーボード操作、ARIA 属性、およびアクセシビリティ要件全体にわたる APG 準拠を検証します。

テストカテゴリ

高優先度: APG キーボード操作

テスト 説明
Escape key ダイアログを閉じる

高優先度: APG ARIA 属性

テスト 説明
role="dialog" ダイアログ要素に dialog ロールがある
aria-modal="true" モーダル動作を示す
aria-labelledby ダイアログのタイトルを参照する
aria-describedby 説明を参照する(提供されている場合)

高優先度: フォーカス管理

テスト 説明
Initial focus 開いたときに最初のフォーカス可能な要素にフォーカスが移動する
Focus restore 閉じたときに開いた要素にフォーカスが戻る
Focus trap Tab サイクルがダイアログ内に留まる(ネイティブ dialog 経由)

中優先度: アクセシビリティ

テスト 説明
axe violations WCAG 2.1 AA 違反がない(jest-axe 経由)

低優先度: プロパティと動作

テスト 説明
closeOnOverlayClick オーバーレイクリック動作を制御する
defaultOpen 初期の開いた状態
onOpenChange 開く/閉じるときにコールバックが発火する
className カスタムクラスが適用される

テストツール

詳細なドキュメントは testing-strategy.md (opens in new tab) を参照してください。

Dialog.test.vue.ts
import { render, screen, within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Dialog from './Dialog.vue';

// テスト用のラッパーコンポーネント
const TestDialog = {
  components: { Dialog },
  props: {
    title: { type: String, default: 'Test Dialog' },
    description: { type: String, default: undefined },
    closeOnOverlayClick: { type: Boolean, default: true },
    defaultOpen: { type: Boolean, default: false },
    className: { type: String, default: '' },
  },
  emits: ['openChange'],
  template: `
    <Dialog
      :title="title"
      :description="description"
      :close-on-overlay-click="closeOnOverlayClick"
      :default-open="defaultOpen"
      :class-name="className"
      @open-change="$emit('openChange', $event)"
    >
      <template #trigger="{ open }">
        <button @click="open">Open Dialog</button>
      </template>
      <slot>
        <p>Dialog content</p>
      </slot>
    </Dialog>
  `,
};

describe('Dialog (Vue)', () => {
  // 🔴 High Priority: APG 準拠の核心
  describe('APG: キーボード操作', () => {
    it('Escape キーでダイアログを閉じる', async () => {
      const user = userEvent.setup();
      const onOpenChange = vi.fn();
      render(TestDialog, {
        props: { onOpenChange },
        attrs: { onOpenChange },
      });

      // ダイアログを開く
      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      expect(screen.getByRole('dialog')).toBeInTheDocument();

      // Escape で閉じる
      await user.keyboard('{Escape}');
      expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
    });
  });

  describe('APG: ARIA 属性', () => {
    it('role="dialog" を持つ', async () => {
      const user = userEvent.setup();
      render(TestDialog);

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      expect(screen.getByRole('dialog')).toBeInTheDocument();
    });

    it('aria-modal="true" を持つ', async () => {
      const user = userEvent.setup();
      render(TestDialog);

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
    });

    it('aria-labelledby でタイトルを参照', async () => {
      const user = userEvent.setup();
      render(TestDialog, {
        props: { title: 'My Dialog Title' },
      });

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      const dialog = screen.getByRole('dialog');
      const titleId = dialog.getAttribute('aria-labelledby');

      expect(titleId).toBeTruthy();
      expect(document.getElementById(titleId!)).toHaveTextContent('My Dialog Title');
    });

    it('description がある場合 aria-describedby で参照', async () => {
      const user = userEvent.setup();
      render(TestDialog, {
        props: { description: 'This is a description' },
      });

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      const dialog = screen.getByRole('dialog');
      const descriptionId = dialog.getAttribute('aria-describedby');

      expect(descriptionId).toBeTruthy();
      expect(document.getElementById(descriptionId!)).toHaveTextContent('This is a description');
    });

    it('description がない場合 aria-describedby なし', async () => {
      const user = userEvent.setup();
      render(TestDialog);

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      const dialog = screen.getByRole('dialog');

      expect(dialog).not.toHaveAttribute('aria-describedby');
    });
  });

  describe('APG: フォーカス管理', () => {
    it('開いた時に最初のフォーカス可能要素にフォーカス', async () => {
      const user = userEvent.setup();
      render(TestDialog);

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));

      // ダイアログ内の最初のフォーカス可能要素(Close ボタン)にフォーカス
      await vi.waitFor(() => {
        expect(screen.getByRole('button', { name: 'Close dialog' })).toHaveFocus();
      });
    });

    it('閉じた時にトリガーにフォーカス復元', async () => {
      const user = userEvent.setup();
      render(TestDialog);

      const trigger = screen.getByRole('button', { name: 'Open Dialog' });
      await user.click(trigger);
      expect(screen.getByRole('dialog')).toBeInTheDocument();

      await user.keyboard('{Escape}');
      expect(trigger).toHaveFocus();
    });

    // Note: フォーカストラップはネイティブ <dialog> 要素の showModal() が処理する。
    // jsdom では showModal() のフォーカストラップ動作が未実装のため、
    // これらのテストはブラウザでの E2E テスト(Playwright)で検証することを推奨。
  });

  // 🟡 Medium Priority: アクセシビリティ検証
  describe('アクセシビリティ', () => {
    it('axe による違反がない', async () => {
      const user = userEvent.setup();
      const { container } = render(TestDialog, {
        props: { description: 'Description' },
      });

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));

      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  describe('Props', () => {
    it('title が表示される', async () => {
      const user = userEvent.setup();
      render(TestDialog, {
        props: { title: 'Custom Title' },
      });

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      expect(screen.getByText('Custom Title')).toBeInTheDocument();
    });

    it('description が表示される', async () => {
      const user = userEvent.setup();
      render(TestDialog, {
        props: { description: 'Custom Description' },
      });

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      expect(screen.getByText('Custom Description')).toBeInTheDocument();
    });

    it('closeOnOverlayClick=true でオーバーレイクリックで閉じる', async () => {
      const user = userEvent.setup();
      render(TestDialog, {
        props: { closeOnOverlayClick: true },
      });

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      const dialog = screen.getByRole('dialog');

      await user.click(dialog);
      expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
    });

    it('closeOnOverlayClick=false でオーバーレイクリックしても閉じない', async () => {
      const user = userEvent.setup();
      render(TestDialog, {
        props: { closeOnOverlayClick: false },
      });

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      const dialog = screen.getByRole('dialog');

      await user.click(dialog);
      expect(screen.getByRole('dialog')).toBeInTheDocument();
    });

    it('defaultOpen=true で初期表示', async () => {
      render(TestDialog, {
        props: { defaultOpen: true },
      });
      expect(screen.getByRole('dialog')).toBeInTheDocument();
    });
  });

  // 🟢 Low Priority: 拡張性
  describe('HTML 属性継承', () => {
    it('className がダイアログに適用される', async () => {
      const user = userEvent.setup();
      render(TestDialog, {
        props: { className: 'custom-class' },
      });

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      expect(screen.getByRole('dialog')).toHaveClass('custom-class');
    });
  });
});

リソース