APG Patterns
English
English

Dialog (Modal)

プライマリウィンドウの上に重ねて表示され、下のコンテンツを不活性にするウィンドウ。

デモ

基本的なダイアログ

タイトル、説明、クローズ機能を持つシンプルなモーダルダイアログ。

Dialog Title

This is a description of the dialog content. It provides additional context for users.

This is the main content of the dialog. You can place any content here, such as text, forms, or other components.

Press Escape or click outside to close.

説明なし

タイトルとコンテンツのみを持つダイアログ。

Simple Dialog

This dialog has no description, only a title and content.

デモのみを開く →

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
dialog ダイアログコンテナ その要素がダイアログウィンドウであることを示す

WAI-ARIA dialog role (opens in new tab)

WAI-ARIA プロパティ

属性 対象要素 必須 説明
aria-modal dialog true はい これがモーダルダイアログであることを示す
aria-labelledby dialog タイトル要素への ID 参照 はい ダイアログのタイトルを参照する
aria-describedby dialog 説明への ID 参照 いいえ オプションの説明テキストを参照する

フォーカス管理

イベント 振る舞い
ダイアログが開く ダイアログ内の最初のフォーカス可能な要素にフォーカスが移動する
ダイアログが閉じる ダイアログを開いた要素にフォーカスが戻る
フォーカストラップ Tab/Shift+Tab はダイアログ内のフォーカス可能な要素間のみをサイクルする
背景 ダイアログ外のコンテンツは不活性化される(フォーカス不可・操作不可)

キーボードサポート

キー アクション
Tab ダイアログ内の次のフォーカス可能な要素にフォーカスを移動する。最後の要素にフォーカスがある場合は最初の要素に移動する。
Shift + Tab ダイアログ内の前のフォーカス可能な要素にフォーカスを移動する。最初の要素にフォーカスがある場合は最後の要素に移動する。
Escape ダイアログを閉じて、開いた要素にフォーカスを戻す

section.additionalNotes

  • アクセシビリティのためにダイアログのタイトルは必須であり、ダイアログの目的を明確に説明する必要があります
  • ダイアログが開いている間はページのスクロールが無効になります
  • オーバーレイ(背景)をクリックするとデフォルトでダイアログが閉じます
  • 閉じるボタンにはスクリーンリーダー向けのアクセシブルなラベルが付いています

ソースコード

Dialog.astro
---
/**
 * APG Dialog (Modal) Pattern - Astro Implementation
 *
 * A window overlaid on the primary window, rendering the content underneath inert.
 * Uses native <dialog> element with Web Components for enhanced control.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
 */

export interface Props {
  /** Dialog title (required for accessibility) */
  title: string;
  /** Optional description text */
  description?: string;
  /** Trigger button text */
  triggerText: string;
  /** Close on overlay click */
  closeOnOverlayClick?: boolean;
  /** Additional CSS class for trigger button */
  triggerClass?: string;
  /** Additional CSS class for dialog */
  class?: string;
}

const {
  title,
  description,
  triggerText,
  closeOnOverlayClick = true,
  triggerClass = '',
  class: className = '',
} = Astro.props;

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

<apg-dialog data-close-on-overlay={closeOnOverlayClick}>
  <!-- Trigger Button -->
  <button type="button" class={`apg-dialog-trigger ${triggerClass}`.trim()} data-dialog-trigger>
    {triggerText}
  </button>

  <!-- Native Dialog Element -->
  <dialog
    class={`apg-dialog ${className}`.trim()}
    aria-labelledby={titleId}
    aria-describedby={description ? descriptionId : undefined}
    data-dialog-content
  >
    <div class="apg-dialog-header">
      <h2 id={titleId} class="apg-dialog-title">
        {title}
      </h2>
      <button type="button" class="apg-dialog-close" data-dialog-close aria-label="Close dialog">
        <svg
          xmlns="http://www.w3.org/2000/svg"
          width="24"
          height="24"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
          aria-hidden="true"
        >
          <line x1="18" y1="6" x2="6" y2="18"></line>
          <line x1="6" y1="6" x2="18" y2="18"></line>
        </svg>
      </button>
    </div>
    {
      description && (
        <p id={descriptionId} class="apg-dialog-description">
          {description}
        </p>
      )
    }
    <div class="apg-dialog-body">
      <slot />
    </div>
  </dialog>
</apg-dialog>

<script>
  class ApgDialog extends HTMLElement {
    private trigger: HTMLButtonElement | null = null;
    private dialog: HTMLDialogElement | null = null;
    private closeButton: HTMLButtonElement | null = null;
    private closeOnOverlayClick = true;

    connectedCallback() {
      this.trigger = this.querySelector('[data-dialog-trigger]');
      this.dialog = this.querySelector('[data-dialog-content]');
      this.closeButton = this.querySelector('[data-dialog-close]');

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

      this.closeOnOverlayClick = this.dataset.closeOnOverlay !== 'false';

      // Attach event listeners
      this.trigger.addEventListener('click', this.open);
      this.closeButton?.addEventListener('click', this.close);
      this.dialog.addEventListener('click', this.handleDialogClick);
      this.dialog.addEventListener('close', this.handleClose);
    }

    disconnectedCallback() {
      this.trigger?.removeEventListener('click', this.open);
      this.closeButton?.removeEventListener('click', this.close);
      this.dialog?.removeEventListener('click', this.handleDialogClick);
      this.dialog?.removeEventListener('close', this.handleClose);
    }

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

      this.dialog.showModal();

      // Focus first focusable element (close button by default)
      const focusableElements = this.getFocusableElements(this.dialog);
      if (focusableElements.length > 0) {
        focusableElements[0].focus();
      }

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

    private close = () => {
      this.dialog?.close();
    };

    private handleClose = () => {
      // Return focus to trigger
      this.trigger?.focus();

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

    private handleDialogClick = (e: Event) => {
      // Close on backdrop click (clicking the dialog element itself, not its contents)
      if (this.closeOnOverlayClick && e.target === this.dialog) {
        this.close();
      }
    };

    private getFocusableElements(container: HTMLElement): HTMLElement[] {
      const focusableSelectors = [
        'a[href]',
        'button:not([disabled])',
        'input:not([disabled])',
        'select:not([disabled])',
        'textarea:not([disabled])',
        '[tabindex]:not([tabindex="-1"])',
      ].join(',');

      return Array.from(container.querySelectorAll<HTMLElement>(focusableSelectors)).filter(
        (el) => el.offsetParent !== null
      );
    }
  }

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

使い方

使用例
---
import Dialog from './Dialog.astro';
---

<Dialog
  title="Dialog Title"
  description="Optional description text"
  triggerText="Open Dialog"
>
  <p>Dialog content goes here.</p>
</Dialog>

API

プロパティ

プロパティ デフォルト 説明
title string 必須 ダイアログのタイトル(アクセシビリティ用)
description string - オプションの説明テキスト
triggerText string 必須 トリガーボタンのテキスト
closeOnOverlayClick boolean true オーバーレイクリックで閉じる
triggerClass string - トリガーボタンの追加CSSクラス
class string - ダイアログの追加CSSクラス

スロット

スロット 説明
default ダイアログのコンテンツ

カスタムイベント

イベント 説明
dialogopen ダイアログが開いたときに発火
dialogclose ダイアログが閉じたときに発火

テスト

テストは、キーボードインタラクション、ARIA属性、アクセシビリティ要件全体でAPG準拠を検証します。Dialogコンポーネントは2層のテスト戦略を採用しています。

テスト戦略

ユニットテスト(Testing Library)

フレームワーク固有のテストライブラリを使用してコンポーネントのレンダリング出力を検証します。これらのテストは正しいHTML構造とARIA属性を確認します。

  • ARIA属性(aria-labelledby、aria-describedby)
  • Escapeキーでダイアログを閉じる
  • 開閉時のフォーカス管理
  • jest-axeによるアクセシビリティ検証

E2Eテスト(Playwright)

すべてのフレームワークで実際のブラウザ環境でコンポーネントの動作を検証します。これらのテストはインタラクションとフレームワーク間の一貫性をカバーします。

  • モーダル動作(showModal、backdrop)
  • フォーカストラップの検証
  • 閉じた時のフォーカス復元
  • オーバーレイクリックで閉じる
  • ライブブラウザでのARIA構造検証
  • axe-coreによるアクセシビリティスキャン
  • フレームワーク間の一貫性チェック

テストカテゴリ

高優先度: APG キーボードインタラクション ( Unit + E2E )

テスト 説明
Escape key ダイアログを閉じる

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

テスト 説明
role="dialog" ダイアログ要素にdialogロールが設定されている
aria-modal="true" モーダル動作を示す
aria-labelledby ダイアログのタイトルを参照
aria-describedby 説明を参照(提供されている場合)

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

テスト 説明
Initial focus 開いた時に最初のフォーカス可能な要素にフォーカスが移動
Focus restore 閉じた時にトリガー要素にフォーカスが戻る
Focus trap Tabサイクルがダイアログ内に留まる(ネイティブdialog経由)

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

テスト 説明
axe violations WCAG 2.1 AA違反なし(jest-axe経由)

低優先度: Props と動作 ( Unit )

テスト 説明
closeOnOverlayClick オーバーレイクリックの動作を制御
defaultOpen 初期の開閉状態
onOpenChange 開閉時にコールバックが発火
className カスタムクラスが適用される

E2E テストコード

e2e/dialog.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

/**
 * E2E Tests for Dialog (Modal) Pattern
 *
 * A window overlaid on the primary content, requiring user interaction.
 * Modal dialogs trap focus and prevent interaction with content outside.
 *
 * Key differences from Alert Dialog:
 * - role="dialog" (not "alertdialog")
 * - Escape key closes the dialog
 * - Has close button (×)
 * - aria-describedby is optional
 * - Initial focus on close button or first focusable element
 *
 * APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
 */

const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

// ============================================
// Helper Functions
// ============================================

const getDialog = (page: import('@playwright/test').Page) => {
  // Use getByRole which only returns visible elements with the role
  // This works for both native <dialog> (implicit role) and custom role="dialog"
  return page.getByRole('dialog');
};

const openDialog = async (page: import('@playwright/test').Page) => {
  const trigger = page.getByRole('button', { name: /open dialog/i }).first();
  await trigger.click();
  // Wait for dialog to be visible (native <dialog> has implicit role="dialog")
  await getDialog(page).waitFor({ state: 'visible' });
  return trigger;
};

// ============================================
// Framework-specific Tests
// ============================================

for (const framework of frameworks) {
  test.describe(`Dialog (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/dialog/${framework}/demo/`);
      // Wait for the trigger button to be visible (indicates hydration complete)
      await page
        .getByRole('button', { name: /open dialog/i })
        .first()
        .waitFor();
    });

    // ------------------------------------------
    // 🔴 High Priority: APG ARIA Structure
    // ------------------------------------------
    test.describe('APG: ARIA Structure', () => {
      test('has role="dialog"', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        await expect(dialog).toBeVisible();
        await expect(dialog).toHaveRole('dialog');
      });

      test('supports native <dialog> or custom role="dialog"', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        const tagName = await dialog.evaluate((el) => el.tagName.toLowerCase());

        // Native <dialog> or a custom element with role="dialog" are both acceptable
        expect(tagName === 'dialog' || (await dialog.getAttribute('role')) === 'dialog').toBe(true);
      });

      test('has aria-modal="true" (for custom dialog) or uses showModal (for native)', async ({
        page,
      }) => {
        await openDialog(page);
        const dialog = getDialog(page);

        const isNative = await dialog.evaluate((el) => el.tagName.toLowerCase() === 'dialog');
        if (isNative) {
          // Native <dialog> opened via showModal() is implicitly modal
          // aria-modal is not required when using showModal()
          const hasOpenAttribute = await dialog.evaluate((el) => el.hasAttribute('open'));
          expect(hasOpenAttribute).toBe(true);
        } else {
          // Custom dialog must have aria-modal="true"
          await expect(dialog).toHaveAttribute('aria-modal', 'true');
        }
      });

      test('is modal (opened via showModal for native dialog)', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        await expect(dialog).toBeVisible();

        const isNative = await dialog.evaluate((el) => el.tagName.toLowerCase() === 'dialog');
        if (isNative) {
          // Verify dialog has 'open' attribute (showModal sets this)
          const hasOpenAttribute = await dialog.evaluate((el) => el.hasAttribute('open'));
          expect(hasOpenAttribute).toBe(true);

          // Verify backdrop exists (showModal() creates ::backdrop)
          const hasBackdrop = await dialog.evaluate((el) => {
            const style = window.getComputedStyle(el, '::backdrop');
            return style.display !== 'none';
          });
          expect(hasBackdrop).toBe(true);
        } else {
          // Custom dialog should have aria-modal="true"
          await expect(dialog).toHaveAttribute('aria-modal', 'true');
        }
      });

      test('has aria-labelledby referencing title', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        const labelledbyId = await dialog.getAttribute('aria-labelledby');

        expect(labelledbyId).toBeTruthy();
        const titleElement = page.locator(`[id="${labelledbyId}"]`);
        await expect(titleElement).toBeVisible();

        // Verify it's an actual title element (heading)
        const tagName = await titleElement.evaluate((el) => el.tagName.toLowerCase());
        expect(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']).toContain(tagName);
      });

      test('has aria-describedby when description is provided', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        const describedbyId = await dialog.getAttribute('aria-describedby');

        // aria-describedby is optional for Dialog
        if (describedbyId) {
          const descriptionElement = page.locator(`[id="${describedbyId}"]`);
          await expect(descriptionElement).toBeVisible();
        }
      });

      test('has close button with accessible label', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        const closeButton = dialog.getByRole('button', { name: /close/i });

        await expect(closeButton).toBeVisible();
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Keyboard Interaction
    // ------------------------------------------
    test.describe('APG: Keyboard Interaction', () => {
      test('Escape closes the dialog', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        await expect(dialog).toBeVisible();

        await page.keyboard.press('Escape');

        // Dialog should be closed
        await expect(dialog).not.toBeVisible();
      });

      test('Tab moves focus to next element', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);

        // Get all focusable elements in dialog
        const focusableElements = dialog.locator(
          'button:not([disabled]), [tabindex="0"], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href]'
        );
        const count = await focusableElements.count();
        expect(count).toBeGreaterThanOrEqual(1);

        // Focus the first element explicitly
        const first = focusableElements.first();
        await first.focus();
        await expect(first).toBeFocused();

        // Tab should move to next element
        await page.keyboard.press('Tab');

        // If there's more than one focusable element, focus should have moved
        if (count > 1) {
          await expect(focusableElements.nth(1)).toBeFocused();
        }
      });

      test('Tab wraps from last to first element (focus trap)', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);

        // Get all focusable elements in dialog
        const focusableElements = dialog.locator(
          'button:not([disabled]), [tabindex="0"], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href]'
        );
        const count = await focusableElements.count();

        // Tab through all elements plus one more to verify wrap
        for (let i = 0; i <= count; i++) {
          await page.keyboard.press('Tab');
        }

        // Focus should still be within dialog
        const focusedElement = page.locator(':focus');
        const isWithinDialog = await focusedElement.evaluate(
          (el) => el.closest('dialog, [role="dialog"]') !== null
        );
        expect(isWithinDialog).toBe(true);
      });

      test('Shift+Tab moves focus to previous element', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        const closeButton = dialog.getByRole('button', { name: /close/i });

        // Click close button to ensure focus is in dialog
        await closeButton.click();
        // Dialog closes on click, so reopen
        await page
          .getByRole('button', { name: /open dialog/i })
          .first()
          .click();
        await getDialog(page).waitFor({ state: 'visible' });

        // Tab once to move focus into dialog
        await page.keyboard.press('Tab');

        // Shift+Tab should move backwards but stay in dialog
        await page.keyboard.press('Shift+Tab');

        // Focus should still be within dialog
        const isWithinDialog = await page.evaluate(() => {
          const focused = document.activeElement;
          return focused?.closest('dialog, [role="dialog"]') !== null;
        });
        expect(isWithinDialog).toBe(true);
      });

      test('Shift+Tab wraps from first to last element', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);

        // Get all focusable elements
        const focusableElements = dialog.locator(
          'button:not([disabled]), [tabindex="0"], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href]'
        );
        const count = await focusableElements.count();

        // Shift+Tab through all elements to test wrap
        for (let i = 0; i <= count; i++) {
          await page.keyboard.press('Shift+Tab');
        }

        // Focus should still be within dialog
        const focusedElement = page.locator(':focus');
        const isWithinDialog = await focusedElement.evaluate(
          (el) => el.closest('dialog, [role="dialog"]') !== null
        );
        expect(isWithinDialog).toBe(true);
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Focus Management
    // ------------------------------------------
    test.describe('APG: Focus Management', () => {
      test('focuses first focusable element on open', async ({ page }) => {
        await openDialog(page);

        // Focus should be within dialog
        const focusedElement = page.locator(':focus');
        const isWithinDialog = await focusedElement.evaluate(
          (el) => el.closest('dialog, [role="dialog"]') !== null
        );
        expect(isWithinDialog).toBe(true);
      });

      test('returns focus to trigger on close via Escape', async ({ page }) => {
        const trigger = await openDialog(page);

        await page.keyboard.press('Escape');

        // Focus should return to trigger
        await expect(trigger).toBeFocused();
      });

      test('returns focus to trigger on close via close button', async ({ page }) => {
        const trigger = await openDialog(page);

        const dialog = getDialog(page);
        const closeButton = dialog.getByRole('button', { name: /close/i });
        await closeButton.click();

        // Focus should return to trigger
        await expect(trigger).toBeFocused();
      });

      test('traps focus within dialog', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);

        // Get count of focusable elements
        const focusableElements = dialog.locator(
          'button:not([disabled]), [tabindex="0"], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href]'
        );
        const count = await focusableElements.count();

        // Tab many times - focus should never leave dialog
        // First Tab may move focus into dialog, then subsequent Tabs should stay within
        const tabCount = Math.max(count * 3, 10);
        for (let i = 0; i < tabCount; i++) {
          await page.keyboard.press('Tab');
        }

        // After many Tabs, focus should still be within dialog
        const isWithinDialog = await page.evaluate(() => {
          const focused = document.activeElement;
          return focused?.closest('dialog, [role="dialog"]') !== null;
        });
        expect(isWithinDialog).toBe(true);
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Click Interaction
    // ------------------------------------------
    test.describe('APG: Click Interaction', () => {
      test('clicking close button closes dialog', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        const closeButton = dialog.getByRole('button', { name: /close/i });
        await closeButton.click();

        await expect(dialog).not.toBeVisible();
      });

      test('clicking overlay closes dialog', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        await expect(dialog).toBeVisible();

        // Get viewport size and dialog bounds to find a safe click position outside dialog
        const viewportSize = page.viewportSize();
        const dialogBox = await dialog.boundingBox();

        if (viewportSize && dialogBox) {
          // Find a safe position outside dialog, handling edge cases
          // Try multiple positions: top, left, right, bottom of dialog
          const candidates = [
            // Above dialog (if there's space)
            { x: dialogBox.x + dialogBox.width / 2, y: Math.max(1, dialogBox.y - 20) },
            // Left of dialog (if there's space)
            { x: Math.max(1, dialogBox.x - 20), y: dialogBox.y + dialogBox.height / 2 },
            // Right of dialog (if there's space)
            {
              x: Math.min(viewportSize.width - 1, dialogBox.x + dialogBox.width + 20),
              y: dialogBox.y + dialogBox.height / 2,
            },
            // Below dialog (if there's space)
            {
              x: dialogBox.x + dialogBox.width / 2,
              y: Math.min(viewportSize.height - 1, dialogBox.y + dialogBox.height + 20),
            },
          ];

          // Find first candidate that's outside dialog bounds
          const isOutsideDialog = (x: number, y: number) =>
            x < dialogBox.x ||
            x > dialogBox.x + dialogBox.width ||
            y < dialogBox.y ||
            y > dialogBox.y + dialogBox.height;

          const safePosition = candidates.find((pos) => isOutsideDialog(pos.x, pos.y));

          if (safePosition) {
            await page.mouse.click(safePosition.x, safePosition.y);
          } else {
            // Fallback: click at viewport corner (1,1)
            await page.mouse.click(1, 1);
          }
        } else {
          // Fallback: click at viewport corner
          await page.mouse.click(1, 1);
        }

        // Dialog should close when clicking overlay
        await expect(dialog).not.toBeVisible();
      });
    });

    // ------------------------------------------
    // 🟢 Low Priority: Accessibility
    // ------------------------------------------
    test.describe('Accessibility', () => {
      test('has no axe-core violations', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        await expect(dialog).toBeVisible();

        const results = await new AxeBuilder({ page })
          .include('dialog')
          .disableRules(['color-contrast'])
          .analyze();

        expect(results.violations).toEqual([]);
      });
    });
  });
}

// ============================================
// Cross-framework Consistency Tests
// ============================================

test.describe('Dialog - Cross-framework Consistency', () => {
  test('all frameworks render dialog with role="dialog"', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/dialog/${framework}/demo/`);
      await page
        .getByRole('button', { name: /open dialog/i })
        .first()
        .waitFor();

      // Open dialog
      const trigger = page.getByRole('button', { name: /open dialog/i }).first();
      await trigger.click();
      await getDialog(page).waitFor({ state: 'visible' });

      const dialog = getDialog(page);
      await expect(dialog).toBeVisible();
      await expect(dialog).toHaveRole('dialog');

      // Close dialog for next iteration
      await page.keyboard.press('Escape');
    }
  });

  test('all frameworks have aria-labelledby', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/dialog/${framework}/demo/`);
      await page
        .getByRole('button', { name: /open dialog/i })
        .first()
        .waitFor();

      const trigger = page.getByRole('button', { name: /open dialog/i }).first();
      await trigger.click();
      await getDialog(page).waitFor({ state: 'visible' });

      const dialog = getDialog(page);
      const labelledbyId = await dialog.getAttribute('aria-labelledby');
      expect(labelledbyId).toBeTruthy();

      await page.keyboard.press('Escape');
    }
  });

  test('all frameworks close on Escape', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/dialog/${framework}/demo/`);
      await page
        .getByRole('button', { name: /open dialog/i })
        .first()
        .waitFor();

      const trigger = page.getByRole('button', { name: /open dialog/i }).first();
      await trigger.click();
      await getDialog(page).waitFor({ state: 'visible' });

      const dialog = getDialog(page);
      await expect(dialog).toBeVisible();

      await page.keyboard.press('Escape');
      await expect(dialog).not.toBeVisible();
    }
  });

  test('all frameworks trap focus within dialog', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/dialog/${framework}/demo/`);
      await page
        .getByRole('button', { name: /open dialog/i })
        .first()
        .waitFor();

      const trigger = page.getByRole('button', { name: /open dialog/i }).first();
      await trigger.click();
      await getDialog(page).waitFor({ state: 'visible' });

      // Tab multiple times
      for (let i = 0; i < 10; i++) {
        await page.keyboard.press('Tab');
      }

      // After many Tabs, focus should still be within dialog
      const isWithinDialog = await page.evaluate(() => {
        const focused = document.activeElement;
        return focused?.closest('dialog, [role="dialog"]') !== null;
      });
      expect(isWithinDialog).toBe(true);

      await page.keyboard.press('Escape');
    }
  });
});

テストの実行

          
            # Dialogのユニットテストを実行
npm run test -- dialog

# DialogのE2Eテストを実行(全フレームワーク)
npm run test:e2e:pattern --pattern=dialog
          
        

テストツール

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

リソース