Dialog (Modal)
プライマリウィンドウの上に重ねて表示され、その下のコンテンツを非アクティブにするウィンドウです。
🤖 AI Implementation Guideデモ
基本的なダイアログ
タイトル、説明文、クローズ機能を備えたシンプルなモーダルダイアログです。
This is the main content of the dialog. You can place any content here, such as text, forms, or other components.
Press Escape or click outside to close.
説明なし
タイトルとコンテンツのみのダイアログです。
This dialog has no description, only a title and content.
アクセシビリティ
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 | カスタムクラスが適用される |
テストツール
- Vitest (opens in new tab) - テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ
- jest-axe (opens in new tab) - 自動アクセシビリティテスト
詳細なドキュメントは 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');
});
});
}); リソース
- WAI-ARIA APG: Dialog (Modal) パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist