APG Patterns
English GitHub
English GitHub

Toggle Button

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

🤖 AI Implementation Guide

デモ

デモのみ表示 →

アクセシビリティ

WAI-ARIA ロール

WAI-ARIA ステート

aria-pressed

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

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

キーボードサポート

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

ソースコード

ToggleButton.svelte
<script lang="ts">
  import type { Snippet } from 'svelte';
  import { untrack } from 'svelte';

  // properties
  interface ToggleButtonProps {
    children?: string | Snippet<[]>;
    initialPressed?: boolean;
    disabled?: boolean;
    onToggle?: (pressed: boolean) => void;
    /** Custom indicator for pressed state (default: "●") */
    pressedIndicator?: string | Snippet<[]>;
    /** Custom indicator for unpressed state (default: "○") */
    unpressedIndicator?: string | Snippet<[]>;
    [key: string]: unknown;
  }

  let {
    children,
    initialPressed = false,
    disabled = false,
    onToggle = (_) => {},
    pressedIndicator = '●',
    unpressedIndicator = '○',
    ...restProps
  }: ToggleButtonProps = $props();

  // state - use untrack to explicitly indicate we only want the initial value
  let pressed = $state(untrack(() => initialPressed));
  let currentIndicator = $derived(pressed ? pressedIndicator : unpressedIndicator);

  // Event handlers
  function handleClick() {
    pressed = !pressed;
    onToggle(pressed);
  }
</script>

<button
  type="button"
  aria-pressed={pressed}
  class="apg-toggle-button"
  {disabled}
  onclick={handleClick}
  {...restProps}
>
  <span class="apg-toggle-button-content">
    {#if typeof children === 'string'}
      {children}
    {:else}
      {@render children?.()}
    {/if}
  </span>
  <span class="apg-toggle-indicator" aria-hidden="true">
    {#if typeof currentIndicator === 'string'}
      {currentIndicator}
    {:else if currentIndicator}
      {@render currentIndicator()}
    {/if}
  </span>
</button>

使い方

使用例
<script>
  import ToggleButton from './ToggleButton.svelte';

  function handleToggle(pressed) {
    console.log('Muted:', pressed);
  }
</script>

<ToggleButton
  initialPressed={false}
  onToggle={handleToggle}
  pressedIndicator="🔇"
  unpressedIndicator="🔊"
>
  Mute
</ToggleButton>

API

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

テスト

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

テスト戦略

ユニットテスト (Testing Library)

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

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

E2Eテスト (Playwright)

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

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

テストカテゴリ

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

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

高優先度: APG ARIA 属性(E2E)

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

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

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

低優先度: HTML属性の継承(Unit)

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

テストツール

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

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

describe('ToggleButton (Svelte)', () => {
  // 🔴 High Priority: APG 準拠の核心
  describe('APG: キーボード操作', () => {
    it('Space キーでトグルする', async () => {
      const user = userEvent.setup();
      render(ToggleButton, {
        props: { children: 'Mute' },
      });
      const button = screen.getByRole('button');

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

    it('Enter キーでトグルする', async () => {
      const user = userEvent.setup();
      render(ToggleButton, {
        props: { children: 'Mute' },
      });
      const button = screen.getByRole('button');

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

    it('disabled 時は Tab キースキップ', async () => {
      const user = userEvent.setup();
      const container = document.createElement('div');
      document.body.appendChild(container);

      // Render three buttons manually to test tab order
      const { unmount: unmount1 } = render(ToggleButton, {
        target: container,
        props: { children: 'Button 1' },
      });
      const { unmount: unmount2 } = render(ToggleButton, {
        target: container,
        props: { children: 'Button 2', disabled: true },
      });
      const { unmount: unmount3 } = render(ToggleButton, {
        target: container,
        props: { children: 'Button 3' },
      });

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

      unmount1();
      unmount2();
      unmount3();
      document.body.removeChild(container);
    });
  });

  describe('APG: ARIA 属性', () => {
    it('role="button" を持つ(暗黙的)', () => {
      render(ToggleButton, {
        props: { children: 'Mute' },
      });
      expect(screen.getByRole('button')).toBeInTheDocument();
    });

    it('初期状態で aria-pressed="false"', () => {
      render(ToggleButton, {
        props: { children: 'Mute' },
      });
      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('aria-pressed', 'false');
    });

    it('クリック後に aria-pressed="true" に変わる', async () => {
      const user = userEvent.setup();
      render(ToggleButton, {
        props: { children: 'Mute' },
      });
      const button = screen.getByRole('button');

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

    it('type="button" が設定されている', () => {
      render(ToggleButton, {
        props: { children: 'Mute' },
      });
      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('type', 'button');
    });

    it('disabled 状態で aria-pressed 変更不可', async () => {
      const user = userEvent.setup();
      render(ToggleButton, {
        props: { children: 'Mute', disabled: true },
      });
      const button = screen.getByRole('button');

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

  // 🟡 Medium Priority: アクセシビリティ検証
  describe('アクセシビリティ', () => {
    it('axe による WCAG 2.1 AA 違反がない', async () => {
      const { container } = render(ToggleButton, {
        props: { children: 'Mute' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('アクセシブルネームが設定されている', () => {
      render(ToggleButton, {
        props: { children: 'Mute Audio' },
      });
      expect(screen.getByRole('button', { name: /Mute Audio/i })).toBeInTheDocument();
    });
  });

  describe('Props', () => {
    it('initialPressed=true で押下状態でレンダリングされる', () => {
      render(ToggleButton, {
        props: { children: 'Mute', initialPressed: true },
      });
      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('aria-pressed', 'true');
    });

    it('onToggle が状態変化時に呼び出される', async () => {
      const handleToggle = vi.fn();
      const user = userEvent.setup();
      render(ToggleButton, {
        props: { children: 'Mute', onToggle: handleToggle },
      });

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

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

  // 🟢 Low Priority: 拡張性
  describe('HTML 属性継承', () => {
    it('デフォルトで apg-toggle-button クラスが設定される', () => {
      render(ToggleButton, {
        props: { children: 'Mute' },
      });
      const button = screen.getByRole('button');
      expect(button).toHaveClass('apg-toggle-button');
    });

    it('data-* 属性が継承される', () => {
      render(ToggleButton, {
        props: { children: 'Mute', 'data-testid': 'custom-toggle' },
      });
      expect(screen.getByTestId('custom-toggle')).toBeInTheDocument();
    });
  });
});

リソース