Alert Dialog
A modal dialog that interrupts the user's workflow to communicate an important message and require a response.
Demo
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
alertdialog | Dialog container | A type of dialog that interrupts the user’s workflow to communicate an important message and require a response. May trigger system alert sounds in assistive technologies. |
WAI-ARIA Properties
aria-modal
Provided automatically by showModal(). No explicit attribute needed when using native <dialog> element.
- Values
- true
- Required
- Implicit
aria-labelledby
References the alert dialog title
- Values
- ID reference to title element
- Required
- Yes
aria-describedby
References the alert message. Unlike regular Dialog, this is required for Alert Dialog as the message is central to the user’s understanding of what action is being confirmed.
- Values
- ID reference to message
- Required
- Yes (required)
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 | Disabled by default. Unlike regular Dialog, Alert Dialog prevents Escape key closure to ensure users explicitly respond to the alert. Can be enabled via allowEscapeClose prop for non-critical alerts. |
| Enter | Activates the focused button |
| Space | Activates the focused button |
- Alert Dialog is for critical messages that require user acknowledgment or a decision. For general content, use regular Dialog.
- The alertdialog role may cause assistive technologies to announce the dialog more urgently or with an alert sound.
- Both title and message are required to provide complete context for the user’s decision.
- The Cancel button should always be the safest choice (no destructive action).
- Consider using the danger variant for destructive actions to provide visual distinction.
Focus Management
| Event | Behavior |
|---|---|
| Dialog opens | Focus moves to the Cancel button (safest action). This differs from regular Dialog which focuses the first focusable element. |
| 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) |
References
Source Code
<template>
<slot name="trigger" :open="openDialog" />
<Teleport to="body">
<dialog
ref="dialogRef"
role="alertdialog"
:class="`apg-alert-dialog ${className}`.trim()"
:aria-labelledby="titleId"
:aria-describedby="messageId"
@keydown.capture="handleKeyDown"
@cancel="handleCancel2"
@close="handleClose"
>
<h2 :id="titleId" class="apg-alert-dialog-title">
{{ title }}
</h2>
<p :id="messageId" class="apg-alert-dialog-message">
{{ message }}
</p>
<div class="apg-alert-dialog-actions">
<button
ref="cancelButtonRef"
type="button"
class="apg-alert-dialog-cancel"
@click="handleCancel"
>
{{ cancelLabel }}
</button>
<button type="button" :class="confirmButtonClass" @click="handleConfirm">
{{ confirmLabel }}
</button>
</div>
</dialog>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue';
export interface AlertDialogProps {
/** Dialog title (required for accessibility) */
title: string;
/** Alert message (required - unlike regular Dialog) */
message: string;
/** Confirm button label */
confirmLabel?: string;
/** Cancel button label */
cancelLabel?: string;
/** Confirm button variant */
confirmVariant?: 'default' | 'danger';
/** Allow closing with Escape key (default: false - unlike regular Dialog) */
allowEscapeClose?: boolean;
/** Default open state */
defaultOpen?: boolean;
/** Additional CSS class */
className?: string;
}
const props = withDefaults(defineProps<AlertDialogProps>(), {
confirmLabel: 'OK',
cancelLabel: 'Cancel',
confirmVariant: 'default',
allowEscapeClose: false,
defaultOpen: false,
className: '',
});
const emit = defineEmits<{
confirm: [];
cancel: [];
}>();
const dialogRef = ref<HTMLDialogElement>();
const cancelButtonRef = ref<HTMLButtonElement>();
const previousActiveElement = ref<HTMLElement | null>(null);
const instanceId = ref('');
onMounted(() => {
instanceId.value = `alert-dialog-${Math.random().toString(36).substr(2, 9)}`;
// Open on mount if defaultOpen
if (props.defaultOpen && dialogRef.value) {
dialogRef.value.showModal();
focusCancelButton();
}
});
const titleId = computed(() => `${instanceId.value}-title`);
const messageId = computed(() => `${instanceId.value}-message`);
const confirmButtonClass = computed(() => {
const base = 'apg-alert-dialog-confirm';
return props.confirmVariant === 'danger' ? `${base} ${base}--danger` : base;
});
const focusCancelButton = async () => {
await nextTick();
cancelButtonRef.value?.focus();
};
const openDialog = () => {
if (dialogRef.value) {
previousActiveElement.value = document.activeElement as HTMLElement;
// Lock body scroll
document.body.style.overflow = 'hidden';
dialogRef.value.showModal();
focusCancelButton();
}
};
const closeDialog = () => {
// Unlock body scroll
document.body.style.overflow = '';
dialogRef.value?.close();
};
const handleClose = () => {
// Unlock body scroll
document.body.style.overflow = '';
// Return focus to trigger
if (previousActiveElement.value) {
previousActiveElement.value.focus();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
// Handle Escape key
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
if (props.allowEscapeClose) {
emit('cancel');
closeDialog();
}
return;
}
// Handle focus trap for Tab key
if (event.key === 'Tab' && dialogRef.value) {
const focusableElements = dialogRef.value.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
// Shift+Tab from first element -> wrap to last
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement?.focus();
}
} else {
// Tab from last element -> wrap to first
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement?.focus();
}
}
}
};
// Handle native cancel event (fired when Escape pressed in real browsers)
const handleCancel2 = (event: Event) => {
if (!props.allowEscapeClose) {
event.preventDefault();
} else {
emit('cancel');
}
};
const handleConfirm = () => {
emit('confirm');
closeDialog();
};
const handleCancel = () => {
emit('cancel');
closeDialog();
};
// Expose methods for external control
defineExpose({
open: openDialog,
close: closeDialog,
});
</script> Usage
<script setup lang="ts">
import AlertDialog from './AlertDialog.vue';
const handleDelete = () => {
console.log('Item deleted');
};
</script>
<template>
<AlertDialog
title="Delete this item?"
message="This action cannot be undone. This will permanently delete the item."
confirmLabel="Delete"
cancelLabel="Cancel"
confirmVariant="danger"
@confirm="handleDelete"
@cancel="() => console.log('Cancelled')"
>
<template #trigger="{ open }">
<button @click="open" class="bg-destructive text-destructive-foreground px-4 py-2 rounded">
Delete Item
</button>
</template>
</AlertDialog>
</template> API
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | required | Alert dialog title |
message | string | required | Alert message (required for accessibility) |
confirmLabel | string | "OK" | Confirm button label |
cancelLabel | string | "Cancel" | Cancel button label |
confirmVariant | 'default' | 'danger' | 'default' | Confirm button visual style |
allowEscapeClose | boolean | false | Allow closing with Escape key |
Slots
| Slot | Default | Description |
|---|---|---|
#trigger | - | Trigger element with open function ({ open }) |
Custom Events
| Event | Detail | Description |
|---|---|---|
@confirm | - | Emitted when Confirm button is clicked |
@cancel | - | Emitted when Cancel button is clicked |
Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. Alert Dialog has stricter requirements than regular Dialog. The Alert Dialog component uses a two-layer testing strategy.
Testing Strategy
Unit Tests (Testing Library / jest-axe)
Verify the component's HTML output, ARIA attributes, and accessibility. These tests ensure correct rendering and compliance with APG requirements.
- role="alertdialog" (NOT dialog)
- aria-labelledby and aria-describedby attributes
- Modal behavior via showModal()
- WCAG 2.1 AA compliance via axe-core
- Props behavior (allowEscapeClose, confirmVariant)
E2E Tests (Playwright)
Verify component behavior in a real browser environment across all frameworks (React, Vue, Svelte, Astro). These tests cover interactions requiring JavaScript execution.
- Focus on Cancel button when dialog opens (safest action)
- Tab/Shift+Tab wrap within dialog (focus trap)
- Enter/Space activates focused button
- Escape key disabled by default
- Focus returns to trigger on close
- No close button (×) unlike regular Dialog
Test Categories
High Priority: APG Keyboard Interaction
| Test | Description |
|---|---|
Escape key (disabled) | Escape does NOT close the dialog by default |
Escape key (enabled) | Escape closes the dialog when allowEscapeClose is true |
Enter on button | Activates the focused button |
Space on button | Activates the focused button |
High Priority: APG ARIA Attributes
| Test | Description |
|---|---|
role="alertdialog" | Dialog element has alertdialog role (not dialog) |
Modal behavior | Opened via showModal() (verified by ::backdrop existence) |
aria-labelledby | References the alert dialog title |
aria-describedby | References the alert message (required, unlike Dialog) |
High Priority: Focus Management
| Test | Description |
|---|---|
Initial focus | Focus moves to Cancel button on open (safest action) |
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) |
Title and message | Both are rendered and properly associated |
Low Priority: Props & Behavior
| Test | Description |
|---|---|
allowEscapeClose | Controls Escape key behavior (default: false) |
confirmVariant | Danger variant applies correct styling |
onConfirm | Callback fires when Confirm button is clicked |
onCancel | Callback fires when Cancel button is clicked |
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
- Playwright (opens in new tab) - E2E testing for cross-framework validation
Running Tests
# Run all AlertDialog unit tests
npm run test:unit -- AlertDialog
# Run framework-specific tests
npm run test:react -- AlertDialog.test.tsx
npm run test:vue -- AlertDialog.test.vue.ts
npm run test:svelte -- AlertDialog.test.svelte.ts
npm run test:astro
# Run all Alert Dialog E2E tests
npm run test:e2e -- alert-dialog.spec.ts
# Run in UI mode
npm run test:e2e:ui -- alert-dialog.spec.ts See testing-strategy.md (opens in new tab) for full documentation.
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import AlertDialog from './AlertDialog.vue';
// テスト用のラッパーコンポーネント
const TestAlertDialog = {
components: { AlertDialog },
props: {
title: { type: String, default: 'Confirm Action' },
message: { type: String, default: 'Are you sure you want to proceed?' },
confirmLabel: { type: String, default: 'Confirm' },
cancelLabel: { type: String, default: 'Cancel' },
confirmVariant: { type: String as () => 'default' | 'danger', default: 'default' },
allowEscapeClose: { type: Boolean, default: false },
defaultOpen: { type: Boolean, default: false },
},
emits: ['confirm', 'cancel'],
template: `
<AlertDialog
:title="title"
:message="message"
:confirm-label="confirmLabel"
:cancel-label="cancelLabel"
:confirm-variant="confirmVariant"
:allow-escape-close="allowEscapeClose"
:default-open="defaultOpen"
@confirm="$emit('confirm')"
@cancel="$emit('cancel')"
>
<template #trigger="{ open }">
<button @click="open">Open Alert</button>
</template>
</AlertDialog>
`,
};
describe('AlertDialog (Vue)', () => {
// 🔴 High Priority: APG ARIA 属性
describe('APG: ARIA 属性', () => {
it('role="alertdialog" を持つ(dialog ではない)', async () => {
const user = userEvent.setup();
render(TestAlertDialog);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('aria-modal="true" を持つ', async () => {
const user = userEvent.setup();
render(TestAlertDialog);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('alertdialog')).toHaveAttribute('aria-modal', 'true');
});
it('aria-labelledby でタイトルを参照', async () => {
const user = userEvent.setup();
render(TestAlertDialog, {
props: { title: 'Delete Item' },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
const dialog = screen.getByRole('alertdialog');
const titleId = dialog.getAttribute('aria-labelledby');
expect(titleId).toBeTruthy();
expect(document.getElementById(titleId!)).toHaveTextContent('Delete Item');
});
it('aria-describedby でメッセージを参照(必須 - Dialog と異なる)', async () => {
const user = userEvent.setup();
render(TestAlertDialog, {
props: { message: 'This action cannot be undone.' },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
const dialog = screen.getByRole('alertdialog');
const messageId = dialog.getAttribute('aria-describedby');
expect(messageId).toBeTruthy();
expect(document.getElementById(messageId!)).toHaveTextContent(
'This action cannot be undone.'
);
});
});
// 🔴 High Priority: キーボード操作
describe('APG: キーボード操作', () => {
it('デフォルトで Escape キーで閉じない(Dialog と異なる)', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(TestAlertDialog, {
props: { onCancel },
attrs: { onCancel },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
expect(onCancel).not.toHaveBeenCalled();
});
it('allowEscapeClose=true で Escape キーで閉じる', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(TestAlertDialog, {
props: { allowEscapeClose: true, onCancel },
attrs: { onCancel },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
expect(onCancel).toHaveBeenCalled();
});
it('Tab で次のフォーカス可能要素に移動', async () => {
const user = userEvent.setup();
render(TestAlertDialog);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
const confirmButton = screen.getByRole('button', { name: 'Confirm' });
await vi.waitFor(() => {
expect(cancelButton).toHaveFocus();
});
await user.tab();
expect(confirmButton).toHaveFocus();
});
// Note: "Tab が最後から最初にループする" テストは E2E で担保
// (e2e/alert-dialog.spec.ts: "Tab wraps from last to first element")
// jsdom 環境でのフォーカス操作が不安定なため、Unit test からは削除
});
// 🔴 High Priority: フォーカス管理
describe('APG: フォーカス管理', () => {
it('開いた時に Cancel ボタンにフォーカス(安全なアクション、Dialog と異なる)', async () => {
const user = userEvent.setup();
render(TestAlertDialog, {
props: { cancelLabel: 'Cancel', confirmLabel: 'Delete' },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
await vi.waitFor(() => {
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus();
});
});
it('閉じた時にトリガーにフォーカス復元', async () => {
const user = userEvent.setup();
render(TestAlertDialog);
const trigger = screen.getByRole('button', { name: 'Open Alert' });
await user.click(trigger);
await vi.waitFor(() => {
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(trigger).toHaveFocus();
});
});
// 🟡 Medium Priority: アクセシビリティ
describe('アクセシビリティ', () => {
it('axe による違反がない', async () => {
const user = userEvent.setup();
const { container } = render(TestAlertDialog);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟢 Low Priority: Props & Callbacks
describe('Props & Callbacks', () => {
it('confirm ボタンクリックで onConfirm を呼ぶ', async () => {
const user = userEvent.setup();
const onConfirm = vi.fn();
render(TestAlertDialog, {
props: { onConfirm },
attrs: { onConfirm },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
await user.click(screen.getByRole('button', { name: 'Confirm' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('cancel ボタンクリックで onCancel を呼ぶ', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(TestAlertDialog, {
props: { onCancel },
attrs: { onCancel },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('カスタムボタンラベルが表示される', async () => {
const user = userEvent.setup();
render(TestAlertDialog, {
props: { confirmLabel: 'Delete', cancelLabel: 'Keep' },
});
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Keep' })).toBeInTheDocument();
});
it('defaultOpen=true で初期表示', async () => {
render(TestAlertDialog, {
props: { defaultOpen: true },
});
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
});
});
// Alert Dialog 固有の動作
describe('Alert Dialog 固有の動作', () => {
it('閉じるボタン(×)がない(通常の Dialog と異なる)', async () => {
const user = userEvent.setup();
render(TestAlertDialog);
await user.click(screen.getByRole('button', { name: 'Open Alert' }));
expect(screen.queryByRole('button', { name: /close/i })).not.toBeInTheDocument();
});
});
}); Resources
- WAI-ARIA APG: Alert Dialog Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist