Alert Dialog
A modal dialog that interrupts the user's workflow to communicate an important message and require a response.
Demo
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).
Delete Confirmation
A destructive action that requires explicit confirmation. Escape key is disabled.
Discard Changes
Confirm before losing unsaved changes.
Information Acknowledgment
Non-destructive alert with Escape key enabled.
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 alertdialog role 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 danger variant for destructive actions to provide visual distinction.
Source Code
---
/**
* APG Alert Dialog Pattern - Astro Implementation
*
* A modal dialog that interrupts the user's workflow to communicate an important
* message and require a response. Unlike regular Dialog, uses role="alertdialog"
* which may trigger system alert sounds in assistive technologies.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/
*/
export interface Props {
/** Dialog title (required for accessibility) */
title: string;
/** Alert message (required - unlike regular Dialog) */
message: string;
/** Trigger button text */
triggerText: string;
/** Confirm button label */
confirmLabel?: string;
/** Cancel button label */
cancelLabel?: string;
/** Confirm button variant */
confirmVariant?: 'default' | 'danger';
/** Allow closing with Escape key (default: false - unlike regular Dialog) */
allowEscapeClose?: boolean;
/** Additional CSS class for trigger button */
triggerClass?: string;
/** Additional CSS class for dialog */
class?: string;
}
const {
title,
message,
triggerText,
confirmLabel = 'OK',
cancelLabel = 'Cancel',
confirmVariant = 'default',
allowEscapeClose = false,
triggerClass = '',
class: className = '',
} = Astro.props;
// Generate unique ID for this instance
const instanceId = `alert-dialog-${Math.random().toString(36).substring(2, 11)}`;
const titleId = `${instanceId}-title`;
const messageId = `${instanceId}-message`;
const confirmButtonClass =
confirmVariant === 'danger'
? 'apg-alert-dialog-confirm apg-alert-dialog-confirm--danger'
: 'apg-alert-dialog-confirm';
---
<apg-alert-dialog data-allow-escape-close={allowEscapeClose ? 'true' : 'false'}>
<!-- Trigger Button -->
<button
type="button"
class={`apg-alert-dialog-trigger ${triggerClass}`.trim()}
data-alert-dialog-trigger
>
{triggerText}
</button>
<!-- Native Dialog Element with alertdialog role -->
<dialog
role="alertdialog"
class={`apg-alert-dialog ${className}`.trim()}
aria-labelledby={titleId}
aria-describedby={messageId}
data-alert-dialog-content
>
<h2 id={titleId} class="apg-alert-dialog-title">
{title}
</h2>
<p id={messageId} class="apg-alert-dialog-message">
{message}
</p>
<div class="apg-alert-dialog-actions">
<button type="button" class="apg-alert-dialog-cancel" data-cancel>
{cancelLabel}
</button>
<button type="button" class={confirmButtonClass} data-confirm>
{confirmLabel}
</button>
</div>
</dialog>
</apg-alert-dialog>
<script>
class ApgAlertDialog extends HTMLElement {
private trigger: HTMLButtonElement | null = null;
private dialog: HTMLDialogElement | null = null;
private cancelButton: HTMLButtonElement | null = null;
private confirmButton: HTMLButtonElement | null = null;
private allowEscapeClose = false;
connectedCallback() {
this.trigger = this.querySelector('[data-alert-dialog-trigger]');
this.dialog = this.querySelector('[data-alert-dialog-content]');
this.cancelButton = this.querySelector('[data-cancel]');
this.confirmButton = this.querySelector('[data-confirm]');
if (!this.trigger || !this.dialog) {
console.warn('apg-alert-dialog: required elements not found');
return;
}
this.allowEscapeClose = this.dataset.allowEscapeClose === 'true';
// Attach event listeners
this.trigger.addEventListener('click', this.open);
this.cancelButton?.addEventListener('click', this.handleCancelClick);
this.confirmButton?.addEventListener('click', this.handleConfirm);
this.dialog.addEventListener('keydown', this.handleKeyDown, true);
this.dialog.addEventListener('cancel', this.handleDialogCancel);
this.dialog.addEventListener('close', this.handleClose);
}
disconnectedCallback() {
this.trigger?.removeEventListener('click', this.open);
this.cancelButton?.removeEventListener('click', this.handleCancelClick);
this.confirmButton?.removeEventListener('click', this.handleConfirm);
this.dialog?.removeEventListener('keydown', this.handleKeyDown, true);
this.dialog?.removeEventListener('cancel', this.handleDialogCancel);
this.dialog?.removeEventListener('close', this.handleClose);
}
private open = () => {
if (!this.dialog) return;
// Lock body scroll
document.body.style.overflow = 'hidden';
this.dialog.showModal();
// Focus cancel button (safest action - unlike regular Dialog)
requestAnimationFrame(() => {
this.cancelButton?.focus();
});
// Dispatch custom event
this.dispatchEvent(
new CustomEvent('alertdialogopen', {
bubbles: true,
})
);
};
private close = () => {
// Unlock body scroll
document.body.style.overflow = '';
this.dialog?.close();
};
private handleClose = () => {
// Unlock body scroll
document.body.style.overflow = '';
// Return focus to trigger
this.trigger?.focus();
// Dispatch custom event
this.dispatchEvent(
new CustomEvent('alertdialogclose', {
bubbles: true,
})
);
};
private handleKeyDown = (e: KeyboardEvent) => {
// Handle Escape key
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
if (this.allowEscapeClose) {
this.dispatchEvent(
new CustomEvent('cancel', {
bubbles: true,
})
);
this.close();
}
return;
}
// Handle focus trap for Tab key
if (e.key === 'Tab' && this.dialog) {
const focusableElements = this.dialog.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
// Shift+Tab from first element -> wrap to last
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
// Tab from last element -> wrap to first
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
}
};
// Handle native dialog cancel event (fired when Escape pressed in real browsers)
private handleDialogCancel = (e: Event) => {
if (!this.allowEscapeClose) {
e.preventDefault();
} else {
this.dispatchEvent(
new CustomEvent('cancel', {
bubbles: true,
})
);
}
};
private handleCancelClick = () => {
this.dispatchEvent(
new CustomEvent('cancel', {
bubbles: true,
})
);
this.close();
};
private handleConfirm = () => {
this.dispatchEvent(
new CustomEvent('confirm', {
bubbles: true,
})
);
this.close();
};
}
// Register the custom element
if (!customElements.get('apg-alert-dialog')) {
customElements.define('apg-alert-dialog', ApgAlertDialog);
}
</script> Usage
---
import AlertDialog from './AlertDialog.astro';
---
<AlertDialog
title="Delete this item?"
message="This action cannot be undone. This will permanently delete the item."
triggerText="Delete Item"
confirmLabel="Delete"
cancelLabel="Cancel"
confirmVariant="danger"
triggerClass="bg-destructive text-destructive-foreground px-4 py-2 rounded"
/>
<script>
// Listen for custom events
document.querySelector('apg-alert-dialog')?.addEventListener('confirm', () => {
console.log('Confirmed!');
});
</script> API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | required | Alert dialog title |
message | string | required | Alert message (required for accessibility) |
triggerText | string | required | Trigger button text |
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 |
triggerClass | string | - | Additional CSS class for trigger button |
Custom Events
| Event | Description |
|---|---|
confirm | Fired when Confirm button is clicked |
cancel | Fired when Cancel button is clicked |
alertdialogopen | Fired when dialog opens |
alertdialogclose | Fired when dialog closes |
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
# 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
# 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.
/**
* AlertDialog Web Component Tests
*
* Note: These are limited unit tests for the Web Component class.
* Full keyboard navigation and focus management tests require E2E testing
* with Playwright due to jsdom limitations with focus events and <dialog> element.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('AlertDialog (Web Component)', () => {
let container: HTMLElement;
// Web Component class extracted for testing
class TestApgAlertDialog extends HTMLElement {
private dialog: HTMLDialogElement | null = null;
private triggerRef: HTMLElement | null = null;
private cancelButton: HTMLButtonElement | null = null;
private confirmButton: HTMLButtonElement | null = null;
private rafId: number | null = null;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.dialog = this.querySelector('dialog');
this.cancelButton = this.querySelector('[data-cancel]');
this.confirmButton = this.querySelector('[data-confirm]');
if (!this.dialog) return;
// Set up event listeners
this.dialog.addEventListener('keydown', this.handleKeyDown);
this.cancelButton?.addEventListener('click', this.handleCancel);
this.confirmButton?.addEventListener('click', this.handleConfirm);
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.dialog?.removeEventListener('keydown', this.handleKeyDown);
this.cancelButton?.removeEventListener('click', this.handleCancel);
this.confirmButton?.removeEventListener('click', this.handleConfirm);
}
open(triggerElement?: HTMLElement) {
if (!this.dialog) return;
this.triggerRef = triggerElement || null;
this.dialog.showModal();
// Focus cancel button (safest action)
requestAnimationFrame(() => {
this.cancelButton?.focus();
});
}
close() {
if (!this.dialog) return;
this.dialog.close();
this.triggerRef?.focus();
}
private handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
const allowEscapeClose = this.dataset.allowEscapeClose === 'true';
if (!allowEscapeClose) {
event.preventDefault();
} else {
this.dispatchEvent(new CustomEvent('cancel', { bubbles: true }));
}
}
};
private handleCancel = () => {
this.dispatchEvent(new CustomEvent('cancel', { bubbles: true }));
this.close();
};
private handleConfirm = () => {
this.dispatchEvent(new CustomEvent('confirm', { bubbles: true }));
this.close();
};
}
function createAlertDialogHTML(
options: {
title?: string;
message?: string;
confirmLabel?: string;
cancelLabel?: string;
allowEscapeClose?: boolean;
open?: boolean;
} = {}
) {
const {
title = 'Confirm Action',
message = 'Are you sure?',
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
allowEscapeClose = false,
open = false,
} = options;
const titleId = 'alert-title';
const messageId = 'alert-message';
return `
<apg-alert-dialog ${allowEscapeClose ? 'data-allow-escape-close="true"' : ''}>
<dialog
role="alertdialog"
aria-modal="true"
aria-labelledby="${titleId}"
aria-describedby="${messageId}"
class="apg-alert-dialog"
${open ? 'open' : ''}
>
<h2 id="${titleId}" class="apg-alert-dialog-title">${title}</h2>
<p id="${messageId}" class="apg-alert-dialog-message">${message}</p>
<div class="apg-alert-dialog-actions">
<button type="button" data-cancel class="apg-alert-dialog-cancel">${cancelLabel}</button>
<button type="button" data-confirm class="apg-alert-dialog-confirm">${confirmLabel}</button>
</div>
</dialog>
</apg-alert-dialog>
`;
}
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
// Register custom element if not already registered
if (!customElements.get('apg-alert-dialog')) {
customElements.define('apg-alert-dialog', TestApgAlertDialog);
}
});
afterEach(() => {
container.remove();
vi.restoreAllMocks();
});
describe('ARIA Attributes', () => {
it('has role="alertdialog" (NOT dialog)', async () => {
container.innerHTML = createAlertDialogHTML();
await new Promise((r) => requestAnimationFrame(r));
const dialog = container.querySelector('dialog');
expect(dialog?.getAttribute('role')).toBe('alertdialog');
});
it('has aria-modal="true"', async () => {
container.innerHTML = createAlertDialogHTML();
await new Promise((r) => requestAnimationFrame(r));
const dialog = container.querySelector('dialog');
expect(dialog?.getAttribute('aria-modal')).toBe('true');
});
it('has aria-labelledby referencing title', async () => {
container.innerHTML = createAlertDialogHTML({ title: 'Delete Item' });
await new Promise((r) => requestAnimationFrame(r));
const dialog = container.querySelector('dialog');
const titleId = dialog?.getAttribute('aria-labelledby');
expect(titleId).toBeTruthy();
expect(document.getElementById(titleId!)?.textContent).toBe('Delete Item');
});
it('has aria-describedby referencing message (required)', async () => {
container.innerHTML = createAlertDialogHTML({ message: 'This cannot be undone.' });
await new Promise((r) => requestAnimationFrame(r));
const dialog = container.querySelector('dialog');
const messageId = dialog?.getAttribute('aria-describedby');
expect(messageId).toBeTruthy();
expect(document.getElementById(messageId!)?.textContent).toBe('This cannot be undone.');
});
});
describe('Escape Key Behavior', () => {
it('prevents close on Escape by default', async () => {
container.innerHTML = createAlertDialogHTML({ allowEscapeClose: false });
await new Promise((r) => requestAnimationFrame(r));
const dialog = container.querySelector('dialog');
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
});
dialog?.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});
it('allows close on Escape when allowEscapeClose=true', async () => {
container.innerHTML = createAlertDialogHTML({ allowEscapeClose: true });
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-alert-dialog') as HTMLElement;
const dialog = container.querySelector('dialog');
const cancelHandler = vi.fn();
element.addEventListener('cancel', cancelHandler);
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
});
dialog?.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(cancelHandler).toHaveBeenCalledTimes(1);
});
});
describe('Button Actions', () => {
it('dispatches cancel event on cancel button click', async () => {
container.innerHTML = createAlertDialogHTML();
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-alert-dialog') as HTMLElement;
const cancelButton = container.querySelector('[data-cancel]') as HTMLButtonElement;
const cancelHandler = vi.fn();
element.addEventListener('cancel', cancelHandler);
cancelButton.click();
expect(cancelHandler).toHaveBeenCalledTimes(1);
});
it('dispatches confirm event on confirm button click', async () => {
container.innerHTML = createAlertDialogHTML();
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-alert-dialog') as HTMLElement;
const confirmButton = container.querySelector('[data-confirm]') as HTMLButtonElement;
const confirmHandler = vi.fn();
element.addEventListener('confirm', confirmHandler);
confirmButton.click();
expect(confirmHandler).toHaveBeenCalledTimes(1);
});
});
describe('Button Labels', () => {
it('displays custom button labels', async () => {
container.innerHTML = createAlertDialogHTML({
confirmLabel: 'Delete',
cancelLabel: 'Keep',
});
await new Promise((r) => requestAnimationFrame(r));
const cancelButton = container.querySelector('[data-cancel]');
const confirmButton = container.querySelector('[data-confirm]');
expect(cancelButton?.textContent).toBe('Keep');
expect(confirmButton?.textContent).toBe('Delete');
});
});
describe('Alert Dialog Specific', () => {
it('does NOT have a close button (×)', async () => {
container.innerHTML = createAlertDialogHTML();
await new Promise((r) => requestAnimationFrame(r));
// Alert dialog should not have the typical close button
const closeButton = container.querySelector(
'[aria-label*="close" i], [aria-label*="Close" i]'
);
expect(closeButton).toBeNull();
});
it('has only Cancel and Confirm buttons', async () => {
container.innerHTML = createAlertDialogHTML();
await new Promise((r) => requestAnimationFrame(r));
const buttons = container.querySelectorAll('button');
expect(buttons.length).toBe(2);
});
});
}); 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