Dialog (Modal)
プライマリウィンドウの上に重なるウィンドウで、背後のコンテンツを不活性にします。
🤖 AI 実装ガイドデモ
基本的なダイアログ
タイトル、説明、閉じる機能を持つシンプルなモーダルダイアログです。
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.
説明なし
タイトルとコンテンツのみを持つダイアログです。
This dialog has no description, only a title and content.
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
dialog | ダイアログコンテナ | その要素がダイアログウィンドウであることを示す |
WAI-ARIA dialog role (opens in new tab)
WAI-ARIA プロパティ
| 属性 | 対象 | 値 | 必須 | 説明 |
|---|---|---|---|---|
aria-modal | dialog | true | はい | これがモーダルダイアログであることを示す |
aria-labelledby | dialog | タイトル要素への ID 参照 | はい | ダイアログのタイトルを参照する |
aria-describedby | dialog | 説明への ID 参照 | いいえ | オプションの説明テキストを参照する |
フォーカス管理
| イベント | 動作 |
|---|---|
| ダイアログが開く | ダイアログ内の最初のフォーカス可能な要素にフォーカスが移動する |
| ダイアログが閉じる | ダイアログを開いた要素にフォーカスが戻る |
| フォーカストラップ | Tab/Shift+Tab はダイアログ内のフォーカス可能な要素間のみをサイクルする |
| 背景 | ダイアログ外のコンテンツは不活性化される(フォーカス不可・操作不可) |
キーボードサポート
| キー | アクション |
|---|---|
| Tab | ダイアログ内の次のフォーカス可能な要素にフォーカスを移動する。最後の要素にフォーカスがある場合は最初の要素に移動する。 |
| Shift + Tab | ダイアログ内の前のフォーカス可能な要素にフォーカスを移動する。最初の要素にフォーカスがある場合は最後の要素に移動する。 |
| Escape | ダイアログを閉じて、開いた要素にフォーカスを戻す |
補足事項
- アクセシビリティのためにダイアログのタイトルは必須であり、ダイアログの目的を明確に説明する必要があります
- ダイアログが開いている間はページのスクロールが無効になります
- オーバーレイ(背景)をクリックするとデフォルトでダイアログが閉じます
- 閉じるボタンにはスクリーンリーダー向けのアクセシブルなラベルが付いています
ソースコード
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 | カスタムクラスが適用される |
テストツール
- Vitest (opens in new tab) - テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ
- jest-axe (opens in new tab) - 自動アクセシビリティテスト
詳細なドキュメントは 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');
});
});
}); リソース
- WAI-ARIA APG: Dialog (Modal) パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist