APG Patterns
ๆ—ฅๆœฌ่ชž
ๆ—ฅๆœฌ่ชž

Toggle Button

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

Demo

Open demo only โ†’

Accessibility Features

WAI-ARIA Roles

RoleTarget ElementDescription
buttonButton elementIndicates a widget that triggers an action when activated

WAI-ARIA States

aria-pressed

Target Element
button
Values
true | false
Required
Yes
Change Trigger
Click, Enter, Space

Keyboard Support

KeyAction
SpaceToggle the button state
EnterToggle the button state
  • Toggle buttons must have an accessible name via visible label text, aria-label, or aria-labelledby.
  • Use type=โ€œbuttonโ€ to prevent accidental form submission.
  • Tri-state buttons may use aria-pressed=โ€œmixedโ€ for partially selected state (e.g., โ€œSelect Allโ€ when some items selected).

Implementation Notes

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

Toggle Button structure and state changes

References

Source Code

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>

Usage

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

Prop Type Default Description
initialPressed boolean false Initial pressed state
pressedIndicator string "โ—" Custom indicator for pressed state
unpressedIndicator string "โ—‹" Custom indicator for unpressed state

Slots

Slot Default Description
default - Button label content
pressed-indicator "โ—" Custom indicator for pressed state
unpressed-indicator "โ—‹" Custom indicator for unpressed state
All other attributes are passed to the underlying <button> element via v-bind="$attrs".

Custom Events

Event Detail Description
toggle boolean Emitted when state changes

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 <code>&lt;button&gt;</code>)
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.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();
    });
  });
});

Resources