Alert Dialog
ユーザーのワークフローを中断し、重要なメッセージを伝えて応答を求めるモーダルダイアログ。
🤖 AI Implementation Guideデモ
以下のアラートダイアログを試してください。重要な確認では Escape キーがデフォルトで無効になっており、初期フォーカスはキャンセルボタン(最も安全なアクション)に移動します。
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
alertdialog | ダイアログコンテナ | ユーザーのワークフローを中断して重要なメッセージを伝え、応答を求めるダイアログの一種。支援技術でシステムのアラート音がトリガーされる場合があります。 |
WAI-ARIA alertdialog role (opens in new tab)
WAI-ARIA プロパティ
| 属性 | 対象 | 値 | 必須 | 説明 |
|---|---|---|---|---|
aria-modal | alertdialog | true | 暗黙的 | showModal() によって自動的に提供される。ネイティブの
<dialog> 要素使用時は明示的な属性は不要。 |
aria-labelledby | alertdialog | タイトル要素への ID 参照 | はい | アラートダイアログのタイトルを参照する |
aria-describedby | alertdialog | メッセージへの ID 参照 | はい(必須) | アラートメッセージを参照する。通常の Dialog とは異なり、Alert Dialog ではメッセージがユーザーの意思決定を理解する上で中心的であるため、この属性は必須です。 |
フォーカス管理
| イベント | 動作 |
|---|---|
| ダイアログが開く | フォーカスはキャンセルボタン(最も安全なアクション)に移動する。これは最初のフォーカス可能な要素にフォーカスする通常の Dialog とは異なります。 |
| ダイアログが閉じる | ダイアログを開いた要素にフォーカスが戻る |
| フォーカストラップ | Tab/Shift+Tab はダイアログ内のフォーカス可能な要素間のみをサイクルする |
| 背景 | ダイアログ外のコンテンツは不活性化される(フォーカス不可・操作不可) |
キーボードサポート
| キー | アクション |
|---|---|
| Tab | ダイアログ内の次のフォーカス可能な要素にフォーカスを移動する。最後の要素にフォーカスがある場合は最初の要素に移動する。 |
| Shift + Tab | ダイアログ内の前のフォーカス可能な要素にフォーカスを移動する。最初の要素にフォーカスがある場合は最後の要素に移動する。 |
| Escape | デフォルトでは無効。通常の Dialog
とは異なり、アラートダイアログではユーザーに明示的に応答させるため、Escape
キーによる閉じる動作を防止します。allowEscapeClose
プロパティで重要度の低いアラートに対して有効にすることができます。 |
| Enter | フォーカスされているボタンを実行する |
| Space | フォーカスされているボタンを実行する |
Dialog との違い
| 機能 | Dialog | Alert Dialog |
|---|---|---|
| ロール | dialog | alertdialog |
メッセージ(aria-describedby) | 任意 | 必須 |
| Escape キー | デフォルトで有効 | デフォルトで無効 |
| 初期フォーカス | 最初のフォーカス可能な要素 | キャンセルボタン(最も安全なアクション) |
| 閉じるボタン | あり(×) | なし(明示的な応答が必要) |
| オーバーレイクリック | ダイアログを閉じる | 閉じない(明示的な応答が必要) |
補足事項
- Alert Dialog はユーザーの確認や意思決定を必要とする重要なメッセージ用です。一般的なコンテンツには通常の Dialog を使用してください。
-
alertdialogロールにより、支援技術はより緊急にダイアログを読み上げたり、アラート音を鳴らしたりする場合があります。 - タイトルとメッセージの両方が、ユーザーの意思決定に完全なコンテキストを提供するために必要です。
- キャンセルボタンは常に最も安全な選択(破壊的なアクションを行わない)であるべきです。
-
破壊的なアクションには視覚的な区別を提供するために
dangerバリアントの使用を検討してください。
ソースコード
AlertDialog.svelte
<script lang="ts" module>
import type { Snippet } from 'svelte';
export interface AlertDialogProps {
/** Dialog title (required for accessibility) */
title: string;
/** Alert message (required - unlike regular Dialog) */
message: string;
/** Confirm button label */
confirmLabel?: string;
/** Cancel button label */
cancelLabel?: string;
/** Confirm button variant */
confirmVariant?: 'default' | 'danger';
/** Allow closing with Escape key (default: false - unlike regular Dialog) */
allowEscapeClose?: boolean;
/** Default open state */
defaultOpen?: boolean;
/** Additional CSS class */
className?: string;
/** Callback when confirm button is clicked */
onConfirm?: () => void;
/** Callback when cancel button is clicked */
onCancel?: () => void;
/** Trigger snippet - receives open function */
trigger: Snippet<[{ open: () => void }]>;
}
</script>
<script lang="ts">
import { onMount, tick } from 'svelte';
let {
title,
message,
confirmLabel = 'OK',
cancelLabel = 'Cancel',
confirmVariant = 'default',
allowEscapeClose = false,
defaultOpen = false,
className = '',
onConfirm = () => {},
onCancel = () => {},
trigger,
}: AlertDialogProps = $props();
let dialogElement = $state<HTMLDialogElement | undefined>(undefined);
let cancelButtonElement = $state<HTMLButtonElement | undefined>(undefined);
let previousActiveElement: HTMLElement | null = null;
let instanceId = $state('');
onMount(() => {
instanceId = `alert-dialog-${Math.random().toString(36).substr(2, 9)}`;
// Open on mount if defaultOpen
if (defaultOpen && dialogElement) {
dialogElement.showModal();
focusCancelButton();
}
});
let titleId = $derived(`${instanceId}-title`);
let messageId = $derived(`${instanceId}-message`);
let confirmButtonClass = $derived(
confirmVariant === 'danger'
? 'apg-alert-dialog-confirm apg-alert-dialog-confirm--danger'
: 'apg-alert-dialog-confirm'
);
async function focusCancelButton() {
await tick();
cancelButtonElement?.focus();
}
export function open() {
if (dialogElement) {
previousActiveElement = document.activeElement as HTMLElement;
// Lock body scroll
document.body.style.overflow = 'hidden';
dialogElement.showModal();
focusCancelButton();
}
}
export function close() {
// Unlock body scroll
document.body.style.overflow = '';
dialogElement?.close();
}
function handleClose() {
// Unlock body scroll
document.body.style.overflow = '';
// Return focus to trigger
if (previousActiveElement) {
previousActiveElement.focus();
}
}
function handleKeyDown(event: KeyboardEvent) {
// Handle Escape key
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
if (allowEscapeClose) {
onCancel();
close();
}
return;
}
// Handle focus trap for Tab key
if (event.key === 'Tab' && dialogElement) {
const focusableElements = dialogElement.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
// Shift+Tab from first element -> wrap to last
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement?.focus();
}
} else {
// Tab from last element -> wrap to first
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement?.focus();
}
}
}
}
// Handle native cancel event (fired when Escape pressed in real browsers)
function handleCancelEvent(event: Event) {
if (!allowEscapeClose) {
event.preventDefault();
} else {
onCancel();
}
}
function handleConfirm() {
onConfirm();
close();
}
function handleCancel() {
onCancel();
close();
}
</script>
<!-- Trigger snippet -->
{@render trigger({ open })}
<!-- Native Dialog Element with alertdialog role -->
<dialog
bind:this={dialogElement}
role="alertdialog"
class={`apg-alert-dialog ${className}`.trim()}
aria-labelledby={titleId}
aria-describedby={messageId}
onkeydowncapture={handleKeyDown}
oncancel={handleCancelEvent}
onclose={handleClose}
>
<h2 id={titleId} class="apg-alert-dialog-title">
{title}
</h2>
<p id={messageId} class="apg-alert-dialog-message">
{message}
</p>
<div class="apg-alert-dialog-actions">
<button
bind:this={cancelButtonElement}
type="button"
class="apg-alert-dialog-cancel"
onclick={handleCancel}
>
{cancelLabel}
</button>
<button type="button" class={confirmButtonClass} onclick={handleConfirm}>
{confirmLabel}
</button>
</div>
</dialog> 使い方
Example
<script lang="ts">
import AlertDialog from './AlertDialog.svelte';
const handleDelete = () => {
console.log('アイテムが削除されました');
};
</script>
<AlertDialog
title="このアイテムを削除しますか?"
message="この操作は取り消せません。アイテムは完全に削除されます。"
confirmLabel="削除"
cancelLabel="キャンセル"
confirmVariant="danger"
onConfirm={handleDelete}
onCancel={() => console.log('キャンセルされました')}
>
{#snippet trigger({ open })}
<button onclick={open} class="bg-destructive text-destructive-foreground px-4 py-2 rounded">
アイテムを削除
</button>
{/snippet}
</AlertDialog> API
Props
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
title | string | 必須 | アラートダイアログのタイトル |
message | string | 必須 | アラートメッセージ(アクセシビリティ上必須) |
confirmLabel | string | "OK" | 確認ボタンのラベル |
cancelLabel | string | "Cancel" | キャンセルボタンのラベル |
confirmVariant | 'default' | 'danger' | 'default' | 確認ボタンのスタイル |
allowEscapeClose | boolean | false | Escape キーで閉じることを許可 |
onConfirm | () => void | - | 確認ボタンクリック時のコールバック |
onCancel | () => void | - | キャンセルボタンクリック時のコールバック |
スニペット
| スニペット | Props | 説明 |
|---|---|---|
trigger | { open } | open 関数を持つトリガー要素 |
テスト
テストは、キーボード操作、ARIA 属性、およびアクセシビリティ要件全体にわたる APG 準拠を検証します。Alert Dialog は通常の Dialog よりも厳格な要件があります。Alert Dialog コンポーネントは2層テスト戦略を使用しています。
テスト戦略
ユニットテスト (Testing Library / jest-axe)
コンポーネントのHTML出力、ARIA属性、およびアクセシビリティを検証します。これらのテストは正しいレンダリングとAPG要件への準拠を保証します。
- role="alertdialog"(dialog ではない)
- aria-labelledby と aria-describedby 属性
- showModal() によるモーダル動作
- axe-core による WCAG 2.1 AA 準拠
- プロパティ動作(allowEscapeClose, confirmVariant)
E2E テスト (Playwright)
すべてのフレームワーク(React、Vue、Svelte、Astro)で実際のブラウザ環境でのコンポーネント動作を検証します。JavaScript 実行を必要とするインタラクションをカバーします。
- ダイアログ表示時にキャンセルボタンにフォーカス(最も安全なアクション)
- Tab/Shift+Tab がダイアログ内でラップする(フォーカストラップ)
- Enter/Space でフォーカスされたボタンを実行
- デフォルトで Escape キー無効
- 閉じる時に開いた要素にフォーカスが戻る
- 通常の Dialog と違い閉じるボタン(×)がない
テストカテゴリ
高優先度: APG キーボード操作(Unit + E2E)
| テスト | 説明 |
|---|---|
Escape key(無効) | デフォルトでは Escape キーでダイアログを閉じない |
Escape key(有効) | allowEscapeClose が true の場合、Escape でダイアログを閉じる |
Enter on button | フォーカスされたボタンを実行する |
Space on button | フォーカスされたボタンを実行する |
高優先度: APG ARIA 属性(Unit + E2E)
| テスト | 説明 |
|---|---|
role="alertdialog" | ダイアログ要素に alertdialog ロールがある(dialog ではない) |
モーダル動作 | showModal() で開かれている(::backdrop の存在で確認) |
aria-labelledby | アラートダイアログのタイトルを参照する |
aria-describedby | アラートメッセージを参照する(Dialog とは異なり必須) |
高優先度: フォーカス管理(E2E)
| テスト | 説明 |
|---|---|
Initial focus | 開いたときにキャンセルボタンにフォーカスが移動する(最も安全なアクション) |
Focus restore | 閉じたときに開いた要素にフォーカスが戻る |
Focus trap | Tab サイクルがダイアログ内に留まる(ネイティブ dialog 経由) |
中優先度: アクセシビリティ(Unit + E2E)
| テスト | 説明 |
|---|---|
axe violations | WCAG 2.1 AA 違反がない(jest-axe 経由) |
Title and message | 両方がレンダリングされ、適切に関連付けられている |
低優先度: プロパティと動作(Unit)
| テスト | 説明 |
|---|---|
allowEscapeClose | Escape キーの動作を制御する(デフォルト: false) |
confirmVariant | danger バリアントが正しいスタイリングを適用する |
onConfirm | 確認ボタンがクリックされたときにコールバックが発火する |
onCancel | キャンセルボタンがクリックされたときにコールバックが発火する |
className | カスタムクラスが適用される |
テストツール
- Vitest (opens in new tab) - テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ
- jest-axe (opens in new tab) - 自動アクセシビリティテスト
- Playwright (opens in new tab) - クロスフレームワーク検証のための E2E テスト
テストの実行
ユニットテスト
# AlertDialog の全ユニットテストを実行
npm run test:unit -- AlertDialog
# フレームワーク別テストを実行
npm run test:react -- AlertDialog.test.tsx
npm run test:vue -- AlertDialog.test.vue.ts
npm run test:svelte -- AlertDialog.test.svelte.ts
npm run test:astro E2E テスト
# Alert Dialog の全 E2E テストを実行
npm run test:e2e -- alert-dialog.spec.ts
# UI モードで実行
npm run test:e2e:ui -- alert-dialog.spec.ts 詳細なドキュメントは testing-strategy.md (opens in new tab) を参照してください。
AlertDialog.test.svelte.ts
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import AlertDialogTestWrapper from './AlertDialogTestWrapper.svelte';
describe('AlertDialog (Svelte)', () => {
// 🔴 High Priority: APG ARIA 属性
describe('APG: ARIA 属性', () => {
it('role="alertdialog" を持つ(dialog ではない)', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('aria-modal="true" を持つ', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('alertdialog')).toHaveAttribute('aria-modal', 'true');
});
it('aria-labelledby でタイトルを参照', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper, {
props: { title: 'Delete Item' },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
const dialog = screen.getByRole('alertdialog');
const titleId = dialog.getAttribute('aria-labelledby');
expect(titleId).toBeTruthy();
expect(document.getElementById(titleId!)).toHaveTextContent('Delete Item');
});
it('aria-describedby でメッセージを参照(必須 - Dialog と異なる)', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper, {
props: { message: 'This action cannot be undone.' },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
const dialog = screen.getByRole('alertdialog');
const messageId = dialog.getAttribute('aria-describedby');
expect(messageId).toBeTruthy();
expect(document.getElementById(messageId!)).toHaveTextContent(
'This action cannot be undone.'
);
});
});
// 🔴 High Priority: キーボード操作
describe('APG: キーボード操作', () => {
it('デフォルトで Escape キーで閉じない(Dialog と異なる)', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(AlertDialogTestWrapper, {
props: { onCancel },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
expect(onCancel).not.toHaveBeenCalled();
});
it('allowEscapeClose=true で Escape キーで閉じる', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(AlertDialogTestWrapper, {
props: { allowEscapeClose: true, onCancel },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
expect(onCancel).toHaveBeenCalled();
});
it('Tab で次のフォーカス可能要素に移動', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
const confirmButton = screen.getByRole('button', { name: 'Confirm' });
await vi.waitFor(() => {
expect(cancelButton).toHaveFocus();
});
await user.tab();
expect(confirmButton).toHaveFocus();
});
it('Tab が最後から最初にループする', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
const confirmButton = screen.getByRole('button', { name: 'Confirm' });
await vi.waitFor(() => {
expect(cancelButton).toHaveFocus();
});
await user.tab();
expect(confirmButton).toHaveFocus();
await user.tab();
expect(cancelButton).toHaveFocus();
});
});
// 🔴 High Priority: フォーカス管理
describe('APG: フォーカス管理', () => {
it('開いた時に Cancel ボタンにフォーカス(安全なアクション、Dialog と異なる)', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper, {
props: { cancelLabel: 'Cancel', confirmLabel: 'Delete' },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
await vi.waitFor(() => {
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus();
});
});
it('閉じた時にトリガーにフォーカス復元', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper);
const trigger = screen.getByRole('button', { name: 'Open Alert' });
await user.click(trigger);
await vi.waitFor(() => {
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(trigger).toHaveFocus();
});
});
// 🟡 Medium Priority: アクセシビリティ
describe('アクセシビリティ', () => {
it('axe による違反がない', async () => {
const user = userEvent.setup();
const { container } = render(AlertDialogTestWrapper);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟢 Low Priority: Props & Callbacks
describe('Props & Callbacks', () => {
it('confirm ボタンクリックで onConfirm を呼ぶ', async () => {
const user = userEvent.setup();
const onConfirm = vi.fn();
render(AlertDialogTestWrapper, {
props: { onConfirm },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
await user.click(screen.getByRole('button', { name: 'Confirm' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('cancel ボタンクリックで onCancel を呼ぶ', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(AlertDialogTestWrapper, {
props: { onCancel },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('カスタムボタンラベルが表示される', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper, {
props: { confirmLabel: 'Delete', cancelLabel: 'Keep' },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Keep' })).toBeInTheDocument();
});
it('defaultOpen=true で初期表示', async () => {
render(AlertDialogTestWrapper, {
props: { defaultOpen: true },
});
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
});
});
// Alert Dialog 固有の動作
describe('Alert Dialog 固有の動作', () => {
it('閉じるボタン(×)がない(通常の Dialog と異なる)', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.queryByRole('button', { name: /close/i })).not.toBeInTheDocument();
});
});
}); リソース
- WAI-ARIA APG: Alert Dialog パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist