APG Patterns
English
English

Dialog (Modal)

プライマリウィンドウの上に重なるウィンドウで、背後のコンテンツを不活性にします。

デモ

基本的なダイアログ

タイトル、説明、閉じる機能を持つシンプルなモーダルダイアログです。

説明なし

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

デモのみを開く →

アクセシビリティ

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.tsx
import React, {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useId,
  useRef,
  useState,
} from 'react';
import { createPortal } from 'react-dom';

// ============================================================================
// Context
// ============================================================================

interface DialogContextValue {
  dialogRef: React.RefObject<HTMLDialogElement | null>;
  open: () => void;
  close: () => void;
  titleId: string;
  descriptionId: string;
}

const DialogContext = createContext<DialogContextValue | null>(null);

function useDialogContext() {
  const context = useContext(DialogContext);
  if (!context) {
    throw new Error('Dialog components must be used within a DialogRoot');
  }
  return context;
}

// ============================================================================
// DialogRoot
// ============================================================================

export interface DialogRootProps {
  children: React.ReactNode;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
}

export function DialogRoot({
  children,
  defaultOpen = false,
  onOpenChange,
}: DialogRootProps): React.ReactElement {
  const dialogRef = useRef<HTMLDialogElement | null>(null);
  const triggerRef = useRef<HTMLElement | null>(null);
  const instanceId = useId();
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  // Open on mount if defaultOpen
  useEffect(() => {
    if (mounted && defaultOpen && dialogRef.current) {
      dialogRef.current.showModal();
      onOpenChange?.(true);
    }
  }, [mounted, defaultOpen, onOpenChange]);

  const open = useCallback(() => {
    if (dialogRef.current) {
      const { activeElement } = document;
      triggerRef.current = activeElement instanceof HTMLElement ? activeElement : null;
      dialogRef.current.showModal();
      onOpenChange?.(true);
    }
  }, [onOpenChange]);

  const close = useCallback(() => {
    dialogRef.current?.close();
  }, []);

  // Handle dialog close event (from Escape key or close() call)
  // Note: mounted must be in dependencies to re-run after Dialog component mounts
  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    const handleClose = () => {
      onOpenChange?.(false);
      triggerRef.current?.focus();
    };

    dialog.addEventListener('close', handleClose);
    return () => dialog.removeEventListener('close', handleClose);
  }, [onOpenChange, mounted]);

  const contextValue: DialogContextValue = {
    dialogRef,
    open,
    close,
    titleId: `${instanceId}-title`,
    descriptionId: `${instanceId}-description`,
  };

  return <DialogContext.Provider value={contextValue}>{children}</DialogContext.Provider>;
}

// ============================================================================
// DialogTrigger
// ============================================================================

export interface DialogTriggerProps extends Omit<
  React.ButtonHTMLAttributes<HTMLButtonElement>,
  'onClick'
> {
  children: React.ReactNode;
}

export function DialogTrigger({
  children,
  className = '',
  ...buttonProps
}: DialogTriggerProps): React.ReactElement {
  const { open } = useDialogContext();

  return (
    <button
      type="button"
      className={`apg-dialog-trigger ${className}`.trim()}
      onClick={open}
      {...buttonProps}
    >
      {children}
    </button>
  );
}

// ============================================================================
// Dialog
// ============================================================================

export interface DialogProps {
  /** Dialog title (required for accessibility) */
  title: string;
  /** Optional description text */
  description?: string;
  /** Dialog content */
  children: React.ReactNode;
  /** Close on overlay click */
  closeOnOverlayClick?: boolean;
  /** Additional CSS class */
  className?: string;
}

export function Dialog({
  title,
  description,
  children,
  closeOnOverlayClick = true,
  className = '',
}: DialogProps): React.ReactElement | null {
  const { dialogRef, close, titleId, descriptionId } = useDialogContext();
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  const handleDialogClick = useCallback(
    (event: React.MouseEvent<HTMLDialogElement>) => {
      // Close on backdrop click
      if (closeOnOverlayClick && event.target === event.currentTarget) {
        close();
      }
    },
    [closeOnOverlayClick, close]
  );

  // SSR safety
  if (typeof document === 'undefined') return null;
  if (!mounted) return null;

  return createPortal(
    // disable dialog a11y warnings, as only dropdown click. there are alternative keyboard ways to close
    // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events
    <dialog
      ref={dialogRef}
      className={`apg-dialog ${className}`.trim()}
      aria-labelledby={titleId}
      aria-describedby={description ? descriptionId : undefined}
      onClick={handleDialogClick}
    >
      <div className="apg-dialog-header">
        <h2 id={titleId} className="apg-dialog-title">
          {title}
        </h2>
        <button
          type="button"
          className="apg-dialog-close"
          onClick={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"
            strokeWidth="2"
            strokeLinecap="round"
            strokeLinejoin="round"
            aria-hidden="true"
          >
            <line x1="18" y1="6" x2="6" y2="18" />
            <line x1="6" y1="6" x2="18" y2="18" />
          </svg>
        </button>
      </div>
      {description && (
        <p id={descriptionId} className="apg-dialog-description">
          {description}
        </p>
      )}
      <div className="apg-dialog-body">{children}</div>
    </dialog>,
    document.body
  );
}

// ============================================================================
// Exports
// ============================================================================

export default {
  Root: DialogRoot,
  Trigger: DialogTrigger,
  Content: Dialog,
};

使い方

Example
import { DialogRoot, DialogTrigger, Dialog } from './Dialog';

function App() {
  return (
    <DialogRoot onOpenChange={(open) => console.log('Dialog:', open)}>
      <DialogTrigger>Open Dialog</DialogTrigger>
      <Dialog
        title="Dialog Title"
        description="Optional description text"
      >
        <p>Dialog content goes here.</p>
      </Dialog>
    </DialogRoot>
  );
}

API

DialogRoot Props

プロパティ デフォルト 説明
children ReactNode 必須 DialogTrigger と Dialog コンポーネント
defaultOpen boolean false 初期の開閉状態
onOpenChange (open: boolean) => void - 開閉状態が変更されたときのコールバック

Dialog Props

プロパティ デフォルト 説明
title string 必須 ダイアログのタイトル(アクセシビリティ用)
description string - 任意の説明テキスト
children ReactNode 必須 ダイアログのコンテンツ
closeOnOverlayClick boolean true オーバーレイクリック時に閉じる

テスト

テストは、キーボードインタラクション、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) を参照してください。

Dialog.test.tsx
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { DialogRoot, DialogTrigger, Dialog } from './Dialog';

// Test wrapper component
function TestDialog({
  title = 'Test Dialog',
  description,
  closeOnOverlayClick = true,
  defaultOpen = false,
  onOpenChange,
  children = <p>Dialog content</p>,
}: {
  title?: string;
  description?: string;
  closeOnOverlayClick?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  children?: React.ReactNode;
}) {
  return (
    <DialogRoot defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
      <DialogTrigger>Open Dialog</DialogTrigger>
      <Dialog title={title} description={description} closeOnOverlayClick={closeOnOverlayClick}>
        {children}
      </Dialog>
    </DialogRoot>
  );
}

describe('Dialog', () => {
  // 🔴 High Priority: APG Core Compliance
  describe('APG: Keyboard Interaction', () => {
    it('closes dialog with Escape key', async () => {
      const user = userEvent.setup();
      render(<TestDialog />);

      // Open dialog
      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      expect(screen.getByRole('dialog')).toBeInTheDocument();

      // Close with Escape
      await user.keyboard('{Escape}');
      expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
    });
  });

  describe('APG: ARIA Attributes', () => {
    it('has role="dialog"', async () => {
      const user = userEvent.setup();
      render(<TestDialog />);

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

    it('has aria-modal="true"', async () => {
      const user = userEvent.setup();
      render(<TestDialog />);

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

    it('references title with aria-labelledby', async () => {
      const user = userEvent.setup();
      render(<TestDialog title="My Dialog Title" />);

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      const dialog = screen.getByRole('dialog');
      const titleId = dialog.getAttribute('aria-labelledby');

      expect(titleId).toBeTruthy();
      expect(document.getElementById(titleId!)).toHaveTextContent('My Dialog Title');
    });

    it('references description with aria-describedby when present', async () => {
      const user = userEvent.setup();
      render(<TestDialog description="This is a description" />);

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      await vi.waitFor(() => {
        const dialog = screen.getByRole('dialog');
        const descriptionId = dialog.getAttribute('aria-describedby');

        expect(descriptionId).toBeTruthy();
        expect(document.getElementById(descriptionId!)).toHaveTextContent('This is a description');
      });
    });

    it('has no aria-describedby when description is absent', async () => {
      const user = userEvent.setup();
      render(<TestDialog />);

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      const dialog = screen.getByRole('dialog');

      expect(dialog).not.toHaveAttribute('aria-describedby');
    });
  });

  describe('APG: Focus Management', () => {
    it('focuses first focusable element when opened', async () => {
      const user = userEvent.setup();
      render(<TestDialog />);

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

      // Focus moves to first focusable element in dialog (Close button)
      await vi.waitFor(() => {
        expect(screen.getByRole('button', { name: 'Close dialog' })).toHaveFocus();
      });
    });

    // Note: Testing autofocus attribute is difficult in jsdom environment
    // because React's autoFocus uses its own focus management, not DOM attributes.
    // Recommended to verify with browser E2E tests (Playwright).

    // Note: Focus restore tests are flaky in jsdom due to showModal() limitations.
    // Covered by E2E tests (Playwright).
    // See: e2e/dialog.spec.ts - Focus Management section
    it.todo('restores focus to trigger when closed');

    // Note: Focus trap is handled by native <dialog> element's showModal().
    // jsdom does not implement showModal()'s focus trap behavior,
    // so these tests should be verified with browser E2E tests (Playwright).
  });

  // 🟡 Medium Priority: Accessibility Validation
  describe('Accessibility', () => {
    it('has no axe violations', async () => {
      const user = userEvent.setup();
      const { container } = render(<TestDialog description="Description" />);

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

      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  describe('Props', () => {
    it('displays title', async () => {
      const user = userEvent.setup();
      render(<TestDialog title="Custom Title" />);

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      expect(screen.getByText('Custom Title')).toBeInTheDocument();
    });

    it('displays description', async () => {
      const user = userEvent.setup();
      render(<TestDialog description="Custom Description" />);

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      expect(screen.getByText('Custom Description')).toBeInTheDocument();
    });

    it('closes on overlay click when closeOnOverlayClick=true', async () => {
      const user = userEvent.setup();
      render(<TestDialog closeOnOverlayClick={true} />);

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      const dialog = screen.getByRole('dialog');

      // Click dialog element itself (equivalent to overlay)
      await user.click(dialog);
      await vi.waitFor(() => {
        expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
      });
    });

    it('does not close on overlay click when closeOnOverlayClick=false', async () => {
      const user = userEvent.setup();
      render(<TestDialog closeOnOverlayClick={false} />);

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      const dialog = screen.getByRole('dialog');

      // Click dialog element itself
      await user.click(dialog);
      await vi.waitFor(() => {
        expect(screen.getByRole('dialog')).toBeInTheDocument();
      });
    });

    it('calls onOpenChange when opened and closed', async () => {
      const user = userEvent.setup();
      const onOpenChange = vi.fn();
      render(<TestDialog onOpenChange={onOpenChange} />);

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      expect(onOpenChange).toHaveBeenCalledWith(true);

      // Close with Close button
      await user.click(screen.getByRole('button', { name: 'Close dialog' }));
      await vi.waitFor(() => {
        expect(onOpenChange).toHaveBeenCalledWith(false);
      });
    });

    it('initially displayed when defaultOpen=true', async () => {
      render(<TestDialog defaultOpen={true} />);
      expect(screen.getByRole('dialog')).toBeInTheDocument();
    });
  });

  // 🟢 Low Priority: Extensibility
  describe('HTML Attribute Inheritance', () => {
    it('applies className to dialog', async () => {
      const user = userEvent.setup();
      render(
        <DialogRoot>
          <DialogTrigger>Open</DialogTrigger>
          <Dialog title="Test" className="custom-class">
            Content
          </Dialog>
        </DialogRoot>
      );

      await user.click(screen.getByRole('button', { name: 'Open' }));
      expect(screen.getByRole('dialog')).toHaveClass('custom-class');
    });

    it('applies className to trigger', async () => {
      render(
        <DialogRoot>
          <DialogTrigger className="trigger-class">Open</DialogTrigger>
          <Dialog title="Test">Content</Dialog>
        </DialogRoot>
      );

      expect(screen.getByRole('button', { name: 'Open' })).toHaveClass('trigger-class');
    });
  });
});

リソース