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

Toggle Button

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

Demo

Open demo only โ†’

Accessibility Features

roles

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)
changeTrigger Click, Enter, Space
reference aria-pressed (opens in new tab)

keyboard

key action
Space Toggle the button state
Enter Toggle the button state

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

Props

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

Events

Event Payload Description
toggle boolean Emitted when state changes

All other attributes are passed to the underlying <button> element via v-bind="$attrs".

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