Toggle Button
「押されている」または「押されていない」の2つの状態を持つボタン。
🤖 AI Implementation Guideデモ
アクセシビリティ
WAI-ARIA ロール
-
button- アクティブ化されたときにアクションをトリガーするウィジェットを示す
WAI-ARIA button ロール (opens in new tab)
WAI-ARIA ステート
aria-pressed
トグルボタンの現在の押下状態を示します。
| 値 | true | false (3状態ボタンでは "mixed" も使用可能) |
| 必須 | はい(トグルボタンの場合) |
| デフォルト | initialPressed プロパティ(デフォルト: false) |
| 変更トリガー | クリック、Enter、Space |
| リファレンス | aria-pressed (opens in new tab) |
キーボードサポート
| キー | アクション |
|---|---|
| Space | ボタンの状態を切り替える |
| Enter | ボタンの状態を切り替える |
ソースコード
ToggleButton.astro
---
/**
* APG Toggle Button Pattern - Astro Implementation
*
* A two-state button that can be either "pressed" or "not pressed".
* Uses Web Components for client-side interactivity.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/button/
*/
export interface Props {
/** Initial pressed state */
initialPressed?: boolean;
/** Whether the button is disabled */
disabled?: boolean;
/** Additional CSS class */
class?: string;
}
const { initialPressed = false, disabled = false, class: className = '' } = Astro.props;
const stateClass = initialPressed ? 'apg-toggle-button--pressed' : 'apg-toggle-button--not-pressed';
const indicatorClass = initialPressed
? 'apg-toggle-indicator--pressed'
: 'apg-toggle-indicator--not-pressed';
// Check if custom indicator slots are provided
const hasPressedIndicator = Astro.slots.has('pressed-indicator');
const hasUnpressedIndicator = Astro.slots.has('unpressed-indicator');
const hasCustomIndicators = hasPressedIndicator || hasUnpressedIndicator;
---
<apg-toggle-button class={className}>
<button
type="button"
class={`apg-toggle-button ${stateClass}`}
aria-pressed={initialPressed}
disabled={disabled}
>
<span class="apg-toggle-button-content">
<slot />
</span>
<span
class={`apg-toggle-indicator ${indicatorClass}`}
aria-hidden="true"
data-custom-indicators={hasCustomIndicators ? 'true' : undefined}
>
{
hasCustomIndicators ? (
<>
<span class="apg-indicator-pressed" hidden={!initialPressed}>
<slot name="pressed-indicator">●</slot>
</span>
<span class="apg-indicator-unpressed" hidden={initialPressed}>
<slot name="unpressed-indicator">○</slot>
</span>
</>
) : initialPressed ? (
'●'
) : (
'○'
)
}
</span>
</button>
</apg-toggle-button>
<script>
class ApgToggleButton extends HTMLElement {
private button: HTMLButtonElement | null = null;
private rafId: number | null = null;
connectedCallback() {
// Use requestAnimationFrame to ensure DOM is fully constructed
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.button = this.querySelector('button');
if (!this.button) {
console.warn('apg-toggle-button: button element not found');
return;
}
this.button.addEventListener('click', this.handleClick);
}
disconnectedCallback() {
// Cancel pending initialization
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
// Remove event listeners
this.button?.removeEventListener('click', this.handleClick);
this.button = null;
}
private handleClick = () => {
if (!this.button || this.button.disabled) return;
const currentPressed = this.button.getAttribute('aria-pressed') === 'true';
const newPressed = !currentPressed;
// Update aria-pressed
this.button.setAttribute('aria-pressed', String(newPressed));
// Update CSS classes
this.button.classList.toggle('apg-toggle-button--pressed', newPressed);
this.button.classList.toggle('apg-toggle-button--not-pressed', !newPressed);
// Update indicator
const indicator = this.button.querySelector('.apg-toggle-indicator');
if (indicator) {
const hasCustomIndicators = indicator.getAttribute('data-custom-indicators') === 'true';
if (hasCustomIndicators) {
// Toggle visibility of custom indicator slots
const pressedIndicator = indicator.querySelector('.apg-indicator-pressed');
const unpressedIndicator = indicator.querySelector('.apg-indicator-unpressed');
if (pressedIndicator instanceof HTMLElement) {
pressedIndicator.hidden = !newPressed;
}
if (unpressedIndicator instanceof HTMLElement) {
unpressedIndicator.hidden = newPressed;
}
} else {
// Use default text indicators
indicator.textContent = newPressed ? '●' : '○';
}
indicator.classList.toggle('apg-toggle-indicator--pressed', newPressed);
indicator.classList.toggle('apg-toggle-indicator--not-pressed', !newPressed);
}
// Dispatch custom event for external listeners
this.dispatchEvent(
new CustomEvent('toggle', {
detail: { pressed: newPressed },
bubbles: true,
})
);
};
}
// Register the custom element
if (!customElements.get('apg-toggle-button')) {
customElements.define('apg-toggle-button', ApgToggleButton);
}
</script> 使い方
使用例
---
import ToggleButton from './ToggleButton.astro';
import Icon from './Icon.astro';
---
<ToggleButton>
<Icon name="volume-off" slot="pressed-indicator" />
<Icon name="volume-2" slot="unpressed-indicator" />
Mute
</ToggleButton>
<script>
// Listen for toggle events
document.querySelector('apg-toggle-button')?.addEventListener('toggle', (e) => {
console.log('Muted:', e.detail.pressed);
});
</script> API
プロパティ
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
initialPressed | boolean | false | 初期の押下状態 |
disabled | boolean | false | ボタンが無効かどうか |
class | string | "" | 追加のCSSクラス |
スロット
| スロット | デフォルト | 説明 |
|---|---|---|
default | - | ボタンラベルのコンテンツ |
pressed-indicator | "●" | 押下状態のカスタムインジケーター |
unpressed-indicator | "○" | 非押下状態のカスタムインジケーター |
カスタムイベント
| イベント | 詳細 | 説明 |
|---|---|---|
toggle | { pressed: boolean } | トグル状態が変更されたときに発火 |
このコンポーネントは、クライアントサイドのインタラクティビティのためにWeb Component(<apg-toggle-button>)を使用しています。
テスト
テストは、キーボード操作、ARIA属性、アクセシビリティ要件の観点からAPG準拠を検証します。Toggle Buttonコンポーネントは2層テスト戦略を採用しています。
テスト戦略
ユニットテスト (Testing Library)
フレームワーク固有のTesting Libraryユーティリティを使用してコンポーネントのレンダリングとインタラクションを検証します。分離された環境で正しいコンポーネント動作を確認できます。
- HTML構造と要素の階層
- 初期属性値(aria-pressed、type)
- クリックイベント処理と状態切り替え
- CSSクラスの適用
E2Eテスト (Playwright)
4つのフレームワーク全体で実際のブラウザ環境でのコンポーネント動作を検証します。フルブラウザコンテキストが必要なインタラクションをカバーします。
- キーボード操作(Space、Enter)
- aria-pressed状態の切り替え
- 無効状態の動作
- フォーカス管理とTabナビゲーション
- クロスフレームワーク一貫性
テストカテゴリ
高優先度: APG キーボード操作(E2E)
| テスト | 説明 |
|---|---|
Space キーでトグル | Spaceキーを押すとボタンの状態が切り替わる |
Enter キーでトグル | Enterキーを押すとボタンの状態が切り替わる |
Tab ナビゲーション | Tabキーでボタン間のフォーカスを移動する |
無効時の Tab スキップ | 無効化されたボタンはTabの順序でスキップされる |
高優先度: APG ARIA 属性(E2E)
| テスト | 説明 |
|---|---|
role="button" | 暗黙的なbuttonロールを持つ(<button> 要素経由) |
aria-pressed 初期値 | 初期状態は aria-pressed="false" |
aria-pressed トグル | クリックで aria-pressed が true に変わる |
type="button" | 明示的なbutton typeがフォーム送信を防ぐ |
無効状態 | 無効化されたボタンはクリックで状態が変わらない |
中優先度: アクセシビリティ(E2E)
| テスト | 説明 |
|---|---|
axe 違反 | WCAG 2.1 AA違反がない(jest-axe経由) |
アクセシブル名 | ボタンがコンテンツからアクセシブルな名前を持つ |
低優先度: HTML属性の継承(Unit)
| テスト | 説明 |
|---|---|
className マージ | カスタムクラスがコンポーネントのクラスとマージされる |
data-* 属性 | カスタムdata属性が渡される |
テストツール
- 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での自動アクセシビリティテスト
詳細は testing-strategy.md (opens in new tab) を参照してください。
リソース
- WAI-ARIA APG: Button パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist