APG Patterns
English GitHub
English GitHub

Toolbar

ボタン、トグルボタン、その他の入力要素などのコントロールセットをグループ化するためのコンテナ。

🤖 AI Implementation Guide

デモ

テキストフォーマットツールバー

トグルボタンと通常のボタンを含む水平ツールバー。

垂直ツールバー

矢印の上下キーを使用してナビゲートします。

無効な項目あり

無効な項目はキーボードナビゲーション中にスキップされます。

イベントハンドリング付きトグルボタン

pressed-change イベントを発行するトグルボタン。現在の状態がログに記録され表示されます。

現在の状態: { bold: false, italic: false, underline: false }

適用されたフォーマットのサンプルテキスト

デフォルトの押下状態

初期状態に defaultPressed を指定したトグルボタン。無効状態も含みます。

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
toolbar コンテナ コントロールをグループ化するためのコンテナ
button ボタン要素 <button> 要素の暗黙のロール
separator セパレータ グループ間の視覚的およびセマンティックなセパレータ

WAI-ARIA toolbar role (opens in new tab)

WAI-ARIA プロパティ

属性 対象 必須 設定方法
aria-label toolbar 文字列 Yes* aria-label prop
aria-labelledby toolbar ID参照 Yes* aria-labelledby prop
aria-orientation toolbar "horizontal" | "vertical" No orientation prop (デフォルト: horizontal)

* aria-label または aria-labelledby のいずれかが必須

WAI-ARIA ステート

aria-pressed

トグルボタンの押下状態を示します。

対象 ToolbarToggleButton
true | false
必須 Yes (トグルボタンの場合)
変更トリガー Click, Enter, Space
リファレンス aria-pressed (opens in new tab)

キーボードサポート

キー アクション
Tab ツールバーへ/からフォーカスを移動(単一のタブストップ)
Arrow Right / Arrow Left コントロール間を移動(水平ツールバー)
Arrow Down / Arrow Up コントロール間を移動(垂直ツールバー)
Home 最初のコントロールにフォーカスを移動
End 最後のコントロールにフォーカスを移動
Enter / Space ボタンを実行 / 押下状態をトグル

フォーカス管理

このコンポーネントは、フォーカス管理に Roving Tabindex パターンを使用します:

  • 一度に1つのコントロールのみが tabindex="0" を持つ
  • 他のコントロールは tabindex="-1" を持つ
  • 矢印キーでコントロール間を移動
  • 無効なコントロールとセパレータはスキップされる
  • フォーカスは端で折り返さない

ソースコード

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>

使い方

---
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

プロパティ デフォルト 説明
orientation 'horizontal' | 'vertical' 'horizontal' ツールバーの方向
aria-label string - ツールバーのアクセシブルラベル
aria-labelledby string - ツールバーをラベル付けする要素の ID
class string '' 追加の CSS クラス

ToolbarButton Props

プロパティ デフォルト 説明
disabled boolean false ボタンが無効かどうか
class string '' 追加の CSS クラス

ToolbarToggleButton Props

プロパティ デフォルト 説明
defaultPressed boolean false 初期の押下状態
disabled boolean false ボタンが無効かどうか
class string '' 追加の CSS クラス

カスタムイベント

イベント 要素 Detail 説明
pressed-change apg-toolbar-toggle-button { pressed: boolean } トグルボタンの状態が変更されたときに発行される

このコンポーネントは、クライアント側のキーボードナビゲーションと状態管理のために Web Components(<apg-toolbar><apg-toolbar-toggle-button><apg-toolbar-separator>)を使用しています。

テスト

テストは、キーボード操作、ARIA属性、アクセシビリティ要件におけるAPG準拠を検証します。

テストカテゴリ

高優先度: APG キーボード操作

テスト 説明
ArrowRight/Left アイテム間でフォーカスを移動(水平)
ArrowDown/Up アイテム間でフォーカスを移動(垂直)
Home 最初のアイテムにフォーカスを移動
End 最後のアイテムにフォーカスを移動
No wrap フォーカスが端で停止(ループしない)
Disabled skip ナビゲーション中に無効なアイテムをスキップ
Enter/Space ボタンを実行またはトグルボタンをトグル

高優先度: APG ARIA 属性

テスト 説明
role="toolbar" コンテナがtoolbarロールを持つ
aria-orientation 水平/垂直の向きを反映
aria-label/labelledby ツールバーがアクセシブルな名前を持つ
aria-pressed トグルボタンが押下状態を反映
role="separator" セパレータが正しいロールと向きを持つ
type="button" ボタンが明示的なtype属性を持つ

高優先度: フォーカス管理(Roving Tabindex)

テスト 説明
tabIndex=0 最初の有効なアイテムがtabIndex=0を持つ
tabIndex=-1 他のアイテムがtabIndex=-1を持つ
Click updates focus アイテムをクリックするとRovingフォーカス位置が更新される

中優先度: アクセシビリティ

テスト 説明
axe violations WCAG 2.1 AA 違反なし(jest-axeを使用)
Vertical toolbar 垂直の向きもaxeに合格

低優先度: HTML属性継承

テスト 説明
className カスタムクラスがすべてのコンポーネントに適用される

テストツール

詳細なドキュメントは testing-strategy.md (opens in new tab) を参照してください。

リソース