APG Patterns
日本語 GitHub
日本語 GitHub

Alert Dialog

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

🤖 AI Implementation Guide

Demo

Try the alert dialogs below. Note that Escape key is disabled by default for critical confirmations, and initial focus goes to the Cancel button (the safest action).

Open demo only →

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
alertdialog Dialog container A type of dialog that interrupts the user's workflow to communicate an important message and require a response. May trigger system alert sounds in assistive technologies.

WAI-ARIA alertdialog role (opens in new tab)

WAI-ARIA Properties

Attribute Target Values Required Description
aria-modal alertdialog true Implicit Provided automatically by showModal(). No explicit attribute needed when using native <dialog> element.
aria-labelledby alertdialog ID reference to title element Yes References the alert dialog title
aria-describedby alertdialog ID reference to message Yes (required) References the alert message. Unlike regular Dialog, this is required for Alert Dialog as the message is central to the user's understanding of what action is being confirmed.

Focus Management

Event Behavior
Dialog opens Focus moves to the Cancel button (safest action). This differs from regular Dialog which focuses the first focusable element.
Dialog closes Focus returns to the element that triggered the dialog
Focus trap Tab/Shift+Tab cycles through focusable elements within the dialog only
Background Content outside dialog is made inert (not focusable or interactive)

Keyboard Support

Key Action
Tab Move focus to next focusable element within dialog. When focus is on the last element, moves to first.
Shift + Tab Move focus to previous focusable element within dialog. When focus is on the first element, moves to last.
Escape Disabled by default. Unlike regular Dialog, Alert Dialog prevents Escape key closure to ensure users explicitly respond to the alert. Can be enabled via allowEscapeClose prop for non-critical alerts.
Enter Activates the focused button
Space Activates the focused button

Differences from Dialog

Feature Dialog Alert Dialog
Role dialog alertdialog
Message (aria-describedby) Optional Required
Escape key Enabled by default Disabled by default
Initial focus First focusable element Cancel button (safest action)
Close button Yes (×) No (explicit response required)
Overlay click Closes dialog Does not close (explicit response required)

Additional Notes

  • Alert Dialog is for critical messages that require user acknowledgment or a decision. For general content, use regular Dialog.
  • The alertdialog role may cause assistive technologies to announce the dialog more urgently or with an alert sound.
  • Both title and message are required to provide complete context for the user's decision.
  • The Cancel button should always be the safest choice (no destructive action).
  • Consider using the danger variant for destructive actions to provide visual distinction.

Source Code

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

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

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.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();
      });
    });

    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