APG Patterns
English GitHub
English GitHub

Switch

チェック済み/未チェックではなく、オン/オフの値を表すチェックボックスの一種。

🤖 AI 実装ガイド

デモ

デモのみ表示 →

アクセシビリティ

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.tsx
import { cn } from '@/lib/utils';
import { useCallback, useState } from 'react';

export interface SwitchProps extends Omit<
  React.ButtonHTMLAttributes<HTMLButtonElement>,
  'onClick' | 'type' | 'role' | 'aria-checked'
> {
  /** Initial checked state */
  initialChecked?: boolean;
  /** Switch label text */
  children?: React.ReactNode;
  /** Callback fired when checked state changes */
  onCheckedChange?: (checked: boolean) => void;
}

export const Switch: React.FC<SwitchProps> = ({
  initialChecked = false,
  children,
  onCheckedChange,
  className = '',
  disabled,
  ...buttonProps
}) => {
  const [checked, setChecked] = useState(initialChecked);

  const handleClick = useCallback(() => {
    if (disabled) return;
    const newChecked = !checked;
    setChecked(newChecked);
    onCheckedChange?.(newChecked);
  }, [checked, onCheckedChange, disabled]);

  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLButtonElement>) => {
      if (disabled) return;
      if (event.key === ' ' || event.key === 'Enter') {
        event.preventDefault();
        const newChecked = !checked;
        setChecked(newChecked);
        onCheckedChange?.(newChecked);
      }
    },
    [checked, onCheckedChange, disabled]
  );

  return (
    <button
      type="button"
      role="switch"
      {...buttonProps}
      className={cn('apg-switch', className)}
      aria-checked={checked}
      aria-disabled={disabled || undefined}
      disabled={disabled}
      onClick={handleClick}
      onKeyDown={handleKeyDown}
    >
      <span className="apg-switch-track">
        <span className="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 className="apg-switch-thumb" />
      </span>
      {children && <span className="apg-switch-label">{children}</span>}
    </button>
  );
};

export default Switch;

使い方

Example
import { Switch } from './Switch';

function App() {
  return (
    <Switch
      initialChecked={false}
      onCheckedChange={(checked) => console.log('Checked:', checked)}
    >
      Enable notifications
    </Switch>
  );
}

API

プロパティ デフォルト 説明
initialChecked boolean false 初期チェック状態
onCheckedChange (checked: boolean) => void - 状態変更時のコールバック
disabled boolean false スイッチが無効かどうか
children ReactNode - スイッチのラベル

その他のプロパティはすべて、基になる<button>要素に渡されます。

テスト

APG準拠に関するテストには、キーボード操作、ARIA属性、アクセシビリティ検証が含まれます。

Switch.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Switch } from './Switch';

describe('Switch', () => {
  // 🔴 High Priority: APG Core Compliance
  describe('APG: ARIA Attributes', () => {
    it('has role="switch"', () => {
      render(<Switch>Wi-Fi</Switch>);
      expect(screen.getByRole('switch')).toBeInTheDocument();
    });

    it('has aria-checked="false" in initial state', () => {
      render(<Switch>Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');
      expect(switchEl).toHaveAttribute('aria-checked', 'false');
    });

    it('changes to aria-checked="true" after click', async () => {
      const user = userEvent.setup();
      render(<Switch>Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');

      expect(switchEl).toHaveAttribute('aria-checked', 'false');
      await user.click(switchEl);
      expect(switchEl).toHaveAttribute('aria-checked', 'true');
    });

    it('has type="button"', () => {
      render(<Switch>Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');
      expect(switchEl).toHaveAttribute('type', 'button');
    });

    it('has aria-disabled when disabled', () => {
      render(<Switch disabled>Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');
      expect(switchEl).toHaveAttribute('aria-disabled', 'true');
    });

    it('cannot change aria-checked when disabled', async () => {
      const user = userEvent.setup();
      render(<Switch disabled>Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');

      expect(switchEl).toHaveAttribute('aria-checked', 'false');
      await user.click(switchEl);
      expect(switchEl).toHaveAttribute('aria-checked', 'false');
    });
  });

  describe('APG: Keyboard Interaction', () => {
    it('toggles with Space key', async () => {
      const user = userEvent.setup();
      render(<Switch>Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');

      expect(switchEl).toHaveAttribute('aria-checked', 'false');
      switchEl.focus();
      await user.keyboard(' ');
      expect(switchEl).toHaveAttribute('aria-checked', 'true');
    });

    it('toggles with Enter key', async () => {
      const user = userEvent.setup();
      render(<Switch>Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');

      expect(switchEl).toHaveAttribute('aria-checked', 'false');
      switchEl.focus();
      await user.keyboard('{Enter}');
      expect(switchEl).toHaveAttribute('aria-checked', 'true');
    });

    it('can move focus with Tab key', async () => {
      const user = userEvent.setup();
      render(
        <>
          <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('skips with Tab key when disabled', async () => {
      const user = userEvent.setup();
      render(
        <>
          <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('keyboard operation disabled when disabled', async () => {
      const user = userEvent.setup();
      render(<Switch disabled>Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');

      switchEl.focus();
      await user.keyboard(' ');
      expect(switchEl).toHaveAttribute('aria-checked', 'false');
    });
  });

  // 🟡 Medium Priority: Accessibility Validation
  describe('Accessibility', () => {
    it('has no WCAG 2.1 AA violations', async () => {
      const { container } = render(<Switch>Wi-Fi</Switch>);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has accessible name via label (children)', () => {
      render(<Switch>Wi-Fi</Switch>);
      expect(screen.getByRole('switch', { name: 'Wi-Fi' })).toBeInTheDocument();
    });

    it('can set accessible name via aria-label', () => {
      render(<Switch aria-label="Enable notifications" />);
      expect(screen.getByRole('switch', { name: 'Enable notifications' })).toBeInTheDocument();
    });

    it('can reference external label via aria-labelledby', () => {
      render(
        <>
          <span id="switch-label">Bluetooth</span>
          <Switch aria-labelledby="switch-label" />
        </>
      );
      expect(screen.getByRole('switch', { name: 'Bluetooth' })).toBeInTheDocument();
    });
  });

  describe('Props', () => {
    it('renders in ON state with initialChecked=true', () => {
      render(<Switch initialChecked>Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');
      expect(switchEl).toHaveAttribute('aria-checked', 'true');
    });

    it('calls onCheckedChange when state changes', async () => {
      const handleCheckedChange = vi.fn();
      const user = userEvent.setup();
      render(<Switch onCheckedChange={handleCheckedChange}>Wi-Fi</Switch>);

      await user.click(screen.getByRole('switch'));
      expect(handleCheckedChange).toHaveBeenCalledWith(true);

      await user.click(screen.getByRole('switch'));
      expect(handleCheckedChange).toHaveBeenCalledWith(false);
    });
  });

  // 🟢 Low Priority: Extensibility
  describe('HTML Attribute Inheritance', () => {
    it('merges className correctly', () => {
      render(<Switch className="custom-class">Wi-Fi</Switch>);
      const switchEl = screen.getByRole('switch');
      expect(switchEl).toHaveClass('custom-class');
      expect(switchEl).toHaveClass('apg-switch');
    });

    it('inherits data-* attributes', () => {
      render(<Switch data-testid="custom-switch">Wi-Fi</Switch>);
      expect(screen.getByTestId('custom-switch')).toBeInTheDocument();
    });
  });
});

リソース