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.vue
<template>
  <slot name="trigger" :open="openDialog" />

  <Teleport to="body">
    <dialog
      ref="dialogRef"
      role="alertdialog"
      :class="`apg-alert-dialog ${className}`.trim()"
      :aria-labelledby="titleId"
      :aria-describedby="messageId"
      @keydown.capture="handleKeyDown"
      @cancel="handleCancel2"
      @close="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
          ref="cancelButtonRef"
          type="button"
          class="apg-alert-dialog-cancel"
          @click="handleCancel"
        >
          {{ cancelLabel }}
        </button>
        <button type="button" :class="confirmButtonClass" @click="handleConfirm">
          {{ confirmLabel }}
        </button>
      </div>
    </dialog>
  </Teleport>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue';

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

const props = withDefaults(defineProps<AlertDialogProps>(), {
  confirmLabel: 'OK',
  cancelLabel: 'Cancel',
  confirmVariant: 'default',
  allowEscapeClose: false,
  defaultOpen: false,
  className: '',
});

const emit = defineEmits<{
  confirm: [];
  cancel: [];
}>();

const dialogRef = ref<HTMLDialogElement>();
const cancelButtonRef = ref<HTMLButtonElement>();
const previousActiveElement = ref<HTMLElement | null>(null);
const instanceId = ref('');

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

  // Open on mount if defaultOpen
  if (props.defaultOpen && dialogRef.value) {
    dialogRef.value.showModal();
    focusCancelButton();
  }
});

const titleId = computed(() => `${instanceId.value}-title`);
const messageId = computed(() => `${instanceId.value}-message`);

const confirmButtonClass = computed(() => {
  const base = 'apg-alert-dialog-confirm';
  return props.confirmVariant === 'danger' ? `${base} ${base}--danger` : base;
});

const focusCancelButton = async () => {
  await nextTick();
  cancelButtonRef.value?.focus();
};

const openDialog = () => {
  if (dialogRef.value) {
    previousActiveElement.value = document.activeElement as HTMLElement;
    // Lock body scroll
    document.body.style.overflow = 'hidden';
    dialogRef.value.showModal();
    focusCancelButton();
  }
};

const closeDialog = () => {
  // Unlock body scroll
  document.body.style.overflow = '';
  dialogRef.value?.close();
};

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

const handleKeyDown = (event: KeyboardEvent) => {
  // Handle Escape key
  if (event.key === 'Escape') {
    event.preventDefault();
    event.stopPropagation();
    if (props.allowEscapeClose) {
      emit('cancel');
      closeDialog();
    }
    return;
  }

  // Handle focus trap for Tab key
  if (event.key === 'Tab' && dialogRef.value) {
    const focusableElements = dialogRef.value.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)
const handleCancel2 = (event: Event) => {
  if (!props.allowEscapeClose) {
    event.preventDefault();
  } else {
    emit('cancel');
  }
};

const handleConfirm = () => {
  emit('confirm');
  closeDialog();
};

const handleCancel = () => {
  emit('cancel');
  closeDialog();
};

// Expose methods for external control
defineExpose({
  open: openDialog,
  close: closeDialog,
});
</script>

使い方

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

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

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

API

Props

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

イベント

イベント 説明
@confirm 確認ボタンがクリックされたときに発火
@cancel キャンセルボタンがクリックされたときに発火

スロット

スロット 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.vue.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import AlertDialog from './AlertDialog.vue';

// テスト用のラッパーコンポーネント
const TestAlertDialog = {
  components: { AlertDialog },
  props: {
    title: { type: String, default: 'Confirm Action' },
    message: { type: String, default: 'Are you sure you want to proceed?' },
    confirmLabel: { type: String, default: 'Confirm' },
    cancelLabel: { type: String, default: 'Cancel' },
    confirmVariant: { type: String as () => 'default' | 'danger', default: 'default' },
    allowEscapeClose: { type: Boolean, default: false },
    defaultOpen: { type: Boolean, default: false },
  },
  emits: ['confirm', 'cancel'],
  template: `
    <AlertDialog
      :title="title"
      :message="message"
      :confirm-label="confirmLabel"
      :cancel-label="cancelLabel"
      :confirm-variant="confirmVariant"
      :allow-escape-close="allowEscapeClose"
      :default-open="defaultOpen"
      @confirm="$emit('confirm')"
      @cancel="$emit('cancel')"
    >
      <template #trigger="{ open }">
        <button @click="open">Open Alert</button>
      </template>
    </AlertDialog>
  `,
};

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

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

      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(TestAlertDialog, {
        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(TestAlertDialog, {
        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(TestAlertDialog, {
        props: { onCancel },
        attrs: { 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(TestAlertDialog, {
        props: { allowEscapeClose: true, onCancel },
        attrs: { 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(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();
      });

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

    it('Tab が最後から最初にループする', 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();
      });

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

  // 🟡 Medium Priority: アクセシビリティ
  describe('アクセシビリティ', () => {
    it('axe による違反がない', 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('confirm ボタンクリックで onConfirm を呼ぶ', async () => {
      const user = userEvent.setup();
      const onConfirm = vi.fn();
      render(TestAlertDialog, {
        props: { onConfirm },
        attrs: { 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(TestAlertDialog, {
        props: { onCancel },
        attrs: { 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(TestAlertDialog, {
        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(TestAlertDialog, {
        props: { defaultOpen: true },
      });
      expect(screen.getByRole('alertdialog')).toBeInTheDocument();
    });
  });

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

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

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

リソース