Toolbar
A container for grouping a set of controls, such as buttons, toggle buttons, or other input elements.
🤖 AI Implementation GuideDemo
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.
Toggle Buttons with Event Handling
Toggle buttons that emit pressed-change events. The current state is logged and displayed.
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
---
/**
* APG Toolbar Pattern - Astro Implementation
*
* A container for grouping a set of controls using Web Components.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/
*/
export interface Props {
/** Direction of the toolbar */
orientation?: 'horizontal' | 'vertical';
/** Accessible label for the toolbar */
'aria-label'?: string;
/** ID of element that labels the toolbar */
'aria-labelledby'?: string;
/** ID for the toolbar element */
id?: string;
/** Additional CSS class */
class?: string;
}
const {
orientation = 'horizontal',
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
id,
class: className = '',
} = Astro.props;
---
<apg-toolbar {...id ? { id } : {}} class={className} data-orientation={orientation}>
<div
role="toolbar"
aria-orientation={orientation}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
class="apg-toolbar"
>
<slot />
</div>
</apg-toolbar>
<script>
class ApgToolbar extends HTMLElement {
private toolbar: HTMLElement | null = null;
private rafId: number | null = null;
private focusedIndex = 0;
private observer: MutationObserver | null = null;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.toolbar = this.querySelector('[role="toolbar"]');
if (!this.toolbar) {
console.warn('apg-toolbar: toolbar element not found');
return;
}
this.toolbar.addEventListener('keydown', this.handleKeyDown);
this.toolbar.addEventListener('focusin', this.handleFocus);
// Observe DOM changes to update roving tabindex
this.observer = new MutationObserver(() => this.updateTabIndices());
this.observer.observe(this.toolbar, { childList: true, subtree: true });
// Initialize roving tabindex
this.updateTabIndices();
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.observer?.disconnect();
this.observer = null;
this.toolbar?.removeEventListener('keydown', this.handleKeyDown);
this.toolbar?.removeEventListener('focusin', this.handleFocus);
this.toolbar = null;
}
private getButtons(): HTMLButtonElement[] {
if (!this.toolbar) return [];
return Array.from(this.toolbar.querySelectorAll<HTMLButtonElement>('button:not([disabled])'));
}
private updateTabIndices() {
const buttons = this.getButtons();
if (buttons.length === 0) return;
// Clamp focusedIndex to valid range
if (this.focusedIndex >= buttons.length) {
this.focusedIndex = buttons.length - 1;
}
buttons.forEach((btn, index) => {
btn.tabIndex = index === this.focusedIndex ? 0 : -1;
});
}
private handleFocus = (event: FocusEvent) => {
const buttons = this.getButtons();
const targetIndex = buttons.findIndex((btn) => btn === event.target);
if (targetIndex !== -1 && targetIndex !== this.focusedIndex) {
this.focusedIndex = targetIndex;
this.updateTabIndices();
}
};
private handleKeyDown = (event: KeyboardEvent) => {
const buttons = this.getButtons();
if (buttons.length === 0) return;
const currentIndex = buttons.findIndex((btn) => btn === document.activeElement);
if (currentIndex === -1) return;
const orientation = this.dataset.orientation || 'horizontal';
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) {
this.focusedIndex = newIndex;
this.updateTabIndices();
buttons[newIndex].focus();
}
}
};
}
if (!customElements.get('apg-toolbar')) {
customElements.define('apg-toolbar', ApgToolbar);
}
</script> ---
/**
* APG Toolbar Button - Astro Implementation
*
* A button component for use within a Toolbar.
*/
export interface Props {
/** Whether the button is disabled */
disabled?: boolean;
/** Additional CSS class */
class?: string;
}
const { disabled = false, class: className = '' } = Astro.props;
---
<button type="button" class={`apg-toolbar-button ${className}`.trim()} disabled={disabled}>
<slot />
</button> ---
// APG Toolbar Toggle Button - Astro Implementation
//
// A toggle button component for use within a Toolbar.
// Uses Web Components for client-side interactivity.
//
// Note: This component is uncontrolled-only (no `pressed` prop for controlled state).
// This is a limitation of the Astro/Web Components architecture where props are
// only available at build time. For controlled state management, use the
// `pressed-change` custom event to sync with external state.
//
// @example
// <ToolbarToggleButton id="bold-btn" defaultPressed={false}>Bold</ToolbarToggleButton>
//
// <script>
// document.getElementById('bold-btn')?.addEventListener('pressed-change', (e) => {
// console.log('Pressed:', e.detail.pressed);
// });
// </script>
export interface Props {
/** Initial pressed state (uncontrolled) */
defaultPressed?: boolean;
/** Whether the button is disabled */
disabled?: boolean;
/** Additional CSS class */
class?: string;
}
const { defaultPressed = false, disabled = false, class: className = '' } = Astro.props;
---
<apg-toolbar-toggle-button>
<button
type="button"
class={`apg-toolbar-button ${className}`.trim()}
aria-pressed={defaultPressed}
disabled={disabled}
>
<slot />
</button>
</apg-toolbar-toggle-button>
<script>
class ApgToolbarToggleButton extends HTMLElement {
private button: HTMLButtonElement | null = null;
private rafId: number | null = null;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.button = this.querySelector('button');
if (!this.button) {
console.warn('apg-toolbar-toggle-button: button element not found');
return;
}
this.button.addEventListener('click', this.handleClick);
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.button?.removeEventListener('click', this.handleClick);
this.button = null;
}
private handleClick = () => {
if (!this.button || this.button.disabled) return;
const currentPressed = this.button.getAttribute('aria-pressed') === 'true';
const newPressed = !currentPressed;
this.button.setAttribute('aria-pressed', String(newPressed));
// Dispatch custom event for external listeners
this.dispatchEvent(
new CustomEvent('pressed-change', {
detail: { pressed: newPressed },
bubbles: true,
})
);
};
}
if (!customElements.get('apg-toolbar-toggle-button')) {
customElements.define('apg-toolbar-toggle-button', ApgToolbarToggleButton);
}
</script> ---
/**
* APG Toolbar Separator - Astro Implementation
*
* A separator component for use within a Toolbar.
* Note: The aria-orientation is set by JavaScript based on the parent toolbar's orientation.
*/
export interface Props {
/** Additional CSS class */
class?: string;
}
const { class: className = '' } = Astro.props;
// Default to vertical (for horizontal toolbar)
// Will be updated by JavaScript if within a vertical toolbar
---
<apg-toolbar-separator>
<div
role="separator"
aria-orientation="vertical"
class={`apg-toolbar-separator ${className}`.trim()}
>
</div>
</apg-toolbar-separator>
<script>
class ApgToolbarSeparator extends HTMLElement {
private separator: HTMLElement | null = null;
private rafId: number | null = null;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.separator = this.querySelector('[role="separator"]');
if (!this.separator) return;
// Find parent toolbar and get its orientation
const toolbar = this.closest('apg-toolbar');
if (toolbar) {
const toolbarOrientation = toolbar.getAttribute('data-orientation') || 'horizontal';
// Separator orientation is perpendicular to toolbar orientation
const separatorOrientation =
toolbarOrientation === 'horizontal' ? 'vertical' : 'horizontal';
this.separator.setAttribute('aria-orientation', separatorOrientation);
}
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.separator = null;
}
}
if (!customElements.get('apg-toolbar-separator')) {
customElements.define('apg-toolbar-separator', ApgToolbarSeparator);
}
</script> Usage
---
import Toolbar from '@patterns/toolbar/Toolbar.astro';
import ToolbarButton from '@patterns/toolbar/ToolbarButton.astro';
import ToolbarToggleButton from '@patterns/toolbar/ToolbarToggleButton.astro';
import ToolbarSeparator from '@patterns/toolbar/ToolbarSeparator.astro';
---
<Toolbar aria-label="Text formatting">
<ToolbarToggleButton>Bold</ToolbarToggleButton>
<ToolbarToggleButton>Italic</ToolbarToggleButton>
<ToolbarSeparator />
<ToolbarButton>Copy</ToolbarButton>
<ToolbarButton>Paste</ToolbarButton>
</Toolbar>
<script>
// Listen for toggle button state changes
document.querySelectorAll('apg-toolbar-toggle-button').forEach(btn => {
btn.addEventListener('pressed-change', (e) => {
console.log('Toggle changed:', e.detail.pressed);
});
});
</script> API
Toolbar Props
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | 'horizontal' | 'vertical' | 'horizontal' | Direction of the toolbar |
aria-label | string | - | Accessible label for the toolbar |
aria-labelledby | string | - | ID of element that labels the toolbar |
class | string | '' | Additional CSS class |
ToolbarButton Props
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Whether the button is disabled |
class | string | '' | Additional CSS class |
ToolbarToggleButton Props
| Prop | Type | Default | Description |
|---|---|---|---|
defaultPressed | boolean | false | Initial pressed state |
disabled | boolean | false | Whether the button is disabled |
class | string | '' | Additional CSS class |
Custom Events
| Event | Element | Detail | Description |
|---|---|---|---|
pressed-change | apg-toolbar-toggle-button | { pressed: boolean } | Fired when toggle button state changes |
This component uses Web Components (<apg-toolbar>, <apg-toolbar-toggle-button>, <apg-toolbar-separator>) for client-side keyboard navigation and state
management.
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.
Resources
-
WAI-ARIA APG: Toolbar Pattern
(opens in new tab)
-
WAI-ARIA: toolbar role
(opens in new tab)
-
AI Implementation Guide (llm.md)
(opens in new tab) - ARIA specs, keyboard support, test checklist