APG Patterns
English GitHub
English GitHub

Alert Dialog

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

🤖 AI Implementation Guide

デモ

以下のアラートダイアログを試してください。重要な確認では 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.svelte
<script lang="ts" module>
  import type { Snippet } from 'svelte';

  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;
    /** Default open state */
    defaultOpen?: boolean;
    /** Additional CSS class */
    className?: string;
    /** Callback when confirm button is clicked */
    onConfirm?: () => void;
    /** Callback when cancel button is clicked */
    onCancel?: () => void;
    /** Trigger snippet - receives open function */
    trigger: Snippet<[{ open: () => void }]>;
  }
</script>

<script lang="ts">
  import { onMount, tick } from 'svelte';

  let {
    title,
    message,
    confirmLabel = 'OK',
    cancelLabel = 'Cancel',
    confirmVariant = 'default',
    allowEscapeClose = false,
    defaultOpen = false,
    className = '',
    onConfirm = () => {},
    onCancel = () => {},
    trigger,
  }: AlertDialogProps = $props();

  let dialogElement = $state<HTMLDialogElement | undefined>(undefined);
  let cancelButtonElement = $state<HTMLButtonElement | undefined>(undefined);
  let previousActiveElement: HTMLElement | null = null;
  let instanceId = $state('');

  onMount(() => {
    instanceId = `alert-dialog-${Math.random().toString(36).substr(2, 9)}`;

    // Open on mount if defaultOpen
    if (defaultOpen && dialogElement) {
      dialogElement.showModal();
      focusCancelButton();
    }
  });

  let titleId = $derived(`${instanceId}-title`);
  let messageId = $derived(`${instanceId}-message`);
  let confirmButtonClass = $derived(
    confirmVariant === 'danger'
      ? 'apg-alert-dialog-confirm apg-alert-dialog-confirm--danger'
      : 'apg-alert-dialog-confirm'
  );

  async function focusCancelButton() {
    await tick();
    cancelButtonElement?.focus();
  }

  export function open() {
    if (dialogElement) {
      previousActiveElement = document.activeElement as HTMLElement;
      // Lock body scroll
      document.body.style.overflow = 'hidden';
      dialogElement.showModal();
      focusCancelButton();
    }
  }

  export function close() {
    // Unlock body scroll
    document.body.style.overflow = '';
    dialogElement?.close();
  }

  function handleClose() {
    // Unlock body scroll
    document.body.style.overflow = '';
    // Return focus to trigger
    if (previousActiveElement) {
      previousActiveElement.focus();
    }
  }

  function handleKeyDown(event: KeyboardEvent) {
    // Handle Escape key
    if (event.key === 'Escape') {
      event.preventDefault();
      event.stopPropagation();
      if (allowEscapeClose) {
        onCancel();
        close();
      }
      return;
    }

    // Handle focus trap for Tab key
    if (event.key === 'Tab' && dialogElement) {
      const focusableElements = dialogElement.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();
        }
      }
    }
  }

  // Handle native cancel event (fired when Escape pressed in real browsers)
  function handleCancelEvent(event: Event) {
    if (!allowEscapeClose) {
      event.preventDefault();
    } else {
      onCancel();
    }
  }

  function handleConfirm() {
    onConfirm();
    close();
  }

  function handleCancel() {
    onCancel();
    close();
  }
</script>

<!-- Trigger snippet -->
{@render trigger({ open })}

<!-- Native Dialog Element with alertdialog role -->
<dialog
  bind:this={dialogElement}
  role="alertdialog"
  class={`apg-alert-dialog ${className}`.trim()}
  aria-labelledby={titleId}
  aria-describedby={messageId}
  onkeydowncapture={handleKeyDown}
  oncancel={handleCancelEvent}
  onclose={handleClose}
>
  <h2 id={titleId} class="apg-alert-dialog-title">
    {title}
  </h2>
  <p id={messageId} class="apg-alert-dialog-message">
    {message}
  </p>
  <div class="apg-alert-dialog-actions">
    <button
      bind:this={cancelButtonElement}
      type="button"
      class="apg-alert-dialog-cancel"
      onclick={handleCancel}
    >
      {cancelLabel}
    </button>
    <button type="button" class={confirmButtonClass} onclick={handleConfirm}>
      {confirmLabel}
    </button>
  </div>
</dialog>

使い方

Example
<script lang="ts">
  import AlertDialog from './AlertDialog.svelte';

  const handleDelete = () => {
    console.log('アイテムが削除されました');
  };
</script>

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

API

Props

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

スニペット

スニペット Props 説明
trigger { open } open 関数を持つトリガー要素

テスト

テストは、キーボード操作、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 キーボード操作(Unit + E2E)

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

高優先度: APG ARIA 属性(Unit + E2E)

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

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

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

中優先度: アクセシビリティ(Unit + E2E)

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

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

テスト 説明
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.svelte.ts
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import AlertDialogTestWrapper from './AlertDialogTestWrapper.svelte';

describe('AlertDialog (Svelte)', () => {
  // 🔴 High Priority: APG ARIA 属性
  describe('APG: ARIA 属性', () => {
    it('role="alertdialog" を持つ(dialog ではない)', async () => {
      const user = userEvent.setup();
      render(AlertDialogTestWrapper);

      await user.click(screen.getByRole('button', { name: 'Open Alert' }));

      expect(screen.getByRole('alertdialog')).toBeInTheDocument();
      expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
    });

    it('aria-modal="true" を持つ', async () => {
      const user = userEvent.setup();
      render(AlertDialogTestWrapper);

      await user.click(screen.getByRole('button', { name: 'Open Alert' }));
      expect(screen.getByRole('alertdialog')).toHaveAttribute('aria-modal', 'true');
    });

    it('aria-labelledby でタイトルを参照', async () => {
      const user = userEvent.setup();
      render(AlertDialogTestWrapper, {
        props: { 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('aria-describedby でメッセージを参照(必須 - Dialog と異なる)', async () => {
      const user = userEvent.setup();
      render(AlertDialogTestWrapper, {
        props: { 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: キーボード操作
  describe('APG: キーボード操作', () => {
    it('デフォルトで Escape キーで閉じない(Dialog と異なる)', async () => {
      const user = userEvent.setup();
      const onCancel = vi.fn();
      render(AlertDialogTestWrapper, {
        props: { onCancel },
      });

      await user.click(screen.getByRole('button', { name: 'Open Alert' }));
      expect(screen.getByRole('alertdialog')).toBeInTheDocument();

      await user.keyboard('{Escape}');

      expect(screen.getByRole('alertdialog')).toBeInTheDocument();
      expect(onCancel).not.toHaveBeenCalled();
    });

    it('allowEscapeClose=true で Escape キーで閉じる', async () => {
      const user = userEvent.setup();
      const onCancel = vi.fn();
      render(AlertDialogTestWrapper, {
        props: { allowEscapeClose: true, 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 で次のフォーカス可能要素に移動', async () => {
      const user = userEvent.setup();
      render(AlertDialogTestWrapper);

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

      await user.tab();
      expect(confirmButton).toHaveFocus();
    });

    it('Tab が最後から最初にループする', async () => {
      const user = userEvent.setup();
      render(AlertDialogTestWrapper);

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

      await user.tab();
      expect(confirmButton).toHaveFocus();

      await user.tab();
      expect(cancelButton).toHaveFocus();
    });
  });

  // 🔴 High Priority: フォーカス管理
  describe('APG: フォーカス管理', () => {
    it('開いた時に Cancel ボタンにフォーカス(安全なアクション、Dialog と異なる)', async () => {
      const user = userEvent.setup();
      render(AlertDialogTestWrapper, {
        props: { cancelLabel: 'Cancel', confirmLabel: 'Delete' },
      });

      await user.click(screen.getByRole('button', { name: 'Open Alert' }));

      await vi.waitFor(() => {
        expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus();
      });
    });

    it('閉じた時にトリガーにフォーカス復元', async () => {
      const user = userEvent.setup();
      render(AlertDialogTestWrapper);

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

  // 🟡 Medium Priority: アクセシビリティ
  describe('アクセシビリティ', () => {
    it('axe による違反がない', async () => {
      const user = userEvent.setup();
      const { container } = render(AlertDialogTestWrapper);

      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('confirm ボタンクリックで onConfirm を呼ぶ', async () => {
      const user = userEvent.setup();
      const onConfirm = vi.fn();
      render(AlertDialogTestWrapper, {
        props: { onConfirm },
      });

      await user.click(screen.getByRole('button', { name: 'Open Alert' }));
      await user.click(screen.getByRole('button', { name: 'Confirm' }));

      expect(onConfirm).toHaveBeenCalledTimes(1);
    });

    it('cancel ボタンクリックで onCancel を呼ぶ', async () => {
      const user = userEvent.setup();
      const onCancel = vi.fn();
      render(AlertDialogTestWrapper, {
        props: { onCancel },
      });

      await user.click(screen.getByRole('button', { name: 'Open Alert' }));
      await user.click(screen.getByRole('button', { name: 'Cancel' }));

      expect(onCancel).toHaveBeenCalledTimes(1);
    });

    it('カスタムボタンラベルが表示される', async () => {
      const user = userEvent.setup();
      render(AlertDialogTestWrapper, {
        props: { 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('defaultOpen=true で初期表示', async () => {
      render(AlertDialogTestWrapper, {
        props: { defaultOpen: true },
      });
      expect(screen.getByRole('alertdialog')).toBeInTheDocument();
    });
  });

  // Alert Dialog 固有の動作
  describe('Alert Dialog 固有の動作', () => {
    it('閉じるボタン(×)がない(通常の Dialog と異なる)', async () => {
      const user = userEvent.setup();
      render(AlertDialogTestWrapper);

      await user.click(screen.getByRole('button', { name: 'Open Alert' }));

      expect(screen.queryByRole('button', { name: /close/i })).not.toBeInTheDocument();
    });
  });
});

リソース