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.
This is the main content of the dialog. You can place any content here, such as text, forms, or other components.
Press Escape or click outside to close.
Without Description
A dialog with only a title and content.
This dialog has no description, 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
import React, {
createContext,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
// ============================================================================
// Context
// ============================================================================
interface DialogContextValue {
dialogRef: React.RefObject<HTMLDialogElement | null>;
open: () => void;
close: () => void;
titleId: string;
descriptionId: string;
}
const DialogContext = createContext<DialogContextValue | null>(null);
function useDialogContext() {
const context = useContext(DialogContext);
if (!context) {
throw new Error('Dialog components must be used within a DialogRoot');
}
return context;
}
// ============================================================================
// DialogRoot
// ============================================================================
export interface DialogRootProps {
children: React.ReactNode;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function DialogRoot({
children,
defaultOpen = false,
onOpenChange,
}: DialogRootProps): React.ReactElement {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const instanceId = useId();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Open on mount if defaultOpen
useEffect(() => {
if (mounted && defaultOpen && dialogRef.current) {
dialogRef.current.showModal();
onOpenChange?.(true);
}
}, [mounted, defaultOpen, onOpenChange]);
const open = useCallback(() => {
if (dialogRef.current) {
const { activeElement } = document;
triggerRef.current = activeElement instanceof HTMLElement ? activeElement : null;
dialogRef.current.showModal();
onOpenChange?.(true);
}
}, [onOpenChange]);
const close = useCallback(() => {
dialogRef.current?.close();
}, []);
// Handle dialog close event (from Escape key or close() call)
// Note: mounted must be in dependencies to re-run after Dialog component mounts
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const handleClose = () => {
onOpenChange?.(false);
triggerRef.current?.focus();
};
dialog.addEventListener('close', handleClose);
return () => dialog.removeEventListener('close', handleClose);
}, [onOpenChange, mounted]);
const contextValue: DialogContextValue = {
dialogRef,
open,
close,
titleId: `${instanceId}-title`,
descriptionId: `${instanceId}-description`,
};
return <DialogContext.Provider value={contextValue}>{children}</DialogContext.Provider>;
}
// ============================================================================
// DialogTrigger
// ============================================================================
export interface DialogTriggerProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
'onClick'
> {
children: React.ReactNode;
}
export function DialogTrigger({
children,
className = '',
...buttonProps
}: DialogTriggerProps): React.ReactElement {
const { open } = useDialogContext();
return (
<button
type="button"
className={`apg-dialog-trigger ${className}`.trim()}
onClick={open}
{...buttonProps}
>
{children}
</button>
);
}
// ============================================================================
// Dialog
// ============================================================================
export interface DialogProps {
/** Dialog title (required for accessibility) */
title: string;
/** Optional description text */
description?: string;
/** Dialog content */
children: React.ReactNode;
/** Close on overlay click */
closeOnOverlayClick?: boolean;
/** Additional CSS class */
className?: string;
}
export function Dialog({
title,
description,
children,
closeOnOverlayClick = true,
className = '',
}: DialogProps): React.ReactElement | null {
const { dialogRef, close, titleId, descriptionId } = useDialogContext();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const handleDialogClick = useCallback(
(event: React.MouseEvent<HTMLDialogElement>) => {
// Close on backdrop click
if (closeOnOverlayClick && event.target === event.currentTarget) {
close();
}
},
[closeOnOverlayClick, close]
);
// SSR safety
if (typeof document === 'undefined') return null;
if (!mounted) return null;
return createPortal(
// disable dialog a11y warnings, as only dropdown click. there are alternative keyboard ways to close
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events
<dialog
ref={dialogRef}
className={`apg-dialog ${className}`.trim()}
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
onClick={handleDialogClick}
>
<div className="apg-dialog-header">
<h2 id={titleId} className="apg-dialog-title">
{title}
</h2>
<button
type="button"
className="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"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="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>
{description && (
<p id={descriptionId} className="apg-dialog-description">
{description}
</p>
)}
<div className="apg-dialog-body">{children}</div>
</dialog>,
document.body
);
}
// ============================================================================
// Exports
// ============================================================================
export default {
Root: DialogRoot,
Trigger: DialogTrigger,
Content: Dialog,
}; Usage
import { DialogRoot, DialogTrigger, Dialog } from './Dialog';
function App() {
return (
<DialogRoot onOpenChange={(open) => console.log('Dialog:', open)}>
<DialogTrigger>Open Dialog</DialogTrigger>
<Dialog
title="Dialog Title"
description="Optional description text"
>
<p>Dialog content goes here.</p>
</Dialog>
</DialogRoot>
);
} API
DialogRoot Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | required | DialogTrigger and Dialog components |
defaultOpen | boolean | false | Initial open state |
onOpenChange | (open: boolean) => void | - | Callback when open state changes |
Dialog Props
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | required | Dialog title (for accessibility) |
description | string | - | Optional description text |
children | ReactNode | required | Dialog content |
closeOnOverlayClick | boolean | true | Close when clicking overlay |
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/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { DialogRoot, DialogTrigger, Dialog } from './Dialog';
// Test wrapper component
function TestDialog({
title = 'Test Dialog',
description,
closeOnOverlayClick = true,
defaultOpen = false,
onOpenChange,
children = <p>Dialog content</p>,
}: {
title?: string;
description?: string;
closeOnOverlayClick?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
}) {
return (
<DialogRoot defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
<DialogTrigger>Open Dialog</DialogTrigger>
<Dialog title={title} description={description} closeOnOverlayClick={closeOnOverlayClick}>
{children}
</Dialog>
</DialogRoot>
);
}
describe('Dialog', () => {
// 🔴 High Priority: APG Core Compliance
describe('APG: Keyboard Interaction', () => {
it('closes dialog with Escape key', async () => {
const user = userEvent.setup();
render(<TestDialog />);
// Open dialog
await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Close with Escape
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
describe('APG: ARIA Attributes', () => {
it('has role="dialog"', async () => {
const user = userEvent.setup();
render(<TestDialog />);
await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('has aria-modal="true"', async () => {
const user = userEvent.setup();
render(<TestDialog />);
await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
});
it('references title with aria-labelledby', async () => {
const user = userEvent.setup();
render(<TestDialog 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('references description with aria-describedby when present', async () => {
const user = userEvent.setup();
render(<TestDialog 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('has no aria-describedby when description is absent', async () => {
const user = userEvent.setup();
render(<TestDialog />);
await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
const dialog = screen.getByRole('dialog');
expect(dialog).not.toHaveAttribute('aria-describedby');
});
});
describe('APG: Focus Management', () => {
it('focuses first focusable element when opened', async () => {
const user = userEvent.setup();
render(<TestDialog />);
await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
// Focus moves to first focusable element in dialog (Close button)
await vi.waitFor(() => {
expect(screen.getByRole('button', { name: 'Close dialog' })).toHaveFocus();
});
});
// Note: Testing autofocus attribute is difficult in jsdom environment
// because React's autoFocus uses its own focus management, not DOM attributes.
// Recommended to verify with browser E2E tests (Playwright).
// Note: Focus restore tests are flaky in jsdom due to showModal() limitations.
// Covered by E2E tests (Playwright).
// See: e2e/dialog.spec.ts - Focus Management section
it.todo('restores focus to trigger when closed');
// Note: Focus trap is handled by native <dialog> element's showModal().
// jsdom does not implement showModal()'s focus trap behavior,
// so these tests should be verified with browser E2E tests (Playwright).
});
// 🟡 Medium Priority: Accessibility Validation
describe('Accessibility', () => {
it('has no axe violations', async () => {
const user = userEvent.setup();
const { container } = render(<TestDialog description="Description" />);
await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
describe('Props', () => {
it('displays title', async () => {
const user = userEvent.setup();
render(<TestDialog title="Custom Title" />);
await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
expect(screen.getByText('Custom Title')).toBeInTheDocument();
});
it('displays description', async () => {
const user = userEvent.setup();
render(<TestDialog description="Custom Description" />);
await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
expect(screen.getByText('Custom Description')).toBeInTheDocument();
});
it('closes on overlay click when closeOnOverlayClick=true', async () => {
const user = userEvent.setup();
render(<TestDialog closeOnOverlayClick={true} />);
await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
const dialog = screen.getByRole('dialog');
// Click dialog element itself (equivalent to overlay)
await user.click(dialog);
await vi.waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
it('does not close on overlay click when closeOnOverlayClick=false', async () => {
const user = userEvent.setup();
render(<TestDialog closeOnOverlayClick={false} />);
await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
const dialog = screen.getByRole('dialog');
// Click dialog element itself
await user.click(dialog);
await vi.waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
it('calls onOpenChange when opened and closed', async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(<TestDialog onOpenChange={onOpenChange} />);
await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
expect(onOpenChange).toHaveBeenCalledWith(true);
// Close with Close button
await user.click(screen.getByRole('button', { name: 'Close dialog' }));
await vi.waitFor(() => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
it('initially displayed when defaultOpen=true', async () => {
render(<TestDialog defaultOpen={true} />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
// 🟢 Low Priority: Extensibility
describe('HTML Attribute Inheritance', () => {
it('applies className to dialog', async () => {
const user = userEvent.setup();
render(
<DialogRoot>
<DialogTrigger>Open</DialogTrigger>
<Dialog title="Test" className="custom-class">
Content
</Dialog>
</DialogRoot>
);
await user.click(screen.getByRole('button', { name: 'Open' }));
expect(screen.getByRole('dialog')).toHaveClass('custom-class');
});
it('applies className to trigger', async () => {
render(
<DialogRoot>
<DialogTrigger className="trigger-class">Open</DialogTrigger>
<Dialog title="Test">Content</Dialog>
</DialogRoot>
);
expect(screen.getByRole('button', { name: 'Open' })).toHaveClass('trigger-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