Alert Dialog
ユーザーのワークフローを中断し、重要なメッセージを伝えて応答を求めるモーダルダイアログ。
🤖 AI Implementation Guideデモ
以下のアラートダイアログを試してください。重要な確認では Escape キーがデフォルトで無効になっており、初期フォーカスはキャンセルボタン(最も安全なアクション)に移動します。
削除確認
明示的な確認が必要な破壊的なアクション。Escape キーは無効です。
変更の破棄
保存されていない変更を失う前に確認します。
情報の確認
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.astro
---
/**
* APG Alert Dialog Pattern - Astro Implementation
*
* A modal dialog that interrupts the user's workflow to communicate an important
* message and require a response. Unlike regular Dialog, uses role="alertdialog"
* which may trigger system alert sounds in assistive technologies.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/
*/
export interface Props {
/** Dialog title (required for accessibility) */
title: string;
/** Alert message (required - unlike regular Dialog) */
message: string;
/** Trigger button text */
triggerText: 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;
/** Additional CSS class for trigger button */
triggerClass?: string;
/** Additional CSS class for dialog */
class?: string;
}
const {
title,
message,
triggerText,
confirmLabel = 'OK',
cancelLabel = 'Cancel',
confirmVariant = 'default',
allowEscapeClose = false,
triggerClass = '',
class: className = '',
} = Astro.props;
// Generate unique ID for this instance
const instanceId = `alert-dialog-${Math.random().toString(36).substring(2, 11)}`;
const titleId = `${instanceId}-title`;
const messageId = `${instanceId}-message`;
const confirmButtonClass =
confirmVariant === 'danger'
? 'apg-alert-dialog-confirm apg-alert-dialog-confirm--danger'
: 'apg-alert-dialog-confirm';
---
<apg-alert-dialog data-allow-escape-close={allowEscapeClose ? 'true' : 'false'}>
<!-- Trigger Button -->
<button
type="button"
class={`apg-alert-dialog-trigger ${triggerClass}`.trim()}
data-alert-dialog-trigger
>
{triggerText}
</button>
<!-- Native Dialog Element with alertdialog role -->
<dialog
role="alertdialog"
class={`apg-alert-dialog ${className}`.trim()}
aria-labelledby={titleId}
aria-describedby={messageId}
data-alert-dialog-content
>
<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 type="button" class="apg-alert-dialog-cancel" data-cancel>
{cancelLabel}
</button>
<button type="button" class={confirmButtonClass} data-confirm>
{confirmLabel}
</button>
</div>
</dialog>
</apg-alert-dialog>
<script>
class ApgAlertDialog extends HTMLElement {
private trigger: HTMLButtonElement | null = null;
private dialog: HTMLDialogElement | null = null;
private cancelButton: HTMLButtonElement | null = null;
private confirmButton: HTMLButtonElement | null = null;
private allowEscapeClose = false;
connectedCallback() {
this.trigger = this.querySelector('[data-alert-dialog-trigger]');
this.dialog = this.querySelector('[data-alert-dialog-content]');
this.cancelButton = this.querySelector('[data-cancel]');
this.confirmButton = this.querySelector('[data-confirm]');
if (!this.trigger || !this.dialog) {
console.warn('apg-alert-dialog: required elements not found');
return;
}
this.allowEscapeClose = this.dataset.allowEscapeClose === 'true';
// Attach event listeners
this.trigger.addEventListener('click', this.open);
this.cancelButton?.addEventListener('click', this.handleCancelClick);
this.confirmButton?.addEventListener('click', this.handleConfirm);
this.dialog.addEventListener('keydown', this.handleKeyDown, true);
this.dialog.addEventListener('cancel', this.handleDialogCancel);
this.dialog.addEventListener('close', this.handleClose);
}
disconnectedCallback() {
this.trigger?.removeEventListener('click', this.open);
this.cancelButton?.removeEventListener('click', this.handleCancelClick);
this.confirmButton?.removeEventListener('click', this.handleConfirm);
this.dialog?.removeEventListener('keydown', this.handleKeyDown, true);
this.dialog?.removeEventListener('cancel', this.handleDialogCancel);
this.dialog?.removeEventListener('close', this.handleClose);
}
private open = () => {
if (!this.dialog) return;
// Lock body scroll
document.body.style.overflow = 'hidden';
this.dialog.showModal();
// Focus cancel button (safest action - unlike regular Dialog)
requestAnimationFrame(() => {
this.cancelButton?.focus();
});
// Dispatch custom event
this.dispatchEvent(
new CustomEvent('alertdialogopen', {
bubbles: true,
})
);
};
private close = () => {
// Unlock body scroll
document.body.style.overflow = '';
this.dialog?.close();
};
private handleClose = () => {
// Unlock body scroll
document.body.style.overflow = '';
// Return focus to trigger
this.trigger?.focus();
// Dispatch custom event
this.dispatchEvent(
new CustomEvent('alertdialogclose', {
bubbles: true,
})
);
};
private handleKeyDown = (e: KeyboardEvent) => {
// Handle Escape key
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
if (this.allowEscapeClose) {
this.dispatchEvent(
new CustomEvent('cancel', {
bubbles: true,
})
);
this.close();
}
return;
}
// Handle focus trap for Tab key
if (e.key === 'Tab' && this.dialog) {
const focusableElements = this.dialog.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
// Shift+Tab from first element -> wrap to last
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
// Tab from last element -> wrap to first
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
}
};
// Handle native dialog cancel event (fired when Escape pressed in real browsers)
private handleDialogCancel = (e: Event) => {
if (!this.allowEscapeClose) {
e.preventDefault();
} else {
this.dispatchEvent(
new CustomEvent('cancel', {
bubbles: true,
})
);
}
};
private handleCancelClick = () => {
this.dispatchEvent(
new CustomEvent('cancel', {
bubbles: true,
})
);
this.close();
};
private handleConfirm = () => {
this.dispatchEvent(
new CustomEvent('confirm', {
bubbles: true,
})
);
this.close();
};
}
// Register the custom element
if (!customElements.get('apg-alert-dialog')) {
customElements.define('apg-alert-dialog', ApgAlertDialog);
}
</script> 使い方
Example
---
import AlertDialog from './AlertDialog.astro';
---
<AlertDialog
title="このアイテムを削除しますか?"
message="この操作は取り消せません。アイテムは完全に削除されます。"
triggerText="アイテムを削除"
confirmLabel="削除"
cancelLabel="キャンセル"
confirmVariant="danger"
triggerClass="bg-destructive text-destructive-foreground px-4 py-2 rounded"
/>
<script>
// カスタムイベントをリッスン
document.querySelector('apg-alert-dialog')?.addEventListener('confirm', () => {
console.log('確認されました!');
});
</script> API
Props
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
title | string | 必須 | アラートダイアログのタイトル |
message | string | 必須 | アラートメッセージ(アクセシビリティ上必須) |
triggerText | string | 必須 | トリガーボタンのテキスト |
confirmLabel | string | "OK" | 確認ボタンのラベル |
cancelLabel | string | "Cancel" | キャンセルボタンのラベル |
confirmVariant | 'default' | 'danger' | 'default' | 確認ボタンのスタイル |
allowEscapeClose | boolean | false | Escape キーで閉じることを許可 |
triggerClass | string | - | トリガーボタンの追加 CSS クラス |
カスタムイベント
| イベント | 説明 |
|---|---|
confirm | 確認ボタンがクリックされたときに発火 |
cancel | キャンセルボタンがクリックされたときに発火 |
alertdialogopen | ダイアログが開いたときに発火 |
alertdialogclose | ダイアログが閉じたときに発火 |
テスト
テストは、キーボード操作、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.astro.ts
/**
* AlertDialog Web Component Tests
*
* Note: These are limited unit tests for the Web Component class.
* Full keyboard navigation and focus management tests require E2E testing
* with Playwright due to jsdom limitations with focus events and <dialog> element.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('AlertDialog (Web Component)', () => {
let container: HTMLElement;
// Web Component class extracted for testing
class TestApgAlertDialog extends HTMLElement {
private dialog: HTMLDialogElement | null = null;
private triggerRef: HTMLElement | null = null;
private cancelButton: HTMLButtonElement | null = null;
private confirmButton: HTMLButtonElement | null = null;
private rafId: number | null = null;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.dialog = this.querySelector('dialog');
this.cancelButton = this.querySelector('[data-cancel]');
this.confirmButton = this.querySelector('[data-confirm]');
if (!this.dialog) return;
// Set up event listeners
this.dialog.addEventListener('keydown', this.handleKeyDown);
this.cancelButton?.addEventListener('click', this.handleCancel);
this.confirmButton?.addEventListener('click', this.handleConfirm);
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.dialog?.removeEventListener('keydown', this.handleKeyDown);
this.cancelButton?.removeEventListener('click', this.handleCancel);
this.confirmButton?.removeEventListener('click', this.handleConfirm);
}
open(triggerElement?: HTMLElement) {
if (!this.dialog) return;
this.triggerRef = triggerElement || null;
this.dialog.showModal();
// Focus cancel button (safest action)
requestAnimationFrame(() => {
this.cancelButton?.focus();
});
}
close() {
if (!this.dialog) return;
this.dialog.close();
this.triggerRef?.focus();
}
private handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
const allowEscapeClose = this.dataset.allowEscapeClose === 'true';
if (!allowEscapeClose) {
event.preventDefault();
} else {
this.dispatchEvent(new CustomEvent('cancel', { bubbles: true }));
}
}
};
private handleCancel = () => {
this.dispatchEvent(new CustomEvent('cancel', { bubbles: true }));
this.close();
};
private handleConfirm = () => {
this.dispatchEvent(new CustomEvent('confirm', { bubbles: true }));
this.close();
};
}
function createAlertDialogHTML(
options: {
title?: string;
message?: string;
confirmLabel?: string;
cancelLabel?: string;
allowEscapeClose?: boolean;
open?: boolean;
} = {}
) {
const {
title = 'Confirm Action',
message = 'Are you sure?',
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
allowEscapeClose = false,
open = false,
} = options;
const titleId = 'alert-title';
const messageId = 'alert-message';
return `
<apg-alert-dialog ${allowEscapeClose ? 'data-allow-escape-close="true"' : ''}>
<dialog
role="alertdialog"
aria-modal="true"
aria-labelledby="${titleId}"
aria-describedby="${messageId}"
class="apg-alert-dialog"
${open ? 'open' : ''}
>
<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 type="button" data-cancel class="apg-alert-dialog-cancel">${cancelLabel}</button>
<button type="button" data-confirm class="apg-alert-dialog-confirm">${confirmLabel}</button>
</div>
</dialog>
</apg-alert-dialog>
`;
}
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
// Register custom element if not already registered
if (!customElements.get('apg-alert-dialog')) {
customElements.define('apg-alert-dialog', TestApgAlertDialog);
}
});
afterEach(() => {
container.remove();
vi.restoreAllMocks();
});
describe('ARIA Attributes', () => {
it('has role="alertdialog" (NOT dialog)', async () => {
container.innerHTML = createAlertDialogHTML();
await new Promise((r) => requestAnimationFrame(r));
const dialog = container.querySelector('dialog');
expect(dialog?.getAttribute('role')).toBe('alertdialog');
});
it('has aria-modal="true"', async () => {
container.innerHTML = createAlertDialogHTML();
await new Promise((r) => requestAnimationFrame(r));
const dialog = container.querySelector('dialog');
expect(dialog?.getAttribute('aria-modal')).toBe('true');
});
it('has aria-labelledby referencing title', async () => {
container.innerHTML = createAlertDialogHTML({ title: 'Delete Item' });
await new Promise((r) => requestAnimationFrame(r));
const dialog = container.querySelector('dialog');
const titleId = dialog?.getAttribute('aria-labelledby');
expect(titleId).toBeTruthy();
expect(document.getElementById(titleId!)?.textContent).toBe('Delete Item');
});
it('has aria-describedby referencing message (required)', async () => {
container.innerHTML = createAlertDialogHTML({ message: 'This cannot be undone.' });
await new Promise((r) => requestAnimationFrame(r));
const dialog = container.querySelector('dialog');
const messageId = dialog?.getAttribute('aria-describedby');
expect(messageId).toBeTruthy();
expect(document.getElementById(messageId!)?.textContent).toBe('This cannot be undone.');
});
});
describe('Escape Key Behavior', () => {
it('prevents close on Escape by default', async () => {
container.innerHTML = createAlertDialogHTML({ allowEscapeClose: false });
await new Promise((r) => requestAnimationFrame(r));
const dialog = container.querySelector('dialog');
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
});
dialog?.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});
it('allows close on Escape when allowEscapeClose=true', async () => {
container.innerHTML = createAlertDialogHTML({ allowEscapeClose: true });
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-alert-dialog') as HTMLElement;
const dialog = container.querySelector('dialog');
const cancelHandler = vi.fn();
element.addEventListener('cancel', cancelHandler);
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
});
dialog?.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(cancelHandler).toHaveBeenCalledTimes(1);
});
});
describe('Button Actions', () => {
it('dispatches cancel event on cancel button click', async () => {
container.innerHTML = createAlertDialogHTML();
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-alert-dialog') as HTMLElement;
const cancelButton = container.querySelector('[data-cancel]') as HTMLButtonElement;
const cancelHandler = vi.fn();
element.addEventListener('cancel', cancelHandler);
cancelButton.click();
expect(cancelHandler).toHaveBeenCalledTimes(1);
});
it('dispatches confirm event on confirm button click', async () => {
container.innerHTML = createAlertDialogHTML();
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-alert-dialog') as HTMLElement;
const confirmButton = container.querySelector('[data-confirm]') as HTMLButtonElement;
const confirmHandler = vi.fn();
element.addEventListener('confirm', confirmHandler);
confirmButton.click();
expect(confirmHandler).toHaveBeenCalledTimes(1);
});
});
describe('Button Labels', () => {
it('displays custom button labels', async () => {
container.innerHTML = createAlertDialogHTML({
confirmLabel: 'Delete',
cancelLabel: 'Keep',
});
await new Promise((r) => requestAnimationFrame(r));
const cancelButton = container.querySelector('[data-cancel]');
const confirmButton = container.querySelector('[data-confirm]');
expect(cancelButton?.textContent).toBe('Keep');
expect(confirmButton?.textContent).toBe('Delete');
});
});
describe('Alert Dialog Specific', () => {
it('does NOT have a close button (×)', async () => {
container.innerHTML = createAlertDialogHTML();
await new Promise((r) => requestAnimationFrame(r));
// Alert dialog should not have the typical close button
const closeButton = container.querySelector(
'[aria-label*="close" i], [aria-label*="Close" i]'
);
expect(closeButton).toBeNull();
});
it('has only Cancel and Confirm buttons', async () => {
container.innerHTML = createAlertDialogHTML();
await new Promise((r) => requestAnimationFrame(r));
const buttons = container.querySelectorAll('button');
expect(buttons.length).toBe(2);
});
});
}); リソース
- WAI-ARIA APG: Alert Dialog パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist