APG Patterns
日本語
日本語

Dialog (Modal)

A window overlaid on the primary window, rendering the content underneath inert.

Demo

Basic Dialog

A simple modal dialog with title, description, and close functionality.

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.

Without Description

A dialog with only a title and content.

Simple Dialog

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

Open demo only →

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
dialog Dialog container Indicates the element is a dialog window

WAI-ARIA dialog role (opens in new tab)

WAI-ARIA Properties

Attribute Target Element Values Required Description
aria-modal dialog true Yes Indicates this is a modal dialog
aria-labelledby dialog ID reference to title element Yes References the dialog title
aria-describedby dialog ID reference to description No References optional description text

Focus Management

Event Behavior
Dialog opens Focus moves to first focusable element inside the dialog
Dialog closes Focus returns to the element that triggered the dialog
Focus trap Tab/Shift+Tab cycles through focusable elements within the dialog only
Background Content outside dialog is made inert (not focusable or interactive)

Keyboard Support

Key Action
Tab Move focus to next focusable element within dialog. When focus is on the last element, moves to first.
Shift + Tab Move focus to previous focusable element within dialog. When focus is on the first element, moves to last.
Escape Close the dialog and return focus to trigger element

section.additionalNotes

  • The dialog title is required for accessibility and should clearly describe the purpose of the dialog
  • Page scrolling is disabled while the dialog is open
  • Clicking the overlay (background) closes the dialog by default
  • The close button has an accessible label for screen readers

Source Code

Dialog.svelte
<script lang="ts" module>
  import type { Snippet } from 'svelte';

  export interface DialogProps {
    /** Dialog title (required for accessibility) */
    title: string;
    /** Optional description text */
    description?: string;
    /** Default open state */
    defaultOpen?: boolean;
    /** Close on overlay click */
    closeOnOverlayClick?: boolean;
    /** Additional CSS class */
    className?: string;
    /** Callback when open state changes */
    onOpenChange?: (open: boolean) => void;
    /** Trigger snippet - receives open function */
    trigger: Snippet<[{ open: () => void }]>;
    /** Dialog content */
    children: Snippet;
  }
</script>

<script lang="ts">
  import { onMount } from 'svelte';

  let {
    title,
    description = undefined,
    defaultOpen = false,
    closeOnOverlayClick = true,
    className = '',
    onOpenChange = () => {},
    trigger,
    children,
  }: DialogProps = $props();

  let dialogElement = $state<HTMLDialogElement | undefined>(undefined);
  let previousActiveElement: HTMLElement | null = null;
  let instanceId = $state('');

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

    // Open on mount if defaultOpen
    if (defaultOpen && dialogElement) {
      dialogElement.showModal();
      onOpenChange(true);
    }
  });

  let titleId = $derived(`${instanceId}-title`);
  let descriptionId = $derived(`${instanceId}-description`);

  export function open() {
    if (dialogElement) {
      previousActiveElement = document.activeElement as HTMLElement;
      dialogElement.showModal();
      onOpenChange(true);
    }
  }

  export function close() {
    dialogElement?.close();
  }

  function handleClose() {
    onOpenChange(false);
    // Return focus to trigger
    if (previousActiveElement) {
      previousActiveElement.focus();
    }
  }

  function handleDialogClick(event: MouseEvent) {
    // Close on backdrop click
    if (closeOnOverlayClick && event.target === dialogElement) {
      close();
    }
  }
</script>

<!-- Trigger snippet -->
{@render trigger({ open })}

<!-- Native Dialog Element -->
<dialog
  bind:this={dialogElement}
  class={`apg-dialog ${className}`.trim()}
  aria-labelledby={titleId}
  aria-describedby={description ? descriptionId : undefined}
  onclick={handleDialogClick}
  onclose={handleClose}
>
  <div class="apg-dialog-header">
    <h2 id={titleId} class="apg-dialog-title">
      {title}
    </h2>
    <button type="button" class="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"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="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>
  {#if description}
    <p id={descriptionId} class="apg-dialog-description">
      {description}
    </p>
  {/if}
  <div class="apg-dialog-body">
    {@render children()}
  </div>
</dialog>

Usage

Example
<script>
  import Dialog from './Dialog.svelte';

  function handleOpenChange(open) {
    console.log('Dialog:', open);
  }
</script>

<Dialog
  title="Dialog Title"
  description="Optional description text"
  onOpenChange={handleOpenChange}
>
  {#snippet trigger({ open })}
    <button onclick={open} class="btn-primary">Open Dialog</button>
  {/snippet}
  {#snippet children()}
    <p>Dialog content goes here.</p>
  {/snippet}
</Dialog>

API

Props

Prop Type Default Description
title string required Dialog title (for accessibility)
description string - Optional description text
defaultOpen boolean false Initial open state
closeOnOverlayClick boolean true Close when clicking overlay
onOpenChange (open: boolean) => void - Callback when open state changes

Snippets

Snippet Props Description
trigger { open: () => void } Trigger element to open the dialog
children - Dialog content

Exported Functions

Function Description
open() Open the dialog programmatically
close() Close the dialog programmatically

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. The Dialog component uses a two-layer testing strategy.

Testing Strategy

Unit Tests (Testing Library)

Verify the component's rendered output using framework-specific testing libraries. These tests ensure correct HTML structure and ARIA attributes.

  • ARIA attributes (aria-labelledby, aria-describedby)
  • Escape key closes dialog
  • Focus management on open/close
  • Accessibility via jest-axe

E2E Tests (Playwright)

Verify component behavior in a real browser environment across all frameworks. These tests cover interactions and cross-framework consistency.

  • Modal behavior (showModal, backdrop)
  • Focus trap verification
  • Focus restoration on close
  • Overlay click to close
  • ARIA structure validation in live browser
  • axe-core accessibility scanning
  • Cross-framework consistency checks

Test Categories

High Priority: APG Keyboard Interaction ( Unit + E2E )

Test Description
Escape key Closes the dialog

High Priority: APG ARIA Attributes ( Unit + E2E )

Test Description
role="dialog" Dialog element has dialog role
aria-modal="true" Indicates modal behavior
aria-labelledby References the dialog title
aria-describedby References description (when provided)

High Priority: Focus Management ( Unit + E2E )

Test Description
Initial focus Focus moves to first focusable element on open
Focus restore Focus returns to trigger on close
Focus trap Tab cycling stays within dialog (via native dialog)

Medium Priority: Accessibility ( Unit + E2E )

Test Description
axe violations No WCAG 2.1 AA violations (via jest-axe)

Low Priority: Props & Behavior ( Unit )

Test Description
closeOnOverlayClick Controls overlay click behavior
defaultOpen Initial open state
onOpenChange Callback fires on open/close
className Custom classes are applied

E2E Test Code

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

Running Tests

          
            # Run unit tests for Dialog
npm run test -- dialog

# Run E2E tests for Dialog (all frameworks)
npm run test:e2e:pattern --pattern=dialog
          
        

Testing Tools

See testing-strategy.md (opens in new tab) for full documentation.

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

describe('Dialog (Svelte)', () => {
  // 🔴 High Priority: APG 準拠の核心
  describe('APG: キーボード操作', () => {
    it('Escape キーでダイアログを閉じる', async () => {
      const user = userEvent.setup();
      const onOpenChange = vi.fn();
      render(DialogTestWrapper, {
        props: { onOpenChange },
      });

      // ダイアログを開く
      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      expect(screen.getByRole('dialog')).toBeInTheDocument();

      // Escape で閉じる
      await user.keyboard('{Escape}');
      expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
      expect(onOpenChange).toHaveBeenLastCalledWith(false);
    });
  });

  describe('APG: ARIA 属性', () => {
    it('role="dialog" を持つ', async () => {
      const user = userEvent.setup();
      render(DialogTestWrapper);

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

    it('aria-modal="true" を持つ', async () => {
      const user = userEvent.setup();
      render(DialogTestWrapper);

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

    it('aria-labelledby でタイトルを参照', async () => {
      const user = userEvent.setup();
      render(DialogTestWrapper, {
        props: { 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('description がある場合 aria-describedby で参照', async () => {
      const user = userEvent.setup();
      render(DialogTestWrapper, {
        props: { 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('description がない場合 aria-describedby なし', async () => {
      const user = userEvent.setup();
      render(DialogTestWrapper);

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

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

  describe('APG: フォーカス管理', () => {
    it('開いた時に最初のフォーカス可能要素にフォーカス', async () => {
      const user = userEvent.setup();
      render(DialogTestWrapper);

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

      // ダイアログ内の最初のフォーカス可能要素(Close ボタン)にフォーカス
      await vi.waitFor(() => {
        expect(screen.getByRole('button', { name: 'Close dialog' })).toHaveFocus();
      });
    });

    // Note: フォーカス復元テストは jsdom の showModal() 制限により flaky。
    // E2E テスト(Playwright)でカバー済み。
    // See: e2e/dialog.spec.ts - Focus Management section
    it.todo('閉じた時にトリガーにフォーカス復元');

    // Note: フォーカストラップはネイティブ <dialog> 要素の showModal() が処理する。
    // jsdom では showModal() のフォーカストラップ動作が未実装のため、
    // これらのテストはブラウザでの E2E テスト(Playwright)で検証することを推奨。
  });

  // 🟡 Medium Priority: アクセシビリティ検証
  describe('アクセシビリティ', () => {
    it('axe による違反がない', async () => {
      const user = userEvent.setup();
      const { container } = render(DialogTestWrapper, {
        props: { description: 'Description' },
      });

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

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

  describe('Props', () => {
    it('title が表示される', async () => {
      const user = userEvent.setup();
      render(DialogTestWrapper, {
        props: { title: 'Custom Title' },
      });

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

    it('description が表示される', async () => {
      const user = userEvent.setup();
      render(DialogTestWrapper, {
        props: { description: 'Custom Description' },
      });

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

    it('closeOnOverlayClick=true でオーバーレイクリックで閉じる', async () => {
      const user = userEvent.setup();
      render(DialogTestWrapper, {
        props: { closeOnOverlayClick: true },
      });

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

      await user.click(dialog);
      await vi.waitFor(() => {
        expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
      });
    });

    it('closeOnOverlayClick=false でオーバーレイクリックしても閉じない', async () => {
      const user = userEvent.setup();
      render(DialogTestWrapper, {
        props: { closeOnOverlayClick: false },
      });

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

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

    it('onOpenChange が開閉時に呼ばれる', async () => {
      const user = userEvent.setup();
      const onOpenChange = vi.fn();
      render(DialogTestWrapper, {
        props: { onOpenChange },
      });

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

      await user.keyboard('{Escape}');
      await vi.waitFor(() => {
        expect(onOpenChange).toHaveBeenCalledWith(false);
      });
    });

    it('defaultOpen=true で初期表示', async () => {
      render(DialogTestWrapper, {
        props: { defaultOpen: true },
      });
      expect(screen.getByRole('dialog')).toBeInTheDocument();
    });
  });

  // 🟢 Low Priority: 拡張性
  describe('HTML 属性継承', () => {
    it('className がダイアログに適用される', async () => {
      const user = userEvent.setup();
      render(DialogTestWrapper, {
        props: { className: 'custom-class' },
      });

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

Resources