APG Patterns
English GitHub
English GitHub

Alert Dialog

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

🤖 AI Implementation Guide

デモ

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

削除確認

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

このアイテムを削除しますか?

この操作は取り消せません。アイテムはアカウントから完全に削除されます。

変更の破棄

保存されていない変更を失う前に確認します。

未保存の変更を破棄しますか?

保存されていない変更があります。本当に破棄しますか?

情報の確認

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

システムメンテナンス

日曜日午前2時にシステムメンテナンスが予定されています。それまでに作業を保存してください。

デモのみ表示 →

アクセシビリティ

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.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="このアイテムを削除しますか?"
  message="この操作は取り消せません。アイテムは完全に削除されます。"
  triggerText="アイテムを削除"
  confirmLabel="削除"
  cancelLabel="キャンセル"
  confirmVariant="danger"
  triggerClass="bg-destructive text-destructive-foreground px-4 py-2 rounded"
/>

<script>
  // カスタムイベントをリッスン
  document.querySelector('apg-alert-dialog')?.addEventListener('confirm', () => {
    console.log('確認されました!');
  });
</script>

API

Props

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

カスタムイベント

イベント 説明
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(無効) デフォルトでは 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.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);
    });
  });
});

リソース