Switch
チェック済み/未チェックではなく、オン/オフの値を表すチェックボックスの一種。
🤖 AI 実装ガイドデモ
アクセシビリティ
WAI-ARIA ロール
-
switch- ユーザーがオン/オフの2つの値のいずれかを選択できる入力ウィジェット
WAI-ARIA switch role (opens in new tab)
WAI-ARIA ステート
aria-checked
スイッチの現在のチェック状態を示します。
| 値 | true | false |
| 必須 | はい(switchロールの場合) |
| デフォルト | initialChecked プロパティ(デフォルト: false) |
| 変更トリガー | クリック、Enter、Space |
| リファレンス | aria-checked (opens in new tab) |
aria-disabled
スイッチが認識可能だが無効化されていることを示します。
| 値 | true | undefined |
| 必須 | いいえ(無効化時のみ) |
| リファレンス | aria-disabled (opens in new tab) |
キーボードサポート
| キー | アクション |
|---|---|
| Space | スイッチの状態を切り替え(オン/オフ) |
| Enter | スイッチの状態を切り替え(オン/オフ) |
アクセシブルな名前
スイッチにはアクセシブルな名前が必要です。以下の方法で提供できます:
- 表示されるラベル(推奨) - スイッチの子要素のコンテンツがアクセシブルな名前を提供
-
aria-label- スイッチに非表示のラベルを提供 -
aria-labelledby- 外部要素をラベルとして参照
ビジュアルデザイン
この実装は、色のみに依存せず状態を示すことでWCAG 1.4.1(色の使用)に準拠しています:
- つまみの位置 - 左 = オフ、右 = オン
- チェックマークアイコン - スイッチがオンの時のみ表示
- 強制カラーモード - Windowsハイコントラストモードでのアクセシビリティのためシステムカラーを使用
ソースコード
Switch.tsx
import { cn } from '@/lib/utils';
import { useCallback, useState } from 'react';
export interface SwitchProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
'onClick' | 'type' | 'role' | 'aria-checked'
> {
/** Initial checked state */
initialChecked?: boolean;
/** Switch label text */
children?: React.ReactNode;
/** Callback fired when checked state changes */
onCheckedChange?: (checked: boolean) => void;
}
export const Switch: React.FC<SwitchProps> = ({
initialChecked = false,
children,
onCheckedChange,
className = '',
disabled,
...buttonProps
}) => {
const [checked, setChecked] = useState(initialChecked);
const handleClick = useCallback(() => {
if (disabled) return;
const newChecked = !checked;
setChecked(newChecked);
onCheckedChange?.(newChecked);
}, [checked, onCheckedChange, disabled]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (disabled) return;
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
const newChecked = !checked;
setChecked(newChecked);
onCheckedChange?.(newChecked);
}
},
[checked, onCheckedChange, disabled]
);
return (
<button
type="button"
role="switch"
{...buttonProps}
className={cn('apg-switch', className)}
aria-checked={checked}
aria-disabled={disabled || undefined}
disabled={disabled}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
<span className="apg-switch-track">
<span className="apg-switch-icon" aria-hidden="true">
<svg viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
<path
d="M10.28 2.28a.75.75 0 00-1.06-1.06L4.5 5.94 2.78 4.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.06 0l5.25-5.25z"
fill="currentColor"
/>
</svg>
</span>
<span className="apg-switch-thumb" />
</span>
{children && <span className="apg-switch-label">{children}</span>}
</button>
);
};
export default Switch; 使い方
Example
import { Switch } from './Switch';
function App() {
return (
<Switch
initialChecked={false}
onCheckedChange={(checked) => console.log('Checked:', checked)}
>
Enable notifications
</Switch>
);
} API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
initialChecked | boolean | false | 初期チェック状態 |
onCheckedChange | (checked: boolean) => void | - | 状態変更時のコールバック |
disabled | boolean | false | スイッチが無効かどうか |
children | ReactNode | - | スイッチのラベル |
その他のプロパティはすべて、基になる<button>要素に渡されます。
テスト
APG準拠に関するテストには、キーボード操作、ARIA属性、アクセシビリティ検証が含まれます。
Switch.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 { Switch } from './Switch';
describe('Switch', () => {
// 🔴 High Priority: APG Core Compliance
describe('APG: ARIA Attributes', () => {
it('has role="switch"', () => {
render(<Switch>Wi-Fi</Switch>);
expect(screen.getByRole('switch')).toBeInTheDocument();
});
it('has aria-checked="false" in initial state', () => {
render(<Switch>Wi-Fi</Switch>);
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-checked', 'false');
});
it('changes to aria-checked="true" after click', async () => {
const user = userEvent.setup();
render(<Switch>Wi-Fi</Switch>);
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-checked', 'false');
await user.click(switchEl);
expect(switchEl).toHaveAttribute('aria-checked', 'true');
});
it('has type="button"', () => {
render(<Switch>Wi-Fi</Switch>);
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('type', 'button');
});
it('has aria-disabled when disabled', () => {
render(<Switch disabled>Wi-Fi</Switch>);
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-disabled', 'true');
});
it('cannot change aria-checked when disabled', async () => {
const user = userEvent.setup();
render(<Switch disabled>Wi-Fi</Switch>);
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-checked', 'false');
await user.click(switchEl);
expect(switchEl).toHaveAttribute('aria-checked', 'false');
});
});
describe('APG: Keyboard Interaction', () => {
it('toggles with Space key', async () => {
const user = userEvent.setup();
render(<Switch>Wi-Fi</Switch>);
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-checked', 'false');
switchEl.focus();
await user.keyboard(' ');
expect(switchEl).toHaveAttribute('aria-checked', 'true');
});
it('toggles with Enter key', async () => {
const user = userEvent.setup();
render(<Switch>Wi-Fi</Switch>);
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-checked', 'false');
switchEl.focus();
await user.keyboard('{Enter}');
expect(switchEl).toHaveAttribute('aria-checked', 'true');
});
it('can move focus with Tab key', async () => {
const user = userEvent.setup();
render(
<>
<Switch>Switch 1</Switch>
<Switch>Switch 2</Switch>
</>
);
await user.tab();
expect(screen.getByRole('switch', { name: 'Switch 1' })).toHaveFocus();
await user.tab();
expect(screen.getByRole('switch', { name: 'Switch 2' })).toHaveFocus();
});
it('skips with Tab key when disabled', async () => {
const user = userEvent.setup();
render(
<>
<Switch>Switch 1</Switch>
<Switch disabled>Switch 2</Switch>
<Switch>Switch 3</Switch>
</>
);
await user.tab();
expect(screen.getByRole('switch', { name: 'Switch 1' })).toHaveFocus();
await user.tab();
expect(screen.getByRole('switch', { name: 'Switch 3' })).toHaveFocus();
});
it('keyboard operation disabled when disabled', async () => {
const user = userEvent.setup();
render(<Switch disabled>Wi-Fi</Switch>);
const switchEl = screen.getByRole('switch');
switchEl.focus();
await user.keyboard(' ');
expect(switchEl).toHaveAttribute('aria-checked', 'false');
});
});
// 🟡 Medium Priority: Accessibility Validation
describe('Accessibility', () => {
it('has no WCAG 2.1 AA violations', async () => {
const { container } = render(<Switch>Wi-Fi</Switch>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has accessible name via label (children)', () => {
render(<Switch>Wi-Fi</Switch>);
expect(screen.getByRole('switch', { name: 'Wi-Fi' })).toBeInTheDocument();
});
it('can set accessible name via aria-label', () => {
render(<Switch aria-label="Enable notifications" />);
expect(screen.getByRole('switch', { name: 'Enable notifications' })).toBeInTheDocument();
});
it('can reference external label via aria-labelledby', () => {
render(
<>
<span id="switch-label">Bluetooth</span>
<Switch aria-labelledby="switch-label" />
</>
);
expect(screen.getByRole('switch', { name: 'Bluetooth' })).toBeInTheDocument();
});
});
describe('Props', () => {
it('renders in ON state with initialChecked=true', () => {
render(<Switch initialChecked>Wi-Fi</Switch>);
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-checked', 'true');
});
it('calls onCheckedChange when state changes', async () => {
const handleCheckedChange = vi.fn();
const user = userEvent.setup();
render(<Switch onCheckedChange={handleCheckedChange}>Wi-Fi</Switch>);
await user.click(screen.getByRole('switch'));
expect(handleCheckedChange).toHaveBeenCalledWith(true);
await user.click(screen.getByRole('switch'));
expect(handleCheckedChange).toHaveBeenCalledWith(false);
});
});
// 🟢 Low Priority: Extensibility
describe('HTML Attribute Inheritance', () => {
it('merges className correctly', () => {
render(<Switch className="custom-class">Wi-Fi</Switch>);
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveClass('custom-class');
expect(switchEl).toHaveClass('apg-switch');
});
it('inherits data-* attributes', () => {
render(<Switch data-testid="custom-switch">Wi-Fi</Switch>);
expect(screen.getByTestId('custom-switch')).toBeInTheDocument();
});
});
}); リソース
- WAI-ARIA APG: Switch パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA仕様、キーボードサポート、テストチェックリスト