APG Patterns
English GitHub
English GitHub

Alert

ユーザーのタスクを中断することなく、ユーザーの注意を引く方法で簡潔で重要なメッセージを表示する要素。

🤖 AI Implementation Guide

デモ

Astro 実装は、アラートコンテンツを更新するための setMessage() メソッドを提供する Web Component を使用しています。ライブリージョンコンテナはページロード時から 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.astro
---
import InfoIcon from 'lucide-static/icons/info.svg';
import CircleCheckIcon from 'lucide-static/icons/circle-check.svg';
import AlertTriangleIcon from 'lucide-static/icons/triangle-alert.svg';
import OctagonAlertIcon from 'lucide-static/icons/octagon-alert.svg';
import XIcon from 'lucide-static/icons/x.svg';
import { type AlertVariant, variantStyles as sharedVariantStyles } from './alert-config';

export type { AlertVariant };

export interface Props {
  /**
   * Alert message content.
   * Changes to this prop trigger screen reader announcements.
   */
  message?: string;
  /**
   * 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;
  /**
   * Additional class name for the alert container
   */
  class?: string;
}

const {
  message = '',
  variant = 'info',
  id,
  dismissible = false,
  class: className = '',
} = Astro.props;

const alertId = id ?? `alert-${crypto.randomUUID().slice(0, 8)}`;

const variantIcons = {
  info: InfoIcon,
  success: CircleCheckIcon,
  warning: AlertTriangleIcon,
  error: OctagonAlertIcon,
};

const IconComponent = variantIcons[variant];
const hasContent = Boolean(message);
---

<apg-alert
  class:list={[
    'apg-alert',
    hasContent && [
      'relative flex items-start gap-3 rounded-lg border px-4 py-3',
      'transition-colors duration-150',
      sharedVariantStyles[variant],
    ],
    !hasContent && 'contents',
    className,
  ]}
  data-alert-id={alertId}
  data-dismissible={dismissible ? 'true' : undefined}
  data-variant={variant}
>
  {/* Live region - contains only content for screen reader announcement */}
  <div
    id={alertId}
    role="alert"
    class:list={[hasContent && 'flex flex-1 items-start gap-3', !hasContent && 'contents']}
  >
    {
      hasContent && (
        <>
          <span class="apg-alert-icon mt-0.5 flex-shrink-0" aria-hidden="true">
            <IconComponent class="size-5" />
          </span>
          <span class="apg-alert-content flex-1">{message}</span>
        </>
      )
    }
  </div>
  {/* Dismiss button - outside live region to avoid SR announcing it as part of alert */}
  {
    hasContent && dismissible && (
      <button
        type="button"
        class:list={[
          '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',
        ]}
        aria-label="Dismiss alert"
      >
        <XIcon class="size-5" aria-hidden="true" />
      </button>
    )
  }
</apg-alert>

<script>
  import { variantStyles, variantIconSvgs, dismissIconSvg } from './alert-config';

  class ApgAlert extends HTMLElement {
    private alertId: string = '';

    connectedCallback() {
      this.alertId = this.dataset.alertId ?? '';
      const dismissBtn = this.querySelector('.apg-alert-dismiss');

      if (dismissBtn) {
        dismissBtn.addEventListener('click', this.handleDismiss);
      }
    }

    disconnectedCallback() {
      const dismissBtn = this.querySelector('.apg-alert-dismiss');
      if (dismissBtn) {
        dismissBtn.removeEventListener('click', this.handleDismiss);
      }
    }

    private handleDismiss = () => {
      // Clear content but keep the alert container (live region)
      const alertEl = this.querySelector(`#${this.alertId}`);
      if (alertEl) {
        alertEl.replaceChildren();
        // Update live region classes
        alertEl.classList.remove('flex-1', 'flex', 'items-start', 'gap-3');
        alertEl.classList.add('contents');
      }
      // Update custom element display
      this.className = 'apg-alert contents';
      // Remove dismiss button
      const existingDismissBtn = this.querySelector('.apg-alert-dismiss');
      if (existingDismissBtn) {
        existingDismissBtn.remove();
      }

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

    /**
     * Method to update alert message programmatically.
     * The live region container remains in DOM - only content changes.
     */
    setMessage(message: string, variant?: string) {
      const alertEl = this.querySelector(`#${this.alertId}`);
      if (!alertEl) return;

      const currentVariant = (variant ??
        this.dataset.variant ??
        'info') as keyof typeof variantStyles;

      if (message) {
        // Update live region classes
        alertEl.classList.remove('contents');
        alertEl.classList.add('flex-1', 'flex', 'items-start', 'gap-3');
        // Update custom element styles
        this.classList.remove('contents');
        this.className = `apg-alert relative flex items-start gap-3 px-4 py-3 rounded-lg border transition-colors duration-150 ${variantStyles[currentVariant]}`;

        // Build DOM nodes safely (avoid innerHTML for user content)
        const iconSpan = document.createElement('span');
        iconSpan.className = 'apg-alert-icon flex-shrink-0 mt-0.5';
        iconSpan.setAttribute('aria-hidden', 'true');
        iconSpan.innerHTML = variantIconSvgs[currentVariant]; // Safe: hardcoded SVG

        const contentSpan = document.createElement('span');
        contentSpan.className = 'apg-alert-content flex-1';
        contentSpan.textContent = message; // Safe: uses textContent

        // Update live region content (icon + message only)
        alertEl.replaceChildren(iconSpan, contentSpan);

        // Remove existing dismiss button if any
        const existingDismissBtn = this.querySelector('.apg-alert-dismiss');
        if (existingDismissBtn) {
          existingDismissBtn.remove();
        }

        // Add dismiss button outside live region
        if (this.dataset.dismissible === 'true') {
          const dismissBtn = document.createElement('button');
          dismissBtn.type = 'button';
          dismissBtn.className =
            'apg-alert-dismiss flex-shrink-0 min-w-11 min-h-11 p-2 -m-2 rounded flex items-center justify-center hover:bg-black/10 dark:hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-current';
          dismissBtn.setAttribute('aria-label', 'Dismiss alert');
          dismissBtn.innerHTML = dismissIconSvg; // Safe: hardcoded SVG
          dismissBtn.addEventListener('click', this.handleDismiss);
          this.appendChild(dismissBtn);
        }
      } else {
        alertEl.replaceChildren();
        // Update live region classes
        alertEl.classList.remove('flex-1', 'flex', 'items-start', 'gap-3');
        alertEl.classList.add('contents');
        // Update custom element display
        this.className = 'apg-alert contents';
        // Remove dismiss button
        const existingDismissBtn = this.querySelector('.apg-alert-dismiss');
        if (existingDismissBtn) {
          existingDismissBtn.remove();
        }
      }
    }
  }

  customElements.define('apg-alert', ApgAlert);
</script>

使い方

使用例
---
import Alert from './Alert.astro';
---

<!-- 重要: Alert コンテナは常に DOM に存在 -->
<Alert
  id="my-alert"
  variant="info"
  dismissible
/>

<button onclick="document.querySelector('apg-alert').setMessage('こんにちは!')">
  アラートを表示
</button>

<script>
  // 閉じるイベントをリッスン
  document.querySelector('apg-alert')?.addEventListener('dismiss', () => {
    console.log('Alert dismissed');
  });
</script>

API

プロパティ

プロパティ デフォルト 説明
message string '' 初期アラートメッセージ
variant 'info' | 'success' | 'warning' | 'error' 'info' 表示スタイルのバリアント
dismissible boolean false 閉じるボタンを表示
id string 自動生成 カスタム ID
class string - 追加の CSS クラス

メソッド

メソッド 説明
setMessage(message, variant?) アラートメッセージをプログラムで更新。コンテナは DOM に残ります。

イベント

イベント 説明
dismiss 閉じるボタンがクリックされたときに発火

テスト

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

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

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

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

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

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

テスト 説明
すべてのフレームワークにアラートがある React、Vue、Svelte、Astroすべてがアラート要素をレンダリング
同じトリガーボタン すべてのフレームワークで一貫したトリガーボタン
クリックでアラートを表示 すべてのフレームワークでボタンクリック時にアラートを表示

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

自動テストは 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) を参照してください。

リソース