Alert Dialog
A modal dialog that interrupts the user's workflow to communicate an important message and require a response.
🤖 AI Implementation GuideDemo
Try the alert dialogs below. Note that Escape key is disabled by default for critical confirmations, and initial focus goes to the Cancel button (the safest action).
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
alertdialog | Dialog container | A type of dialog that interrupts the user's workflow to communicate an important message and require a response. May trigger system alert sounds in assistive technologies. |
WAI-ARIA alertdialog role (opens in new tab)
WAI-ARIA Properties
| Attribute | Target | Values | Required | Description |
|---|---|---|---|---|
aria-modal | alertdialog | true | Implicit | Provided automatically by showModal(). No explicit attribute needed when
using native <dialog> element. |
aria-labelledby | alertdialog | ID reference to title element | Yes | References the alert dialog title |
aria-describedby | alertdialog | ID reference to message | Yes (required) | References the alert message. Unlike regular Dialog, this is required for Alert Dialog as the message is central to the user's understanding of what action is being confirmed. |
Focus Management
| Event | Behavior |
|---|---|
| Dialog opens | Focus moves to the Cancel button (safest action). This differs from regular Dialog which focuses the first focusable element. |
| 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 | Disabled by default. Unlike regular Dialog, Alert Dialog prevents Escape key closure to
ensure users explicitly respond to the alert. Can be enabled via
allowEscapeClose prop for non-critical alerts. |
| Enter | Activates the focused button |
| Space | Activates the focused button |
Differences from Dialog
| Feature | Dialog | Alert Dialog |
|---|---|---|
| Role | dialog | alertdialog |
Message (aria-describedby) | Optional | Required |
| Escape key | Enabled by default | Disabled by default |
| Initial focus | First focusable element | Cancel button (safest action) |
| Close button | Yes (×) | No (explicit response required) |
| Overlay click | Closes dialog | Does not close (explicit response required) |
Additional Notes
- Alert Dialog is for critical messages that require user acknowledgment or a decision. For general content, use regular Dialog.
-
The
alertdialogrole may cause assistive technologies to announce the dialog more urgently or with an alert sound. - Both title and message are required to provide complete context for the user's decision.
- The Cancel button should always be the safest choice (no destructive action).
-
Consider using the
dangervariant for destructive actions to provide visual distinction.
Source Code
<script lang="ts" module>
import type { Snippet } from 'svelte';
export interface AlertDialogProps {
/** Dialog title (required for accessibility) */
title: string;
/** Alert message (required - unlike regular Dialog) */
message: string;
/** Confirm button label */
confirmLabel?: string;
/** Cancel button label */
cancelLabel?: string;
/** Confirm button variant */
confirmVariant?: 'default' | 'danger';
/** Allow closing with Escape key (default: false - unlike regular Dialog) */
allowEscapeClose?: boolean;
/** Default open state */
defaultOpen?: boolean;
/** Additional CSS class */
className?: string;
/** Callback when confirm button is clicked */
onConfirm?: () => void;
/** Callback when cancel button is clicked */
onCancel?: () => void;
/** Trigger snippet - receives open function */
trigger: Snippet<[{ open: () => void }]>;
}
</script>
<script lang="ts">
import { onMount, tick } from 'svelte';
let {
title,
message,
confirmLabel = 'OK',
cancelLabel = 'Cancel',
confirmVariant = 'default',
allowEscapeClose = false,
defaultOpen = false,
className = '',
onConfirm = () => {},
onCancel = () => {},
trigger,
}: AlertDialogProps = $props();
let dialogElement = $state<HTMLDialogElement | undefined>(undefined);
let cancelButtonElement = $state<HTMLButtonElement | undefined>(undefined);
let previousActiveElement: HTMLElement | null = null;
let instanceId = $state('');
onMount(() => {
instanceId = `alert-dialog-${Math.random().toString(36).substr(2, 9)}`;
// Open on mount if defaultOpen
if (defaultOpen && dialogElement) {
dialogElement.showModal();
focusCancelButton();
}
});
let titleId = $derived(`${instanceId}-title`);
let messageId = $derived(`${instanceId}-message`);
let confirmButtonClass = $derived(
confirmVariant === 'danger'
? 'apg-alert-dialog-confirm apg-alert-dialog-confirm--danger'
: 'apg-alert-dialog-confirm'
);
async function focusCancelButton() {
await tick();
cancelButtonElement?.focus();
}
export function open() {
if (dialogElement) {
previousActiveElement = document.activeElement as HTMLElement;
// Lock body scroll
document.body.style.overflow = 'hidden';
dialogElement.showModal();
focusCancelButton();
}
}
export function close() {
// Unlock body scroll
document.body.style.overflow = '';
dialogElement?.close();
}
function handleClose() {
// Unlock body scroll
document.body.style.overflow = '';
// Return focus to trigger
if (previousActiveElement) {
previousActiveElement.focus();
}
}
function handleKeyDown(event: KeyboardEvent) {
// Handle Escape key
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
if (allowEscapeClose) {
onCancel();
close();
}
return;
}
// Handle focus trap for Tab key
if (event.key === 'Tab' && dialogElement) {
const focusableElements = dialogElement.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
// Shift+Tab from first element -> wrap to last
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement?.focus();
}
} else {
// Tab from last element -> wrap to first
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement?.focus();
}
}
}
}
// Handle native cancel event (fired when Escape pressed in real browsers)
function handleCancelEvent(event: Event) {
if (!allowEscapeClose) {
event.preventDefault();
} else {
onCancel();
}
}
function handleConfirm() {
onConfirm();
close();
}
function handleCancel() {
onCancel();
close();
}
</script>
<!-- Trigger snippet -->
{@render trigger({ open })}
<!-- Native Dialog Element with alertdialog role -->
<dialog
bind:this={dialogElement}
role="alertdialog"
class={`apg-alert-dialog ${className}`.trim()}
aria-labelledby={titleId}
aria-describedby={messageId}
onkeydowncapture={handleKeyDown}
oncancel={handleCancelEvent}
onclose={handleClose}
>
<h2 id={titleId} class="apg-alert-dialog-title">
{title}
</h2>
<p id={messageId} class="apg-alert-dialog-message">
{message}
</p>
<div class="apg-alert-dialog-actions">
<button
bind:this={cancelButtonElement}
type="button"
class="apg-alert-dialog-cancel"
onclick={handleCancel}
>
{cancelLabel}
</button>
<button type="button" class={confirmButtonClass} onclick={handleConfirm}>
{confirmLabel}
</button>
</div>
</dialog> Usage
<script lang="ts">
import AlertDialog from './AlertDialog.svelte';
const handleDelete = () => {
console.log('Item deleted');
};
</script>
<AlertDialog
title="Delete this item?"
message="This action cannot be undone. This will permanently delete the item."
confirmLabel="Delete"
cancelLabel="Cancel"
confirmVariant="danger"
onConfirm={handleDelete}
onCancel={() => console.log('Cancelled')}
>
{#snippet trigger({ open })}
<button onclick={open} class="bg-destructive text-destructive-foreground px-4 py-2 rounded">
Delete Item
</button>
{/snippet}
</AlertDialog> API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | required | Alert dialog title |
message | string | required | Alert message (required for accessibility) |
confirmLabel | string | "OK" | Confirm button label |
cancelLabel | string | "Cancel" | Cancel button label |
confirmVariant | 'default' | 'danger' | 'default' | Confirm button visual style |
allowEscapeClose | boolean | false | Allow closing with Escape key |
onConfirm | () => void | - | Callback when Confirm is clicked |
onCancel | () => void | - | Callback when Cancel is clicked |
Snippets
| Snippet | Props | Description |
|---|---|---|
trigger | { open } | Trigger element with open function |
Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. Alert Dialog has stricter requirements than regular Dialog. The Alert Dialog component uses a two-layer testing strategy.
Testing Strategy
Unit Tests (Testing Library / jest-axe)
Verify the component's HTML output, ARIA attributes, and accessibility. These tests ensure correct rendering and compliance with APG requirements.
- role="alertdialog" (NOT dialog)
- aria-labelledby and aria-describedby attributes
- Modal behavior via showModal()
- WCAG 2.1 AA compliance via axe-core
- Props behavior (allowEscapeClose, confirmVariant)
E2E Tests (Playwright)
Verify component behavior in a real browser environment across all frameworks (React, Vue, Svelte, Astro). These tests cover interactions requiring JavaScript execution.
- Focus on Cancel button when dialog opens (safest action)
- Tab/Shift+Tab wrap within dialog (focus trap)
- Enter/Space activates focused button
- Escape key disabled by default
- Focus returns to trigger on close
- No close button (×) unlike regular Dialog
Test Categories
High Priority: APG Keyboard Interaction
| Test | Description |
|---|---|
Escape key (disabled) | Escape does NOT close the dialog by default |
Escape key (enabled) | Escape closes the dialog when allowEscapeClose is true |
Enter on button | Activates the focused button |
Space on button | Activates the focused button |
High Priority: APG ARIA Attributes
| Test | Description |
|---|---|
role="alertdialog" | Dialog element has alertdialog role (not dialog) |
Modal behavior | Opened via showModal() (verified by ::backdrop existence) |
aria-labelledby | References the alert dialog title |
aria-describedby | References the alert message (required, unlike Dialog) |
High Priority: Focus Management
| Test | Description |
|---|---|
Initial focus | Focus moves to Cancel button on open (safest action) |
Focus restore | Focus returns to trigger on close |
Focus trap | Tab cycling stays within dialog (via native dialog) |
Medium Priority: Accessibility
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations (via jest-axe) |
Title and message | Both are rendered and properly associated |
Low Priority: Props & Behavior
| Test | Description |
|---|---|
allowEscapeClose | Controls Escape key behavior (default: false) |
confirmVariant | Danger variant applies correct styling |
onConfirm | Callback fires when Confirm button is clicked |
onCancel | Callback fires when Cancel button is clicked |
className | Custom classes are applied |
Testing Tools
- Vitest (opens in new tab) - Test runner
- Testing Library (opens in new tab) - Framework-specific testing utilities
- jest-axe (opens in new tab) - Automated accessibility testing
- Playwright (opens in new tab) - E2E testing for cross-framework validation
Running Tests
Unit Tests
# Run all AlertDialog unit tests
npm run test:unit -- AlertDialog
# Run framework-specific tests
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 Tests
# Run all Alert Dialog E2E tests
npm run test:e2e -- alert-dialog.spec.ts
# Run in UI mode
npm run test:e2e:ui -- alert-dialog.spec.ts See testing-strategy.md (opens in new tab) for full documentation.
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import AlertDialogTestWrapper from './AlertDialogTestWrapper.svelte';
describe('AlertDialog (Svelte)', () => {
// 🔴 High Priority: APG ARIA 属性
describe('APG: ARIA 属性', () => {
it('role="alertdialog" を持つ(dialog ではない)', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('aria-modal="true" を持つ', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('alertdialog')).toHaveAttribute('aria-modal', 'true');
});
it('aria-labelledby でタイトルを参照', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper, {
props: { title: 'Delete Item' },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
const dialog = screen.getByRole('alertdialog');
const titleId = dialog.getAttribute('aria-labelledby');
expect(titleId).toBeTruthy();
expect(document.getElementById(titleId!)).toHaveTextContent('Delete Item');
});
it('aria-describedby でメッセージを参照(必須 - Dialog と異なる)', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper, {
props: { message: 'This action cannot be undone.' },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
const dialog = screen.getByRole('alertdialog');
const messageId = dialog.getAttribute('aria-describedby');
expect(messageId).toBeTruthy();
expect(document.getElementById(messageId!)).toHaveTextContent(
'This action cannot be undone.'
);
});
});
// 🔴 High Priority: キーボード操作
describe('APG: キーボード操作', () => {
it('デフォルトで Escape キーで閉じない(Dialog と異なる)', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(AlertDialogTestWrapper, {
props: { onCancel },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
expect(onCancel).not.toHaveBeenCalled();
});
it('allowEscapeClose=true で Escape キーで閉じる', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(AlertDialogTestWrapper, {
props: { allowEscapeClose: true, onCancel },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
expect(onCancel).toHaveBeenCalled();
});
it('Tab で次のフォーカス可能要素に移動', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
const confirmButton = screen.getByRole('button', { name: 'Confirm' });
await vi.waitFor(() => {
expect(cancelButton).toHaveFocus();
});
await user.tab();
expect(confirmButton).toHaveFocus();
});
it('Tab が最後から最初にループする', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
const confirmButton = screen.getByRole('button', { name: 'Confirm' });
await vi.waitFor(() => {
expect(cancelButton).toHaveFocus();
});
await user.tab();
expect(confirmButton).toHaveFocus();
await user.tab();
expect(cancelButton).toHaveFocus();
});
});
// 🔴 High Priority: フォーカス管理
describe('APG: フォーカス管理', () => {
it('開いた時に Cancel ボタンにフォーカス(安全なアクション、Dialog と異なる)', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper, {
props: { cancelLabel: 'Cancel', confirmLabel: 'Delete' },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
await vi.waitFor(() => {
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus();
});
});
it('閉じた時にトリガーにフォーカス復元', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper);
const trigger = screen.getByRole('button', { name: 'Open Alert' });
await user.click(trigger);
await vi.waitFor(() => {
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(trigger).toHaveFocus();
});
});
// 🟡 Medium Priority: アクセシビリティ
describe('アクセシビリティ', () => {
it('axe による違反がない', async () => {
const user = userEvent.setup();
const { container } = render(AlertDialogTestWrapper);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟢 Low Priority: Props & Callbacks
describe('Props & Callbacks', () => {
it('confirm ボタンクリックで onConfirm を呼ぶ', async () => {
const user = userEvent.setup();
const onConfirm = vi.fn();
render(AlertDialogTestWrapper, {
props: { onConfirm },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
await user.click(screen.getByRole('button', { name: 'Confirm' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('cancel ボタンクリックで onCancel を呼ぶ', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(AlertDialogTestWrapper, {
props: { onCancel },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('カスタムボタンラベルが表示される', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper, {
props: { confirmLabel: 'Delete', cancelLabel: 'Keep' },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Keep' })).toBeInTheDocument();
});
it('defaultOpen=true で初期表示', async () => {
render(AlertDialogTestWrapper, {
props: { defaultOpen: true },
});
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
});
});
// Alert Dialog 固有の動作
describe('Alert Dialog 固有の動作', () => {
it('閉じるボタン(×)がない(通常の Dialog と異なる)', async () => {
const user = userEvent.setup();
render(AlertDialogTestWrapper);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.queryByRole('button', { name: /close/i })).not.toBeInTheDocument();
});
});
}); Resources
- WAI-ARIA APG: Alert Dialog Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist