APG Patterns
English
English

スイッチ

オンとオフの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.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>

使い方

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

プロパティ デフォルト 説明
initialChecked boolean false 初期のチェック状態
disabled boolean false スイッチが無効かどうか

スロット

スロット デフォルト 説明
default - スイッチのラベルコンテンツ

Custom Events

イベント Detail 説明
@change boolean 状態が変更されたときに発火

テスト

テストは、キーボードインタラクション、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
          
        

テストツール

完全なドキュメントは testing-strategy.md (opens in new tab) を参照してください。

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();
    });
  });
});

リソース