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

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