Toggle Button
A two-state button that can be either "pressed" or "not pressed".
Demo
Accessibility Features
WAI-ARIA Roles
-
button- Indicates a widget that triggers an action when activated
WAI-ARIA button role (opens in new tab)
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
- Vitest (opens in new tab) - Test runner
- Testing Library (opens in new tab) - Framework-specific testing utilities
- jest-axe (opens in new tab) - Automated accessibility testing
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();
});
});
});