APG Patterns
English
English

Alert

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

デモ

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

デモのみ表示 →

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
alert アラートコンテナ ユーザーのタスクを中断することなく、ユーザーの注意を引く簡潔で重要なメッセージを表示する要素

暗黙の ARIA プロパティ

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

キーボードサポート

キー アクション
Enter 閉じるボタンをアクティブ化(存在する場合)
Space 閉じるボタンをアクティブ化(存在する場合)
  • スクリーンリーダーは、ライブリージョン内の DOM の変更を検知してアナウンスします。ライブリージョン自体が動的に追加される場合、一部のスクリーンリーダーではコンテンツが確実にアナウンスされない可能性があります。

フォーカス管理

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

実装ノート

<!-- Container always in DOM -->
<div role="alert">
  <!-- Content added dynamically -->
  <span>Your changes have been saved.</span>
</div>

Announcement Behavior:
- Page load content: NOT announced
- Dynamic changes: ANNOUNCED immediately
- aria-live="assertive": interrupts current speech

Alert vs Status:
┌─────────────┬──────────────────────┐
│ role="alert"│ role="status"        │
├─────────────┼──────────────────────┤
│ assertive   │ polite               │
│ interrupts  │ waits for pause      │
│ urgent info │ non-urgent updates   │
└─────────────┴──────────────────────┘

アラートコンポーネントの構造とアナウンス動作

Alert を使用する場合

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

Alert Dialog (role=“alertdialog”) を使用する場合

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

重要な注意事項

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

参考資料

ソースコード

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

プロパティデフォルト説明
messagestring-アラートメッセージの内容
childrenReactNode-複雑なコンテンツ(messageの代替)
variant'info' | 'success' | 'warning' | 'error''info'視覚スタイルのバリアント
dismissiblebooleanfalse閉じるボタンを表示
onDismiss() => void-閉じられた時のコールバック
idstringauto-generatedSSR用のカスタムID
classNamestring-追加の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 IDSSR サポート
className inheritanceスタイルのカスタマイズ
children for complex contentコンテンツの柔軟性
onDismiss callback firesイベント処理

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

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

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

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

スクリーンリーダープラットフォーム
VoiceOvermacOS / iOS
NVDAWindows
JAWSWindows
TalkBackAndroid

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

テストツール

テストの実行

# すべての 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();
    });
  });
});

リソース