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.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { untrack } from 'svelte';
// properties
interface ToggleButtonProps {
children?: string | Snippet<[]>;
initialPressed?: boolean;
disabled?: boolean;
onToggle?: (pressed: boolean) => void;
/** Custom indicator for pressed state (default: "●") */
pressedIndicator?: string | Snippet<[]>;
/** Custom indicator for unpressed state (default: "○") */
unpressedIndicator?: string | Snippet<[]>;
[key: string]: unknown;
}
let {
children,
initialPressed = false,
disabled = false,
onToggle = (_) => {},
pressedIndicator = '●',
unpressedIndicator = '○',
...restProps
}: ToggleButtonProps = $props();
// state - use untrack to explicitly indicate we only want the initial value
let pressed = $state(untrack(() => initialPressed));
let currentIndicator = $derived(pressed ? pressedIndicator : unpressedIndicator);
// Event handlers
function handleClick() {
pressed = !pressed;
onToggle(pressed);
}
</script>
<button
type="button"
aria-pressed={pressed}
class="apg-toggle-button"
{disabled}
onclick={handleClick}
{...restProps}
>
<span class="apg-toggle-button-content">
{#if typeof children === 'string'}
{children}
{:else}
{@render children?.()}
{/if}
</span>
<span class="apg-toggle-indicator" aria-hidden="true">
{#if typeof currentIndicator === 'string'}
{currentIndicator}
{:else if currentIndicator}
{@render currentIndicator()}
{/if}
</span>
</button> 使い方
使用例
<script>
import ToggleButton from './ToggleButton.svelte';
function handleToggle(pressed) {
console.log('Muted:', pressed);
}
</script>
<ToggleButton
initialPressed={false}
onToggle={handleToggle}
pressedIndicator="🔇"
unpressedIndicator="🔊"
>
Mute
</ToggleButton> API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
initialPressed | boolean | false | 初期の押下状態 |
onToggle | (pressed: boolean) => void | - | 状態変更時のコールバック |
pressedIndicator | Snippet | string | "●" | 押下状態のカスタムインジケーター |
unpressedIndicator | Snippet | string | "○" | 非押下状態のカスタムインジケーター |
children | Snippet | string | - | ボタンのラベル(スロットコンテンツ) |
テスト
テストは、キーボード操作、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) を参照してください。
ToggleButton.test.svelte.ts
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import ToggleButton from './ToggleButton.svelte';
describe('ToggleButton (Svelte)', () => {
// 🔴 High Priority: APG 準拠の核心
describe('APG: キーボード操作', () => {
it('Space キーでトグルする', async () => {
const user = userEvent.setup();
render(ToggleButton, {
props: { children: 'Mute' },
});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
button.focus();
await user.keyboard(' ');
expect(button).toHaveAttribute('aria-pressed', 'true');
});
it('Enter キーでトグルする', async () => {
const user = userEvent.setup();
render(ToggleButton, {
props: { children: 'Mute' },
});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
button.focus();
await user.keyboard('{Enter}');
expect(button).toHaveAttribute('aria-pressed', 'true');
});
it('disabled 時は Tab キースキップ', async () => {
const user = userEvent.setup();
const container = document.createElement('div');
document.body.appendChild(container);
// Render three buttons manually to test tab order
const { unmount: unmount1 } = render(ToggleButton, {
target: container,
props: { children: 'Button 1' },
});
const { unmount: unmount2 } = render(ToggleButton, {
target: container,
props: { children: 'Button 2', disabled: true },
});
const { unmount: unmount3 } = render(ToggleButton, {
target: container,
props: { children: 'Button 3' },
});
await user.tab();
expect(screen.getByRole('button', { name: 'Button 1' })).toHaveFocus();
await user.tab();
expect(screen.getByRole('button', { name: 'Button 3' })).toHaveFocus();
unmount1();
unmount2();
unmount3();
document.body.removeChild(container);
});
});
describe('APG: ARIA 属性', () => {
it('role="button" を持つ(暗黙的)', () => {
render(ToggleButton, {
props: { children: 'Mute' },
});
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('初期状態で aria-pressed="false"', () => {
render(ToggleButton, {
props: { children: 'Mute' },
});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
});
it('クリック後に aria-pressed="true" に変わる', async () => {
const user = userEvent.setup();
render(ToggleButton, {
props: { children: 'Mute' },
});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
await user.click(button);
expect(button).toHaveAttribute('aria-pressed', 'true');
});
it('type="button" が設定されている', () => {
render(ToggleButton, {
props: { children: 'Mute' },
});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('type', 'button');
});
it('disabled 状態で aria-pressed 変更不可', async () => {
const user = userEvent.setup();
render(ToggleButton, {
props: { children: 'Mute', disabled: true },
});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
await user.click(button);
expect(button).toHaveAttribute('aria-pressed', 'false');
});
});
// 🟡 Medium Priority: アクセシビリティ検証
describe('アクセシビリティ', () => {
it('axe による WCAG 2.1 AA 違反がない', async () => {
const { container } = render(ToggleButton, {
props: { children: 'Mute' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('アクセシブルネームが設定されている', () => {
render(ToggleButton, {
props: { children: 'Mute Audio' },
});
expect(screen.getByRole('button', { name: /Mute Audio/i })).toBeInTheDocument();
});
});
describe('Props', () => {
it('initialPressed=true で押下状態でレンダリングされる', () => {
render(ToggleButton, {
props: { children: 'Mute', initialPressed: true },
});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'true');
});
it('onToggle が状態変化時に呼び出される', async () => {
const handleToggle = vi.fn();
const user = userEvent.setup();
render(ToggleButton, {
props: { children: 'Mute', onToggle: handleToggle },
});
await user.click(screen.getByRole('button'));
expect(handleToggle).toHaveBeenCalledWith(true);
await user.click(screen.getByRole('button'));
expect(handleToggle).toHaveBeenCalledWith(false);
});
});
// 🟢 Low Priority: 拡張性
describe('HTML 属性継承', () => {
it('デフォルトで apg-toggle-button クラスが設定される', () => {
render(ToggleButton, {
props: { children: 'Mute' },
});
const button = screen.getByRole('button');
expect(button).toHaveClass('apg-toggle-button');
});
it('data-* 属性が継承される', () => {
render(ToggleButton, {
props: { children: 'Mute', 'data-testid': 'custom-toggle' },
});
expect(screen.getByTestId('custom-toggle')).toBeInTheDocument();
});
});
}); リソース
- WAI-ARIA APG: Button パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist