APG Patterns
日本語 GitHub
日本語 GitHub

Alert Dialog

A modal dialog that interrupts the user's workflow to communicate an important message and require a response.

🤖 AI Implementation Guide

Demo

Try the alert dialogs below. Note that Escape key is disabled by default for critical confirmations, and initial focus goes to the Cancel button (the safest action).

Open demo only →

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 alertdialog role (opens in new tab)

WAI-ARIA Properties

Attribute Target Values Required Description
aria-modal alertdialog true Implicit Provided automatically by showModal(). No explicit attribute needed when using native <dialog> element.
aria-labelledby alertdialog ID reference to title element Yes References the alert dialog title
aria-describedby alertdialog ID reference to message Yes (required) 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.

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)

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

Differences from Dialog

Feature Dialog Alert Dialog
Role dialog alertdialog
Message (aria-describedby) Optional Required
Escape key Enabled by default Disabled by default
Initial focus First focusable element Cancel button (safest action)
Close button Yes (×) No (explicit response required)
Overlay click Closes dialog Does not close (explicit response required)

Additional Notes

  • 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.

Source Code

AlertDialog.vue
<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

Example
<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

Props

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

Events

Event Description
@confirm Emitted when Confirm button is clicked
@cancel Emitted when Cancel button is clicked

Slots

Slot Props Description
#trigger { open } Trigger element with open function

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

Running Tests

Unit 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

E2E Tests

# 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.

AlertDialog.test.vue.ts
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();
    });

    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();

      await user.tab();
      expect(cancelButton).toHaveFocus();
    });
  });

  // 🔴 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