APG Patterns
English GitHub
English GitHub

Switch

オンとオフの2つの状態を切り替えることができるコントロール。

🤖 AI Implementation Guide

デモ

デモのみ表示 →

アクセシビリティ

WAI-ARIA ロール

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

リソース