APG Patterns
English GitHub
English GitHub

Alert

ユーザーのタスクを中断せずに、重要なメッセージを目立つ形で表示する要素。

🤖 AI 実装ガイド

デモ

下のボタンをクリックして、異なるバリアントのアラートを表示します。ライブリージョンコンテナはページ読み込み時からDOMに存在し、コンテンツのみが変化します。

アクセシビリティ

重要な実装上の注意

ライブリージョンのコンテナ(role="alert")は、ページ読み込み時から DOM に存在している必要があります。 コンテナ自体を動的に追加・削除しないでください。コンテナ内のコンテンツのみを動的に変更するようにしてください。

// 誤り: ライブリージョンを動的に追加
{showAlert && <div role="alert">Message</div>}

// 正しい: ライブリージョンは常に存在し、コンテンツを変更
<div role="alert">
  {message && <span>{message}</span>}
</div>

スクリーンリーダーは、ライブリージョン内の DOM の変更を検知してアナウンスします。ライブリージョン自体が動的に追加される場合、一部のスクリーンリーダーではコンテンツが確実にアナウンスされない可能性があります。

WAI-ARIA ロール

暗黙的な ARIA プロパティ

role="alert" は以下の ARIA プロパティを暗黙的に設定します。これらを手動で追加する必要はありません:

プロパティ 暗黙的な値 説明
aria-live assertive スクリーンリーダーを中断して即座にアナウンス
aria-atomic true 変更された部分だけでなく、アラート全体のコンテンツをアナウンス

キーボードサポート

アラートはキーボード操作を必要としません。ユーザーのワークフローを中断することなく情報を伝えることを目的としています。アラートのコンテンツは、変更されると自動的にスクリーンリーダーによってアナウンスされます。

アラートに閉じるボタンが含まれる場合、ボタンは標準的なボタンのキーボード操作に従います:

キー アクション
Enter 閉じるボタンをアクティブ化
Space 閉じるボタンをアクティブ化

フォーカス管理

  • アラートはフォーカスを移動してはいけません - アラートは非モーダルであり、フォーカスを奪うことでユーザーのワークフローを中断してはいけません。
  • アラートコンテナはフォーカス不可 - アラート要素は tabindex を持たず、キーボードフォーカスを受け取ってはいけません。
  • 閉じるボタンはフォーカス可能 - 存在する場合、閉じるボタンは Tab ナビゲーションで到達可能です。

重要なガイドライン

自動非表示の禁止

アラートは自動的に消えてはいけません。 WCAG 2.2.3 制限時間なし (opens in new tab) に従い、ユーザーがコンテンツを読むのに十分な時間が必要です。自動非表示が必要な場合:

  • 表示時間を一時停止・延長するためのユーザーコントロールを提供する
  • 十分な表示時間を確保する(最低5秒 + 読む時間)
  • コンテンツが本当に必須でないかを検討する

アラートの頻度

過度なアラートは、特に視覚障がいや認知障がいを持つユーザーにとって使いやすさを損なう可能性があります( WCAG 2.2.4 割り込み (opens in new tab) )。本当に重要なメッセージのためだけにアラートを使用してください。

Alert vs Alert Dialog

Alert を使用する場合:

  • メッセージが情報提供のみでユーザーアクションを必要としない
  • ユーザーのワークフローを中断すべきでない
  • フォーカスは現在のタスクに留まるべき

Alert Dialog (role="alertdialog") を使用する場合:

  • メッセージが即座のユーザー応答を必要とする
  • ユーザーが続行する前に確認またはアクションをとる必要がある
  • フォーカスをダイアログに移動すべき(モーダル動作)

注意: role="alertdialog" にはフォーカス管理とキーボード処理(Escapeで閉じる、フォーカストラップ)が必要です。モーダルな中断が適切な場合にのみ使用してください。

スクリーンリーダーの動作

  • 即座のアナウンス - アラートのコンテンツが変更されると、スクリーンリーダーは現在の読み上げを中断してアラートをアナウンスします(aria-live="assertive")。
  • 完全なコンテンツのアナウンス - 変更された部分だけでなく、アラート全体のコンテンツが読み上げられます(aria-atomic="true")。
  • 初期コンテンツはアナウンスされない - ページ読み込み時に存在するアラートは自動的にアナウンスされません。動的な変更のみがアナウンスをトリガーします。

参考資料

ソースコード

Alert.tsx
import { cn } from '@/lib/utils';
import { Info, CircleCheck, AlertTriangle, OctagonAlert, X } from 'lucide-react';
import { useId, type ReactNode } from 'react';
import { type AlertVariant, variantStyles } from './alert-config';

export type { AlertVariant };

export interface AlertProps extends Omit<
  React.HTMLAttributes<HTMLDivElement>,
  'role' | 'children'
> {
  /**
   * Alert message content.
   * Changes to this prop trigger screen reader announcements.
   */
  message?: string;
  /**
   * Optional children for complex content.
   * Use message prop for simple text alerts.
   */
  children?: ReactNode;
  /**
   * Alert variant for visual styling.
   * Does NOT affect ARIA - all variants use role="alert"
   */
  variant?: AlertVariant;
  /**
   * Custom ID for the alert container.
   * Useful for SSR/hydration consistency.
   */
  id?: string;
  /**
   * Whether to show dismiss button.
   * Note: Manual dismiss only - NO auto-dismiss per WCAG 2.2.3
   */
  dismissible?: boolean;
  /**
   * Callback when alert is dismissed.
   * Should clear the message to hide the alert content.
   */
  onDismiss?: () => void;
}

const variantIcons: Record<AlertVariant, React.ReactNode> = {
  info: <Info className="size-5" />,
  success: <CircleCheck className="size-5" />,
  warning: <AlertTriangle className="size-5" />,
  error: <OctagonAlert className="size-5" />,
};

/**
 * Alert component following WAI-ARIA APG Alert Pattern
 *
 * IMPORTANT: The live region container (role="alert") is always present in the DOM.
 * Only the content inside changes dynamically - NOT the container itself.
 * This ensures screen readers properly announce alert messages.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/alert/
 */
export const Alert: React.FC<AlertProps> = ({
  message,
  children,
  variant = 'info',
  id: providedId,
  className,
  dismissible = false,
  onDismiss,
  ...restProps
}) => {
  const generatedId = useId();
  const alertId = providedId ?? `alert-${generatedId}`;

  const content = message || children;
  const hasContent = Boolean(content);

  return (
    <div
      className={cn(
        'apg-alert',
        hasContent && [
          'relative flex items-start gap-3 rounded-lg border px-4 py-3',
          'transition-colors duration-150',
          variantStyles[variant],
        ],
        !hasContent && 'contents',
        className
      )}
      {...restProps}
    >
      {/* Live region - contains only content for screen reader announcement */}
      <div
        id={alertId}
        role="alert"
        className={cn(hasContent && 'flex flex-1 items-start gap-3', !hasContent && 'contents')}
      >
        {hasContent && (
          <>
            <span className="apg-alert-icon mt-0.5 flex-shrink-0" aria-hidden="true">
              {variantIcons[variant]}
            </span>
            <span className="apg-alert-content flex-1">{content}</span>
          </>
        )}
      </div>
      {/* Dismiss button - outside live region to avoid SR announcing it as part of alert */}
      {hasContent && dismissible && (
        <button
          type="button"
          className={cn(
            'apg-alert-dismiss',
            '-m-2 min-h-11 min-w-11 flex-shrink-0 rounded p-2',
            'flex items-center justify-center',
            'hover:bg-black/10 dark:hover:bg-white/10',
            'focus:ring-2 focus:ring-current focus:ring-offset-2 focus:outline-none'
          )}
          onClick={onDismiss}
          aria-label="Dismiss alert"
        >
          <X className="size-5" aria-hidden="true" />
        </button>
      )}
    </div>
  );
};

export default Alert;

使い方

Example
import { useState } from 'react';
import { Alert } from './Alert';

function App() {
  const [message, setMessage] = useState('');

  return (
    <div>
      {/* IMPORTANT: Alert container is always in DOM */}
      <Alert
        message={message}
        variant="info"
        dismissible
        onDismiss={() => setMessage('')}
      />

      <button onClick={() => setMessage('Operation completed!')}>
        Show Alert
      </button>
    </div>
  );
}

API

プロパティ デフォルト 説明
message string - アラートメッセージの内容
children ReactNode - 複雑なコンテンツ(messageの代替)
variant 'info' | 'success' | 'warning' | 'error' 'info' 視覚スタイルのバリアント
dismissible boolean false 閉じるボタンを表示
onDismiss () => void - 閉じられた時のコールバック
id string auto-generated SSR用のカスタムID
className string - 追加のCSSクラス

テスト

テスト概要

Alert コンポーネントのテストは、正しいライブリージョンの動作と APG 準拠の検証に焦点を当てています。最も重要なテストは、コンテンツが変更されたときにアラートコンテナが DOM に残ることを確認することです。

テストカテゴリ

高優先度: APG コア準拠

テスト APG 要件
role="alert" の存在 アラートコンテナは alert ロールを持つ必要がある
コンテナが常に DOM に存在 ライブリージョンは動的に追加・削除してはならない
メッセージ変更時も同じコンテナ 更新時にコンテナ要素の同一性が保持される
アラート後もフォーカスは変わらない アラートはキーボードフォーカスを移動してはならない
アラートはフォーカス不可 アラートコンテナは tabindex を持ってはならない

中優先度: アクセシビリティ検証

テスト WCAG 要件
axe 違反なし(メッセージあり) WCAG 2.1 AA 準拠
axe 違反なし(空) WCAG 2.1 AA 準拠
axe 違反なし(閉じるボタンあり) WCAG 2.1 AA 準拠
閉じるボタンにアクセシブルな名前 ボタンは aria-label を持つ
閉じるボタンは type="button" フォーム送信を防ぐ

低優先度: Props と拡張性

テスト 機能
variant prop でスタイルを変更 ビジュアルのカスタマイズ
id prop でカスタム ID を設定 SSR サポート
className の継承 スタイルのカスタマイズ
複雑なコンテンツの children コンテンツの柔軟性
onDismiss コールバックが発火 イベント処理

スクリーンリーダーテスト

自動テストは DOM 構造を検証しますが、実際のアナウンス動作を検証するにはスクリーンリーダーによる手動テストが不可欠です:

スクリーンリーダー プラットフォーム
VoiceOver macOS / iOS
NVDA Windows
JAWS Windows
TalkBack Android

メッセージの変更が即座のアナウンスをトリガーすること、およびページ読み込み時に存在するコンテンツはアナウンスされないことを確認してください。

テストツール

テストの実行

# すべての Alert テストを実行
npm run test -- alert

# 特定のフレームワークのテストを実行
npm run test -- Alert.test.tsx    # React
npm run test -- Alert.test.vue    # Vue
npm run test -- Alert.test.svelte # Svelte
Alert.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Alert } from './Alert';

describe('Alert', () => {
  // High Priority: APG Core Compliance
  describe('APG: ARIA Attributes', () => {
    it('has role="alert"', () => {
      render(<Alert message="Test message" />);
      expect(screen.getByRole('alert')).toBeInTheDocument();
    });

    it('role=alert container exists in DOM even without message', () => {
      render(<Alert />);
      expect(screen.getByRole('alert')).toBeInTheDocument();
    });

    it('container remains the same element when message changes', () => {
      const { rerender } = render(<Alert message="First message" />);
      const alertElement = screen.getByRole('alert');
      const alertId = alertElement.id;

      rerender(<Alert message="Second message" />);
      expect(screen.getByRole('alert')).toHaveAttribute('id', alertId);
      expect(screen.getByRole('alert')).toHaveTextContent('Second message');
    });

    it('container remains when message is cleared', () => {
      const { rerender } = render(<Alert message="Test message" />);
      expect(screen.getByRole('alert')).toHaveTextContent('Test message');

      rerender(<Alert message="" />);
      expect(screen.getByRole('alert')).toBeInTheDocument();
      expect(screen.getByRole('alert')).not.toHaveTextContent('Test message');
    });
  });

  describe('APG: Focus Management', () => {
    it('does not move focus when alert is displayed', async () => {
      const user = userEvent.setup();
      render(
        <>
          <button>Other button</button>
          <Alert message="Test message" />
        </>
      );

      const button = screen.getByRole('button', { name: 'Other button' });
      await user.click(button);
      expect(button).toHaveFocus();

      // Focus should not move when alert is displayed
      expect(button).toHaveFocus();
    });

    it('alert itself does not receive focus (no tabindex)', () => {
      render(<Alert message="Test message" />);
      expect(screen.getByRole('alert')).not.toHaveAttribute('tabindex');
    });
  });

  describe('Dismiss Feature', () => {
    it('shows dismiss button when dismissible=true', () => {
      render(<Alert message="Test message" dismissible />);
      expect(screen.getByRole('button', { name: 'Dismiss alert' })).toBeInTheDocument();
    });

    it('does not show dismiss button when dismissible=false (default)', () => {
      render(<Alert message="Test message" />);
      expect(screen.queryByRole('button', { name: 'Dismiss alert' })).not.toBeInTheDocument();
    });

    it('calls onDismiss when dismiss button is clicked', async () => {
      const handleDismiss = vi.fn();
      const user = userEvent.setup();
      render(<Alert message="Test message" dismissible onDismiss={handleDismiss} />);

      await user.click(screen.getByRole('button', { name: 'Dismiss alert' }));
      expect(handleDismiss).toHaveBeenCalledTimes(1);
    });

    it('dismiss button has type=button', () => {
      render(<Alert message="Test message" dismissible />);
      expect(screen.getByRole('button', { name: 'Dismiss alert' })).toHaveAttribute(
        'type',
        'button'
      );
    });

    it('dismiss button has aria-label', () => {
      render(<Alert message="Test message" dismissible />);
      expect(screen.getByRole('button', { name: 'Dismiss alert' })).toHaveAccessibleName(
        'Dismiss alert'
      );
    });
  });

  // Medium Priority: Accessibility Validation
  describe('Accessibility', () => {
    it('has no WCAG 2.1 AA violations (with message)', async () => {
      const { container } = render(<Alert message="Test message" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no WCAG 2.1 AA violations (without message)', async () => {
      const { container } = render(<Alert />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no WCAG 2.1 AA violations (dismissible)', async () => {
      const { container } = render(
        <Alert message="Test message" dismissible onDismiss={() => {}} />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  describe('Variant Styles', () => {
    it.each(['info', 'success', 'warning', 'error'] as const)(
      'applies appropriate style class for variant=%s',
      (variant) => {
        render(<Alert message="Test message" variant={variant} />);
        const alert = screen.getByRole('alert');
        // apg-alert class is on the parent wrapper, not on role="alert"
        const wrapper = alert.parentElement;
        expect(wrapper).toHaveClass('apg-alert');
      }
    );

    it('default variant is info', () => {
      render(<Alert message="Test message" />);
      const alert = screen.getByRole('alert');
      // info variant style is applied to the parent wrapper
      const wrapper = alert.parentElement;
      expect(wrapper).toHaveClass('bg-blue-50');
    });
  });

  // Low Priority: Props & Extensibility
  describe('Props', () => {
    it('can set custom ID with id prop', () => {
      render(<Alert message="Test message" id="custom-alert-id" />);
      expect(screen.getByRole('alert')).toHaveAttribute('id', 'custom-alert-id');
    });

    it('merges className correctly', () => {
      render(<Alert message="Test message" className="custom-class" />);
      const alert = screen.getByRole('alert');
      // className is applied to the parent wrapper
      const wrapper = alert.parentElement;
      expect(wrapper).toHaveClass('apg-alert');
      expect(wrapper).toHaveClass('custom-class');
    });

    it('can pass complex content via children', () => {
      render(
        <Alert>
          <strong>Important:</strong> This is a message
        </Alert>
      );
      expect(screen.getByRole('alert')).toHaveTextContent('Important: This is a message');
    });

    it('message takes priority when both message and children are provided', () => {
      render(
        <Alert message="Message prop">
          <span>Children content</span>
        </Alert>
      );
      expect(screen.getByRole('alert')).toHaveTextContent('Message prop');
      expect(screen.getByRole('alert')).not.toHaveTextContent('Children content');
    });
  });

  describe('HTML Attribute Inheritance', () => {
    it('can pass additional HTML attributes', () => {
      render(<Alert message="Test" data-testid="custom-alert" />);
      expect(screen.getByTestId('custom-alert')).toBeInTheDocument();
    });
  });
});

リソース