Dialog (Modal)
プライマリウィンドウ上に重ねて表示され、下のコンテンツを非アクティブにするウィンドウ。
🤖 AI Implementation Guideデモ
基本的な Dialog
タイトル、説明、クローズ機能を持つシンプルなモーダルダイアログ。
説明なし
タイトルとコンテンツのみを持つダイアログ。
アクセシビリティ
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.svelte
<script lang="ts" module>
import type { Snippet } from 'svelte';
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;
/** Callback when open state changes */
onOpenChange?: (open: boolean) => void;
/** Trigger snippet - receives open function */
trigger: Snippet<[{ open: () => void }]>;
/** Dialog content */
children: Snippet;
}
</script>
<script lang="ts">
import { onMount } from 'svelte';
let {
title,
description = undefined,
defaultOpen = false,
closeOnOverlayClick = true,
className = '',
onOpenChange = () => {},
trigger,
children,
}: DialogProps = $props();
let dialogElement = $state<HTMLDialogElement | undefined>(undefined);
let previousActiveElement: HTMLElement | null = null;
let instanceId = $state('');
onMount(() => {
instanceId = `dialog-${Math.random().toString(36).substr(2, 9)}`;
// Open on mount if defaultOpen
if (defaultOpen && dialogElement) {
dialogElement.showModal();
onOpenChange(true);
}
});
let titleId = $derived(`${instanceId}-title`);
let descriptionId = $derived(`${instanceId}-description`);
export function open() {
if (dialogElement) {
previousActiveElement = document.activeElement as HTMLElement;
dialogElement.showModal();
onOpenChange(true);
}
}
export function close() {
dialogElement?.close();
}
function handleClose() {
onOpenChange(false);
// Return focus to trigger
if (previousActiveElement) {
previousActiveElement.focus();
}
}
function handleDialogClick(event: MouseEvent) {
// Close on backdrop click
if (closeOnOverlayClick && event.target === dialogElement) {
close();
}
}
</script>
<!-- Trigger snippet -->
{@render trigger({ open })}
<!-- Native Dialog Element -->
<dialog
bind:this={dialogElement}
class={`apg-dialog ${className}`.trim()}
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
onclick={handleDialogClick}
onclose={handleClose}
>
<div class="apg-dialog-header">
<h2 id={titleId} class="apg-dialog-title">
{title}
</h2>
<button type="button" class="apg-dialog-close" onclick={close} 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>
{#if description}
<p id={descriptionId} class="apg-dialog-description">
{description}
</p>
{/if}
<div class="apg-dialog-body">
{@render children()}
</div>
</dialog> 使い方
使用例
<script>
import Dialog from './Dialog.svelte';
function handleOpenChange(open) {
console.log('Dialog:', open);
}
</script>
<Dialog
title="Dialog Title"
description="Optional description text"
onOpenChange={handleOpenChange}
>
{#snippet trigger({ open })}
<button onclick={open} class="btn-primary">Open Dialog</button>
{/snippet}
{#snippet children()}
<p>Dialog content goes here.</p>
{/snippet}
</Dialog> API
Props
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
title | string | 必須 | ダイアログのタイトル(アクセシビリティ用) |
description | string | - | オプションの説明テキスト |
defaultOpen | boolean | false | 初期のオープン状態 |
closeOnOverlayClick | boolean | true | オーバーレイをクリックしたときに閉じるかどうか |
onOpenChange | (open: boolean) => void | - | オープン状態が変更されたときのコールバック |
Snippets
| スニペット | Props | 説明 |
|---|---|---|
trigger | { open: () => void } | ダイアログを開くトリガー要素 |
children | - | ダイアログのコンテンツ |
エクスポートされた関数
| 関数 | 説明 |
|---|---|
open() | プログラムでダイアログを開く |
close() | プログラムでダイアログを閉じる |
テスト
テストは、キーボード操作、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.svelte.ts
import { render, screen, within } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import DialogTestWrapper from './DialogTestWrapper.svelte';
describe('Dialog (Svelte)', () => {
// 🔴 High Priority: APG 準拠の核心
describe('APG: キーボード操作', () => {
it('Escape キーでダイアログを閉じる', async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(DialogTestWrapper, {
props: { 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();
expect(onOpenChange).toHaveBeenLastCalledWith(false);
});
});
describe('APG: ARIA 属性', () => {
it('role="dialog" を持つ', async () => {
const user = userEvent.setup();
render(DialogTestWrapper);
await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('aria-modal="true" を持つ', async () => {
const user = userEvent.setup();
render(DialogTestWrapper);
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(DialogTestWrapper, {
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(DialogTestWrapper, {
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(DialogTestWrapper);
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(DialogTestWrapper);
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(DialogTestWrapper);
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(DialogTestWrapper, {
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(DialogTestWrapper, {
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(DialogTestWrapper, {
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(DialogTestWrapper, {
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(DialogTestWrapper, {
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('onOpenChange が開閉時に呼ばれる', async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(DialogTestWrapper, {
props: { onOpenChange },
});
await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
expect(onOpenChange).toHaveBeenCalledWith(true);
await user.keyboard('{Escape}');
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('defaultOpen=true で初期表示', async () => {
render(DialogTestWrapper, {
props: { defaultOpen: true },
});
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
// 🟢 Low Priority: 拡張性
describe('HTML 属性継承', () => {
it('className がダイアログに適用される', async () => {
const user = userEvent.setup();
render(DialogTestWrapper, {
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