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.

Without Description

A dialog with only a title and content.

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 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

Additional Notes

  • 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.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<HTMLButtonElement | 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) {
      triggerRef.current = document.activeElement as HTMLButtonElement;
      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(
    <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,
};

Usage

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

Prop Type Default Description
children ReactNode required DialogTrigger and Dialog components
defaultOpen boolean false Initial open state
onOpenChange (open: boolean) => void - Callback when open state changes

Dialog Props

Prop Type Default Description
title string required Dialog title (for accessibility)
description string - Optional description text
children ReactNode required Dialog content
closeOnOverlayClick boolean true Close when clicking overlay

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements.

Test Categories

High Priority: APG Keyboard Interaction

Test Description
Escape key Closes the dialog

High Priority: APG ARIA Attributes

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

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

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

Low Priority: Props & Behavior

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

Testing Tools

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

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";

// テスト用のラッパーコンポーネント
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 準拠の核心
  describe("APG: キーボード操作", () => {
    it("Escape キーでダイアログを閉じる", async () => {
      const user = userEvent.setup();
      render(<TestDialog />);

      // ダイアログを開く
      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();
    });
  });

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

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

    it('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("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("description がある場合 aria-describedby で参照", async () => {
      const user = userEvent.setup();
      render(<TestDialog description="This is a description" />);

      await user.click(screen.getByRole("button", { name: "Open Dialog" }));
      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(<TestDialog />);

      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(<TestDialog />);

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

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

    // Note: autofocus 属性のテストは、React の autoFocus が DOM 属性ではなく
    // React 独自のフォーカス管理を使用するため、jsdom 環境では検証が困難。
    // ブラウザでの E2E テスト(Playwright)で検証することを推奨。

    it("閉じた時にトリガーにフォーカス復元", async () => {
      const user = userEvent.setup();
      render(<TestDialog />);

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

      await user.keyboard("{Escape}");
      expect(trigger).toHaveFocus();
    });

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

  // 🟡 Medium Priority: アクセシビリティ検証
  describe("アクセシビリティ", () => {
    it("axe による違反がない", 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("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("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("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");

      // dialog 要素自体をクリック(オーバーレイ相当)
      await user.click(dialog);
      expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
    });

    it("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");

      // dialog 要素自体をクリック
      await user.click(dialog);
      expect(screen.getByRole("dialog")).toBeInTheDocument();
    });

    it("onOpenChange が開閉時に呼ばれる", 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 ボタンで閉じる
      await user.click(screen.getByRole("button", { name: "Close dialog" }));
      expect(onOpenChange).toHaveBeenCalledWith(false);
    });

    it("defaultOpen=true で初期表示", async () => {
      render(<TestDialog defaultOpen={true} />);
      expect(screen.getByRole("dialog")).toBeInTheDocument();
    });
  });

  // 🟢 Low Priority: 拡張性
  describe("HTML 属性継承", () => {
    it("className がダイアログに適用される", 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("トリガーに className が適用される", async () => {
      render(
        <DialogRoot>
          <DialogTrigger className="trigger-class">Open</DialogTrigger>
          <Dialog title="Test">Content</Dialog>
        </DialogRoot>
      );

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

Resources