Toolbar
A container for grouping a set of controls, such as buttons, toggle buttons, or other input elements.
Demo
Text Formatting Toolbar
A horizontal toolbar with toggle buttons and regular buttons.
Vertical Toolbar
Use arrow up/down keys to navigate.
With Disabled Items
Disabled items are skipped during keyboard navigation.
Controlled Toggle Buttons
Toggle buttons with controlled state. The current state is displayed and applied to the sample text.
Current state: {"bold":false,"italic":false,"underline":false}
Default Pressed States
Toggle buttons with defaultPressed for initial state, including disabled states.
Accessibility
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
toolbar | Container | Container for grouping controls |
button | Button elements | Implicit role for <button> elements |
separator | Separator | Visual and semantic separator between groups |
WAI-ARIA toolbar role (opens in new tab)
WAI-ARIA Properties
| Attribute | Target | Values | Required | Configuration |
|---|---|---|---|---|
aria-label | toolbar | String | Yes* | aria-label prop |
aria-labelledby | toolbar | ID reference | Yes* | aria-labelledby prop |
aria-orientation | toolbar | "horizontal" | "vertical" | No | orientation prop (default: horizontal) |
* Either aria-label or aria-labelledby is required
WAI-ARIA States
aria-pressed
Indicates the pressed state of toggle buttons.
| Target | ToolbarToggleButton |
| Values | true | false |
| Required | Yes (for toggle buttons) |
| Change Trigger | Click, Enter, Space |
| Reference | aria-pressed (opens in new tab) |
Keyboard Support
| Key | Action |
|---|---|
| Tab | Move focus into/out of the toolbar (single tab stop) |
| Arrow Right / Arrow Left | Navigate between controls (horizontal toolbar) |
| Arrow Down / Arrow Up | Navigate between controls (vertical toolbar) |
| Home | Move focus to first control |
| End | Move focus to last control |
| Enter / Space | Activate button / toggle pressed state |
Focus Management
This component uses the Roving Tabindex pattern for focus management:
- Only one control has
tabindex="0"at a time - Other controls have
tabindex="-1" - Arrow keys move focus between controls
- Disabled controls and separators are skipped
- Focus does not wrap (stops at edges)
Source Code
<script lang="ts">
import type { Snippet } from 'svelte';
import { setToolbarContext } from './toolbar-context.svelte';
interface ToolbarProps {
children?: Snippet<[]>;
orientation?: 'horizontal' | 'vertical';
class?: string;
[key: string]: unknown;
}
let {
children,
orientation = 'horizontal',
class: className = '',
...restProps
}: ToolbarProps = $props();
let toolbarRef: HTMLDivElement | undefined = $state();
let focusedIndex = $state(0);
// Provide reactive context to child components
setToolbarContext(() => orientation);
function getButtons(): HTMLButtonElement[] {
if (!toolbarRef) return [];
return Array.from(
toolbarRef.querySelectorAll<HTMLButtonElement>('button:not([disabled])')
);
}
// Track DOM mutations to detect slot content changes
let mutationCount = $state(0);
$effect(() => {
if (!toolbarRef) return;
const observer = new MutationObserver(() => {
mutationCount++;
});
observer.observe(toolbarRef, { childList: true, subtree: true });
return () => observer.disconnect();
});
// Roving tabindex: only the focused button should have tabIndex=0
$effect(() => {
// Dependencies: focusedIndex and mutationCount (for slot content changes)
void mutationCount;
const buttons = getButtons();
if (buttons.length === 0) return;
// Clamp focusedIndex to valid range
if (focusedIndex >= buttons.length) {
focusedIndex = buttons.length - 1;
return; // Will re-run with corrected index
}
buttons.forEach((btn, index) => {
btn.tabIndex = index === focusedIndex ? 0 : -1;
});
});
function handleFocus(event: FocusEvent) {
const buttons = getButtons();
const targetIndex = buttons.findIndex(btn => btn === event.target);
if (targetIndex !== -1) {
focusedIndex = targetIndex;
}
}
function handleKeyDown(event: KeyboardEvent) {
const buttons = getButtons();
if (buttons.length === 0) return;
const currentIndex = buttons.findIndex(btn => btn === document.activeElement);
if (currentIndex === -1) return;
const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
const prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
const invalidKeys = orientation === 'vertical'
? ['ArrowLeft', 'ArrowRight']
: ['ArrowUp', 'ArrowDown'];
// Ignore invalid direction keys
if (invalidKeys.includes(event.key)) {
return;
}
let newIndex = currentIndex;
let shouldPreventDefault = false;
switch (event.key) {
case nextKey:
// No wrap - stop at end
if (currentIndex < buttons.length - 1) {
newIndex = currentIndex + 1;
}
shouldPreventDefault = true;
break;
case prevKey:
// No wrap - stop at start
if (currentIndex > 0) {
newIndex = currentIndex - 1;
}
shouldPreventDefault = true;
break;
case 'Home':
newIndex = 0;
shouldPreventDefault = true;
break;
case 'End':
newIndex = buttons.length - 1;
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
event.preventDefault();
if (newIndex !== currentIndex) {
buttons[newIndex].focus();
focusedIndex = newIndex;
}
}
}
</script>
<div
bind:this={toolbarRef}
role="toolbar"
aria-orientation={orientation}
class="apg-toolbar {className}"
{...restProps}
onfocusin={handleFocus}
onkeydown={handleKeyDown}
>
{#if children}
{@render children()}
{/if}
</div> <script lang="ts">
import type { Snippet } from 'svelte';
import { getToolbarContext } from './toolbar-context.svelte';
interface ToolbarButtonProps {
children?: Snippet<[]>;
disabled?: boolean;
class?: string;
onclick?: (event: MouseEvent) => void;
[key: string]: unknown;
}
let {
children,
disabled = false,
class: className = '',
onclick,
...restProps
}: ToolbarButtonProps = $props();
// Verify we're inside a Toolbar
const context = getToolbarContext();
if (!context) {
console.warn('ToolbarButton must be used within a Toolbar');
}
</script>
<button
type="button"
class="apg-toolbar-button {className}"
{disabled}
{onclick}
{...restProps}
>
{#if children}
{@render children()}
{/if}
</button> <script lang="ts">
import type { Snippet } from 'svelte';
import { untrack } from 'svelte';
import { getToolbarContext } from './toolbar-context.svelte';
interface ToolbarToggleButtonProps {
children?: Snippet<[]>;
/** Controlled pressed state */
pressed?: boolean;
/** Default pressed state (uncontrolled) */
defaultPressed?: boolean;
/** Callback when pressed state changes */
onPressedChange?: (pressed: boolean) => void;
disabled?: boolean;
class?: string;
onclick?: (event: MouseEvent) => void;
[key: string]: unknown;
}
let {
children,
pressed: controlledPressed = undefined,
defaultPressed = false,
onPressedChange,
disabled = false,
class: className = '',
onclick,
...restProps
}: ToolbarToggleButtonProps = $props();
// Verify we're inside a Toolbar
const context = getToolbarContext();
if (!context) {
console.warn('ToolbarToggleButton must be used within a Toolbar');
}
let internalPressed = $state(untrack(() => defaultPressed));
let isControlled = $derived(controlledPressed !== undefined);
let pressed = $derived(isControlled ? controlledPressed : internalPressed);
function handleClick(event: MouseEvent) {
if (disabled) return;
const newPressed = !pressed;
if (!isControlled) {
internalPressed = newPressed;
}
onPressedChange?.(newPressed);
onclick?.(event);
}
</script>
<button
type="button"
aria-pressed={pressed}
class="apg-toolbar-button {className}"
{disabled}
onclick={handleClick}
{...restProps}
>
{#if children}
{@render children()}
{/if}
</button> <script lang="ts">
import { getToolbarContext } from './toolbar-context.svelte';
interface ToolbarSeparatorProps {
class?: string;
}
let {
class: className = ''
}: ToolbarSeparatorProps = $props();
// Verify we're inside a Toolbar
const context = getToolbarContext();
if (!context) {
console.warn('ToolbarSeparator must be used within a Toolbar');
}
// Separator orientation is perpendicular to toolbar orientation
let separatorOrientation = $derived(
context?.orientation === 'horizontal' ? 'vertical' : 'horizontal'
);
</script>
<div
role="separator"
aria-orientation={separatorOrientation}
class="apg-toolbar-separator {className}"
/> Usage
<script>
import Toolbar from '@patterns/toolbar/Toolbar.svelte';
import ToolbarButton from '@patterns/toolbar/ToolbarButton.svelte';
import ToolbarToggleButton from '@patterns/toolbar/ToolbarToggleButton.svelte';
import ToolbarSeparator from '@patterns/toolbar/ToolbarSeparator.svelte';
</script>
<Toolbar aria-label="Text formatting">
<ToolbarToggleButton>Bold</ToolbarToggleButton>
<ToolbarToggleButton>Italic</ToolbarToggleButton>
<ToolbarSeparator />
<ToolbarButton>Copy</ToolbarButton>
<ToolbarButton>Paste</ToolbarButton>
</Toolbar> API
Toolbar Props
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | 'horizontal' | 'vertical' | 'horizontal' | Direction of the toolbar |
ToolbarToggleButton Props
| Prop | Type | Default | Description |
|---|---|---|---|
pressed | boolean | - | Controlled pressed state |
defaultPressed | boolean | false | Initial pressed state |
onPressedChange | (pressed: boolean) => void | - | Callback when pressed state changes |
Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements.
Test Categories
High Priority: APG Keyboard Interaction
| Test | Description |
|---|---|
ArrowRight/Left | Moves focus between items (horizontal) |
ArrowDown/Up | Moves focus between items (vertical) |
Home | Moves focus to first item |
End | Moves focus to last item |
No wrap | Focus stops at edges (no looping) |
Disabled skip | Skips disabled items during navigation |
Enter/Space | Activates button or toggles toggle button |
High Priority: APG ARIA Attributes
| Test | Description |
|---|---|
role="toolbar" | Container has toolbar role |
aria-orientation | Reflects horizontal/vertical orientation |
aria-label/labelledby | Toolbar has accessible name |
aria-pressed | Toggle buttons reflect pressed state |
role="separator" | Separator has correct role and orientation |
type="button" | Buttons have explicit type attribute |
High Priority: Focus Management (Roving Tabindex)
| Test | Description |
|---|---|
tabIndex=0 | First enabled item has tabIndex=0 |
tabIndex=-1 | Other items have tabIndex=-1 |
Click updates focus | Clicking an item updates roving focus position |
Medium Priority: Accessibility
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations (via jest-axe) |
Vertical toolbar | Vertical orientation also passes axe |
Low Priority: HTML Attribute Inheritance
| Test | Description |
|---|---|
className | Custom classes applied to all components |
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.
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 test wrapper components
import ToolbarTestBasic from "./test-wrappers/ToolbarTestBasic.svelte";
import ToolbarTestVertical from "./test-wrappers/ToolbarTestVertical.svelte";
import ToolbarTestDisabled from "./test-wrappers/ToolbarTestDisabled.svelte";
import ToolbarTestToggle from "./test-wrappers/ToolbarTestToggle.svelte";
import ToolbarTestSeparator from "./test-wrappers/ToolbarTestSeparator.svelte";
import ToolbarTestSeparatorVertical from "./test-wrappers/ToolbarTestSeparatorVertical.svelte";
describe("Toolbar (Svelte)", () => {
// 🔴 High Priority: APG 準拠の核心
describe("APG: ARIA 属性", () => {
it('role="toolbar" が設定される', () => {
render(ToolbarTestBasic);
expect(screen.getByRole("toolbar")).toBeInTheDocument();
});
it('aria-orientation がデフォルトで "horizontal"', () => {
render(ToolbarTestBasic);
expect(screen.getByRole("toolbar")).toHaveAttribute(
"aria-orientation",
"horizontal"
);
});
it('aria-orientation が orientation prop を反映する', () => {
render(ToolbarTestVertical);
expect(screen.getByRole("toolbar")).toHaveAttribute(
"aria-orientation",
"vertical"
);
});
it("aria-label が透過される", () => {
render(ToolbarTestBasic);
expect(screen.getByRole("toolbar")).toHaveAttribute(
"aria-label",
"Test toolbar"
);
});
});
describe("APG: キーボード操作 (Horizontal)", () => {
it("ArrowRight で次のボタンにフォーカス移動", async () => {
const user = userEvent.setup();
render(ToolbarTestBasic);
const firstButton = screen.getByRole("button", { name: "First" });
firstButton.focus();
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("button", { name: "Second" })).toHaveFocus();
});
it("ArrowLeft で前のボタンにフォーカス移動", async () => {
const user = userEvent.setup();
render(ToolbarTestBasic);
const secondButton = screen.getByRole("button", { name: "Second" });
secondButton.focus();
await user.keyboard("{ArrowLeft}");
expect(screen.getByRole("button", { name: "First" })).toHaveFocus();
});
it("ArrowRight で最後から先頭にラップしない(端で止まる)", async () => {
const user = userEvent.setup();
render(ToolbarTestBasic);
const thirdButton = screen.getByRole("button", { name: "Third" });
thirdButton.focus();
await user.keyboard("{ArrowRight}");
expect(thirdButton).toHaveFocus();
});
it("ArrowLeft で先頭から最後にラップしない(端で止まる)", async () => {
const user = userEvent.setup();
render(ToolbarTestBasic);
const firstButton = screen.getByRole("button", { name: "First" });
firstButton.focus();
await user.keyboard("{ArrowLeft}");
expect(firstButton).toHaveFocus();
});
it("ArrowUp/Down は水平ツールバーでは無効", async () => {
const user = userEvent.setup();
render(ToolbarTestBasic);
const firstButton = screen.getByRole("button", { name: "First" });
firstButton.focus();
await user.keyboard("{ArrowDown}");
expect(firstButton).toHaveFocus();
await user.keyboard("{ArrowUp}");
expect(firstButton).toHaveFocus();
});
it("Home で最初のボタンにフォーカス移動", async () => {
const user = userEvent.setup();
render(ToolbarTestBasic);
const thirdButton = screen.getByRole("button", { name: "Third" });
thirdButton.focus();
await user.keyboard("{Home}");
expect(screen.getByRole("button", { name: "First" })).toHaveFocus();
});
it("End で最後のボタンにフォーカス移動", async () => {
const user = userEvent.setup();
render(ToolbarTestBasic);
const firstButton = screen.getByRole("button", { name: "First" });
firstButton.focus();
await user.keyboard("{End}");
expect(screen.getByRole("button", { name: "Third" })).toHaveFocus();
});
it("disabled アイテムをスキップして移動", async () => {
const user = userEvent.setup();
render(ToolbarTestDisabled);
const firstButton = screen.getByRole("button", { name: "First" });
firstButton.focus();
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("button", { name: "Third" })).toHaveFocus();
});
});
describe("APG: キーボード操作 (Vertical)", () => {
it("ArrowDown で次のボタンにフォーカス移動", async () => {
const user = userEvent.setup();
render(ToolbarTestVertical);
const firstButton = screen.getByRole("button", { name: "First" });
firstButton.focus();
await user.keyboard("{ArrowDown}");
expect(screen.getByRole("button", { name: "Second" })).toHaveFocus();
});
it("ArrowUp で前のボタンにフォーカス移動", async () => {
const user = userEvent.setup();
render(ToolbarTestVertical);
const secondButton = screen.getByRole("button", { name: "Second" });
secondButton.focus();
await user.keyboard("{ArrowUp}");
expect(screen.getByRole("button", { name: "First" })).toHaveFocus();
});
it("ArrowLeft/Right は垂直ツールバーでは無効", async () => {
const user = userEvent.setup();
render(ToolbarTestVertical);
const firstButton = screen.getByRole("button", { name: "First" });
firstButton.focus();
await user.keyboard("{ArrowRight}");
expect(firstButton).toHaveFocus();
await user.keyboard("{ArrowLeft}");
expect(firstButton).toHaveFocus();
});
});
});
describe("ToolbarButton (Svelte)", () => {
describe("ARIA 属性", () => {
it('role="button" が暗黙的に設定される', () => {
render(ToolbarTestBasic);
expect(screen.getByRole("button", { name: "First" })).toBeInTheDocument();
});
it('type="button" が設定される', () => {
render(ToolbarTestBasic);
expect(screen.getByRole("button", { name: "First" })).toHaveAttribute(
"type",
"button"
);
});
});
describe("機能", () => {
it("disabled 時はフォーカス対象外(disabled属性で非フォーカス)", () => {
render(ToolbarTestDisabled);
const disabledButton = screen.getByRole("button", { name: "Second (disabled)" });
expect(disabledButton).toBeDisabled();
});
});
});
describe("ToolbarToggleButton (Svelte)", () => {
describe("ARIA 属性", () => {
it('aria-pressed="false" が初期状態で設定される', () => {
render(ToolbarTestToggle);
expect(screen.getByRole("button", { name: "Toggle" })).toHaveAttribute(
"aria-pressed",
"false"
);
});
it('type="button" が設定される', () => {
render(ToolbarTestToggle);
expect(screen.getByRole("button", { name: "Toggle" })).toHaveAttribute(
"type",
"button"
);
});
});
describe("機能", () => {
it("クリックで aria-pressed がトグル", async () => {
const user = userEvent.setup();
render(ToolbarTestToggle);
const button = screen.getByRole("button", { name: "Toggle" });
expect(button).toHaveAttribute("aria-pressed", "false");
await user.click(button);
expect(button).toHaveAttribute("aria-pressed", "true");
await user.click(button);
expect(button).toHaveAttribute("aria-pressed", "false");
});
it("Enter で aria-pressed がトグル", async () => {
const user = userEvent.setup();
render(ToolbarTestToggle);
const button = screen.getByRole("button", { name: "Toggle" });
button.focus();
expect(button).toHaveAttribute("aria-pressed", "false");
await user.keyboard("{Enter}");
expect(button).toHaveAttribute("aria-pressed", "true");
});
it("Space で aria-pressed がトグル", async () => {
const user = userEvent.setup();
render(ToolbarTestToggle);
const button = screen.getByRole("button", { name: "Toggle" });
button.focus();
expect(button).toHaveAttribute("aria-pressed", "false");
await user.keyboard(" ");
expect(button).toHaveAttribute("aria-pressed", "true");
});
});
});
describe("ToolbarSeparator (Svelte)", () => {
describe("ARIA 属性", () => {
it('role="separator" が設定される', () => {
render(ToolbarTestSeparator);
expect(screen.getByRole("separator")).toBeInTheDocument();
});
it('horizontal toolbar 時に aria-orientation="vertical"', () => {
render(ToolbarTestSeparator);
expect(screen.getByRole("separator")).toHaveAttribute(
"aria-orientation",
"vertical"
);
});
it('vertical toolbar 時に aria-orientation="horizontal"', () => {
render(ToolbarTestSeparatorVertical);
expect(screen.getByRole("separator")).toHaveAttribute(
"aria-orientation",
"horizontal"
);
});
});
});
describe("アクセシビリティ (Svelte)", () => {
it("axe による WCAG 2.1 AA 違反がない", async () => {
const { container } = render(ToolbarTestSeparator);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("vertical toolbar でも WCAG 2.1 AA 違反がない", async () => {
const { container } = render(ToolbarTestSeparatorVertical);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});