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.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.
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
- Vitest (opens in new tab) - Test runner
- Testing Library (opens in new tab) - Framework-specific testing utilities
- jest-axe (opens in new tab) - Automated accessibility testing
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" }));
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();
});
});
it("閉じた時にトリガーにフォーカス復元", async () => {
const user = userEvent.setup();
render(DialogTestWrapper);
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(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);
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);
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}");
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");
});
});
});