Switch
A control that allows users to toggle between two states: on and off.
🤖 AI Implementation GuideDemo
Accessibility Features
WAI-ARIA Roles
-
switch- An input widget that allows users to choose one of two values: on or off
WAI-ARIA switch role (opens in new tab)
WAI-ARIA States
aria-checked
Indicates the current checked state of the switch.
| Values | true | false |
| Required | Yes (for switch role) |
| Default | initialChecked prop (default: false) |
| Change Trigger | Click, Enter, Space |
| Reference | aria-checked (opens in new tab) |
aria-disabled
Indicates the switch is perceivable but disabled.
| Values | true | undefined |
| Required | No (only when disabled) |
| Reference | aria-disabled (opens in new tab) |
Keyboard Support
| Key | Action |
|---|---|
| Space | Toggle the switch state (on/off) |
| Enter | Toggle the switch state (on/off) |
Accessible Naming
Switches must have an accessible name. This can be provided through:
- Visible label (recommended) - The switch's child content provides the accessible name
-
aria-label- Provides an invisible label for the switch -
aria-labelledby- References an external element as the label
Visual Design
This implementation follows WCAG 1.4.1 (Use of Color) by not relying solely on color to indicate state:
- Thumb position - Left = off, Right = on
- Checkmark icon - Visible only when the switch is on
- Forced colors mode - Uses system colors for accessibility in Windows High Contrast Mode
Source Code
Switch.vue
<template>
<button
type="button"
role="switch"
class="apg-switch"
:aria-checked="checked"
:aria-disabled="props.disabled || undefined"
:disabled="props.disabled"
v-bind="$attrs"
@click="handleClick"
@keydown="handleKeyDown"
>
<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 v-if="$slots.default" class="apg-switch-label">
<slot />
</span>
</button>
</template>
<script setup lang="ts">
import { ref } from 'vue';
defineOptions({
inheritAttrs: false,
});
export interface SwitchProps {
/** Initial checked state */
initialChecked?: boolean;
/** Whether the switch is disabled */
disabled?: boolean;
/** Callback fired when checked state changes */
onCheckedChange?: (checked: boolean) => void;
}
const props = withDefaults(defineProps<SwitchProps>(), {
initialChecked: false,
disabled: false,
onCheckedChange: undefined,
});
const emit = defineEmits<{
change: [checked: boolean];
}>();
defineSlots<{
default(): unknown;
}>();
const checked = ref(props.initialChecked);
const toggle = () => {
if (props.disabled) return;
const newChecked = !checked.value;
checked.value = newChecked;
props.onCheckedChange?.(newChecked);
emit('change', newChecked);
};
const handleClick = () => {
toggle();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
toggle();
}
};
</script> Usage
Example
<script setup>
import Switch from './Switch.vue';
function handleChange(checked) {
console.log('Checked:', checked);
}
</script>
<template>
<Switch
:initial-checked="false"
@change="handleChange"
>
Enable notifications
</Switch>
</template> API
| Prop | Type | Default | Description |
|---|---|---|---|
initialChecked | boolean | false | Initial checked state |
disabled | boolean | false | Whether the switch is disabled |
Events
| Event | Payload | Description |
|---|---|---|
@change | boolean | Emitted when state changes |
Slots
| Slot | Description |
|---|---|
default | Switch label content |
Testing
Tests cover APG compliance including keyboard interactions, ARIA attributes, and accessibility validation.
Switch.test.vue.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Switch from './Switch.vue';
describe('Switch (Vue)', () => {
// 🔴 High Priority: APG 準拠の核心
describe('APG: ARIA 属性', () => {
it('role="switch" を持つ', () => {
render(Switch, {
slots: { default: 'Wi-Fi' },
});
expect(screen.getByRole('switch')).toBeInTheDocument();
});
it('初期状態で aria-checked="false"', () => {
render(Switch, {
slots: { default: 'Wi-Fi' },
});
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-checked', 'false');
});
it('クリック後に aria-checked="true" に変わる', async () => {
const user = userEvent.setup();
render(Switch, {
slots: { default: '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, {
slots: { default: 'Wi-Fi' },
});
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('type', 'button');
});
it('disabled 時に aria-disabled が設定される', () => {
render(Switch, {
props: { disabled: true },
slots: { default: 'Wi-Fi' },
});
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-disabled', 'true');
});
it('disabled 状態で aria-checked 変更不可', async () => {
const user = userEvent.setup();
render(Switch, {
props: { disabled: true },
slots: { default: 'Wi-Fi' },
});
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, {
slots: { default: '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, {
slots: { default: '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('Tab キーでフォーカス移動可能', async () => {
const user = userEvent.setup();
render({
components: { Switch },
template: `
<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('disabled 時は Tab キースキップ', async () => {
const user = userEvent.setup();
render({
components: { Switch },
template: `
<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('disabled 時はキー操作無効', async () => {
const user = userEvent.setup();
render(Switch, {
props: { disabled: true },
slots: { default: 'Wi-Fi' },
});
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, {
slots: { default: 'Wi-Fi' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('ラベル(children)でアクセシブルネームを持つ', () => {
render(Switch, {
slots: { default: 'Wi-Fi' },
});
expect(screen.getByRole('switch', { name: 'Wi-Fi' })).toBeInTheDocument();
});
it('aria-label でアクセシブルネームを設定できる', () => {
render(Switch, {
attrs: { 'aria-label': 'Enable notifications' },
});
expect(screen.getByRole('switch', { name: 'Enable notifications' })).toBeInTheDocument();
});
it('aria-labelledby で外部ラベルを参照できる', () => {
render({
components: { Switch },
template: `
<span id="switch-label">Bluetooth</span>
<Switch aria-labelledby="switch-label" />
`,
});
expect(screen.getByRole('switch', { name: 'Bluetooth' })).toBeInTheDocument();
});
});
describe('Props', () => {
it('initialChecked=true で ON 状態でレンダリングされる', () => {
render(Switch, {
props: { initialChecked: true },
slots: { default: 'Wi-Fi' },
});
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: { onCheckedChange: handleCheckedChange },
slots: { default: 'Wi-Fi' },
});
await user.click(screen.getByRole('switch'));
expect(handleCheckedChange).toHaveBeenCalledWith(true);
await user.click(screen.getByRole('switch'));
expect(handleCheckedChange).toHaveBeenCalledWith(false);
});
it('@change イベントが状態変化時に発火する', async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(Switch, {
props: { onCheckedChange: handleChange },
slots: { default: 'Wi-Fi' },
});
await user.click(screen.getByRole('switch'));
expect(handleChange).toHaveBeenCalledWith(true);
});
});
// 🟢 Low Priority: 拡張性
describe('HTML 属性継承', () => {
it('class が正しくマージされる', () => {
render(Switch, {
attrs: { class: 'custom-class' },
slots: { default: 'Wi-Fi' },
});
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveClass('custom-class');
expect(switchEl).toHaveClass('apg-switch');
});
it('data-* 属性が継承される', () => {
render(Switch, {
attrs: { 'data-testid': 'custom-switch' },
slots: { default: 'Wi-Fi' },
});
expect(screen.getByTestId('custom-switch')).toBeInTheDocument();
});
});
}); Resources
- WAI-ARIA APG: Switch Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist