スイッチ
オンとオフの2つの状態を切り替えることができるコントロール。
デモ
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
switch | スイッチ要素 | ユーザーがオン/オフの2つの値のいずれかを選択できる入力ウィジェット |
WAI-ARIA ステート
aria-checked
- 対象要素
- switch 要素
- 値
- true | false
- 必須
- はい
- 変更トリガー
- クリック、Enter、Space
aria-disabled
- 対象要素
- switch 要素
- 値
- true | undefined
- 必須
- いいえ
- 変更トリガー
- 無効化時のみ
キーボードサポート
| キー | アクション |
|---|---|
| Space | スイッチの状態を切り替え(オン/オフ) |
| Enter | スイッチの状態を切り替え(オン/オフ) |
- スイッチには表示されるラベル(推奨)、aria-label、またはaria-labelledbyを通じてアクセシブルな名前が必要です。
- この実装は、色のみに依存せず状態を示すことでWCAG 1.4.1(色の使用)に準拠しています。
- つまみの位置: 左 = オフ、右 = オン。チェックマークアイコンはオンの時のみ表示。
- 強制カラーモードはWindowsハイコントラストモードでのアクセシビリティのためにシステムカラーを使用します。
実装ノート
Structure:
<button role="switch" aria-checked="false">
<span class="switch-track">
<span class="switch-thumb" />
</span>
Enable notifications
</button>
Visual States:
┌─────────┬────────────┐
│ OFF │ ON │
├─────────┼────────────┤
│ [○ ] │ [ ✓] │
│ Left │ Right+icon │
└─────────┴────────────┘
Switch vs Checkbox:
- Switch: immediate effect, on/off semantics
- Checkbox: may require form submit, checked/unchecked semantics
Use Switch when:
- Action takes effect immediately
- Represents on/off, enable/disable
- Similar to a physical switch
Switchコンポーネントの構造と視覚的な状態
参考資料
ソースコード
Switch.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { untrack } from 'svelte';
interface SwitchProps {
children?: string | Snippet<[]>;
initialChecked?: boolean;
disabled?: boolean;
onCheckedChange?: (checked: boolean) => void;
[key: string]: unknown;
}
let {
children,
initialChecked = false,
disabled = false,
onCheckedChange = () => {},
...restProps
}: SwitchProps = $props();
let checked = $state(untrack(() => initialChecked));
function toggle() {
if (disabled) return;
checked = !checked;
onCheckedChange(checked);
}
function handleClick() {
toggle();
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
toggle();
}
}
</script>
<button
type="button"
role="switch"
aria-checked={checked}
aria-disabled={disabled || undefined}
class="apg-switch"
{disabled}
onclick={handleClick}
onkeydown={handleKeyDown}
{...restProps}
>
<span class="apg-switch-track">
<span class="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 class="apg-switch-thumb"></span>
</span>
{#if children}
<span class="apg-switch-label">
{#if typeof children === 'string'}
{children}
{:else}
{@render children?.()}
{/if}
</span>
{/if}
</button> 使い方
Example
<script>
import Switch from './Switch.svelte';
function handleChange(checked) {
console.log('Checked:', checked);
}
</script>
<Switch
initialChecked={false}
onCheckedChange={handleChange}
>
Enable notifications
</Switch> API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
initialChecked | boolean | false | 初期のチェック状態 |
onCheckedChange | (checked: boolean) => void | - | 状態が変更されたときのコールバック |
disabled | boolean | false | スイッチを無効にするかどうか |
children | Snippet | string | - | スイッチのラベル |
その他のすべてのプロパティは、内部の
<button> 要素に渡されます。 テスト
テストは、キーボードインタラクション、ARIA属性、アクセシビリティ要件全体でAPG準拠を検証します。Switchコンポーネントは2層のテスト戦略を採用しています。
テスト戦略
ユニットテスト(Testing Library)
フレームワーク固有のテストライブラリを使用してコンポーネントのレンダリング出力を検証します。これらのテストは正しいHTML構造とARIA属性を確認します。
- ARIA属性(role="switch"、aria-checked)
- キーボードインタラクション(Space、Enter)
- 無効状態の処理
- jest-axeによるアクセシビリティ検証
E2Eテスト(Playwright)
すべてのフレームワークで実際のブラウザ環境でコンポーネントの動作を検証します。これらのテストはインタラクションとフレームワーク間の一貫性をカバーします。
- クリックとキーボードのトグル動作
- ライブブラウザでのARIA構造
- 無効状態のインタラクション
- axe-coreによるアクセシビリティスキャン
- フレームワーク間の一貫性チェック
テストカテゴリ
高優先度: APG キーボードインタラクション ( Unit + E2E )
| テスト | 説明 |
|---|---|
Space key | スイッチの状態を切り替え |
Enter key | スイッチの状態を切り替え |
Tab navigation | Tabキーでスイッチ間をフォーカス移動 |
Disabled Tab skip | 無効化されたスイッチはTab順序でスキップされる |
Disabled key ignore | 無効化されたスイッチはキー押下を無視する |
高優先度: APG ARIA 属性 ( Unit + E2E )
| テスト | 説明 |
|---|---|
role="switch" | 要素にswitchロールが設定されている |
aria-checked initial | 初期状態が aria-checked="false" |
aria-checked toggle | クリックで aria-checked 値が変更される |
type="button" | 明示的なボタンタイプでフォーム送信を防止 |
aria-disabled | 無効化されたスイッチは aria-disabled="true" を持つ |
中優先度: アクセシビリティ ( Unit + E2E )
| テスト | 説明 |
|---|---|
axe violations | WCAG 2.1 AA違反なし(jest-axe経由) |
Accessible name (children) | スイッチが子要素のコンテンツから名前を持つ |
aria-label | aria-label経由でアクセシブルな名前 |
aria-labelledby | 外部要素経由でアクセシブルな名前 |
低優先度: HTML属性継承 ( Unit )
| テスト | 説明 |
|---|---|
className merge | カスタムクラスがコンポーネントクラスとマージされる |
data-* attributes | カスタムdata属性が引き継がれる |
低優先度: フレームワーク間の一貫性 ( E2E )
| テスト | 説明 |
|---|---|
All frameworks have switch | React、Vue、Svelte、Astroすべてがスイッチ要素をレンダリング |
Toggle on click | すべてのフレームワークでクリック時に正しくトグル |
Consistent ARIA | すべてのフレームワークで一貫したARIA構造 |
テストコード例
以下は実際のE2Eテストファイルです (e2e/switch.spec.ts).
e2e/switch.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
/**
* E2E Tests for Switch Pattern
*
* A switch is a type of checkbox that represents on/off values.
* It uses `role="switch"` and `aria-checked` to communicate state
* to assistive technology.
*
* APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/switch/
*/
const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;
// Helper to get switch elements
const getSwitches = (page: import('@playwright/test').Page) => {
return page.locator('[role="switch"]');
};
for (const framework of frameworks) {
test.describe(`Switch (${framework})`, () => {
test.beforeEach(async ({ page }) => {
await page.goto(`patterns/switch/${framework}/demo/`);
await page.waitForLoadState('networkidle');
});
// 🔴 High Priority: ARIA Structure
test.describe('APG: ARIA Structure', () => {
test('has role="switch"', async ({ page }) => {
const switches = getSwitches(page);
const count = await switches.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
await expect(switches.nth(i)).toHaveAttribute('role', 'switch');
}
});
test('has aria-checked attribute', async ({ page }) => {
const switches = getSwitches(page);
const count = await switches.count();
for (let i = 0; i < count; i++) {
const ariaChecked = await switches.nth(i).getAttribute('aria-checked');
expect(['true', 'false']).toContain(ariaChecked);
}
});
test('has accessible name', async ({ page }) => {
const switches = getSwitches(page);
const count = await switches.count();
for (let i = 0; i < count; i++) {
const switchEl = switches.nth(i);
const text = await switchEl.textContent();
const ariaLabel = await switchEl.getAttribute('aria-label');
const ariaLabelledby = await switchEl.getAttribute('aria-labelledby');
const hasAccessibleName =
(text && text.trim().length > 0) || ariaLabel !== null || ariaLabelledby !== null;
expect(hasAccessibleName).toBe(true);
}
});
});
// 🔴 High Priority: Click Interaction
test.describe('APG: Click Interaction', () => {
test('toggles aria-checked on click', async ({ page }) => {
const switchEl = getSwitches(page).first();
const initialState = await switchEl.getAttribute('aria-checked');
await switchEl.click();
const newState = await switchEl.getAttribute('aria-checked');
expect(newState).not.toBe(initialState);
// Click again to toggle back
await switchEl.click();
const finalState = await switchEl.getAttribute('aria-checked');
expect(finalState).toBe(initialState);
});
});
// 🔴 High Priority: Keyboard Interaction
test.describe('APG: Keyboard Interaction', () => {
test('toggles on Space key', async ({ page }) => {
const switchEl = getSwitches(page).first();
const initialState = await switchEl.getAttribute('aria-checked');
await switchEl.focus();
await expect(switchEl).toBeFocused();
await switchEl.press('Space');
const newState = await switchEl.getAttribute('aria-checked');
expect(newState).not.toBe(initialState);
});
test('toggles on Enter key', async ({ page }) => {
const switchEl = getSwitches(page).first();
const initialState = await switchEl.getAttribute('aria-checked');
await switchEl.focus();
await expect(switchEl).toBeFocused();
await switchEl.press('Enter');
const newState = await switchEl.getAttribute('aria-checked');
expect(newState).not.toBe(initialState);
});
test('is focusable via Tab', async ({ page }) => {
const switchEl = getSwitches(page).first();
// Tab to the switch
let found = false;
for (let i = 0; i < 20; i++) {
await page.keyboard.press('Tab');
if (await switchEl.evaluate((el) => el === document.activeElement)) {
found = true;
break;
}
}
expect(found).toBe(true);
});
});
// 🔴 High Priority: Disabled State
test.describe('Disabled State', () => {
test('disabled switch has aria-disabled="true"', async ({ page }) => {
const disabledSwitch = page.locator('[role="switch"][aria-disabled="true"]');
if ((await disabledSwitch.count()) > 0) {
await expect(disabledSwitch.first()).toHaveAttribute('aria-disabled', 'true');
}
});
test('disabled switch does not toggle on click', async ({ page }) => {
const disabledSwitch = page.locator('[role="switch"][aria-disabled="true"]');
if ((await disabledSwitch.count()) > 0) {
const initialState = await disabledSwitch.first().getAttribute('aria-checked');
await disabledSwitch.first().click({ force: true });
const newState = await disabledSwitch.first().getAttribute('aria-checked');
expect(newState).toBe(initialState);
}
});
test('disabled switch does not toggle on keyboard', async ({ page }) => {
const disabledSwitch = page.locator('[role="switch"][aria-disabled="true"]');
if ((await disabledSwitch.count()) > 0) {
const initialState = await disabledSwitch.first().getAttribute('aria-checked');
await disabledSwitch.first().focus();
await page.keyboard.press('Space');
const newState = await disabledSwitch.first().getAttribute('aria-checked');
expect(newState).toBe(initialState);
}
});
});
// 🟡 Medium Priority: Accessibility
test.describe('Accessibility', () => {
test('has no axe-core violations', async ({ page }) => {
const switches = getSwitches(page);
await switches.first().waitFor();
const accessibilityScanResults = await new AxeBuilder({ page })
.include('[role="switch"]')
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
});
});
}
// Cross-framework consistency tests
test.describe('Switch - Cross-framework Consistency', () => {
test('all frameworks have switch elements', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/switch/${framework}/demo/`);
await page.waitForLoadState('networkidle');
const switches = page.locator('[role="switch"]');
const count = await switches.count();
expect(count).toBeGreaterThan(0);
}
});
test('all frameworks toggle correctly on click', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/switch/${framework}/demo/`);
await page.waitForLoadState('networkidle');
const switchEl = page.locator('[role="switch"]').first();
const initialState = await switchEl.getAttribute('aria-checked');
await switchEl.click();
const newState = await switchEl.getAttribute('aria-checked');
expect(newState).not.toBe(initialState);
}
});
test('all frameworks have consistent ARIA structure', async ({ page }) => {
const ariaStructures: Record<string, unknown[]> = {};
for (const framework of frameworks) {
await page.goto(`patterns/switch/${framework}/demo/`);
await page.waitForLoadState('networkidle');
ariaStructures[framework] = await page.evaluate(() => {
const switches = document.querySelectorAll('[role="switch"]');
return Array.from(switches).map((switchEl) => ({
hasAriaChecked: switchEl.hasAttribute('aria-checked'),
hasAccessibleName:
(switchEl.textContent && switchEl.textContent.trim().length > 0) ||
switchEl.hasAttribute('aria-label') ||
switchEl.hasAttribute('aria-labelledby'),
}));
});
}
// All frameworks should have the same structure
const reactStructure = ariaStructures['react'];
for (const framework of frameworks) {
expect(ariaStructures[framework]).toEqual(reactStructure);
}
});
}); テストの実行
# Switchのユニットテストを実行
npm run test -- switch
# SwitchのE2Eテストを実行(全フレームワーク)
npm run test:e2e:pattern --pattern=switch
テストツール
- 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) を参照してください。
Switch.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 Switch from './Switch.svelte';
describe('Switch (Svelte)', () => {
// 🔴 High Priority: APG 準拠の核心
describe('APG: ARIA 属性', () => {
it('role="switch" を持つ', () => {
render(Switch, {
props: { children: 'Wi-Fi' },
});
expect(screen.getByRole('switch')).toBeInTheDocument();
});
it('初期状態で aria-checked="false"', () => {
render(Switch, {
props: { children: 'Wi-Fi' },
});
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-checked', 'false');
});
it('クリック後に aria-checked="true" に変わる', async () => {
const user = userEvent.setup();
render(Switch, {
props: { children: 'Wi-Fi' },
});
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-checked', 'false');
await user.click(switchEl);
expect(switchEl).toHaveAttribute('aria-checked', 'true');
});
it('type="button" が設定されている', () => {
render(Switch, {
props: { children: 'Wi-Fi' },
});
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('type', 'button');
});
it('disabled 時に aria-disabled が設定される', () => {
render(Switch, {
props: { children: 'Wi-Fi', disabled: true },
});
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-disabled', 'true');
});
it('disabled 状態で aria-checked 変更不可', async () => {
const user = userEvent.setup();
render(Switch, {
props: { children: 'Wi-Fi', disabled: true },
});
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-checked', 'false');
await user.click(switchEl);
expect(switchEl).toHaveAttribute('aria-checked', 'false');
});
});
describe('APG: キーボード操作', () => {
it('Space キーでトグルする', async () => {
const user = userEvent.setup();
render(Switch, {
props: { children: 'Wi-Fi' },
});
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-checked', 'false');
switchEl.focus();
await user.keyboard(' ');
expect(switchEl).toHaveAttribute('aria-checked', 'true');
});
it('Enter キーでトグルする', async () => {
const user = userEvent.setup();
render(Switch, {
props: { children: 'Wi-Fi' },
});
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-checked', 'false');
switchEl.focus();
await user.keyboard('{Enter}');
expect(switchEl).toHaveAttribute('aria-checked', 'true');
});
it('disabled 時は Tab キースキップ', async () => {
const user = userEvent.setup();
const container = document.createElement('div');
document.body.appendChild(container);
const { unmount: unmount1 } = render(Switch, {
target: container,
props: { children: 'Switch 1' },
});
const { unmount: unmount2 } = render(Switch, {
target: container,
props: { children: 'Switch 2', disabled: true },
});
const { unmount: unmount3 } = render(Switch, {
target: container,
props: { children: 'Switch 3' },
});
await user.tab();
expect(screen.getByRole('switch', { name: 'Switch 1' })).toHaveFocus();
await user.tab();
expect(screen.getByRole('switch', { name: 'Switch 3' })).toHaveFocus();
unmount1();
unmount2();
unmount3();
document.body.removeChild(container);
});
it('disabled 時はキー操作無効', async () => {
const user = userEvent.setup();
render(Switch, {
props: { children: 'Wi-Fi', disabled: true },
});
const switchEl = screen.getByRole('switch');
switchEl.focus();
await user.keyboard(' ');
expect(switchEl).toHaveAttribute('aria-checked', 'false');
});
});
// 🟡 Medium Priority: アクセシビリティ検証
describe('アクセシビリティ', () => {
it('axe による WCAG 2.1 AA 違反がない', async () => {
const { container } = render(Switch, {
props: { children: 'Wi-Fi' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('ラベル(children)でアクセシブルネームを持つ', () => {
render(Switch, {
props: { children: 'Wi-Fi' },
});
expect(screen.getByRole('switch', { name: 'Wi-Fi' })).toBeInTheDocument();
});
it('aria-label でアクセシブルネームを設定できる', () => {
render(Switch, {
props: { 'aria-label': 'Enable notifications' },
});
expect(screen.getByRole('switch', { name: 'Enable notifications' })).toBeInTheDocument();
});
it('aria-labelledby で外部ラベルを参照できる', () => {
const container = document.createElement('div');
container.innerHTML = '<span id="switch-label">Bluetooth</span>';
document.body.appendChild(container);
render(Switch, {
target: container,
props: { 'aria-labelledby': 'switch-label' },
});
expect(screen.getByRole('switch', { name: 'Bluetooth' })).toBeInTheDocument();
document.body.removeChild(container);
});
});
describe('Props', () => {
it('initialChecked=true で ON 状態でレンダリングされる', () => {
render(Switch, {
props: { children: 'Wi-Fi', initialChecked: true },
});
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-checked', 'true');
});
it('onCheckedChange が状態変化時に呼び出される', async () => {
const handleCheckedChange = vi.fn();
const user = userEvent.setup();
render(Switch, {
props: { children: 'Wi-Fi', onCheckedChange: handleCheckedChange },
});
await user.click(screen.getByRole('switch'));
expect(handleCheckedChange).toHaveBeenCalledWith(true);
await user.click(screen.getByRole('switch'));
expect(handleCheckedChange).toHaveBeenCalledWith(false);
});
});
// 🟢 Low Priority: 拡張性
describe('HTML 属性継承', () => {
it('デフォルトで apg-switch クラスが設定される', () => {
render(Switch, {
props: { children: 'Wi-Fi' },
});
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveClass('apg-switch');
});
it('data-* 属性が継承される', () => {
render(Switch, {
props: { children: 'Wi-Fi', 'data-testid': 'custom-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 specs, keyboard support, test checklist