APG Patterns
日本語 GitHub
日本語 GitHub

Switch

A control that allows users to toggle between two states: on and off.

🤖 AI Implementation Guide

Demo

Open demo only →

Accessibility Features

WAI-ARIA Roles

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