Dialog (Modal)
プライマリウィンドウの上に重なるウィンドウで、背後のコンテンツを不活性にします。
デモ
基本ダイアログ
タイトル、説明、閉じる機能を備えたシンプルなモーダルダイアログ。
説明なし
タイトルとコンテンツのみのダイアログ。
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
dialog | ダイアログコンテナ | その要素がダイアログウィンドウであることを示す |
WAI-ARIA プロパティ
aria-modal
これがモーダルダイアログであることを示す
- 値
- true
- 必須
- はい
aria-labelledby
ダイアログのタイトルを参照する
- 値
- タイトル要素への ID 参照
- 必須
- はい
aria-describedby
オプションの説明テキストを参照する
- 値
- 説明への ID 参照
- 必須
- いいえ
キーボードサポート
| キー | アクション |
|---|---|
| Tab | ダイアログ内の次のフォーカス可能な要素にフォーカスを移動する。最後の要素にフォーカスがある場合は最初の要素に移動する。 |
| Shift + Tab | ダイアログ内の前のフォーカス可能な要素にフォーカスを移動する。最初の要素にフォーカスがある場合は最後の要素に移動する。 |
| Escape | ダイアログを閉じて、開いた要素にフォーカスを戻す |
- アクセシビリティのためにダイアログのタイトルは必須であり、ダイアログの目的を明確に説明する必要があります
- ダイアログが開いている間はページのスクロールが無効になります
- オーバーレイ(背景)をクリックするとデフォルトでダイアログが閉じます
- 閉じるボタンにはスクリーンリーダー向けのアクセシブルなラベルが付いています
フォーカス管理
| イベント | 振る舞い |
|---|---|
| ダイアログが開く | ダイアログ内の最初のフォーカス可能な要素にフォーカスが移動する |
| ダイアログが閉じる | ダイアログを開いた要素にフォーカスが戻る |
| フォーカストラップ | Tab/Shift+Tab はダイアログ内のフォーカス可能な要素間のみをサイクルする |
| 背景 | ダイアログ外のコンテンツは不活性化される(フォーカス不可・操作不可) |
実装ノート
Structure:
+-------------------------------------+
| Dialog Title [X] | <- aria-labelledby target
+-------------------------------------+
| |
| Dialog content... | <- aria-describedby target (optional)
| |
| [Cancel] [Confirm] | <- focusable elements
+-------------------------------------+
Focus Trap:
- First focusable -> ... -> Last focusable -> First focusable (loop)
- Store trigger element reference before opening
- Restore focus to trigger on close
Dialogコンポーネントの構造とフォーカス管理
参考資料
ソースコード
Dialog.astro
---
/**
* APG Dialog (Modal) Pattern - Astro Implementation
*
* A window overlaid on the primary window, rendering the content underneath inert.
* Uses native <dialog> element with Web Components for enhanced control.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
*/
export interface Props {
/** Dialog title (required for accessibility) */
title: string;
/** Optional description text */
description?: string;
/** Trigger button text */
triggerText: string;
/** Close on overlay click */
closeOnOverlayClick?: boolean;
/** Additional CSS class for trigger button */
triggerClass?: string;
/** Additional CSS class for dialog */
class?: string;
}
const {
title,
description,
triggerText,
closeOnOverlayClick = true,
triggerClass = '',
class: className = '',
} = Astro.props;
// Generate unique ID for this instance
const instanceId = `dialog-${Math.random().toString(36).substring(2, 11)}`;
const titleId = `${instanceId}-title`;
const descriptionId = `${instanceId}-description`;
---
<apg-dialog data-close-on-overlay={closeOnOverlayClick}>
<!-- Trigger Button -->
<button type="button" class={`apg-dialog-trigger ${triggerClass}`.trim()} data-dialog-trigger>
{triggerText}
</button>
<!-- Native Dialog Element -->
<dialog
class={`apg-dialog ${className}`.trim()}
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
data-dialog-content
>
<div class="apg-dialog-header">
<h2 id={titleId} class="apg-dialog-title">
{title}
</h2>
<button type="button" class="apg-dialog-close" data-dialog-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>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
{
description && (
<p id={descriptionId} class="apg-dialog-description">
{description}
</p>
)
}
<div class="apg-dialog-body">
<slot />
</div>
</dialog>
</apg-dialog>
<script>
class ApgDialog extends HTMLElement {
private trigger: HTMLButtonElement | null = null;
private dialog: HTMLDialogElement | null = null;
private closeButton: HTMLButtonElement | null = null;
private closeOnOverlayClick = true;
connectedCallback() {
this.trigger = this.querySelector('[data-dialog-trigger]');
this.dialog = this.querySelector('[data-dialog-content]');
this.closeButton = this.querySelector('[data-dialog-close]');
if (!this.trigger || !this.dialog) {
console.warn('apg-dialog: required elements not found');
return;
}
this.closeOnOverlayClick = this.dataset.closeOnOverlay !== 'false';
// Attach event listeners
this.trigger.addEventListener('click', this.open);
this.closeButton?.addEventListener('click', this.close);
this.dialog.addEventListener('click', this.handleDialogClick);
this.dialog.addEventListener('close', this.handleClose);
}
disconnectedCallback() {
this.trigger?.removeEventListener('click', this.open);
this.closeButton?.removeEventListener('click', this.close);
this.dialog?.removeEventListener('click', this.handleDialogClick);
this.dialog?.removeEventListener('close', this.handleClose);
}
private open = () => {
if (!this.dialog) return;
this.dialog.showModal();
// Focus first focusable element (close button by default)
const focusableElements = this.getFocusableElements(this.dialog);
if (focusableElements.length > 0) {
focusableElements[0].focus();
}
// Dispatch custom event
this.dispatchEvent(
new CustomEvent('dialogopen', {
bubbles: true,
})
);
};
private close = () => {
this.dialog?.close();
};
private handleClose = () => {
// Return focus to trigger
this.trigger?.focus();
// Dispatch custom event
this.dispatchEvent(
new CustomEvent('dialogclose', {
bubbles: true,
})
);
};
private handleDialogClick = (e: Event) => {
// Close on backdrop click (clicking the dialog element itself, not its contents)
if (this.closeOnOverlayClick && e.target === this.dialog) {
this.close();
}
};
private getFocusableElements(container: HTMLElement): HTMLElement[] {
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
return Array.from(container.querySelectorAll<HTMLElement>(focusableSelectors)).filter(
(el) => el.offsetParent !== null
);
}
}
// Register the custom element
if (!customElements.get('apg-dialog')) {
customElements.define('apg-dialog', ApgDialog);
}
</script> 使い方
Example
---
import Dialog from './Dialog.astro';
---
<Dialog
title="Dialog Title"
description="Optional description text"
triggerText="Open Dialog"
>
<p>Dialog content goes here.</p>
</Dialog> API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
title | string | required | ダイアログのタイトル(アクセシビリティ用) |
description | string | - | オプションの説明テキスト |
triggerText | string | required | トリガーボタンのテキスト |
closeOnOverlayClick | boolean | true | オーバーレイクリックで閉じる |
triggerClass | string | - | トリガーボタンの追加CSSクラス |
class | string | - | ダイアログの追加CSSクラス |
スロット
| スロット | デフォルト | 説明 |
|---|---|---|
default | - | ダイアログのコンテンツ |
Custom Events
| イベント | Detail | 説明 |
|---|---|---|
dialogopen | - | ダイアログが開いたときに発火 |
dialogclose | - | ダイアログが閉じたときに発火 |
テスト
テストは、キーボードインタラクション、ARIA属性、アクセシビリティ要件全体でAPG準拠を検証します。Dialogコンポーネントは2層のテスト戦略を採用しています。
テスト戦略
ユニットテスト(Testing Library)
フレームワーク固有のテストライブラリを使用してコンポーネントのレンダリング出力を検証します。これらのテストは正しいHTML構造とARIA属性を確認します。
- ARIA属性(aria-labelledby、aria-describedby)
- Escapeキーでダイアログを閉じる
- 開閉時のフォーカス管理
- jest-axeによるアクセシビリティ検証
E2Eテスト(Playwright)
すべてのフレームワークで実際のブラウザ環境でコンポーネントの動作を検証します。これらのテストはインタラクションとフレームワーク間の一貫性をカバーします。
- モーダル動作(showModal、backdrop)
- フォーカストラップの検証
- 閉じた時のフォーカス復元
- オーバーレイクリックで閉じる
- ライブブラウザでのARIA構造検証
- axe-coreによるアクセシビリティスキャン
- フレームワーク間の一貫性チェック
テストカテゴリ
高優先度: APG キーボードインタラクション ( Unit + E2E )
| テスト | 説明 |
|---|---|
Escape key | ダイアログを閉じる |
高優先度: APG ARIA 属性 ( Unit + E2E )
| テスト | 説明 |
|---|---|
role="dialog" | ダイアログ要素にdialogロールが設定されている |
aria-modal="true" | モーダル動作を示す |
aria-labelledby | ダイアログのタイトルを参照 |
aria-describedby | 説明を参照(提供されている場合) |
高優先度: フォーカス管理 ( Unit + E2E )
| テスト | 説明 |
|---|---|
Initial focus | 開いた時に最初のフォーカス可能な要素にフォーカスが移動 |
Focus restore | 閉じた時にトリガー要素にフォーカスが戻る |
Focus trap | Tabサイクルがダイアログ内に留まる(ネイティブdialog経由) |
中優先度: アクセシビリティ ( Unit + E2E )
| テスト | 説明 |
|---|---|
axe violations | WCAG 2.1 AA違反なし(jest-axe経由) |
低優先度: Props と動作 ( Unit )
| テスト | 説明 |
|---|---|
closeOnOverlayClick | オーバーレイクリックの動作を制御 |
defaultOpen | 初期の開閉状態 |
onOpenChange | 開閉時にコールバックが発火 |
className | カスタムクラスが適用される |
E2E テストコード
e2e/dialog.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
/**
* E2E Tests for Dialog (Modal) Pattern
*
* A window overlaid on the primary content, requiring user interaction.
* Modal dialogs trap focus and prevent interaction with content outside.
*
* Key differences from Alert Dialog:
* - role="dialog" (not "alertdialog")
* - Escape key closes the dialog
* - Has close button (×)
* - aria-describedby is optional
* - Initial focus on close button or first focusable element
*
* APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
*/
const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;
// ============================================
// Helper Functions
// ============================================
const getDialog = (page: import('@playwright/test').Page) => {
// Use getByRole which only returns visible elements with the role
// This works for both native <dialog> (implicit role) and custom role="dialog"
return page.getByRole('dialog');
};
const openDialog = async (page: import('@playwright/test').Page) => {
const trigger = page.getByRole('button', { name: /open dialog/i }).first();
await trigger.click();
// Wait for dialog to be visible (native <dialog> has implicit role="dialog")
await getDialog(page).waitFor({ state: 'visible' });
return trigger;
};
// ============================================
// Framework-specific Tests
// ============================================
for (const framework of frameworks) {
test.describe(`Dialog (${framework})`, () => {
test.beforeEach(async ({ page }) => {
await page.goto(`patterns/dialog/${framework}/demo/`);
// Wait for the trigger button to be visible (indicates hydration complete)
await page
.getByRole('button', { name: /open dialog/i })
.first()
.waitFor();
});
// ------------------------------------------
// 🔴 High Priority: APG ARIA Structure
// ------------------------------------------
test.describe('APG: ARIA Structure', () => {
test('has role="dialog"', async ({ page }) => {
await openDialog(page);
const dialog = getDialog(page);
await expect(dialog).toBeVisible();
await expect(dialog).toHaveRole('dialog');
});
test('supports native <dialog> or custom role="dialog"', async ({ page }) => {
await openDialog(page);
const dialog = getDialog(page);
const tagName = await dialog.evaluate((el) => el.tagName.toLowerCase());
// Native <dialog> or a custom element with role="dialog" are both acceptable
expect(tagName === 'dialog' || (await dialog.getAttribute('role')) === 'dialog').toBe(true);
});
test('has aria-modal="true" (for custom dialog) or uses showModal (for native)', async ({
page,
}) => {
await openDialog(page);
const dialog = getDialog(page);
const isNative = await dialog.evaluate((el) => el.tagName.toLowerCase() === 'dialog');
if (isNative) {
// Native <dialog> opened via showModal() is implicitly modal
// aria-modal is not required when using showModal()
const hasOpenAttribute = await dialog.evaluate((el) => el.hasAttribute('open'));
expect(hasOpenAttribute).toBe(true);
} else {
// Custom dialog must have aria-modal="true"
await expect(dialog).toHaveAttribute('aria-modal', 'true');
}
});
test('is modal (opened via showModal for native dialog)', async ({ page }) => {
await openDialog(page);
const dialog = getDialog(page);
await expect(dialog).toBeVisible();
const isNative = await dialog.evaluate((el) => el.tagName.toLowerCase() === 'dialog');
if (isNative) {
// Verify dialog has 'open' attribute (showModal sets this)
const hasOpenAttribute = await dialog.evaluate((el) => el.hasAttribute('open'));
expect(hasOpenAttribute).toBe(true);
// Verify backdrop exists (showModal() creates ::backdrop)
const hasBackdrop = await dialog.evaluate((el) => {
const style = window.getComputedStyle(el, '::backdrop');
return style.display !== 'none';
});
expect(hasBackdrop).toBe(true);
} else {
// Custom dialog should have aria-modal="true"
await expect(dialog).toHaveAttribute('aria-modal', 'true');
}
});
test('has aria-labelledby referencing title', async ({ page }) => {
await openDialog(page);
const dialog = getDialog(page);
const labelledbyId = await dialog.getAttribute('aria-labelledby');
expect(labelledbyId).toBeTruthy();
const titleElement = page.locator(`[id="${labelledbyId}"]`);
await expect(titleElement).toBeVisible();
// Verify it's an actual title element (heading)
const tagName = await titleElement.evaluate((el) => el.tagName.toLowerCase());
expect(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']).toContain(tagName);
});
test('has aria-describedby when description is provided', async ({ page }) => {
await openDialog(page);
const dialog = getDialog(page);
const describedbyId = await dialog.getAttribute('aria-describedby');
// aria-describedby is optional for Dialog
if (describedbyId) {
const descriptionElement = page.locator(`[id="${describedbyId}"]`);
await expect(descriptionElement).toBeVisible();
}
});
test('has close button with accessible label', async ({ page }) => {
await openDialog(page);
const dialog = getDialog(page);
const closeButton = dialog.getByRole('button', { name: /close/i });
await expect(closeButton).toBeVisible();
});
});
// ------------------------------------------
// 🔴 High Priority: Keyboard Interaction
// ------------------------------------------
test.describe('APG: Keyboard Interaction', () => {
test('Escape closes the dialog', async ({ page }) => {
await openDialog(page);
const dialog = getDialog(page);
await expect(dialog).toBeVisible();
await page.keyboard.press('Escape');
// Dialog should be closed
await expect(dialog).not.toBeVisible();
});
test('Tab moves focus to next element', async ({ page }) => {
await openDialog(page);
const dialog = getDialog(page);
// Get all focusable elements in dialog
const focusableElements = dialog.locator(
'button:not([disabled]), [tabindex="0"], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href]'
);
const count = await focusableElements.count();
expect(count).toBeGreaterThanOrEqual(1);
// Focus the first element explicitly
const first = focusableElements.first();
await first.focus();
await expect(first).toBeFocused();
// Tab should move to next element
await page.keyboard.press('Tab');
// If there's more than one focusable element, focus should have moved
if (count > 1) {
await expect(focusableElements.nth(1)).toBeFocused();
}
});
test('Tab wraps from last to first element (focus trap)', async ({ page }) => {
await openDialog(page);
const dialog = getDialog(page);
// Get all focusable elements in dialog
const focusableElements = dialog.locator(
'button:not([disabled]), [tabindex="0"], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href]'
);
const count = await focusableElements.count();
// Tab through all elements plus one more to verify wrap
for (let i = 0; i <= count; i++) {
await page.keyboard.press('Tab');
}
// Focus should still be within dialog
const focusedElement = page.locator(':focus');
const isWithinDialog = await focusedElement.evaluate(
(el) => el.closest('dialog, [role="dialog"]') !== null
);
expect(isWithinDialog).toBe(true);
});
test('Shift+Tab moves focus to previous element', async ({ page }) => {
await openDialog(page);
const dialog = getDialog(page);
const closeButton = dialog.getByRole('button', { name: /close/i });
// Click close button to ensure focus is in dialog
await closeButton.click();
// Dialog closes on click, so reopen
await page
.getByRole('button', { name: /open dialog/i })
.first()
.click();
await getDialog(page).waitFor({ state: 'visible' });
// Tab once to move focus into dialog
await page.keyboard.press('Tab');
// Shift+Tab should move backwards but stay in dialog
await page.keyboard.press('Shift+Tab');
// Focus should still be within dialog
const isWithinDialog = await page.evaluate(() => {
const focused = document.activeElement;
return focused?.closest('dialog, [role="dialog"]') !== null;
});
expect(isWithinDialog).toBe(true);
});
test('Shift+Tab wraps from first to last element', async ({ page }) => {
await openDialog(page);
const dialog = getDialog(page);
// Get all focusable elements
const focusableElements = dialog.locator(
'button:not([disabled]), [tabindex="0"], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href]'
);
const count = await focusableElements.count();
// Shift+Tab through all elements to test wrap
for (let i = 0; i <= count; i++) {
await page.keyboard.press('Shift+Tab');
}
// Focus should still be within dialog
const focusedElement = page.locator(':focus');
const isWithinDialog = await focusedElement.evaluate(
(el) => el.closest('dialog, [role="dialog"]') !== null
);
expect(isWithinDialog).toBe(true);
});
});
// ------------------------------------------
// 🔴 High Priority: Focus Management
// ------------------------------------------
test.describe('APG: Focus Management', () => {
test('focuses first focusable element on open', async ({ page }) => {
await openDialog(page);
// Focus should be within dialog
const focusedElement = page.locator(':focus');
const isWithinDialog = await focusedElement.evaluate(
(el) => el.closest('dialog, [role="dialog"]') !== null
);
expect(isWithinDialog).toBe(true);
});
test('returns focus to trigger on close via Escape', async ({ page }) => {
const trigger = await openDialog(page);
await page.keyboard.press('Escape');
// Focus should return to trigger
await expect(trigger).toBeFocused();
});
test('returns focus to trigger on close via close button', async ({ page }) => {
const trigger = await openDialog(page);
const dialog = getDialog(page);
const closeButton = dialog.getByRole('button', { name: /close/i });
await closeButton.click();
// Focus should return to trigger
await expect(trigger).toBeFocused();
});
test('traps focus within dialog', async ({ page }) => {
await openDialog(page);
const dialog = getDialog(page);
// Get count of focusable elements
const focusableElements = dialog.locator(
'button:not([disabled]), [tabindex="0"], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href]'
);
const count = await focusableElements.count();
// Tab many times - focus should never leave dialog
// First Tab may move focus into dialog, then subsequent Tabs should stay within
const tabCount = Math.max(count * 3, 10);
for (let i = 0; i < tabCount; i++) {
await page.keyboard.press('Tab');
}
// After many Tabs, focus should still be within dialog
const isWithinDialog = await page.evaluate(() => {
const focused = document.activeElement;
return focused?.closest('dialog, [role="dialog"]') !== null;
});
expect(isWithinDialog).toBe(true);
});
});
// ------------------------------------------
// 🔴 High Priority: Click Interaction
// ------------------------------------------
test.describe('APG: Click Interaction', () => {
test('clicking close button closes dialog', async ({ page }) => {
await openDialog(page);
const dialog = getDialog(page);
const closeButton = dialog.getByRole('button', { name: /close/i });
await closeButton.click();
await expect(dialog).not.toBeVisible();
});
test('clicking overlay closes dialog', async ({ page }) => {
await openDialog(page);
const dialog = getDialog(page);
await expect(dialog).toBeVisible();
// Get viewport size and dialog bounds to find a safe click position outside dialog
const viewportSize = page.viewportSize();
const dialogBox = await dialog.boundingBox();
if (viewportSize && dialogBox) {
// Find a safe position outside dialog, handling edge cases
// Try multiple positions: top, left, right, bottom of dialog
const candidates = [
// Above dialog (if there's space)
{ x: dialogBox.x + dialogBox.width / 2, y: Math.max(1, dialogBox.y - 20) },
// Left of dialog (if there's space)
{ x: Math.max(1, dialogBox.x - 20), y: dialogBox.y + dialogBox.height / 2 },
// Right of dialog (if there's space)
{
x: Math.min(viewportSize.width - 1, dialogBox.x + dialogBox.width + 20),
y: dialogBox.y + dialogBox.height / 2,
},
// Below dialog (if there's space)
{
x: dialogBox.x + dialogBox.width / 2,
y: Math.min(viewportSize.height - 1, dialogBox.y + dialogBox.height + 20),
},
];
// Find first candidate that's outside dialog bounds
const isOutsideDialog = (x: number, y: number) =>
x < dialogBox.x ||
x > dialogBox.x + dialogBox.width ||
y < dialogBox.y ||
y > dialogBox.y + dialogBox.height;
const safePosition = candidates.find((pos) => isOutsideDialog(pos.x, pos.y));
if (safePosition) {
await page.mouse.click(safePosition.x, safePosition.y);
} else {
// Fallback: click at viewport corner (1,1)
await page.mouse.click(1, 1);
}
} else {
// Fallback: click at viewport corner
await page.mouse.click(1, 1);
}
// Dialog should close when clicking overlay
await expect(dialog).not.toBeVisible();
});
});
// ------------------------------------------
// 🟢 Low Priority: Accessibility
// ------------------------------------------
test.describe('Accessibility', () => {
test('has no axe-core violations', async ({ page }) => {
await openDialog(page);
const dialog = getDialog(page);
await expect(dialog).toBeVisible();
const results = await new AxeBuilder({ page })
.include('dialog')
.disableRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
});
});
}
// ============================================
// Cross-framework Consistency Tests
// ============================================
test.describe('Dialog - Cross-framework Consistency', () => {
test('all frameworks render dialog with role="dialog"', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/dialog/${framework}/demo/`);
await page
.getByRole('button', { name: /open dialog/i })
.first()
.waitFor();
// Open dialog
const trigger = page.getByRole('button', { name: /open dialog/i }).first();
await trigger.click();
await getDialog(page).waitFor({ state: 'visible' });
const dialog = getDialog(page);
await expect(dialog).toBeVisible();
await expect(dialog).toHaveRole('dialog');
// Close dialog for next iteration
await page.keyboard.press('Escape');
}
});
test('all frameworks have aria-labelledby', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/dialog/${framework}/demo/`);
await page
.getByRole('button', { name: /open dialog/i })
.first()
.waitFor();
const trigger = page.getByRole('button', { name: /open dialog/i }).first();
await trigger.click();
await getDialog(page).waitFor({ state: 'visible' });
const dialog = getDialog(page);
const labelledbyId = await dialog.getAttribute('aria-labelledby');
expect(labelledbyId).toBeTruthy();
await page.keyboard.press('Escape');
}
});
test('all frameworks close on Escape', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/dialog/${framework}/demo/`);
await page
.getByRole('button', { name: /open dialog/i })
.first()
.waitFor();
const trigger = page.getByRole('button', { name: /open dialog/i }).first();
await trigger.click();
await getDialog(page).waitFor({ state: 'visible' });
const dialog = getDialog(page);
await expect(dialog).toBeVisible();
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible();
}
});
test('all frameworks trap focus within dialog', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/dialog/${framework}/demo/`);
await page
.getByRole('button', { name: /open dialog/i })
.first()
.waitFor();
const trigger = page.getByRole('button', { name: /open dialog/i }).first();
await trigger.click();
await getDialog(page).waitFor({ state: 'visible' });
// Tab multiple times
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Tab');
}
// After many Tabs, focus should still be within dialog
const isWithinDialog = await page.evaluate(() => {
const focused = document.activeElement;
return focused?.closest('dialog, [role="dialog"]') !== null;
});
expect(isWithinDialog).toBe(true);
await page.keyboard.press('Escape');
}
});
}); テストの実行
# Dialogのユニットテストを実行
npm run test -- dialog
# DialogのE2Eテストを実行(全フレームワーク)
npm run test:e2e:pattern --pattern=dialog
テストツール
- Vitest (opens in new tab) - テストランナー
- Testing Library (opens in new tab) - フレームワーク別テストユーティリティ
- Playwright (opens in new tab) - E2Eテストフレームワーク
- jest-axe (opens in new tab) - 自動アクセシビリティテスト(Unit)
- axe-core (opens in new tab) - 自動アクセシビリティテスト(E2E)
完全なドキュメントは testing-strategy.md (opens in new tab) を参照してください。
リソース
- WAI-ARIA APG: Dialog (Modal) パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist