Dialog (Modal)
A window overlaid on the primary window, rendering the content underneath inert.
Demo
Basic Dialog
A simple modal dialog with title, description, and close functionality.
Without Description
A dialog with only a title and content.
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
dialog | Dialog container | Indicates the element is a dialog window |
WAI-ARIA dialog role (opens in new tab)
WAI-ARIA Properties
| Attribute | Target Element | Values | Required | Description |
|---|---|---|---|---|
aria-modal | dialog | true | Yes | Indicates this is a modal dialog |
aria-labelledby | dialog | ID reference to title element | Yes | References the dialog title |
aria-describedby | dialog | ID reference to description | No | References optional description text |
Focus Management
| Event | Behavior |
|---|---|
| Dialog opens | Focus moves to first focusable element inside the dialog |
| Dialog closes | Focus returns to the element that triggered the dialog |
| Focus trap | Tab/Shift+Tab cycles through focusable elements within the dialog only |
| Background | Content outside dialog is made inert (not focusable or interactive) |
Keyboard Support
| Key | Action |
|---|---|
| Tab | Move focus to next focusable element within dialog. When focus is on the last element, moves to first. |
| Shift + Tab | Move focus to previous focusable element within dialog. When focus is on the first element, moves to last. |
| Escape | Close the dialog and return focus to trigger element |
section.additionalNotes
- The dialog title is required for accessibility and should clearly describe the purpose of the dialog
- Page scrolling is disabled while the dialog is open
- Clicking the overlay (background) closes the dialog by default
- The close button has an accessible label for screen readers
Source Code
<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> Usage
<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
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | required | Dialog title (for accessibility) |
description | string | - | Optional description text |
defaultOpen | boolean | false | Initial open state |
closeOnOverlayClick | boolean | true | Close when clicking overlay |
onOpenChange | (open: boolean) => void | - | Callback when open state changes |
Snippets
| Snippet | Props | Description |
|---|---|---|
trigger | { open: () => void } | Trigger element to open the dialog |
children | - | Dialog content |
Exported Functions
| Function | Description |
|---|---|
open() | Open the dialog programmatically |
close() | Close the dialog programmatically |
Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. The Dialog component uses a two-layer testing strategy.
Testing Strategy
Unit Tests (Testing Library)
Verify the component's rendered output using framework-specific testing libraries. These tests ensure correct HTML structure and ARIA attributes.
- ARIA attributes (aria-labelledby, aria-describedby)
- Escape key closes dialog
- Focus management on open/close
- Accessibility via jest-axe
E2E Tests (Playwright)
Verify component behavior in a real browser environment across all frameworks. These tests cover interactions and cross-framework consistency.
- Modal behavior (showModal, backdrop)
- Focus trap verification
- Focus restoration on close
- Overlay click to close
- ARIA structure validation in live browser
- axe-core accessibility scanning
- Cross-framework consistency checks
Test Categories
High Priority: APG Keyboard Interaction ( Unit + E2E )
| Test | Description |
|---|---|
Escape key | Closes the dialog |
High Priority: APG ARIA Attributes ( Unit + E2E )
| Test | Description |
|---|---|
role="dialog" | Dialog element has dialog role |
aria-modal="true" | Indicates modal behavior |
aria-labelledby | References the dialog title |
aria-describedby | References description (when provided) |
High Priority: Focus Management ( Unit + E2E )
| Test | Description |
|---|---|
Initial focus | Focus moves to first focusable element on open |
Focus restore | Focus returns to trigger on close |
Focus trap | Tab cycling stays within dialog (via native dialog) |
Medium Priority: Accessibility ( Unit + E2E )
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations (via jest-axe) |
Low Priority: Props & Behavior ( Unit )
| Test | Description |
|---|---|
closeOnOverlayClick | Controls overlay click behavior |
defaultOpen | Initial open state |
onOpenChange | Callback fires on open/close |
className | Custom classes are applied |
E2E Test Code
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');
}
});
}); Running Tests
# Run unit tests for Dialog
npm run test -- dialog
# Run E2E tests for Dialog (all frameworks)
npm run test:e2e:pattern --pattern=dialog
Testing Tools
- Vitest (opens in new tab) - Test runner
- Testing Library (opens in new tab) - Framework-specific testing utilities
- Playwright (opens in new tab) - E2E testing framework
- jest-axe (opens in new tab) - Automated accessibility testing (Unit)
- axe-core (opens in new tab) - Automated accessibility testing (E2E)
See testing-strategy.md (opens in new tab) for full documentation.
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' }));
await vi.waitFor(() => {
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();
});
});
// Note: フォーカス復元テストは jsdom の showModal() 制限により flaky。
// E2E テスト(Playwright)でカバー済み。
// See: e2e/dialog.spec.ts - Focus Management section
it.todo('閉じた時にトリガーにフォーカス復元');
// 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);
await vi.waitFor(() => {
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);
await vi.waitFor(() => {
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}');
await vi.waitFor(() => {
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');
});
});
}); Resources
- WAI-ARIA APG: Dialog (Modal) Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist