APG Patterns
English
English

Alert

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

デモ

下のボタンをクリックして、異なるバリアントのアラートを表示します。ライブリージョンコンテナはページ読み込み時から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 プロパティを暗黙的に設定します。これらを手動で追加する必要はありません:

table.property 暗黙的な値 説明
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クラス

テスト

テストは、ライブリージョンの動作、ARIA属性、アクセシビリティ要件全体にわたってAPG準拠を検証します。Alertコンポーネントは2層のテスト戦略を採用しています。

テスト戦略

ユニットテスト(Testing Library)

フレームワーク固有のテストライブラリを使用してコンポーネントのレンダリング出力を検証します。これらのテストは正しいHTML構造とARIA属性を確認します。

  • ARIA 属性 (role="alert")
  • ライブリージョンコンテナの DOM 内での永続性
  • 閉じるボタンのアクセシビリティ
  • jest-axe によるアクセシビリティ検証

E2E テスト (Playwright)

すべてのフレームワークで実際のブラウザ環境でコンポーネントの動作を検証します。これらのテストはインタラクションとフレームワーク間の一貫性をカバーします。

  • ライブブラウザでの ARIA 構造
  • フォーカス管理(アラートはフォーカスを奪わない)
  • 閉じるボタンのキーボード操作
  • Tab ナビゲーションの動作
  • axe-core アクセシビリティスキャン
  • フレームワーク間の一貫性チェック

テストカテゴリ

高優先度 : APG コア準拠(Unit + E2E)

テスト APG 要件
role="alert" exists アラートコンテナは alert ロールを持つ必要がある
Container always in DOM ライブリージョンは動的に追加・削除してはならない
Same container on message change 更新時にコンテナ要素の同一性が保持される
Focus unchanged after alert アラートはキーボードフォーカスを移動してはならない
Alert not focusable アラートコンテナは tabindex を持ってはならない

中優先度 : アクセシビリティ検証(Unit + E2E)

テスト WCAG 要件
No axe violations (with message) WCAG 2.1 AA 準拠
No axe violations (empty) WCAG 2.1 AA 準拠
No axe violations (dismissible) WCAG 2.1 AA 準拠
Dismiss button accessible name ボタンは aria-label を持つ
Dismiss button type="button" フォーム送信を防ぐ

低優先度 : Props と拡張性(Unit)

テスト 機能
variant prop changes styling ビジュアルのカスタマイズ
id prop sets custom ID SSR サポート
className inheritance スタイルのカスタマイズ
children for complex content コンテンツの柔軟性
onDismiss callback fires イベント処理

低優先度 : フレームワーク間の一貫性(E2E)

テスト 機能
All frameworks have alert React、Vue、Svelte、Astro すべてがアラート要素をレンダリング
Same trigger buttons すべてのフレームワークで一貫したトリガーボタン
Show alert on click すべてのフレームワークでボタンクリック時にアラートを表示

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

自動テストは 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

詳細なドキュメントについては、 testing-strategy.md (opens in new tab) を参照してください。

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

リソース