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
import React, {
createContext,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
// ============================================================================
// Context
// ============================================================================
interface AlertDialogContextValue {
dialogRef: React.RefObject<HTMLDialogElement | null>;
open: () => void;
close: () => void;
titleId: string;
messageId: string;
}
const AlertDialogContext = createContext<AlertDialogContextValue | null>(null);
function useAlertDialogContext() {
const context = useContext(AlertDialogContext);
if (!context) {
throw new Error('AlertDialog components must be used within an AlertDialogRoot');
}
return context;
}
// ============================================================================
// AlertDialogRoot
// ============================================================================
export interface AlertDialogRootProps {
children: React.ReactNode;
defaultOpen?: boolean;
}
export function AlertDialogRoot({
children,
defaultOpen = false,
}: AlertDialogRootProps): 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();
}
}, [mounted, defaultOpen]);
const open = useCallback(() => {
if (dialogRef.current) {
const { activeElement } = document;
triggerRef.current = activeElement instanceof HTMLElement ? activeElement : null;
// Lock body scroll
document.body.style.overflow = 'hidden';
dialogRef.current.showModal();
}
}, []);
const close = useCallback(() => {
// Unlock body scroll
document.body.style.overflow = '';
dialogRef.current?.close();
triggerRef.current?.focus();
}, []);
// Handle dialog close event
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const handleClose = () => {
// Unlock body scroll
document.body.style.overflow = '';
triggerRef.current?.focus();
};
dialog.addEventListener('close', handleClose);
return () => dialog.removeEventListener('close', handleClose);
}, [mounted]);
const contextValue: AlertDialogContextValue = {
dialogRef,
open,
close,
titleId: `${instanceId}-title`,
messageId: `${instanceId}-message`,
};
return <AlertDialogContext.Provider value={contextValue}>{children}</AlertDialogContext.Provider>;
}
// ============================================================================
// AlertDialogTrigger
// ============================================================================
export interface AlertDialogTriggerProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
'onClick'
> {
children: React.ReactNode;
}
export function AlertDialogTrigger({
children,
className = '',
...buttonProps
}: AlertDialogTriggerProps): React.ReactElement {
const { open } = useAlertDialogContext();
return (
<button
type="button"
className={`apg-alert-dialog-trigger ${className}`.trim()}
onClick={open}
{...buttonProps}
>
{children}
</button>
);
}
// ============================================================================
// AlertDialog
// ============================================================================
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;
/** Callback when confirm button is clicked */
onConfirm?: () => void;
/** Callback when cancel button is clicked */
onCancel?: () => void;
/** Additional CSS class */
className?: string;
}
export function AlertDialog({
title,
message,
confirmLabel = 'OK',
cancelLabel = 'Cancel',
confirmVariant = 'default',
allowEscapeClose = false,
onConfirm,
onCancel,
className = '',
}: AlertDialogProps): React.ReactElement | null {
const { dialogRef, close, titleId, messageId } = useAlertDialogContext();
const cancelButtonRef = useRef<HTMLButtonElement>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Focus cancel button when dialog opens (safest action)
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const handleOpen = () => {
// Use requestAnimationFrame to ensure dialog is fully rendered
requestAnimationFrame(() => {
cancelButtonRef.current?.focus();
});
};
// MutationObserver to detect when dialog is opened
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.attributeName === 'open' && dialog.hasAttribute('open')) {
handleOpen();
}
}
});
observer.observe(dialog, { attributes: true });
return () => observer.disconnect();
}, [dialogRef, mounted]);
// Handle cancel event (fired by native dialog on Escape key in browsers)
// This event fires BEFORE keydown, so we must handle it here
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const handleCancel = (event: Event) => {
if (!allowEscapeClose) {
event.preventDefault();
} else {
onCancel?.();
}
};
dialog.addEventListener('cancel', handleCancel);
return () => dialog.removeEventListener('cancel', handleCancel);
}, [dialogRef, allowEscapeClose, onCancel, mounted]);
// Handle Escape keydown (for JSDOM and environments where cancel event is not fired)
// Listen at document level in capture phase to intercept before any default behavior
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const handleKeyDown = (event: KeyboardEvent) => {
// Only handle Escape when dialog is open
if (event.key === 'Escape' && dialog.hasAttribute('open')) {
// Prevent default to stop any built-in close behavior
event.preventDefault();
event.stopPropagation();
if (allowEscapeClose) {
onCancel?.();
close();
}
}
};
// Use document level capture phase to intercept before dialog's default behavior
document.addEventListener('keydown', handleKeyDown, true);
return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [dialogRef, allowEscapeClose, onCancel, close, mounted]);
// Manual focus trap for JSDOM and older browsers
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return;
const focusableElements = dialog.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();
}
}
};
dialog.addEventListener('keydown', handleKeyDown);
return () => dialog.removeEventListener('keydown', handleKeyDown);
}, [dialogRef, mounted]);
const handleConfirm = useCallback(() => {
onConfirm?.();
close();
}, [onConfirm, close]);
const handleCancel = useCallback(() => {
onCancel?.();
close();
}, [onCancel, close]);
// SSR safety
if (typeof document === 'undefined') return null;
if (!mounted) return null;
const confirmButtonClass =
`apg-alert-dialog-confirm ${confirmVariant === 'danger' ? 'apg-alert-dialog-confirm--danger' : ''}`.trim();
return createPortal(
<dialog
ref={dialogRef}
role="alertdialog"
className={`apg-alert-dialog ${className}`.trim()}
aria-labelledby={titleId}
aria-describedby={messageId}
>
<h2 id={titleId} className="apg-alert-dialog-title">
{title}
</h2>
<p id={messageId} className="apg-alert-dialog-message">
{message}
</p>
<div className="apg-alert-dialog-actions">
<button
ref={cancelButtonRef}
type="button"
className="apg-alert-dialog-cancel"
onClick={handleCancel}
>
{cancelLabel}
</button>
<button type="button" className={confirmButtonClass} onClick={handleConfirm}>
{confirmLabel}
</button>
</div>
</dialog>,
document.body
);
}
// ============================================================================
// Exports
// ============================================================================
export default {
Root: AlertDialogRoot,
Trigger: AlertDialogTrigger,
Content: AlertDialog,
}; Usage
import { AlertDialogRoot, AlertDialogTrigger, AlertDialog } from './AlertDialog';
function App() {
const handleDelete = () => {
// Perform delete action
console.log('Item deleted');
};
return (
<AlertDialogRoot>
<AlertDialogTrigger className="bg-destructive text-destructive-foreground px-4 py-2 rounded">
Delete Item
</AlertDialogTrigger>
<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')}
/>
</AlertDialogRoot>
);
} API
AlertDialogRoot Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | required | AlertDialogTrigger and AlertDialog components |
defaultOpen | boolean | false | Initial open state |
onOpenChange | (open: boolean) => void | - | Callback when open state changes |
AlertDialog 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 |
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/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { AlertDialogRoot, AlertDialogTrigger, AlertDialog } from './AlertDialog';
// Test wrapper component
function TestAlertDialog({
title = 'Confirm Action',
message = 'Are you sure you want to proceed?',
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
confirmVariant = 'default' as const,
allowEscapeClose = false,
defaultOpen = false,
onConfirm,
onCancel,
}: {
title?: string;
message?: string;
confirmLabel?: string;
cancelLabel?: string;
confirmVariant?: 'default' | 'danger';
allowEscapeClose?: boolean;
defaultOpen?: boolean;
onConfirm?: () => void;
onCancel?: () => void;
}) {
return (
<AlertDialogRoot defaultOpen={defaultOpen}>
<AlertDialogTrigger>Open Alert</AlertDialogTrigger>
<AlertDialog
title={title}
message={message}
confirmLabel={confirmLabel}
cancelLabel={cancelLabel}
confirmVariant={confirmVariant}
allowEscapeClose={allowEscapeClose}
onConfirm={onConfirm}
onCancel={onCancel}
/>
</AlertDialogRoot>
);
}
describe('AlertDialog', () => {
// 🔴 High Priority: APG ARIA Attributes
describe('APG: ARIA Attributes', () => {
it('has role="alertdialog" (NOT dialog)', async () => {
const user = userEvent.setup();
render(<TestAlertDialog />);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
// Must be alertdialog, not dialog
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('has aria-modal="true"', async () => {
const user = userEvent.setup();
render(<TestAlertDialog />);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('alertdialog')).toHaveAttribute('aria-modal', 'true');
});
it('has aria-labelledby referencing title', async () => {
const user = userEvent.setup();
render(<TestAlertDialog 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('has aria-describedby referencing message (REQUIRED unlike Dialog)', async () => {
const user = userEvent.setup();
render(<TestAlertDialog 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: APG Keyboard Interaction
describe('APG: Keyboard Interaction', () => {
it('does NOT close on Escape by default (unlike Dialog)', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(<TestAlertDialog onCancel={onCancel} />);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
await user.keyboard('{Escape}');
// Should NOT close
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
expect(onCancel).not.toHaveBeenCalled();
});
it('closes on Escape when allowEscapeClose=true', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(<TestAlertDialog allowEscapeClose={true} onCancel={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 moves focus to next focusable element', async () => {
const user = userEvent.setup();
render(<TestAlertDialog />);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
const confirmButton = screen.getByRole('button', { name: 'Confirm' });
// Initial focus should be on Cancel
await vi.waitFor(() => {
expect(cancelButton).toHaveFocus();
});
await user.tab();
expect(confirmButton).toHaveFocus();
});
it('Shift+Tab moves focus to previous focusable element', async () => {
const user = userEvent.setup();
render(<TestAlertDialog />);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
const confirmButton = screen.getByRole('button', { name: 'Confirm' });
// Move to confirm button first
await vi.waitFor(() => {
expect(cancelButton).toHaveFocus();
});
await user.tab();
expect(confirmButton).toHaveFocus();
// Shift+Tab back to cancel
await user.tab({ shift: true });
expect(cancelButton).toHaveFocus();
});
it('Tab wraps from last to first element', async () => {
const user = userEvent.setup();
render(<TestAlertDialog />);
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();
});
// Tab to confirm
await user.tab();
expect(confirmButton).toHaveFocus();
// Tab should wrap to cancel
await user.tab();
expect(cancelButton).toHaveFocus();
});
it('Shift+Tab wraps from first to last element', async () => {
const user = userEvent.setup();
render(<TestAlertDialog />);
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();
});
// Shift+Tab should wrap to confirm
await user.tab({ shift: true });
expect(confirmButton).toHaveFocus();
});
it('Enter activates focused button', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(<TestAlertDialog onCancel={onCancel} />);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
await vi.waitFor(() => {
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus();
});
await user.keyboard('{Enter}');
expect(onCancel).toHaveBeenCalled();
});
it('Space activates focused button', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(<TestAlertDialog onCancel={onCancel} />);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
await vi.waitFor(() => {
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus();
});
await user.keyboard(' ');
expect(onCancel).toHaveBeenCalled();
});
});
// 🔴 High Priority: Focus Management
describe('APG: Focus Management', () => {
it('focuses Cancel button on open (safest action, unlike Dialog)', async () => {
const user = userEvent.setup();
render(<TestAlertDialog cancelLabel="Cancel" confirmLabel="Delete" />);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
await vi.waitFor(() => {
// Cancel should be focused, NOT Delete (destructive action)
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus();
});
});
it('returns focus to trigger when closed via Cancel', async () => {
const user = userEvent.setup();
render(<TestAlertDialog />);
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();
});
it('returns focus to trigger when closed via Confirm', async () => {
const user = userEvent.setup();
render(<TestAlertDialog />);
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: 'Confirm' }));
expect(trigger).toHaveFocus();
});
it('returns focus to trigger when closed via Escape (when allowed)', async () => {
const user = userEvent.setup();
render(<TestAlertDialog allowEscapeClose={true} />);
const trigger = screen.getByRole('button', { name: 'Open Alert' });
await user.click(trigger);
await vi.waitFor(() => {
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
});
await user.keyboard('{Escape}');
expect(trigger).toHaveFocus();
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const user = userEvent.setup();
const { container } = render(<TestAlertDialog />);
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('calls onConfirm when confirm button clicked', async () => {
const user = userEvent.setup();
const onConfirm = vi.fn();
render(<TestAlertDialog onConfirm={onConfirm} />);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
await user.click(screen.getByRole('button', { name: 'Confirm' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('calls onCancel when cancel button clicked', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(<TestAlertDialog onCancel={onCancel} />);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('closes dialog after confirm action', async () => {
const user = userEvent.setup();
render(<TestAlertDialog />);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Confirm' }));
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
});
it('closes dialog after cancel action', async () => {
const user = userEvent.setup();
render(<TestAlertDialog />);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
});
it('displays custom button labels', async () => {
const user = userEvent.setup();
render(<TestAlertDialog 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('applies danger variant to confirm button', async () => {
const user = userEvent.setup();
render(<TestAlertDialog confirmVariant="danger" confirmLabel="Delete" />);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('button', { name: 'Delete' })).toHaveClass(
'apg-alert-dialog-confirm--danger'
);
});
it('initially displays when defaultOpen=true', async () => {
render(<TestAlertDialog defaultOpen={true} />);
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
});
});
// No close button (×) by design
describe('Alert Dialog Specific Behavior', () => {
it('does NOT have a close button (×) unlike regular Dialog', async () => {
const user = userEvent.setup();
render(<TestAlertDialog />);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
// Should NOT have close button
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