APG Patterns
日本語 GitHub
日本語 GitHub

Toggle Button

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

🤖 AI Implementation Guide

Demo

Open demo only →

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.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>

Usage

Example
<script>
  import ToggleButton from './ToggleButton.svelte';

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

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

API

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

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. The Toggle Button component uses a two-layer testing strategy.

Testing Strategy

Unit Tests (Testing Library)

Verify component rendering and interactions using framework-specific Testing Library utilities. These tests ensure correct component behavior in isolation.

  • HTML structure and element hierarchy
  • Initial attribute values (aria-pressed, type)
  • Click event handling and state toggling
  • CSS class application

E2E Tests (Playwright)

Verify component behavior in a real browser environment across all four frameworks. These tests cover interactions that require full browser context.

  • Keyboard interactions (Space, Enter)
  • aria-pressed state toggling
  • Disabled state behavior
  • Focus management and Tab navigation
  • Cross-framework consistency

Test Categories

High Priority: APG Keyboard Interaction (E2E)

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 (E2E)

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 (E2E)

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 (Unit)

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.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();
    });
  });
});

Resources