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

Delete Confirmation

A destructive action that requires explicit confirmation. Escape key is disabled.

Delete this item?

This action cannot be undone. This will permanently delete the item from your account.

Discard Changes

Confirm before losing unsaved changes.

Discard unsaved changes?

You have unsaved changes. Are you sure you want to discard them?

Information Acknowledgment

Non-destructive alert with Escape key enabled.

System Maintenance

The system will undergo maintenance on Sunday at 2:00 AM. Please save your work before then.

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.astro
---
/**
 * APG Alert Dialog Pattern - Astro Implementation
 *
 * A modal dialog that interrupts the user's workflow to communicate an important
 * message and require a response. Unlike regular Dialog, uses role="alertdialog"
 * which may trigger system alert sounds in assistive technologies.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/
 */

export interface Props {
  /** Dialog title (required for accessibility) */
  title: string;
  /** Alert message (required - unlike regular Dialog) */
  message: string;
  /** Trigger button text */
  triggerText: 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;
  /** Additional CSS class for trigger button */
  triggerClass?: string;
  /** Additional CSS class for dialog */
  class?: string;
}

const {
  title,
  message,
  triggerText,
  confirmLabel = 'OK',
  cancelLabel = 'Cancel',
  confirmVariant = 'default',
  allowEscapeClose = false,
  triggerClass = '',
  class: className = '',
} = Astro.props;

// Generate unique ID for this instance
const instanceId = `alert-dialog-${Math.random().toString(36).substring(2, 11)}`;
const titleId = `${instanceId}-title`;
const messageId = `${instanceId}-message`;

const confirmButtonClass =
  confirmVariant === 'danger'
    ? 'apg-alert-dialog-confirm apg-alert-dialog-confirm--danger'
    : 'apg-alert-dialog-confirm';
---

<apg-alert-dialog data-allow-escape-close={allowEscapeClose ? 'true' : 'false'}>
  <!-- Trigger Button -->
  <button
    type="button"
    class={`apg-alert-dialog-trigger ${triggerClass}`.trim()}
    data-alert-dialog-trigger
  >
    {triggerText}
  </button>

  <!-- Native Dialog Element with alertdialog role -->
  <dialog
    role="alertdialog"
    class={`apg-alert-dialog ${className}`.trim()}
    aria-labelledby={titleId}
    aria-describedby={messageId}
    data-alert-dialog-content
  >
    <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 type="button" class="apg-alert-dialog-cancel" data-cancel>
        {cancelLabel}
      </button>
      <button type="button" class={confirmButtonClass} data-confirm>
        {confirmLabel}
      </button>
    </div>
  </dialog>
</apg-alert-dialog>

<script>
  class ApgAlertDialog extends HTMLElement {
    private trigger: HTMLButtonElement | null = null;
    private dialog: HTMLDialogElement | null = null;
    private cancelButton: HTMLButtonElement | null = null;
    private confirmButton: HTMLButtonElement | null = null;
    private allowEscapeClose = false;

    connectedCallback() {
      this.trigger = this.querySelector('[data-alert-dialog-trigger]');
      this.dialog = this.querySelector('[data-alert-dialog-content]');
      this.cancelButton = this.querySelector('[data-cancel]');
      this.confirmButton = this.querySelector('[data-confirm]');

      if (!this.trigger || !this.dialog) {
        console.warn('apg-alert-dialog: required elements not found');
        return;
      }

      this.allowEscapeClose = this.dataset.allowEscapeClose === 'true';

      // Attach event listeners
      this.trigger.addEventListener('click', this.open);
      this.cancelButton?.addEventListener('click', this.handleCancelClick);
      this.confirmButton?.addEventListener('click', this.handleConfirm);
      this.dialog.addEventListener('keydown', this.handleKeyDown, true);
      this.dialog.addEventListener('cancel', this.handleDialogCancel);
      this.dialog.addEventListener('close', this.handleClose);
    }

    disconnectedCallback() {
      this.trigger?.removeEventListener('click', this.open);
      this.cancelButton?.removeEventListener('click', this.handleCancelClick);
      this.confirmButton?.removeEventListener('click', this.handleConfirm);
      this.dialog?.removeEventListener('keydown', this.handleKeyDown, true);
      this.dialog?.removeEventListener('cancel', this.handleDialogCancel);
      this.dialog?.removeEventListener('close', this.handleClose);
    }

    private open = () => {
      if (!this.dialog) return;

      // Lock body scroll
      document.body.style.overflow = 'hidden';
      this.dialog.showModal();

      // Focus cancel button (safest action - unlike regular Dialog)
      requestAnimationFrame(() => {
        this.cancelButton?.focus();
      });

      // Dispatch custom event
      this.dispatchEvent(
        new CustomEvent('alertdialogopen', {
          bubbles: true,
        })
      );
    };

    private close = () => {
      // Unlock body scroll
      document.body.style.overflow = '';
      this.dialog?.close();
    };

    private handleClose = () => {
      // Unlock body scroll
      document.body.style.overflow = '';
      // Return focus to trigger
      this.trigger?.focus();

      // Dispatch custom event
      this.dispatchEvent(
        new CustomEvent('alertdialogclose', {
          bubbles: true,
        })
      );
    };

    private handleKeyDown = (e: KeyboardEvent) => {
      // Handle Escape key
      if (e.key === 'Escape') {
        e.preventDefault();
        e.stopPropagation();
        if (this.allowEscapeClose) {
          this.dispatchEvent(
            new CustomEvent('cancel', {
              bubbles: true,
            })
          );
          this.close();
        }
        return;
      }

      // Handle focus trap for Tab key
      if (e.key === 'Tab' && this.dialog) {
        const focusableElements = this.dialog.querySelectorAll<HTMLElement>(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        const firstElement = focusableElements[0];
        const lastElement = focusableElements[focusableElements.length - 1];

        if (e.shiftKey) {
          // Shift+Tab from first element -> wrap to last
          if (document.activeElement === firstElement) {
            e.preventDefault();
            lastElement?.focus();
          }
        } else {
          // Tab from last element -> wrap to first
          if (document.activeElement === lastElement) {
            e.preventDefault();
            firstElement?.focus();
          }
        }
      }
    };

    // Handle native dialog cancel event (fired when Escape pressed in real browsers)
    private handleDialogCancel = (e: Event) => {
      if (!this.allowEscapeClose) {
        e.preventDefault();
      } else {
        this.dispatchEvent(
          new CustomEvent('cancel', {
            bubbles: true,
          })
        );
      }
    };

    private handleCancelClick = () => {
      this.dispatchEvent(
        new CustomEvent('cancel', {
          bubbles: true,
        })
      );
      this.close();
    };

    private handleConfirm = () => {
      this.dispatchEvent(
        new CustomEvent('confirm', {
          bubbles: true,
        })
      );
      this.close();
    };
  }

  // Register the custom element
  if (!customElements.get('apg-alert-dialog')) {
    customElements.define('apg-alert-dialog', ApgAlertDialog);
  }
</script>

Usage

Example
---
import AlertDialog from './AlertDialog.astro';
---

<AlertDialog
  title="Delete this item?"
  message="This action cannot be undone. This will permanently delete the item."
  triggerText="Delete Item"
  confirmLabel="Delete"
  cancelLabel="Cancel"
  confirmVariant="danger"
  triggerClass="bg-destructive text-destructive-foreground px-4 py-2 rounded"
/>

<script>
  // Listen for custom events
  document.querySelector('apg-alert-dialog')?.addEventListener('confirm', () => {
    console.log('Confirmed!');
  });
</script>

API

Props

Prop Type Default Description
title string required Alert dialog title
message string required Alert message (required for accessibility)
triggerText string required Trigger button text
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
triggerClass string - Additional CSS class for trigger button

Custom Events

Event Description
confirm Fired when Confirm button is clicked
cancel Fired when Cancel button is clicked
alertdialogopen Fired when dialog opens
alertdialogclose Fired when dialog closes

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.astro.ts
/**
 * AlertDialog Web Component Tests
 *
 * Note: These are limited unit tests for the Web Component class.
 * Full keyboard navigation and focus management tests require E2E testing
 * with Playwright due to jsdom limitations with focus events and <dialog> element.
 */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

describe('AlertDialog (Web Component)', () => {
  let container: HTMLElement;

  // Web Component class extracted for testing
  class TestApgAlertDialog extends HTMLElement {
    private dialog: HTMLDialogElement | null = null;
    private triggerRef: HTMLElement | null = null;
    private cancelButton: HTMLButtonElement | null = null;
    private confirmButton: HTMLButtonElement | null = null;
    private rafId: number | null = null;

    connectedCallback() {
      this.rafId = requestAnimationFrame(() => this.initialize());
    }

    private initialize() {
      this.rafId = null;
      this.dialog = this.querySelector('dialog');
      this.cancelButton = this.querySelector('[data-cancel]');
      this.confirmButton = this.querySelector('[data-confirm]');

      if (!this.dialog) return;

      // Set up event listeners
      this.dialog.addEventListener('keydown', this.handleKeyDown);
      this.cancelButton?.addEventListener('click', this.handleCancel);
      this.confirmButton?.addEventListener('click', this.handleConfirm);
    }

    disconnectedCallback() {
      if (this.rafId !== null) {
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
      }
      this.dialog?.removeEventListener('keydown', this.handleKeyDown);
      this.cancelButton?.removeEventListener('click', this.handleCancel);
      this.confirmButton?.removeEventListener('click', this.handleConfirm);
    }

    open(triggerElement?: HTMLElement) {
      if (!this.dialog) return;
      this.triggerRef = triggerElement || null;
      this.dialog.showModal();

      // Focus cancel button (safest action)
      requestAnimationFrame(() => {
        this.cancelButton?.focus();
      });
    }

    close() {
      if (!this.dialog) return;
      this.dialog.close();
      this.triggerRef?.focus();
    }

    private handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        const allowEscapeClose = this.dataset.allowEscapeClose === 'true';
        if (!allowEscapeClose) {
          event.preventDefault();
        } else {
          this.dispatchEvent(new CustomEvent('cancel', { bubbles: true }));
        }
      }
    };

    private handleCancel = () => {
      this.dispatchEvent(new CustomEvent('cancel', { bubbles: true }));
      this.close();
    };

    private handleConfirm = () => {
      this.dispatchEvent(new CustomEvent('confirm', { bubbles: true }));
      this.close();
    };
  }

  function createAlertDialogHTML(
    options: {
      title?: string;
      message?: string;
      confirmLabel?: string;
      cancelLabel?: string;
      allowEscapeClose?: boolean;
      open?: boolean;
    } = {}
  ) {
    const {
      title = 'Confirm Action',
      message = 'Are you sure?',
      confirmLabel = 'Confirm',
      cancelLabel = 'Cancel',
      allowEscapeClose = false,
      open = false,
    } = options;

    const titleId = 'alert-title';
    const messageId = 'alert-message';

    return `
      <apg-alert-dialog ${allowEscapeClose ? 'data-allow-escape-close="true"' : ''}>
        <dialog
          role="alertdialog"
          aria-modal="true"
          aria-labelledby="${titleId}"
          aria-describedby="${messageId}"
          class="apg-alert-dialog"
          ${open ? 'open' : ''}
        >
          <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 type="button" data-cancel class="apg-alert-dialog-cancel">${cancelLabel}</button>
            <button type="button" data-confirm class="apg-alert-dialog-confirm">${confirmLabel}</button>
          </div>
        </dialog>
      </apg-alert-dialog>
    `;
  }

  beforeEach(() => {
    container = document.createElement('div');
    document.body.appendChild(container);

    // Register custom element if not already registered
    if (!customElements.get('apg-alert-dialog')) {
      customElements.define('apg-alert-dialog', TestApgAlertDialog);
    }
  });

  afterEach(() => {
    container.remove();
    vi.restoreAllMocks();
  });

  describe('ARIA Attributes', () => {
    it('has role="alertdialog" (NOT dialog)', async () => {
      container.innerHTML = createAlertDialogHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const dialog = container.querySelector('dialog');
      expect(dialog?.getAttribute('role')).toBe('alertdialog');
    });

    it('has aria-modal="true"', async () => {
      container.innerHTML = createAlertDialogHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const dialog = container.querySelector('dialog');
      expect(dialog?.getAttribute('aria-modal')).toBe('true');
    });

    it('has aria-labelledby referencing title', async () => {
      container.innerHTML = createAlertDialogHTML({ title: 'Delete Item' });
      await new Promise((r) => requestAnimationFrame(r));

      const dialog = container.querySelector('dialog');
      const titleId = dialog?.getAttribute('aria-labelledby');

      expect(titleId).toBeTruthy();
      expect(document.getElementById(titleId!)?.textContent).toBe('Delete Item');
    });

    it('has aria-describedby referencing message (required)', async () => {
      container.innerHTML = createAlertDialogHTML({ message: 'This cannot be undone.' });
      await new Promise((r) => requestAnimationFrame(r));

      const dialog = container.querySelector('dialog');
      const messageId = dialog?.getAttribute('aria-describedby');

      expect(messageId).toBeTruthy();
      expect(document.getElementById(messageId!)?.textContent).toBe('This cannot be undone.');
    });
  });

  describe('Escape Key Behavior', () => {
    it('prevents close on Escape by default', async () => {
      container.innerHTML = createAlertDialogHTML({ allowEscapeClose: false });
      await new Promise((r) => requestAnimationFrame(r));

      const dialog = container.querySelector('dialog');
      const event = new KeyboardEvent('keydown', {
        key: 'Escape',
        bubbles: true,
        cancelable: true,
      });

      dialog?.dispatchEvent(event);

      expect(event.defaultPrevented).toBe(true);
    });

    it('allows close on Escape when allowEscapeClose=true', async () => {
      container.innerHTML = createAlertDialogHTML({ allowEscapeClose: true });
      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-alert-dialog') as HTMLElement;
      const dialog = container.querySelector('dialog');
      const cancelHandler = vi.fn();

      element.addEventListener('cancel', cancelHandler);

      const event = new KeyboardEvent('keydown', {
        key: 'Escape',
        bubbles: true,
        cancelable: true,
      });
      dialog?.dispatchEvent(event);

      expect(event.defaultPrevented).toBe(false);
      expect(cancelHandler).toHaveBeenCalledTimes(1);
    });
  });

  describe('Button Actions', () => {
    it('dispatches cancel event on cancel button click', async () => {
      container.innerHTML = createAlertDialogHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-alert-dialog') as HTMLElement;
      const cancelButton = container.querySelector('[data-cancel]') as HTMLButtonElement;
      const cancelHandler = vi.fn();

      element.addEventListener('cancel', cancelHandler);
      cancelButton.click();

      expect(cancelHandler).toHaveBeenCalledTimes(1);
    });

    it('dispatches confirm event on confirm button click', async () => {
      container.innerHTML = createAlertDialogHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-alert-dialog') as HTMLElement;
      const confirmButton = container.querySelector('[data-confirm]') as HTMLButtonElement;
      const confirmHandler = vi.fn();

      element.addEventListener('confirm', confirmHandler);
      confirmButton.click();

      expect(confirmHandler).toHaveBeenCalledTimes(1);
    });
  });

  describe('Button Labels', () => {
    it('displays custom button labels', async () => {
      container.innerHTML = createAlertDialogHTML({
        confirmLabel: 'Delete',
        cancelLabel: 'Keep',
      });
      await new Promise((r) => requestAnimationFrame(r));

      const cancelButton = container.querySelector('[data-cancel]');
      const confirmButton = container.querySelector('[data-confirm]');

      expect(cancelButton?.textContent).toBe('Keep');
      expect(confirmButton?.textContent).toBe('Delete');
    });
  });

  describe('Alert Dialog Specific', () => {
    it('does NOT have a close button (×)', async () => {
      container.innerHTML = createAlertDialogHTML();
      await new Promise((r) => requestAnimationFrame(r));

      // Alert dialog should not have the typical close button
      const closeButton = container.querySelector(
        '[aria-label*="close" i], [aria-label*="Close" i]'
      );
      expect(closeButton).toBeNull();
    });

    it('has only Cancel and Confirm buttons', async () => {
      container.innerHTML = createAlertDialogHTML();
      await new Promise((r) => requestAnimationFrame(r));

      const buttons = container.querySelectorAll('button');
      expect(buttons.length).toBe(2);
    });
  });
});

Resources