APG Patterns
English
English

Alert Dialog

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

デモ

削除の確認

明示的な確認が必要な破壊的アクションです。Escapeキーは無効です。

Delete this item?

This action cannot be undone. This will permanently delete the item from your account.

変更の破棄

未保存の変更を失う前に確認します。

Discard unsaved changes?

You have unsaved changes. Are you sure you want to discard them?

情報の確認

Escapeキーが有効な非破壊的アラートです。

System Maintenance

The system will undergo maintenance on Sunday at 2:00 AM. Please save your work before then.

デモのみ表示 →

アクセシビリティ

WAI-ARIA ロール

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

WAI-ARIA プロパティ

aria-modal

showModal() によって自動的に提供される。ネイティブの <dialog> 要素使用時は明示的な属性は不要。

true
必須
暗黙的

aria-labelledby

アラートダイアログのタイトルを参照する

タイトル要素への ID 参照
必須
はい

aria-describedby

アラートメッセージを参照する。通常の Dialog とは異なり、Alert Dialog ではメッセージがユーザーの意思決定を理解する上で中心的であるため、この属性は必須です。

メッセージへの ID 参照
必須
はい(必須)

キーボードサポート

キーアクション
Tabダイアログ内の次のフォーカス可能な要素にフォーカスを移動する。最後の要素にフォーカスがある場合は最初の要素に移動する。
Shift + Tabダイアログ内の前のフォーカス可能な要素にフォーカスを移動する。最初の要素にフォーカスがある場合は最後の要素に移動する。
Escapeデフォルトでは無効。通常の Dialog とは異なり、アラートダイアログではユーザーに明示的に応答させるため、Escape キーによる閉じる動作を防止します。allowEscapeClose プロパティで重要度の低いアラートに対して有効にすることができます。
Enterフォーカスされているボタンを実行する
Spaceフォーカスされているボタンを実行する
  • Alert Dialog はユーザーの確認や意思決定を必要とする重要なメッセージ用です。一般的なコンテンツには通常の Dialog を使用してください。
  • alertdialog ロールにより、支援技術はより緊急にダイアログを読み上げたり、アラート音を鳴らしたりする場合があります。
  • タイトルとメッセージの両方が、ユーザーの意思決定に完全なコンテキストを提供するために必要です。
  • キャンセルボタンは常に最も安全な選択(破壊的なアクションを行わない)であるべきです。
  • 破壊的なアクションには視覚的な区別を提供するために danger バリアントの使用を検討してください。

フォーカス管理

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

参考資料

ソースコード

AlertDialog.astro
---
/**
 * APG Alert Dialog Pattern - Astro Implementation
 *
 * A modal dialog that interrupts the user's workflow to communicate an important
 * message and require a response. Unlike regular Dialog, uses role="alertdialog"
 * which may trigger system alert sounds in assistive technologies.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/
 */

export interface Props {
  /** Dialog title (required for accessibility) */
  title: string;
  /** Alert message (required - unlike regular Dialog) */
  message: string;
  /** Trigger button text */
  triggerText: 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;
  /** Additional CSS class for trigger button */
  triggerClass?: string;
  /** Additional CSS class for dialog */
  class?: string;
}

const {
  title,
  message,
  triggerText,
  confirmLabel = 'OK',
  cancelLabel = 'Cancel',
  confirmVariant = 'default',
  allowEscapeClose = false,
  triggerClass = '',
  class: className = '',
} = Astro.props;

// Generate unique ID for this instance
const instanceId = `alert-dialog-${Math.random().toString(36).substring(2, 11)}`;
const titleId = `${instanceId}-title`;
const messageId = `${instanceId}-message`;

const confirmButtonClass =
  confirmVariant === 'danger'
    ? 'apg-alert-dialog-confirm apg-alert-dialog-confirm--danger'
    : 'apg-alert-dialog-confirm';
---

<apg-alert-dialog data-allow-escape-close={allowEscapeClose ? 'true' : 'false'}>
  <!-- Trigger Button -->
  <button
    type="button"
    class={`apg-alert-dialog-trigger ${triggerClass}`.trim()}
    data-alert-dialog-trigger
  >
    {triggerText}
  </button>

  <!-- Native Dialog Element with alertdialog role -->
  <dialog
    role="alertdialog"
    class={`apg-alert-dialog ${className}`.trim()}
    aria-labelledby={titleId}
    aria-describedby={messageId}
    data-alert-dialog-content
  >
    <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 type="button" class="apg-alert-dialog-cancel" data-cancel>
        {cancelLabel}
      </button>
      <button type="button" class={confirmButtonClass} data-confirm>
        {confirmLabel}
      </button>
    </div>
  </dialog>
</apg-alert-dialog>

<script>
  class ApgAlertDialog extends HTMLElement {
    private trigger: HTMLButtonElement | null = null;
    private dialog: HTMLDialogElement | null = null;
    private cancelButton: HTMLButtonElement | null = null;
    private confirmButton: HTMLButtonElement | null = null;
    private allowEscapeClose = false;

    connectedCallback() {
      this.trigger = this.querySelector('[data-alert-dialog-trigger]');
      this.dialog = this.querySelector('[data-alert-dialog-content]');
      this.cancelButton = this.querySelector('[data-cancel]');
      this.confirmButton = this.querySelector('[data-confirm]');

      if (!this.trigger || !this.dialog) {
        console.warn('apg-alert-dialog: required elements not found');
        return;
      }

      this.allowEscapeClose = this.dataset.allowEscapeClose === 'true';

      // Attach event listeners
      this.trigger.addEventListener('click', this.open);
      this.cancelButton?.addEventListener('click', this.handleCancelClick);
      this.confirmButton?.addEventListener('click', this.handleConfirm);
      this.dialog.addEventListener('keydown', this.handleKeyDown, true);
      this.dialog.addEventListener('cancel', this.handleDialogCancel);
      this.dialog.addEventListener('close', this.handleClose);
    }

    disconnectedCallback() {
      this.trigger?.removeEventListener('click', this.open);
      this.cancelButton?.removeEventListener('click', this.handleCancelClick);
      this.confirmButton?.removeEventListener('click', this.handleConfirm);
      this.dialog?.removeEventListener('keydown', this.handleKeyDown, true);
      this.dialog?.removeEventListener('cancel', this.handleDialogCancel);
      this.dialog?.removeEventListener('close', this.handleClose);
    }

    private open = () => {
      if (!this.dialog) return;

      // Lock body scroll
      document.body.style.overflow = 'hidden';
      this.dialog.showModal();

      // Focus cancel button (safest action - unlike regular Dialog)
      requestAnimationFrame(() => {
        this.cancelButton?.focus();
      });

      // Dispatch custom event
      this.dispatchEvent(
        new CustomEvent('alertdialogopen', {
          bubbles: true,
        })
      );
    };

    private close = () => {
      // Unlock body scroll
      document.body.style.overflow = '';
      this.dialog?.close();
    };

    private handleClose = () => {
      // Unlock body scroll
      document.body.style.overflow = '';
      // Return focus to trigger
      this.trigger?.focus();

      // Dispatch custom event
      this.dispatchEvent(
        new CustomEvent('alertdialogclose', {
          bubbles: true,
        })
      );
    };

    private handleKeyDown = (e: KeyboardEvent) => {
      // Handle Escape key
      if (e.key === 'Escape') {
        e.preventDefault();
        e.stopPropagation();
        if (this.allowEscapeClose) {
          this.dispatchEvent(
            new CustomEvent('cancel', {
              bubbles: true,
            })
          );
          this.close();
        }
        return;
      }

      // Handle focus trap for Tab key
      if (e.key === 'Tab' && this.dialog) {
        const focusableElements = this.dialog.querySelectorAll<HTMLElement>(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        const firstElement = focusableElements[0];
        const lastElement = focusableElements[focusableElements.length - 1];

        if (e.shiftKey) {
          // Shift+Tab from first element -> wrap to last
          if (document.activeElement === firstElement) {
            e.preventDefault();
            lastElement?.focus();
          }
        } else {
          // Tab from last element -> wrap to first
          if (document.activeElement === lastElement) {
            e.preventDefault();
            firstElement?.focus();
          }
        }
      }
    };

    // Handle native dialog cancel event (fired when Escape pressed in real browsers)
    private handleDialogCancel = (e: Event) => {
      if (!this.allowEscapeClose) {
        e.preventDefault();
      } else {
        this.dispatchEvent(
          new CustomEvent('cancel', {
            bubbles: true,
          })
        );
      }
    };

    private handleCancelClick = () => {
      this.dispatchEvent(
        new CustomEvent('cancel', {
          bubbles: true,
        })
      );
      this.close();
    };

    private handleConfirm = () => {
      this.dispatchEvent(
        new CustomEvent('confirm', {
          bubbles: true,
        })
      );
      this.close();
    };
  }

  // Register the custom element
  if (!customElements.get('apg-alert-dialog')) {
    customElements.define('apg-alert-dialog', ApgAlertDialog);
  }
</script>

使い方

Example
---
import AlertDialog from './AlertDialog.astro';
---

<AlertDialog
  title="Delete this item?"
  message="This action cannot be undone. This will permanently delete the item."
  triggerText="Delete Item"
  confirmLabel="Delete"
  cancelLabel="Cancel"
  confirmVariant="danger"
  triggerClass="bg-destructive text-destructive-foreground px-4 py-2 rounded"
/>

<script>
  // Listen for custom events
  document.querySelector('apg-alert-dialog')?.addEventListener('confirm', () => {
    console.log('Confirmed!');
  });
</script>

API

プロパティ デフォルト 説明
title string required アラートダイアログのタイトル
message string required アラートメッセージ(アクセシビリティ上必須)
triggerText string required トリガーボタンのテキスト
confirmLabel string "OK" 確認ボタンのラベル
cancelLabel string "Cancel" キャンセルボタンのラベル
confirmVariant 'default' | 'danger' 'default' 確認ボタンのスタイル
allowEscapeClose boolean false Escape キーで閉じることを許可
triggerClass string - トリガーボタンの追加 CSS クラス
このコンポーネントは、クライアントサイドのインタラクティビティのためにWeb Component(<apg-alert-dialog>)を使用しています。

Custom Events

イベント Detail 説明
confirm - 確認ボタンがクリックされたときに発火
cancel - キャンセルボタンがクリックされたときに発火
alertdialogopen - ダイアログが開いたときに発火
alertdialogclose - ダイアログが閉じたときに発火

テスト

テストは、キーボード操作、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 (disabled) デフォルトでは Escape キーでダイアログを閉じない
Escape key (enabled) allowEscapeClose が true の場合、Escape でダイアログを閉じる
Enter on button フォーカスされたボタンを実行する
Space on button フォーカスされたボタンを実行する

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

テスト 説明
role="alertdialog" ダイアログ要素に alertdialog ロールがある(dialog ではない)
Modal behavior 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

# 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.astro.ts
/**
 * AlertDialog Web Component Tests
 *
 * Note: These are limited unit tests for the Web Component class.
 * Full keyboard navigation and focus management tests require E2E testing
 * with Playwright due to jsdom limitations with focus events and <dialog> element.
 */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

describe('AlertDialog (Web Component)', () => {
  let container: HTMLElement;

  // Web Component class extracted for testing
  class TestApgAlertDialog extends HTMLElement {
    private dialog: HTMLDialogElement | null = null;
    private triggerRef: HTMLElement | null = null;
    private cancelButton: HTMLButtonElement | null = null;
    private confirmButton: HTMLButtonElement | null = null;
    private rafId: number | null = null;

    connectedCallback() {
      this.rafId = requestAnimationFrame(() => this.initialize());
    }

    private initialize() {
      this.rafId = null;
      this.dialog = this.querySelector('dialog');
      this.cancelButton = this.querySelector('[data-cancel]');
      this.confirmButton = this.querySelector('[data-confirm]');

      if (!this.dialog) return;

      // Set up event listeners
      this.dialog.addEventListener('keydown', this.handleKeyDown);
      this.cancelButton?.addEventListener('click', this.handleCancel);
      this.confirmButton?.addEventListener('click', this.handleConfirm);
    }

    disconnectedCallback() {
      if (this.rafId !== null) {
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
      }
      this.dialog?.removeEventListener('keydown', this.handleKeyDown);
      this.cancelButton?.removeEventListener('click', this.handleCancel);
      this.confirmButton?.removeEventListener('click', this.handleConfirm);
    }

    open(triggerElement?: HTMLElement) {
      if (!this.dialog) return;
      this.triggerRef = triggerElement || null;
      this.dialog.showModal();

      // Focus cancel button (safest action)
      requestAnimationFrame(() => {
        this.cancelButton?.focus();
      });
    }

    close() {
      if (!this.dialog) return;
      this.dialog.close();
      this.triggerRef?.focus();
    }

    private handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        const allowEscapeClose = this.dataset.allowEscapeClose === 'true';
        if (!allowEscapeClose) {
          event.preventDefault();
        } else {
          this.dispatchEvent(new CustomEvent('cancel', { bubbles: true }));
        }
      }
    };

    private handleCancel = () => {
      this.dispatchEvent(new CustomEvent('cancel', { bubbles: true }));
      this.close();
    };

    private handleConfirm = () => {
      this.dispatchEvent(new CustomEvent('confirm', { bubbles: true }));
      this.close();
    };
  }

  function createAlertDialogHTML(
    options: {
      title?: string;
      message?: string;
      confirmLabel?: string;
      cancelLabel?: string;
      allowEscapeClose?: boolean;
      open?: boolean;
    } = {}
  ) {
    const {
      title = 'Confirm Action',
      message = 'Are you sure?',
      confirmLabel = 'Confirm',
      cancelLabel = 'Cancel',
      allowEscapeClose = false,
      open = false,
    } = options;

    const titleId = 'alert-title';
    const messageId = 'alert-message';

    return `
      <apg-alert-dialog ${allowEscapeClose ? 'data-allow-escape-close="true"' : ''}>
        <dialog
          role="alertdialog"
          aria-modal="true"
          aria-labelledby="${titleId}"
          aria-describedby="${messageId}"
          class="apg-alert-dialog"
          ${open ? 'open' : ''}
        >
          <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 type="button" data-cancel class="apg-alert-dialog-cancel">${cancelLabel}</button>
            <button type="button" data-confirm class="apg-alert-dialog-confirm">${confirmLabel}</button>
          </div>
        </dialog>
      </apg-alert-dialog>
    `;
  }

  beforeEach(() => {
    container = document.createElement('div');
    document.body.appendChild(container);

    // Register custom element if not already registered
    if (!customElements.get('apg-alert-dialog')) {
      customElements.define('apg-alert-dialog', TestApgAlertDialog);
    }
  });

  afterEach(() => {
    container.remove();
    vi.restoreAllMocks();
  });

  describe('ARIA Attributes', () => {
    it('has role="alertdialog" (NOT dialog)', async () => {
      container.innerHTML = createAlertDialogHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const dialog = container.querySelector('dialog');
      expect(dialog?.getAttribute('role')).toBe('alertdialog');
    });

    it('has aria-modal="true"', async () => {
      container.innerHTML = createAlertDialogHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const dialog = container.querySelector('dialog');
      expect(dialog?.getAttribute('aria-modal')).toBe('true');
    });

    it('has aria-labelledby referencing title', async () => {
      container.innerHTML = createAlertDialogHTML({ title: 'Delete Item' });
      await new Promise((r) => requestAnimationFrame(r));

      const dialog = container.querySelector('dialog');
      const titleId = dialog?.getAttribute('aria-labelledby');

      expect(titleId).toBeTruthy();
      expect(document.getElementById(titleId!)?.textContent).toBe('Delete Item');
    });

    it('has aria-describedby referencing message (required)', async () => {
      container.innerHTML = createAlertDialogHTML({ message: 'This cannot be undone.' });
      await new Promise((r) => requestAnimationFrame(r));

      const dialog = container.querySelector('dialog');
      const messageId = dialog?.getAttribute('aria-describedby');

      expect(messageId).toBeTruthy();
      expect(document.getElementById(messageId!)?.textContent).toBe('This cannot be undone.');
    });
  });

  describe('Escape Key Behavior', () => {
    it('prevents close on Escape by default', async () => {
      container.innerHTML = createAlertDialogHTML({ allowEscapeClose: false });
      await new Promise((r) => requestAnimationFrame(r));

      const dialog = container.querySelector('dialog');
      const event = new KeyboardEvent('keydown', {
        key: 'Escape',
        bubbles: true,
        cancelable: true,
      });

      dialog?.dispatchEvent(event);

      expect(event.defaultPrevented).toBe(true);
    });

    it('allows close on Escape when allowEscapeClose=true', async () => {
      container.innerHTML = createAlertDialogHTML({ allowEscapeClose: true });
      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-alert-dialog') as HTMLElement;
      const dialog = container.querySelector('dialog');
      const cancelHandler = vi.fn();

      element.addEventListener('cancel', cancelHandler);

      const event = new KeyboardEvent('keydown', {
        key: 'Escape',
        bubbles: true,
        cancelable: true,
      });
      dialog?.dispatchEvent(event);

      expect(event.defaultPrevented).toBe(false);
      expect(cancelHandler).toHaveBeenCalledTimes(1);
    });
  });

  describe('Button Actions', () => {
    it('dispatches cancel event on cancel button click', async () => {
      container.innerHTML = createAlertDialogHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-alert-dialog') as HTMLElement;
      const cancelButton = container.querySelector('[data-cancel]') as HTMLButtonElement;
      const cancelHandler = vi.fn();

      element.addEventListener('cancel', cancelHandler);
      cancelButton.click();

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

    it('dispatches confirm event on confirm button click', async () => {
      container.innerHTML = createAlertDialogHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-alert-dialog') as HTMLElement;
      const confirmButton = container.querySelector('[data-confirm]') as HTMLButtonElement;
      const confirmHandler = vi.fn();

      element.addEventListener('confirm', confirmHandler);
      confirmButton.click();

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

  describe('Button Labels', () => {
    it('displays custom button labels', async () => {
      container.innerHTML = createAlertDialogHTML({
        confirmLabel: 'Delete',
        cancelLabel: 'Keep',
      });
      await new Promise((r) => requestAnimationFrame(r));

      const cancelButton = container.querySelector('[data-cancel]');
      const confirmButton = container.querySelector('[data-confirm]');

      expect(cancelButton?.textContent).toBe('Keep');
      expect(confirmButton?.textContent).toBe('Delete');
    });
  });

  describe('Alert Dialog Specific', () => {
    it('does NOT have a close button (×)', async () => {
      container.innerHTML = createAlertDialogHTML();
      await new Promise((r) => requestAnimationFrame(r));

      // Alert dialog should not have the typical close button
      const closeButton = container.querySelector(
        '[aria-label*="close" i], [aria-label*="Close" i]'
      );
      expect(closeButton).toBeNull();
    });

    it('has only Cancel and Confirm buttons', async () => {
      container.innerHTML = createAlertDialogHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const buttons = container.querySelectorAll('button');
      expect(buttons.length).toBe(2);
    });
  });
});

リソース