APG Patterns
日本語 GitHub
日本語 GitHub

Toolbar

A container for grouping a set of controls, such as buttons, toggle buttons, or other input elements.

🤖 AI Implementation Guide

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 }

Sample text with applied formatting

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

Toolbar.astro
---
/**
 * 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>
ToolbarButton.astro
---
/**
 * 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>
ToolbarToggleButton.astro
---
// 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>
ToolbarSeparator.astro
---
/**
 * 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

See testing-strategy.md (opens in new tab) for full documentation.

Resources