APG Patterns
English
English

Toggle Button

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

デモ

デモのみ表示 →

アクセシビリティ

WAI-ARIA ロール

ロール対象要素説明
buttonボタン要素アクティブ化されたときにアクションをトリガーするウィジェットを示す

WAI-ARIA ステート

aria-pressed

対象要素
ボタン
true | false
必須
はい
変更トリガー
クリック、Enter、Space

キーボードサポート

キーアクション
Spaceボタンの状態を切り替える
Enterボタンの状態を切り替える
  • トグルボタンには表示されるラベルテキスト、aria-label、またはaria-labelledbyを通じてアクセシブルな名前が必要です。
  • type=“button”を使用して誤ったフォーム送信を防ぎます。
  • 3状態ボタンでは部分的に選択された状態(例:一部のアイテムが選択された「すべて選択」)に aria-pressed=“mixed” を使用できます。

実装ノート

Structure:
<button type="button" aria-pressed="false">
  Mute
</button>

State Changes:
- Initial: aria-pressed="false" (not pressed)
- After click: aria-pressed="true" (pressed)

Use type="button":
- Prevents accidental form submission
- Native <button> defaults to type="submit"

Tri-state (rare):
- aria-pressed="mixed" for partially selected state
- Example: "Select All" when some items selected

トグルボタンの構造と状態変化

参考資料

ソースコード

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

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

スロット

スロット デフォルト 説明
default - ボタンのラベルコンテンツ
pressed-indicator "●" 押下状態のカスタムインジケーター
unpressed-indicator "○" 非押下状態のカスタムインジケーター
その他の属性は v-bind="$attrs" を介して、基盤となる <button> 要素に渡されます。

Custom Events

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

テスト

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

テスト戦略

ユニットテスト (Testing Library)

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

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

E2Eテスト (Playwright)

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

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

テストカテゴリ

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

test description
Space key toggles Spaceキーを押すとボタンの状態が切り替わる
Enter key toggles Enterキーを押すとボタンの状態が切り替わる
Tab navigation Tabキーでボタン間のフォーカスを移動する
Disabled Tab skip 無効化されたボタンはTabの順序でスキップされる

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

test description
role="button" 暗黙的なbuttonロールを持つ(<code>&lt;button&gt;</code> 要素経由)
aria-pressed initial 初期状態は aria-pressed="false"
aria-pressed toggle クリックで aria-pressed が true に変わる
type="button" 明示的なbutton typeがフォーム送信を防ぐ
disabled state 無効化されたボタンはクリックで状態が変わらない

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

test description
axe violations WCAG 2.1 AA違反がない(jest-axe経由)
accessible name ボタンがコンテンツからアクセシブルな名前を持つ

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

test description
className merge カスタムクラスがコンポーネントのクラスとマージされる
data-* attributes カスタム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();
    });
  });
});

リソース