APG Patterns
日本語
日本語

Alert Dialog

A modal dialog that interrupts the user's workflow to communicate an important message and require a response.

Demo

Open demo only →

Accessibility Features

WAI-ARIA Roles

RoleTarget ElementDescription
alertdialogDialog containerA 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

KeyAction
TabMove focus to next focusable element within dialog. When focus is on the last element, moves to first.
Shift + TabMove focus to previous focusable element within dialog. When focus is on the first element, moves to last.
EscapeDisabled 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.
EnterActivates the focused button
SpaceActivates 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

EventBehavior
Dialog opensFocus moves to the Cancel button (safest action). This differs from regular Dialog which focuses the first focusable element.
Dialog closesFocus returns to the element that triggered the dialog
Focus trapTab/Shift+Tab cycles through focusable elements within the dialog only
BackgroundContent outside dialog is made inert (not focusable or interactive)

References

Source Code

AlertDialog.tsx
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

Example
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
The React implementation uses a compound component pattern: <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

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.test.tsx
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