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.vue
<template>
  <button
    type="button"
    class="apg-toggle-button"
    :aria-pressed="pressed"
    :disabled="props.disabled"
    v-bind="$attrs"
    @click="handleClick"
  >
    <span class="apg-toggle-button-content">
      <slot />
    </span>
    <span class="apg-toggle-indicator" aria-hidden="true">
      <template v-if="pressed">
        <slot name="pressed-indicator">{{ props.pressedIndicator ?? '●' }}</slot>
      </template>
      <template v-else>
        <slot name="unpressed-indicator">{{ props.unpressedIndicator ?? '○' }}</slot>
      </template>
    </span>
  </button>
</template>

<script setup lang="ts">
import { ref } from 'vue';

// Inherit all HTML button attributes
defineOptions({
  inheritAttrs: false,
});

export interface ToggleButtonProps {
  /** Initial pressed state */
  initialPressed?: boolean;
  /** Whether the button is disabled */
  disabled?: boolean;
  /** Callback fired when toggle state changes */
  onToggle?: (pressed: boolean) => void;
  /** Custom indicator for pressed state (default: "●") */
  pressedIndicator?: string;
  /** Custom indicator for unpressed state (default: "○") */
  unpressedIndicator?: string;
}

const props = withDefaults(defineProps<ToggleButtonProps>(), {
  initialPressed: false,
  disabled: false,
  onToggle: undefined,
  pressedIndicator: undefined,
  unpressedIndicator: undefined,
});

const emit = defineEmits<{
  toggle: [pressed: boolean];
}>();

defineSlots<{
  default(): unknown;
  'pressed-indicator'(): unknown;
  'unpressed-indicator'(): unknown;
}>();

const pressed = ref(props.initialPressed);

const handleClick = () => {
  const newPressed = !pressed.value;
  pressed.value = newPressed;

  // Call onToggle prop if provided (for React compatibility)
  props.onToggle?.(newPressed);
  // Emit Vue event
  emit('toggle', newPressed);
};
</script>

使い方

Example
<script setup>
import ToggleButton from './ToggleButton.vue';
import { Volume2, VolumeOff } from 'lucide-vue-next';

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

<template>
  <ToggleButton
    :initial-pressed="false"
    @toggle="handleToggle"
  >
    <template #pressed-indicator>
      <VolumeOff :size="20" />
    </template>
    <template #unpressed-indicator>
      <Volume2 :size="20" />
    </template>
    Mute
  </ToggleButton>
</template>

API

Props

プロパティ デフォルト 説明
initialPressed boolean false 初期の押下状態
pressedIndicator string "●" 押下状態のカスタムインジケーター
unpressedIndicator string "○" 非押下状態のカスタムインジケーター

Slots

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

Events

イベント ペイロード 説明
toggle boolean 状態が変更されたときに発火

その他の属性は v-bind="$attrs" を介して、基盤となる <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) を参照してください。

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

describe('ToggleButton (Vue)', () => {
  // 🔴 High Priority: APG 準拠の核心
  describe('APG: キーボード操作', () => {
    it('Space キーでトグルする', async () => {
      const user = userEvent.setup();
      render(ToggleButton, {
        slots: { default: '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, {
        slots: { default: '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('Tab キーでフォーカス移動可能', async () => {
      const user = userEvent.setup();
      render({
        components: { ToggleButton },
        template: `
          <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('disabled 時は Tab キースキップ', async () => {
      const user = userEvent.setup();
      render({
        components: { ToggleButton },
        template: `
          <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 属性', () => {
    it('role="button" を持つ(暗黙的)', () => {
      render(ToggleButton, {
        slots: { default: 'Mute' },
      });
      expect(screen.getByRole('button')).toBeInTheDocument();
    });

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

    it('クリック後に aria-pressed="true" に変わる', async () => {
      const user = userEvent.setup();
      render(ToggleButton, {
        slots: { default: '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, {
        slots: { default: 'Mute' },
      });
      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('type', 'button');
    });

    it('disabled 状態で aria-pressed 変更不可', async () => {
      const user = userEvent.setup();
      render(ToggleButton, {
        props: { disabled: true },
        slots: { default: 'Mute' },
      });
      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, {
        slots: { default: 'Mute' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

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

  describe('Props', () => {
    it('initialPressed=true で押下状態でレンダリングされる', () => {
      render(ToggleButton, {
        props: { initialPressed: true },
        slots: { default: 'Mute' },
      });
      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: { onToggle: handleToggle },
        slots: { default: 'Mute' },
      });

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

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

    it('@toggle イベントが状態変化時に発火する', async () => {
      const handleToggle = vi.fn();
      const user = userEvent.setup();
      render(ToggleButton, {
        props: { onToggle: handleToggle },
        slots: { default: 'Mute' },
      });

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

  // 🟢 Low Priority: 拡張性
  describe('HTML 属性継承', () => {
    it('class が正しくマージされる', () => {
      render(ToggleButton, {
        attrs: { class: 'custom-class' },
        slots: { default: 'Mute' },
      });
      const button = screen.getByRole('button');
      expect(button).toHaveClass('custom-class');
      expect(button).toHaveClass('apg-toggle-button');
    });

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

リソース