APG Patterns
English
English

Alert

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

デモ

Astro実装では、アラートコンテンツの更新に setMessage() メソッドを提供するWeb Componentを使用します。ライブリージョンコンテナはページ読み込み時から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.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>

使い方

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

<!-- IMPORTANT: Alert container is always in DOM -->
<Alert
  id="my-alert"
  variant="info"
  dismissible
/>

<button onclick="document.querySelector('apg-alert').setMessage('Hello!')">
  Show Alert
</button>

<script>
  // Listen for dismiss events
  document.querySelector('apg-alert')?.addEventListener('dismiss', () => {
    console.log('Alert dismissed');
  });
</script>

API

プロパティデフォルト説明
messagestring''初期アラートメッセージ
variant'info' | 'success' | 'warning' | 'error''info'表示スタイルのバリアント
dismissiblebooleanfalse閉じるボタンを表示
idstringauto-generatedカスタム ID
classstring-追加の CSS クラス
このコンポーネントは、クライアントサイドのインタラクティビティのためにWeb Component(<apg-alert>)を使用しています。setMessage(message, variant?) メソッドでアラートメッセージをプログラムで更新できます。

Custom Events

イベントDetail説明
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" 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

詳細はテスト戦略ガイドを参照してください。

リソース