APG Patterns
English GitHub
English GitHub

Toggle Button

「押されている」または「押されていない」の2つの状態を持つボタン。

🤖 AI 実装ガイド

デモ

デモのみ表示 →

アクセシビリティ

WAI-ARIA ロール

WAI-ARIA ステート

aria-pressed

トグルボタンの現在の押下状態を示します。

true | false (3状態ボタンでは "mixed" も使用可能)
必須 はい(トグルボタンの場合)
デフォルト initialPressed プロパティ(デフォルト: false
変更トリガー クリック、Enter、Space
リファレンス aria-pressed (opens in new tab)

キーボードサポート

キー アクション
Space ボタンの状態を切り替える
Enter ボタンの状態を切り替える

ソースコード

ToggleButton.tsx
import { cn } from '@/lib/utils';
import { useCallback, useState } from 'react';

export interface ToggleButtonProps extends Omit<
  React.ButtonHTMLAttributes<HTMLButtonElement>,
  'onClick' | 'type' | 'aria-pressed' | 'onToggle'
> {
  /** Initial pressed state */
  initialPressed?: boolean;
  /** Button label text */
  children: React.ReactNode;
  /** Callback fired when toggle state changes */
  onPressedChange?: (pressed: boolean) => void;
  /** Custom indicator for pressed state (default: "●") */
  pressedIndicator?: React.ReactNode;
  /** Custom indicator for unpressed state (default: "○") */
  unpressedIndicator?: React.ReactNode;
}

export const ToggleButton: React.FC<ToggleButtonProps> = ({
  initialPressed = false,
  children,
  onPressedChange,
  pressedIndicator = '●',
  unpressedIndicator = '○',
  className = '',
  ...buttonProps
}) => {
  const [pressed, setPressed] = useState(initialPressed);

  const handleClick = useCallback(() => {
    setPressed(!pressed);
    onPressedChange?.(!pressed);
  }, [pressed, onPressedChange]);

  return (
    <button
      type="button"
      {...buttonProps}
      className={cn('apg-toggle-button', className)}
      aria-pressed={pressed}
      onClick={handleClick}
    >
      <span className="apg-toggle-button-content">{children}</span>
      <span className="apg-toggle-indicator" aria-hidden="true">
        {pressed ? pressedIndicator : unpressedIndicator}
      </span>
    </button>
  );
};

export default ToggleButton;

使い方

使用例
import { ToggleButton } from './ToggleButton';
import { Volume2, VolumeOff } from 'lucide-react';

function App() {
  return (
    <ToggleButton
      initialPressed={false}
      onPressedChange={(pressed) => console.log('Muted:', pressed)}
      pressedIndicator={<VolumeOff size={20} />}
      unpressedIndicator={<Volume2 size={20} />}
    >
      Mute
    </ToggleButton>
  );
}

API

プロパティ デフォルト 説明
initialPressed boolean false 初期の押下状態
onPressedChange (pressed: boolean) => void - 状態変更時のコールバック
pressedIndicator ReactNode "●" 押下状態のカスタムインジケーター
unpressedIndicator ReactNode "○" 非押下状態のカスタムインジケーター
children ReactNode - ボタンのラベル

その他のプロパティは、内部の <button> 要素に渡されます。

テスト

テストは、キーボード操作、ARIA属性、アクセシビリティ要件の観点からAPG準拠を検証します。Toggle Buttonコンポーネントは2層テスト戦略を採用しています。

テスト戦略

ユニットテスト (Testing Library)

フレームワーク固有のTesting Libraryユーティリティを使用してコンポーネントのレンダリングとインタラクションを検証します。分離された環境で正しいコンポーネント動作を確認できます。

  • HTML構造と要素の階層
  • 初期属性値(aria-pressed、type)
  • クリックイベント処理と状態切り替え
  • CSSクラスの適用

E2Eテスト (Playwright)

4つのフレームワーク全体で実際のブラウザ環境でのコンポーネント動作を検証します。フルブラウザコンテキストが必要なインタラクションをカバーします。

  • キーボード操作(Space、Enter)
  • aria-pressed状態の切り替え
  • 無効状態の動作
  • フォーカス管理とTabナビゲーション
  • クロスフレームワーク一貫性

テストカテゴリ

高優先度: APG キーボード操作

テスト 説明
Space キーでトグル Spaceキーを押すとボタンの状態が切り替わる
Enter キーでトグル Enterキーを押すとボタンの状態が切り替わる
Tab ナビゲーション Tabキーでボタン間のフォーカスを移動する
無効時の Tab スキップ 無効化されたボタンはTabの順序でスキップされる

高優先度: APG ARIA 属性

テスト 説明
role="button" 暗黙的なbuttonロールを持つ(<button> 要素経由)
aria-pressed 初期値 初期状態は aria-pressed="false"
aria-pressed トグル クリックで aria-pressedtrue に変わる
type="button" 明示的なbutton typeがフォーム送信を防ぐ
無効状態 無効化されたボタンはクリックで状態が変わらない

中優先度: アクセシビリティ

テスト 説明
axe 違反 WCAG 2.1 AA違反がない(jest-axe経由)
アクセシブル名 ボタンがコンテンツからアクセシブルな名前を持つ

低優先度: HTML属性の継承

テスト 説明
className マージ カスタムクラスがコンポーネントのクラスとマージされる
data-* 属性 カスタムdata属性が渡される

テストツール

詳細は testing-strategy.md (opens in new tab) を参照してください。

ToggleButton.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 { ToggleButton } from './ToggleButton';

describe('ToggleButton', () => {
  // 🔴 High Priority: APG Core Compliance
  describe('APG: Keyboard Interaction', () => {
    it('toggles with Space key', async () => {
      const user = userEvent.setup();
      render(<ToggleButton>Mute</ToggleButton>);
      const button = screen.getByRole('button');

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

    it('toggles with Enter key', async () => {
      const user = userEvent.setup();
      render(<ToggleButton>Mute</ToggleButton>);
      const button = screen.getByRole('button');

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

    it('can move focus with Tab key', async () => {
      const user = userEvent.setup();
      render(
        <>
          <ToggleButton>Button 1</ToggleButton>
          <ToggleButton>Button 2</ToggleButton>
        </>
      );

      await user.tab();
      expect(screen.getByRole('button', { name: 'Button 1' })).toHaveFocus();
      await user.tab();
      expect(screen.getByRole('button', { name: 'Button 2' })).toHaveFocus();
    });

    it('skips with Tab key when disabled', async () => {
      const user = userEvent.setup();
      render(
        <>
          <ToggleButton>Button 1</ToggleButton>
          <ToggleButton disabled>Button 2</ToggleButton>
          <ToggleButton>Button 3</ToggleButton>
        </>
      );

      await user.tab();
      expect(screen.getByRole('button', { name: 'Button 1' })).toHaveFocus();
      await user.tab();
      expect(screen.getByRole('button', { name: 'Button 3' })).toHaveFocus();
    });
  });

  describe('APG: ARIA Attributes', () => {
    it('has implicit role="button"', () => {
      render(<ToggleButton>Mute</ToggleButton>);
      expect(screen.getByRole('button')).toBeInTheDocument();
    });

    it('has aria-pressed="false" in initial state', () => {
      render(<ToggleButton>Mute</ToggleButton>);
      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('aria-pressed', 'false');
    });

    it('changes to aria-pressed="true" after click', async () => {
      const user = userEvent.setup();
      render(<ToggleButton>Mute</ToggleButton>);
      const button = screen.getByRole('button');

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

    it('has type="button"', () => {
      render(<ToggleButton>Mute</ToggleButton>);
      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('type', 'button');
    });

    it('cannot change aria-pressed when disabled', async () => {
      const user = userEvent.setup();
      render(<ToggleButton disabled>Mute</ToggleButton>);
      const button = screen.getByRole('button');

      expect(button).toHaveAttribute('aria-pressed', 'false');
      await user.click(button);
      expect(button).toHaveAttribute('aria-pressed', 'false');
    });
  });

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

    it('has accessible name', () => {
      render(<ToggleButton>Mute Audio</ToggleButton>);
      expect(screen.getByRole('button', { name: /Mute Audio/i })).toBeInTheDocument();
    });
  });

  describe('Props', () => {
    it('renders in pressed state with initialPressed=true', () => {
      render(<ToggleButton initialPressed>Mute</ToggleButton>);
      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('aria-pressed', 'true');
    });

    it('calls onPressedChange when state changes', async () => {
      const handlePressedChange = vi.fn();
      const user = userEvent.setup();
      render(<ToggleButton onPressedChange={handlePressedChange}>Mute</ToggleButton>);

      await user.click(screen.getByRole('button'));
      expect(handlePressedChange).toHaveBeenCalledWith(true);

      await user.click(screen.getByRole('button'));
      expect(handlePressedChange).toHaveBeenCalledWith(false);
    });
  });

  describe('Custom Indicators', () => {
    it('displays default ●/○ indicator', () => {
      render(<ToggleButton>Mute</ToggleButton>);
      const button = screen.getByRole('button');
      const indicator = button.querySelector('.apg-toggle-indicator');
      expect(indicator).toHaveTextContent('○');
    });

    it('can set custom indicator with pressedIndicator', () => {
      render(
        <ToggleButton initialPressed pressedIndicator="🔇">
          Mute
        </ToggleButton>
      );
      const button = screen.getByRole('button');
      const indicator = button.querySelector('.apg-toggle-indicator');
      expect(indicator).toHaveTextContent('🔇');
    });

    it('can set custom indicator with unpressedIndicator', () => {
      render(<ToggleButton unpressedIndicator="🔊">Mute</ToggleButton>);
      const button = screen.getByRole('button');
      const indicator = button.querySelector('.apg-toggle-indicator');
      expect(indicator).toHaveTextContent('🔊');
    });

    it('switches custom indicator on toggle', async () => {
      const user = userEvent.setup();
      render(
        <ToggleButton pressedIndicator="🔇" unpressedIndicator="🔊">
          Mute
        </ToggleButton>
      );
      const button = screen.getByRole('button');
      const indicator = button.querySelector('.apg-toggle-indicator');

      expect(indicator).toHaveTextContent('🔊');
      await user.click(button);
      expect(indicator).toHaveTextContent('🔇');
      await user.click(button);
      expect(indicator).toHaveTextContent('🔊');
    });

    it('can pass ReactNode as custom indicator', () => {
      render(
        <ToggleButton initialPressed pressedIndicator={<span data-testid="custom-icon">X</span>}>
          Mute
        </ToggleButton>
      );
      expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
    });

    it('maintains aria-hidden with custom indicator', () => {
      render(
        <ToggleButton pressedIndicator="🔇" unpressedIndicator="🔊">
          Mute
        </ToggleButton>
      );
      const button = screen.getByRole('button');
      const indicator = button.querySelector('.apg-toggle-indicator');
      expect(indicator).toHaveAttribute('aria-hidden', 'true');
    });

    it('has no axe violations with custom indicator', async () => {
      const { container } = render(
        <ToggleButton pressedIndicator="🔇" unpressedIndicator="🔊">
          Mute
        </ToggleButton>
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

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

    it('inherits data-* attributes', () => {
      render(<ToggleButton data-testid="custom-toggle">Mute</ToggleButton>);
      expect(screen.getByTestId('custom-toggle')).toBeInTheDocument();
    });

    it('works correctly with React node children', () => {
      render(
        <ToggleButton>
          <span>Icon</span> Text
        </ToggleButton>
      );
      const button = screen.getByRole('button');
      expect(button).toHaveTextContent('Icon');
      expect(button).toHaveTextContent('Text');
    });
  });
});

リソース