Alert Dialog
A modal dialog that interrupts the user's workflow to communicate an important message and require a response.
Demo
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 Properties
aria-modal
Provided automatically by showModal(). No explicit attribute needed when using native <dialog> element.
- Values
- true
- Required
- Implicit
aria-labelledby
References the alert dialog title
- Values
- ID reference to title element
- Required
- Yes
aria-describedby
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.
- Values
- ID reference to message
- Required
- Yes (required)
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 |
- 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.
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) |
References
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
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | required | AlertDialogRoot: AlertDialogTrigger and AlertDialog components |
defaultOpen | boolean | false | AlertDialogRoot: Initial open state |
onOpenChange | (open: boolean) => void | - | AlertDialogRoot: Callback when open state changes |
title | string | required | AlertDialog: Alert dialog title |
message | string | required | AlertDialog: Alert message (required for accessibility) |
confirmLabel | string | "OK" | AlertDialog: Confirm button label |
cancelLabel | string | "Cancel" | AlertDialog: Cancel button label |
confirmVariant | 'default' | 'danger' | 'default' | AlertDialog: Confirm button visual style |
allowEscapeClose | boolean | false | AlertDialog: Allow closing with Escape key |
onConfirm | () => void | - | AlertDialog: Callback when Confirm is clicked |
onCancel | () => void | - | AlertDialog: Callback when Cancel is clicked |
<AlertDialogRoot> manages state, <AlertDialogTrigger> opens the dialog, and <AlertDialog> renders the modal. 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.
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();
});
});
// Note: Focus restore tests are covered by E2E tests (Playwright)
// due to jsdom limitations with showModal() focus management.
// See: e2e/alert-dialog.spec.ts - Focus Management section
});
// 🟡 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