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
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
message | string | '' | 初期アラートメッセージ |
variant | 'info' | 'success' | 'warning' | 'error' | 'info' | 表示スタイルのバリアント |
dismissible | boolean | false | 閉じるボタンを表示 |
id | string | auto-generated | カスタム ID |
class | string | - | 追加の 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 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 |
メッセージの変更が即座のアナウンスをトリガーすること、およびページ読み込み時に存在するコンテンツはアナウンスされないことを確認してください。
テストツール
- 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