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.astro
---
/**
 * APG Toggle Button Pattern - Astro Implementation
 *
 * A two-state button that can be either "pressed" or "not pressed".
 * Uses Web Components for client-side interactivity.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/button/
 */

export interface Props {
  /** Initial pressed state */
  initialPressed?: boolean;
  /** Whether the button is disabled */
  disabled?: boolean;
  /** Additional CSS class */
  class?: string;
}

const { initialPressed = false, disabled = false, class: className = '' } = Astro.props;

const stateClass = initialPressed ? 'apg-toggle-button--pressed' : 'apg-toggle-button--not-pressed';

const indicatorClass = initialPressed
  ? 'apg-toggle-indicator--pressed'
  : 'apg-toggle-indicator--not-pressed';

// Check if custom indicator slots are provided
const hasPressedIndicator = Astro.slots.has('pressed-indicator');
const hasUnpressedIndicator = Astro.slots.has('unpressed-indicator');
const hasCustomIndicators = hasPressedIndicator || hasUnpressedIndicator;
---

<apg-toggle-button class={className}>
  <button
    type="button"
    class={`apg-toggle-button ${stateClass}`}
    aria-pressed={initialPressed}
    disabled={disabled}
  >
    <span class="apg-toggle-button-content">
      <slot />
    </span>
    <span
      class={`apg-toggle-indicator ${indicatorClass}`}
      aria-hidden="true"
      data-custom-indicators={hasCustomIndicators ? 'true' : undefined}
    >
      {
        hasCustomIndicators ? (
          <>
            <span class="apg-indicator-pressed" hidden={!initialPressed}>
              <slot name="pressed-indicator">●</slot>
            </span>
            <span class="apg-indicator-unpressed" hidden={initialPressed}>
              <slot name="unpressed-indicator">○</slot>
            </span>
          </>
        ) : initialPressed ? (
          '●'
        ) : (
          '○'
        )
      }
    </span>
  </button>
</apg-toggle-button>

<script>
  class ApgToggleButton extends HTMLElement {
    private button: HTMLButtonElement | null = null;
    private rafId: number | null = null;

    connectedCallback() {
      // Use requestAnimationFrame to ensure DOM is fully constructed
      this.rafId = requestAnimationFrame(() => this.initialize());
    }

    private initialize() {
      this.rafId = null;
      this.button = this.querySelector('button');
      if (!this.button) {
        console.warn('apg-toggle-button: button element not found');
        return;
      }

      this.button.addEventListener('click', this.handleClick);
    }

    disconnectedCallback() {
      // Cancel pending initialization
      if (this.rafId !== null) {
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
      }
      // Remove event listeners
      this.button?.removeEventListener('click', this.handleClick);
      this.button = null;
    }

    private handleClick = () => {
      if (!this.button || this.button.disabled) return;

      const currentPressed = this.button.getAttribute('aria-pressed') === 'true';
      const newPressed = !currentPressed;

      // Update aria-pressed
      this.button.setAttribute('aria-pressed', String(newPressed));

      // Update CSS classes
      this.button.classList.toggle('apg-toggle-button--pressed', newPressed);
      this.button.classList.toggle('apg-toggle-button--not-pressed', !newPressed);

      // Update indicator
      const indicator = this.button.querySelector('.apg-toggle-indicator');
      if (indicator) {
        const hasCustomIndicators = indicator.getAttribute('data-custom-indicators') === 'true';

        if (hasCustomIndicators) {
          // Toggle visibility of custom indicator slots
          const pressedIndicator = indicator.querySelector('.apg-indicator-pressed');
          const unpressedIndicator = indicator.querySelector('.apg-indicator-unpressed');
          if (pressedIndicator instanceof HTMLElement) {
            pressedIndicator.hidden = !newPressed;
          }
          if (unpressedIndicator instanceof HTMLElement) {
            unpressedIndicator.hidden = newPressed;
          }
        } else {
          // Use default text indicators
          indicator.textContent = newPressed ? '●' : '○';
        }

        indicator.classList.toggle('apg-toggle-indicator--pressed', newPressed);
        indicator.classList.toggle('apg-toggle-indicator--not-pressed', !newPressed);
      }

      // Dispatch custom event for external listeners
      this.dispatchEvent(
        new CustomEvent('toggle', {
          detail: { pressed: newPressed },
          bubbles: true,
        })
      );
    };
  }

  // Register the custom element
  if (!customElements.get('apg-toggle-button')) {
    customElements.define('apg-toggle-button', ApgToggleButton);
  }
</script>

使い方

使用例
---
import ToggleButton from './ToggleButton.astro';
import Icon from './Icon.astro';
---

<ToggleButton>
  <Icon name="volume-off" slot="pressed-indicator" />
  <Icon name="volume-2" slot="unpressed-indicator" />
  Mute
</ToggleButton>

<script>
  // Listen for toggle events
  document.querySelector('apg-toggle-button')?.addEventListener('toggle', (e) => {
    console.log('Muted:', e.detail.pressed);
  });
</script>

API

プロパティ

プロパティ デフォルト 説明
initialPressed boolean false 初期の押下状態
disabled boolean false ボタンが無効かどうか
class string "" 追加のCSSクラス

スロット

スロット デフォルト 説明
default - ボタンラベルのコンテンツ
pressed-indicator "●" 押下状態のカスタムインジケーター
unpressed-indicator "○" 非押下状態のカスタムインジケーター

カスタムイベント

イベント 詳細 説明
toggle { pressed: boolean } トグル状態が変更されたときに発火

このコンポーネントは、クライアントサイドのインタラクティビティのためにWeb Component(<apg-toggle-button>)を使用しています。

テスト

テストは、キーボード操作、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) を参照してください。

リソース