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.
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.
This dialog has no description, 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.vue
<template>
<slot name="trigger" :open="openDialog" />
<Teleport to="body">
<dialog
ref="dialogRef"
:class="`apg-dialog ${className}`.trim()"
:aria-labelledby="titleId"
:aria-describedby="description ? descriptionId : undefined"
@click="handleDialogClick"
@close="handleClose"
>
<div class="apg-dialog-header">
<h2 :id="titleId" class="apg-dialog-title">
{{ title }}
</h2>
<button
type="button"
class="apg-dialog-close"
@click="closeDialog"
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>
<p v-if="description" :id="descriptionId" class="apg-dialog-description">
{{ description }}
</p>
<div class="apg-dialog-body">
<slot />
</div>
</dialog>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
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
}
const props = withDefaults(defineProps<DialogProps>(), {
description: undefined,
defaultOpen: false,
closeOnOverlayClick: true,
className: ''
})
const emit = defineEmits<{
openChange: [open: boolean]
}>()
const dialogRef = ref<HTMLDialogElement>()
const previousActiveElement = ref<HTMLElement | null>(null)
const instanceId = ref('')
onMounted(() => {
instanceId.value = `dialog-${Math.random().toString(36).substr(2, 9)}`
// Open on mount if defaultOpen
if (props.defaultOpen && dialogRef.value) {
dialogRef.value.showModal()
emit('openChange', true)
}
})
const titleId = computed(() => `${instanceId.value}-title`)
const descriptionId = computed(() => `${instanceId.value}-description`)
const openDialog = () => {
if (dialogRef.value) {
previousActiveElement.value = document.activeElement as HTMLElement
dialogRef.value.showModal()
emit('openChange', true)
}
}
const closeDialog = () => {
dialogRef.value?.close()
}
const handleClose = () => {
emit('openChange', false)
// Return focus to trigger
if (previousActiveElement.value) {
previousActiveElement.value.focus()
}
}
const handleDialogClick = (event: MouseEvent) => {
// Close on backdrop click
if (props.closeOnOverlayClick && event.target === dialogRef.value) {
closeDialog()
}
}
// Expose methods for external control
defineExpose({
open: openDialog,
close: closeDialog
})
</script> Usage
Example
<template>
<Dialog
title="Dialog Title"
description="Optional description text"
@open-change="handleOpenChange"
>
<template #trigger="{ open }">
<button @click="open" class="btn-primary">Open Dialog</button>
</template>
<p>Dialog content goes here.</p>
</Dialog>
</template>
<script setup>
import Dialog from './Dialog.vue';
function handleOpenChange(open) {
console.log('Dialog:', open);
}
</script> 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 |
Events
| Event | Payload | Description |
|---|---|---|
openChange | boolean | Emitted when open state changes |
Slots
| Slot | Props | Description |
|---|---|---|
trigger | { open: () => void } | Trigger element to open the dialog |
default | - | Dialog content |
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.vue.ts
import { render, screen, within } from "@testing-library/vue";
import userEvent from "@testing-library/user-event";
import { axe } from "jest-axe";
import { describe, expect, it, vi } from "vitest";
import Dialog from "./Dialog.vue";
// テスト用のラッパーコンポーネント
const TestDialog = {
components: { Dialog },
props: {
title: { type: String, default: "Test Dialog" },
description: { type: String, default: undefined },
closeOnOverlayClick: { type: Boolean, default: true },
defaultOpen: { type: Boolean, default: false },
className: { type: String, default: "" },
},
emits: ["openChange"],
template: `
<Dialog
:title="title"
:description="description"
:close-on-overlay-click="closeOnOverlayClick"
:default-open="defaultOpen"
:class-name="className"
@open-change="$emit('openChange', $event)"
>
<template #trigger="{ open }">
<button @click="open">Open Dialog</button>
</template>
<slot>
<p>Dialog content</p>
</slot>
</Dialog>
`,
};
describe("Dialog (Vue)", () => {
// 🔴 High Priority: APG 準拠の核心
describe("APG: キーボード操作", () => {
it("Escape キーでダイアログを閉じる", async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(TestDialog, {
props: { onOpenChange },
attrs: { 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();
});
});
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, {
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(TestDialog, {
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(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();
});
});
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, {
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(TestDialog, {
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(TestDialog, {
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(TestDialog, {
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(TestDialog, {
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("defaultOpen=true で初期表示", async () => {
render(TestDialog, {
props: { defaultOpen: true },
});
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
});
// 🟢 Low Priority: 拡張性
describe("HTML 属性継承", () => {
it("className がダイアログに適用される", async () => {
const user = userEvent.setup();
render(TestDialog, {
props: { className: "custom-class" },
});
await user.click(screen.getByRole("button", { name: "Open Dialog" }));
expect(screen.getByRole("dialog")).toHaveClass("custom-class");
});
});
});