APG Patterns
English GitHub
English GitHub

Dialog (Modal)

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

🤖 AI 実装ガイド

デモ

基本的なダイアログ

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

説明なし

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

アクセシビリティ

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 ダイアログを閉じて、開いた要素にフォーカスを戻す

補足事項

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

ソースコード

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(
    <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 準拠を検証します。

テストカテゴリ

高優先度: APG キーボード操作

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

高優先度: APG ARIA 属性

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

高優先度: フォーカス管理

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

中優先度: アクセシビリティ

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

低優先度: プロパティと動作

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

テストツール

詳細なドキュメントは 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' }));
      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).

    it('restores focus to trigger when closed', 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: 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);
      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);
      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' }));
      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');
    });
  });
});

リソース