APG Patterns
English GitHub
English GitHub

Alert Dialog

ユーザーのワークフローを中断し、重要なメッセージを伝えて応答を求めるモーダルダイアログ。

🤖 AI 実装ガイド

デモ

以下のアラートダイアログを試してください。重要な確認では Escape キーがデフォルトで無効になっており、初期フォーカスはキャンセルボタン(最も安全なアクション)に移動します。

デモのみ表示 →

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
alertdialog ダイアログコンテナ ユーザーのワークフローを中断して重要なメッセージを伝え、応答を求めるダイアログの一種。支援技術でシステムのアラート音がトリガーされる場合があります。

WAI-ARIA alertdialog role (opens in new tab)

WAI-ARIA プロパティ

属性 対象 必須 説明
aria-modal alertdialog true 暗黙的 showModal() によって自動的に提供される。ネイティブの <dialog> 要素使用時は明示的な属性は不要。
aria-labelledby alertdialog タイトル要素への ID 参照 はい アラートダイアログのタイトルを参照する
aria-describedby alertdialog メッセージへの ID 参照 はい(必須) アラートメッセージを参照する。通常の Dialog とは異なり、Alert Dialog ではメッセージがユーザーの意思決定を理解する上で中心的であるため、この属性は必須です。

フォーカス管理

イベント 動作
ダイアログが開く フォーカスはキャンセルボタン(最も安全なアクション)に移動する。これは最初のフォーカス可能な要素にフォーカスする通常の Dialog とは異なります。
ダイアログが閉じる ダイアログを開いた要素にフォーカスが戻る
フォーカストラップ Tab/Shift+Tab はダイアログ内のフォーカス可能な要素間のみをサイクルする
背景 ダイアログ外のコンテンツは不活性化される(フォーカス不可・操作不可)

キーボードサポート

キー アクション
Tab ダイアログ内の次のフォーカス可能な要素にフォーカスを移動する。最後の要素にフォーカスがある場合は最初の要素に移動する。
Shift + Tab ダイアログ内の前のフォーカス可能な要素にフォーカスを移動する。最初の要素にフォーカスがある場合は最後の要素に移動する。
Escape デフォルトでは無効。通常の Dialog とは異なり、アラートダイアログではユーザーに明示的に応答させるため、Escape キーによる閉じる動作を防止します。allowEscapeClose プロパティで重要度の低いアラートに対して有効にすることができます。
Enter フォーカスされているボタンを実行する
Space フォーカスされているボタンを実行する

Dialog との違い

機能 Dialog Alert Dialog
ロール dialog alertdialog
メッセージ(aria-describedby 任意 必須
Escape キー デフォルトで有効 デフォルトで無効
初期フォーカス 最初のフォーカス可能な要素 キャンセルボタン(最も安全なアクション)
閉じるボタン あり(×) なし(明示的な応答が必要)
オーバーレイクリック ダイアログを閉じる 閉じない(明示的な応答が必要)

補足事項

  • Alert Dialog はユーザーの確認や意思決定を必要とする重要なメッセージ用です。一般的なコンテンツには通常の Dialog を使用してください。
  • alertdialog ロールにより、支援技術はより緊急にダイアログを読み上げたり、アラート音を鳴らしたりする場合があります。
  • タイトルとメッセージの両方が、ユーザーの意思決定に完全なコンテキストを提供するために必要です。
  • キャンセルボタンは常に最も安全な選択(破壊的なアクションを行わない)であるべきです。
  • 破壊的なアクションには視覚的な区別を提供するために danger バリアントの使用を検討してください。

ソースコード

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,
};

使い方

Example
import { AlertDialogRoot, AlertDialogTrigger, AlertDialog } from './AlertDialog';

function App() {
  const handleDelete = () => {
    // 削除アクションを実行
    console.log('Item deleted');
  };

  return (
    <AlertDialogRoot>
      <AlertDialogTrigger className="bg-destructive text-destructive-foreground px-4 py-2 rounded">
        アイテムを削除
      </AlertDialogTrigger>
      <AlertDialog
        title="このアイテムを削除しますか?"
        message="この操作は取り消せません。アイテムは完全に削除されます。"
        confirmLabel="削除"
        cancelLabel="キャンセル"
        confirmVariant="danger"
        onConfirm={handleDelete}
        onCancel={() => console.log('キャンセルされました')}
      />
    </AlertDialogRoot>
  );
}

API

AlertDialogRoot Props

プロパティ デフォルト 説明
children ReactNode 必須 AlertDialogTrigger と AlertDialog コンポーネント
defaultOpen boolean false 初期の開閉状態
onOpenChange (open: boolean) => void - 開閉状態が変更されたときのコールバック

AlertDialog Props

プロパティ デフォルト 説明
title string 必須 アラートダイアログのタイトル
message string 必須 アラートメッセージ(アクセシビリティ上必須)
confirmLabel string "OK" 確認ボタンのラベル
cancelLabel string "Cancel" キャンセルボタンのラベル
confirmVariant 'default' | 'danger' 'default' 確認ボタンのスタイル
allowEscapeClose boolean false Escape キーで閉じることを許可
onConfirm () => void - 確認ボタンクリック時のコールバック
onCancel () => void - キャンセルボタンクリック時のコールバック

テスト

テストは、キーボード操作、ARIA 属性、およびアクセシビリティ要件全体にわたる APG 準拠を検証します。Alert Dialog は通常の Dialog よりも厳格な要件があります。Alert Dialog コンポーネントは2層テスト戦略を使用しています。

テスト戦略

ユニットテスト (Testing Library / jest-axe)

コンポーネントのHTML出力、ARIA属性、およびアクセシビリティを検証します。これらのテストは正しいレンダリングとAPG要件への準拠を保証します。

  • role="alertdialog"(dialog ではない)
  • aria-labelledby と aria-describedby 属性
  • showModal() によるモーダル動作
  • axe-core による WCAG 2.1 AA 準拠
  • プロパティ動作(allowEscapeClose, confirmVariant)

E2E テスト (Playwright)

すべてのフレームワーク(React、Vue、Svelte、Astro)で実際のブラウザ環境でのコンポーネント動作を検証します。JavaScript 実行を必要とするインタラクションをカバーします。

  • ダイアログ表示時にキャンセルボタンにフォーカス(最も安全なアクション)
  • Tab/Shift+Tab がダイアログ内でラップする(フォーカストラップ)
  • Enter/Space でフォーカスされたボタンを実行
  • デフォルトで Escape キー無効
  • 閉じる時に開いた要素にフォーカスが戻る
  • 通常の Dialog と違い閉じるボタン(×)がない

テストカテゴリ

高優先度: APG キーボード操作

テスト 説明
Escape key(無効) デフォルトでは Escape キーでダイアログを閉じない
Escape key(有効) allowEscapeClose が true の場合、Escape でダイアログを閉じる
Enter on button フォーカスされたボタンを実行する
Space on button フォーカスされたボタンを実行する

高優先度: APG ARIA 属性

テスト 説明
role="alertdialog" ダイアログ要素に alertdialog ロールがある(dialog ではない)
モーダル動作 showModal() で開かれている(::backdrop の存在で確認)
aria-labelledby アラートダイアログのタイトルを参照する
aria-describedby アラートメッセージを参照する(Dialog とは異なり必須)

高優先度: フォーカス管理

テスト 説明
Initial focus 開いたときにキャンセルボタンにフォーカスが移動する(最も安全なアクション)
Focus restore 閉じたときに開いた要素にフォーカスが戻る
Focus trap Tab サイクルがダイアログ内に留まる(ネイティブ dialog 経由)

中優先度: アクセシビリティ

テスト 説明
axe violations WCAG 2.1 AA 違反がない(jest-axe 経由)
Title and message 両方がレンダリングされ、適切に関連付けられている

低優先度: プロパティと動作

テスト 説明
allowEscapeClose Escape キーの動作を制御する(デフォルト: false)
confirmVariant danger バリアントが正しいスタイリングを適用する
onConfirm 確認ボタンがクリックされたときにコールバックが発火する
onCancel キャンセルボタンがクリックされたときにコールバックが発火する
className カスタムクラスが適用される

テストツール

テストの実行

ユニットテスト

# AlertDialog の全ユニットテストを実行
npm run test:unit -- AlertDialog

# フレームワーク別テストを実行
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 テスト

# Alert Dialog の全 E2E テストを実行
npm run test:e2e -- alert-dialog.spec.ts

# UI モードで実行
npm run test:e2e:ui -- alert-dialog.spec.ts

詳細なドキュメントは testing-strategy.md (opens in new tab) を参照してください。

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

リソース