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).
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
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
---
/**
* 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
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.
/**
* 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