APG Patterns

Toggle Button

A two-state button that can be either "pressed" or "not pressed".

Demo

Accessibility Features

WAI-ARIA Roles

WAI-ARIA States

aria-pressed

Indicates the current pressed state of the toggle button.

Values true | false (tri-state buttons may also use "mixed")
Required Yes (for toggle buttons)
Default initialPressed prop (default: false)
Change Trigger Click, Enter, Space
Reference aria-pressed (opens in new tab)

Keyboard Support

Key Action
Space Toggle the button state
Enter Toggle the button state

Source Code

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;

Usage

Example
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

Prop Type Default Description
initialPressed boolean false Initial pressed state
onPressedChange (pressed: boolean) => void - Callback when state changes
pressedIndicator ReactNode "●" Custom indicator for pressed state
unpressedIndicator ReactNode "○" Custom indicator for unpressed state
children ReactNode - Button label

All other props are passed to the underlying <button> element.

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements.

Test Categories

High Priority: APG Keyboard Interaction

Test Description
Space key toggles Pressing Space toggles the button state
Enter key toggles Pressing Enter toggles the button state
Tab navigation Tab key moves focus between buttons
Disabled Tab skip Disabled buttons are skipped in Tab order

High Priority: APG ARIA Attributes

Test Description
role="button" Has implicit button role (via <button>)
aria-pressed initial Initial state is aria-pressed="false"
aria-pressed toggle Click changes aria-pressed to true
type="button" Explicit button type prevents form submission
disabled state Disabled buttons don't change state on click

Medium Priority: Accessibility

Test Description
axe violations No WCAG 2.1 AA violations (via jest-axe)
accessible name Button has an accessible name from content

Low Priority: HTML Attribute Inheritance

Test Description
className merge Custom classes are merged with component classes
data-* attributes Custom data attributes are passed through

Testing Tools

See testing-strategy.md (opens in new tab) for full documentation.

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

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

    it('クリック後に aria-pressed="true" に変わる', 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('type="button" が設定されている', () => {
      render(<ToggleButton>Mute</ToggleButton>);
      const button = screen.getByRole("button");
      expect(button).toHaveAttribute("type", "button");
    });

    it("disabled 状態で aria-pressed 変更不可", 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: アクセシビリティ検証
  describe("アクセシビリティ", () => {
    it("axe による WCAG 2.1 AA 違反がない", async () => {
      const { container } = render(<ToggleButton>Mute</ToggleButton>);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

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

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

    it("onPressedChange が状態変化時に呼び出される", 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("カスタムインジケーター", () => {
    it("デフォルトで●/○インジケーターが表示される", () => {
      render(<ToggleButton>Mute</ToggleButton>);
      const button = screen.getByRole("button");
      const indicator = button.querySelector(".apg-toggle-indicator");
      expect(indicator).toHaveTextContent("○");
    });

    it("pressedIndicator でカスタムインジケーターを設定できる", () => {
      render(
        <ToggleButton initialPressed pressedIndicator="🔇">
          Mute
        </ToggleButton>
      );
      const button = screen.getByRole("button");
      const indicator = button.querySelector(".apg-toggle-indicator");
      expect(indicator).toHaveTextContent("🔇");
    });

    it("unpressedIndicator でカスタムインジケーターを設定できる", () => {
      render(
        <ToggleButton unpressedIndicator="🔊">Mute</ToggleButton>
      );
      const button = screen.getByRole("button");
      const indicator = button.querySelector(".apg-toggle-indicator");
      expect(indicator).toHaveTextContent("🔊");
    });

    it("トグル時にカスタムインジケーターが切り替わる", 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("ReactNode としてカスタムインジケーターを渡せる", () => {
      render(
        <ToggleButton
          initialPressed
          pressedIndicator={<span data-testid="custom-icon">X</span>}
        >
          Mute
        </ToggleButton>
      );
      expect(screen.getByTestId("custom-icon")).toBeInTheDocument();
    });

    it("カスタムインジケーターでも aria-hidden が維持される", () => {
      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("カスタムインジケーターでも axe 違反がない", async () => {
      const { container } = render(
        <ToggleButton pressedIndicator="🔇" unpressedIndicator="🔊">
          Mute
        </ToggleButton>
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

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

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

    it("子要素が React ノードでも正常動作", () => {
      render(
        <ToggleButton>
          <span>Icon</span> Text
        </ToggleButton>
      );
      const button = screen.getByRole("button");
      expect(button).toHaveTextContent("Icon");
      expect(button).toHaveTextContent("Text");
    });
  });
});

Resources