Switch
オンとオフの2つの状態を切り替えることができるコントロール。
🤖 AI Implementation Guideデモ
アクセシビリティ
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.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> 使い方
使用例
<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.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