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.
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()}
/>
</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.
Resources