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 ロール
-
alert- ユーザーのタスクを中断することなく、ユーザーの注意を引く簡潔で重要なメッセージを表示する要素
WAI-ARIA alert role (opens in new tab)
暗黙的な 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")。 - 初期コンテンツはアナウンスされない - ページ読み込み時に存在するアラートは自動的にアナウンスされません。動的な変更のみがアナウンスをトリガーします。
参考資料
ソースコード
---
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 |
メッセージの変更が即座のアナウンスをトリガーすること、およびページ読み込み時に存在するコンテンツはアナウンスされないことを確認してください。
テストツール
- Vitest (opens in new tab) - ユニットテストランナー
- Testing Library (opens in new tab) - フレームワーク別テストユーティリティ(React、Vue、Svelte)
- Playwright (opens in new tab) - E2Eテスト用ブラウザ自動化
- axe-core/playwright (opens in new tab) - E2Eでの自動アクセシビリティテスト
テストの実行
# すべての 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) を参照してください。
リソース
- WAI-ARIA APG: Alert パターン (opens in new tab)
- WAI-ARIA APG: Alert Dialog パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist