Toggle Button
「押されている」または「押されていない」の2つの状態を持つボタン。
🤖 AI 実装ガイドデモ
アクセシビリティ
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.tsx
import { cn } from '@/lib/utils';
import { useCallback, useState } from 'react';
export interface ToggleButtonProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
'onClick' | 'type' | 'aria-pressed' | 'onToggle'
> {
/** Initial pressed state */
initialPressed?: boolean;
/** Button label text */
children: React.ReactNode;
/** Callback fired when toggle state changes */
onPressedChange?: (pressed: boolean) => void;
/** Custom indicator for pressed state (default: "●") */
pressedIndicator?: React.ReactNode;
/** Custom indicator for unpressed state (default: "○") */
unpressedIndicator?: React.ReactNode;
}
export const ToggleButton: React.FC<ToggleButtonProps> = ({
initialPressed = false,
children,
onPressedChange,
pressedIndicator = '●',
unpressedIndicator = '○',
className = '',
...buttonProps
}) => {
const [pressed, setPressed] = useState(initialPressed);
const handleClick = useCallback(() => {
setPressed(!pressed);
onPressedChange?.(!pressed);
}, [pressed, onPressedChange]);
return (
<button
type="button"
{...buttonProps}
className={cn('apg-toggle-button', className)}
aria-pressed={pressed}
onClick={handleClick}
>
<span className="apg-toggle-button-content">{children}</span>
<span className="apg-toggle-indicator" aria-hidden="true">
{pressed ? pressedIndicator : unpressedIndicator}
</span>
</button>
);
};
export default ToggleButton; 使い方
使用例
import { ToggleButton } from './ToggleButton';
import { Volume2, VolumeOff } from 'lucide-react';
function App() {
return (
<ToggleButton
initialPressed={false}
onPressedChange={(pressed) => console.log('Muted:', pressed)}
pressedIndicator={<VolumeOff size={20} />}
unpressedIndicator={<Volume2 size={20} />}
>
Mute
</ToggleButton>
);
} API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
initialPressed | boolean | false | 初期の押下状態 |
onPressedChange | (pressed: boolean) => void | - | 状態変更時のコールバック |
pressedIndicator | ReactNode | "●" | 押下状態のカスタムインジケーター |
unpressedIndicator | ReactNode | "○" | 非押下状態のカスタムインジケーター |
children | ReactNode | - | ボタンのラベル |
その他のプロパティは、内部の <button> 要素に渡されます。
テスト
テストは、キーボード操作、ARIA属性、アクセシビリティ要件の観点からAPG準拠を検証します。Toggle Buttonコンポーネントは2層テスト戦略を採用しています。
テスト戦略
ユニットテスト (Testing Library)
フレームワーク固有のTesting Libraryユーティリティを使用してコンポーネントのレンダリングとインタラクションを検証します。分離された環境で正しいコンポーネント動作を確認できます。
- HTML構造と要素の階層
- 初期属性値(aria-pressed、type)
- クリックイベント処理と状態切り替え
- CSSクラスの適用
E2Eテスト (Playwright)
4つのフレームワーク全体で実際のブラウザ環境でのコンポーネント動作を検証します。フルブラウザコンテキストが必要なインタラクションをカバーします。
- キーボード操作(Space、Enter)
- aria-pressed状態の切り替え
- 無効状態の動作
- フォーカス管理とTabナビゲーション
- クロスフレームワーク一貫性
テストカテゴリ
高優先度: APG キーボード操作
| テスト | 説明 |
|---|---|
Space キーでトグル | Spaceキーを押すとボタンの状態が切り替わる |
Enter キーでトグル | Enterキーを押すとボタンの状態が切り替わる |
Tab ナビゲーション | Tabキーでボタン間のフォーカスを移動する |
無効時の Tab スキップ | 無効化されたボタンはTabの順序でスキップされる |
高優先度: APG ARIA 属性
| テスト | 説明 |
|---|---|
role="button" | 暗黙的なbuttonロールを持つ(<button> 要素経由) |
aria-pressed 初期値 | 初期状態は aria-pressed="false" |
aria-pressed トグル | クリックで aria-pressed が true に変わる |
type="button" | 明示的なbutton typeがフォーム送信を防ぐ |
無効状態 | 無効化されたボタンはクリックで状態が変わらない |
中優先度: アクセシビリティ
| テスト | 説明 |
|---|---|
axe 違反 | WCAG 2.1 AA違反がない(jest-axe経由) |
アクセシブル名 | ボタンがコンテンツからアクセシブルな名前を持つ |
低優先度: HTML属性の継承
| テスト | 説明 |
|---|---|
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.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { ToggleButton } from './ToggleButton';
describe('ToggleButton', () => {
// 🔴 High Priority: APG Core Compliance
describe('APG: Keyboard Interaction', () => {
it('toggles with Space key', async () => {
const user = userEvent.setup();
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
button.focus();
await user.keyboard(' ');
expect(button).toHaveAttribute('aria-pressed', 'true');
});
it('toggles with Enter key', async () => {
const user = userEvent.setup();
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
button.focus();
await user.keyboard('{Enter}');
expect(button).toHaveAttribute('aria-pressed', 'true');
});
it('can move focus with Tab key', async () => {
const user = userEvent.setup();
render(
<>
<ToggleButton>Button 1</ToggleButton>
<ToggleButton>Button 2</ToggleButton>
</>
);
await user.tab();
expect(screen.getByRole('button', { name: 'Button 1' })).toHaveFocus();
await user.tab();
expect(screen.getByRole('button', { name: 'Button 2' })).toHaveFocus();
});
it('skips with Tab key when disabled', async () => {
const user = userEvent.setup();
render(
<>
<ToggleButton>Button 1</ToggleButton>
<ToggleButton disabled>Button 2</ToggleButton>
<ToggleButton>Button 3</ToggleButton>
</>
);
await user.tab();
expect(screen.getByRole('button', { name: 'Button 1' })).toHaveFocus();
await user.tab();
expect(screen.getByRole('button', { name: 'Button 3' })).toHaveFocus();
});
});
describe('APG: ARIA Attributes', () => {
it('has implicit role="button"', () => {
render(<ToggleButton>Mute</ToggleButton>);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('has aria-pressed="false" in initial state', () => {
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
});
it('changes to aria-pressed="true" after click', async () => {
const user = userEvent.setup();
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
await user.click(button);
expect(button).toHaveAttribute('aria-pressed', 'true');
});
it('has type="button"', () => {
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('type', 'button');
});
it('cannot change aria-pressed when disabled', async () => {
const user = userEvent.setup();
render(<ToggleButton disabled>Mute</ToggleButton>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
await user.click(button);
expect(button).toHaveAttribute('aria-pressed', 'false');
});
});
// 🟡 Medium Priority: Accessibility Validation
describe('Accessibility', () => {
it('has no WCAG 2.1 AA violations', async () => {
const { container } = render(<ToggleButton>Mute</ToggleButton>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has accessible name', () => {
render(<ToggleButton>Mute Audio</ToggleButton>);
expect(screen.getByRole('button', { name: /Mute Audio/i })).toBeInTheDocument();
});
});
describe('Props', () => {
it('renders in pressed state with initialPressed=true', () => {
render(<ToggleButton initialPressed>Mute</ToggleButton>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'true');
});
it('calls onPressedChange when state changes', async () => {
const handlePressedChange = vi.fn();
const user = userEvent.setup();
render(<ToggleButton onPressedChange={handlePressedChange}>Mute</ToggleButton>);
await user.click(screen.getByRole('button'));
expect(handlePressedChange).toHaveBeenCalledWith(true);
await user.click(screen.getByRole('button'));
expect(handlePressedChange).toHaveBeenCalledWith(false);
});
});
describe('Custom Indicators', () => {
it('displays default ●/○ indicator', () => {
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole('button');
const indicator = button.querySelector('.apg-toggle-indicator');
expect(indicator).toHaveTextContent('○');
});
it('can set custom indicator with pressedIndicator', () => {
render(
<ToggleButton initialPressed pressedIndicator="🔇">
Mute
</ToggleButton>
);
const button = screen.getByRole('button');
const indicator = button.querySelector('.apg-toggle-indicator');
expect(indicator).toHaveTextContent('🔇');
});
it('can set custom indicator with unpressedIndicator', () => {
render(<ToggleButton unpressedIndicator="🔊">Mute</ToggleButton>);
const button = screen.getByRole('button');
const indicator = button.querySelector('.apg-toggle-indicator');
expect(indicator).toHaveTextContent('🔊');
});
it('switches custom indicator on toggle', async () => {
const user = userEvent.setup();
render(
<ToggleButton pressedIndicator="🔇" unpressedIndicator="🔊">
Mute
</ToggleButton>
);
const button = screen.getByRole('button');
const indicator = button.querySelector('.apg-toggle-indicator');
expect(indicator).toHaveTextContent('🔊');
await user.click(button);
expect(indicator).toHaveTextContent('🔇');
await user.click(button);
expect(indicator).toHaveTextContent('🔊');
});
it('can pass ReactNode as custom indicator', () => {
render(
<ToggleButton initialPressed pressedIndicator={<span data-testid="custom-icon">X</span>}>
Mute
</ToggleButton>
);
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
});
it('maintains aria-hidden with custom indicator', () => {
render(
<ToggleButton pressedIndicator="🔇" unpressedIndicator="🔊">
Mute
</ToggleButton>
);
const button = screen.getByRole('button');
const indicator = button.querySelector('.apg-toggle-indicator');
expect(indicator).toHaveAttribute('aria-hidden', 'true');
});
it('has no axe violations with custom indicator', async () => {
const { container } = render(
<ToggleButton pressedIndicator="🔇" unpressedIndicator="🔊">
Mute
</ToggleButton>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟢 Low Priority: Extensibility
describe('HTML Attribute Inheritance', () => {
it('merges className correctly', () => {
render(<ToggleButton className="custom-class">Mute</ToggleButton>);
const button = screen.getByRole('button');
expect(button).toHaveClass('custom-class');
expect(button).toHaveClass('apg-toggle-button');
});
it('inherits data-* attributes', () => {
render(<ToggleButton data-testid="custom-toggle">Mute</ToggleButton>);
expect(screen.getByTestId('custom-toggle')).toBeInTheDocument();
});
it('works correctly with React node children', () => {
render(
<ToggleButton>
<span>Icon</span> Text
</ToggleButton>
);
const button = screen.getByRole('button');
expect(button).toHaveTextContent('Icon');
expect(button).toHaveTextContent('Text');
});
});
}); リソース
- WAI-ARIA APG: Button パターン (opens in new tab)
- AI 実装ガイド (llm.md) (opens in new tab) - ARIA 仕様、キーボード操作、テストチェックリスト