APG Patterns
English GitHub
English GitHub

Toolbar

ボタン、トグルボタン、チェックボックスなどのコントロールセットをグループ化するコンテナ。

🤖 AI 実装ガイド

デモ

テキスト書式設定ツールバー

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

垂直ツールバー

上下矢印キーでナビゲートできます。

無効化されたアイテムを含む

無効化されたアイテムはキーボードナビゲーション時にスキップされます。

制御されたトグルボタン

制御された状態を持つトグルボタン。現在の状態が表示され、サンプルテキストに適用されます。

Current state: {"bold":false,"italic":false,"underline":false}

Sample text with applied formatting

デフォルトの押下状態

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.tsx
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';

/**
 * Toolbar context for managing focus state
 */
interface ToolbarContextValue {
  orientation: 'horizontal' | 'vertical';
}

// Default context value for SSR compatibility
const defaultContext: ToolbarContextValue = {
  orientation: 'horizontal',
};

const ToolbarContext = createContext<ToolbarContextValue>(defaultContext);

function useToolbarContext() {
  return useContext(ToolbarContext);
}

/**
 * Props for the Toolbar component
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/
 */
export interface ToolbarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'role'> {
  /** Direction of the toolbar */
  orientation?: 'horizontal' | 'vertical';
  /** Child elements (ToolbarButton, ToolbarToggleButton, ToolbarSeparator) */
  children: React.ReactNode;
}

/**
 * Toolbar container component implementing WAI-ARIA Toolbar pattern
 *
 * @example
 * ```tsx
 * <Toolbar aria-label="Text formatting">
 *   <ToolbarToggleButton>Bold</ToolbarToggleButton>
 *   <ToolbarToggleButton>Italic</ToolbarToggleButton>
 *   <ToolbarSeparator />
 *   <ToolbarButton>Copy</ToolbarButton>
 * </Toolbar>
 * ```
 */
export function Toolbar({
  orientation = 'horizontal',
  children,
  className = '',
  onKeyDown,
  ...props
}: ToolbarProps): React.ReactElement {
  const toolbarRef = useRef<HTMLDivElement>(null);
  const [focusedIndex, setFocusedIndex] = useState(0);

  const getButtons = useCallback((): HTMLButtonElement[] => {
    if (!toolbarRef.current) return [];
    return Array.from(
      toolbarRef.current.querySelectorAll<HTMLButtonElement>('button:not([disabled])')
    );
  }, []);

  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLDivElement>) => {
      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();
          setFocusedIndex(newIndex);
        }
      }

      onKeyDown?.(event);
    },
    [orientation, getButtons, onKeyDown]
  );

  const handleFocus = useCallback(
    (event: React.FocusEvent<HTMLDivElement>) => {
      const { target } = event;
      if (!(target instanceof HTMLButtonElement)) return;

      const buttons = getButtons();
      const targetIndex = buttons.findIndex((btn) => btn === target);
      if (targetIndex !== -1) {
        setFocusedIndex(targetIndex);
      }
    },
    [getButtons]
  );

  // Roving tabindex: only the focused button should have tabIndex=0
  useEffect(() => {
    const buttons = getButtons();
    if (buttons.length === 0) return;

    // Clamp focusedIndex to valid range
    const validIndex = Math.min(focusedIndex, buttons.length - 1);
    if (validIndex !== focusedIndex) {
      setFocusedIndex(validIndex);
      return; // Will re-run with corrected index
    }

    buttons.forEach((btn, index) => {
      btn.tabIndex = index === focusedIndex ? 0 : -1;
    });
  }, [focusedIndex, getButtons, children]);

  return (
    <ToolbarContext.Provider value={{ orientation }}>
      <div
        ref={toolbarRef}
        role="toolbar"
        aria-orientation={orientation}
        className={`apg-toolbar ${className}`.trim()}
        onKeyDown={handleKeyDown}
        onFocus={handleFocus}
        {...props}
      >
        {children}
      </div>
    </ToolbarContext.Provider>
  );
}

/**
 * Props for the ToolbarButton component
 */
export interface ToolbarButtonProps extends Omit<
  React.ButtonHTMLAttributes<HTMLButtonElement>,
  'type'
> {
  /** Button content */
  children: React.ReactNode;
}

/**
 * Button component for use within a Toolbar
 */
export function ToolbarButton({
  children,
  className = '',
  disabled,
  ...props
}: ToolbarButtonProps): React.ReactElement {
  // Verify we're inside a Toolbar
  useToolbarContext();

  return (
    <button
      type="button"
      className={`apg-toolbar-button ${className}`.trim()}
      disabled={disabled}
      {...props}
    >
      {children}
    </button>
  );
}

/**
 * Props for the ToolbarToggleButton component
 */
export interface ToolbarToggleButtonProps extends Omit<
  React.ButtonHTMLAttributes<HTMLButtonElement>,
  'type' | 'aria-pressed'
> {
  /** Controlled pressed state */
  pressed?: boolean;
  /** Default pressed state (uncontrolled) */
  defaultPressed?: boolean;
  /** Callback when pressed state changes */
  onPressedChange?: (pressed: boolean) => void;
  /** Button content */
  children: React.ReactNode;
}

/**
 * Toggle button component for use within a Toolbar
 */
export function ToolbarToggleButton({
  pressed: controlledPressed,
  defaultPressed = false,
  onPressedChange,
  children,
  className = '',
  disabled,
  onClick,
  ...props
}: ToolbarToggleButtonProps): React.ReactElement {
  // Verify we're inside a Toolbar
  useToolbarContext();

  const [internalPressed, setInternalPressed] = useState(defaultPressed);
  const isControlled = controlledPressed !== undefined;
  const pressed = isControlled ? controlledPressed : internalPressed;

  const handleClick = useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      if (disabled) return;

      const newPressed = !pressed;

      if (!isControlled) {
        setInternalPressed(newPressed);
      }

      onPressedChange?.(newPressed);
      onClick?.(event);
    },
    [disabled, pressed, isControlled, onPressedChange, onClick]
  );

  return (
    <button
      type="button"
      aria-pressed={pressed}
      className={`apg-toolbar-button ${className}`.trim()}
      disabled={disabled}
      onClick={handleClick}
      {...props}
    >
      {children}
    </button>
  );
}

/**
 * Props for the ToolbarSeparator component
 */
export interface ToolbarSeparatorProps {
  /** Additional CSS class */
  className?: string;
}

/**
 * Separator component for use within a Toolbar
 */
export function ToolbarSeparator({ className = '' }: ToolbarSeparatorProps): React.ReactElement {
  const { orientation } = useToolbarContext();

  // Separator orientation is perpendicular to toolbar orientation
  const separatorOrientation = orientation === 'horizontal' ? 'vertical' : 'horizontal';

  return (
    <div
      role="separator"
      aria-orientation={separatorOrientation}
      className={`apg-toolbar-separator ${className}`.trim()}
    />
  );
}

使い方

import {
  Toolbar,
  ToolbarButton,
  ToolbarToggleButton,
  ToolbarSeparator
} from '@patterns/toolbar/Toolbar';

// Basic usage
<Toolbar aria-label="Text formatting">
  <ToolbarToggleButton>Bold</ToolbarToggleButton>
  <ToolbarToggleButton>Italic</ToolbarToggleButton>
  <ToolbarSeparator />
  <ToolbarButton>Copy</ToolbarButton>
  <ToolbarButton>Paste</ToolbarButton>
</Toolbar>

// Vertical toolbar
<Toolbar orientation="vertical" aria-label="Actions">
  <ToolbarButton>New</ToolbarButton>
  <ToolbarButton>Open</ToolbarButton>
  <ToolbarButton>Save</ToolbarButton>
</Toolbar>

// Controlled toggle button
const [isBold, setIsBold] = useState(false);

<Toolbar aria-label="Formatting">
  <ToolbarToggleButton
    pressed={isBold}
    onPressedChange={setIsBold}
  >
    Bold
  </ToolbarToggleButton>
</Toolbar>

API

Toolbar Props

プロパティ デフォルト 説明
orientation 'horizontal' | 'vertical' 'horizontal' ツールバーの方向
aria-label string - ツールバーのアクセシブルラベル
children React.ReactNode - ツールバーのコンテンツ

ToolbarButton Props

プロパティ デフォルト 説明
disabled boolean false ボタンが無効化されているかどうか
onClick () => void - クリックハンドラー

ToolbarToggleButton Props

プロパティ デフォルト 説明
pressed boolean - 制御された押下状態
defaultPressed boolean false 初期押下状態(非制御)
onPressedChange (pressed: boolean) => void - 押下状態が変更された時のコールバック
disabled boolean false ボタンが無効化されているかどうか

テスト

テストは、キーボード操作、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) を参照してください。

Toolbar.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Toolbar, ToolbarButton, ToolbarToggleButton, ToolbarSeparator } from './Toolbar';

describe('Toolbar', () => {
  // 🔴 High Priority: APG Core Compliance
  describe('APG: ARIA Attributes', () => {
    it('has role="toolbar"', () => {
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton>Button</ToolbarButton>
        </Toolbar>
      );
      expect(screen.getByRole('toolbar')).toBeInTheDocument();
    });

    it('has aria-orientation="horizontal" by default', () => {
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton>Button</ToolbarButton>
        </Toolbar>
      );
      expect(screen.getByRole('toolbar')).toHaveAttribute('aria-orientation', 'horizontal');
    });

    it('aria-orientation reflects orientation prop', () => {
      const { rerender } = render(
        <Toolbar aria-label="Test toolbar" orientation="vertical">
          <ToolbarButton>Button</ToolbarButton>
        </Toolbar>
      );
      expect(screen.getByRole('toolbar')).toHaveAttribute('aria-orientation', 'vertical');

      rerender(
        <Toolbar aria-label="Test toolbar" orientation="horizontal">
          <ToolbarButton>Button</ToolbarButton>
        </Toolbar>
      );
      expect(screen.getByRole('toolbar')).toHaveAttribute('aria-orientation', 'horizontal');
    });

    it('passes through aria-label', () => {
      render(
        <Toolbar aria-label="Text formatting">
          <ToolbarButton>Button</ToolbarButton>
        </Toolbar>
      );
      expect(screen.getByRole('toolbar')).toHaveAttribute('aria-label', 'Text formatting');
    });

    it('passes through aria-labelledby', () => {
      render(
        <>
          <h2 id="toolbar-label">Toolbar Label</h2>
          <Toolbar aria-labelledby="toolbar-label">
            <ToolbarButton>Button</ToolbarButton>
          </Toolbar>
        </>
      );
      expect(screen.getByRole('toolbar')).toHaveAttribute('aria-labelledby', 'toolbar-label');
    });
  });

  describe('APG: Keyboard Interaction (Horizontal)', () => {
    it('moves focus to next button with ArrowRight', async () => {
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton>First</ToolbarButton>
          <ToolbarButton>Second</ToolbarButton>
          <ToolbarButton>Third</ToolbarButton>
        </Toolbar>
      );

      const firstButton = screen.getByRole('button', { name: 'First' });
      firstButton.focus();

      await user.keyboard('{ArrowRight}');

      expect(screen.getByRole('button', { name: 'Second' })).toHaveFocus();
    });

    it('moves focus to previous button with ArrowLeft', async () => {
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton>First</ToolbarButton>
          <ToolbarButton>Second</ToolbarButton>
          <ToolbarButton>Third</ToolbarButton>
        </Toolbar>
      );

      const secondButton = screen.getByRole('button', { name: 'Second' });
      secondButton.focus();

      await user.keyboard('{ArrowLeft}');

      expect(screen.getByRole('button', { name: 'First' })).toHaveFocus();
    });

    it('does not wrap from last to first with ArrowRight (stops at edge)', async () => {
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton>First</ToolbarButton>
          <ToolbarButton>Second</ToolbarButton>
          <ToolbarButton>Third</ToolbarButton>
        </Toolbar>
      );

      const thirdButton = screen.getByRole('button', { name: 'Third' });
      thirdButton.focus();

      await user.keyboard('{ArrowRight}');

      expect(thirdButton).toHaveFocus();
    });

    it('does not wrap from first to last with ArrowLeft (stops at edge)', async () => {
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton>First</ToolbarButton>
          <ToolbarButton>Second</ToolbarButton>
          <ToolbarButton>Third</ToolbarButton>
        </Toolbar>
      );

      const firstButton = screen.getByRole('button', { name: 'First' });
      firstButton.focus();

      await user.keyboard('{ArrowLeft}');

      expect(firstButton).toHaveFocus();
    });

    it('ArrowUp/Down are disabled in horizontal toolbar', async () => {
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton>First</ToolbarButton>
          <ToolbarButton>Second</ToolbarButton>
        </Toolbar>
      );

      const firstButton = screen.getByRole('button', { name: 'First' });
      firstButton.focus();

      await user.keyboard('{ArrowDown}');
      expect(firstButton).toHaveFocus();

      await user.keyboard('{ArrowUp}');
      expect(firstButton).toHaveFocus();
    });

    it('moves focus to first button with Home', async () => {
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton>First</ToolbarButton>
          <ToolbarButton>Second</ToolbarButton>
          <ToolbarButton>Third</ToolbarButton>
        </Toolbar>
      );

      const thirdButton = screen.getByRole('button', { name: 'Third' });
      thirdButton.focus();

      await user.keyboard('{Home}');

      expect(screen.getByRole('button', { name: 'First' })).toHaveFocus();
    });

    it('moves focus to last button with End', async () => {
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton>First</ToolbarButton>
          <ToolbarButton>Second</ToolbarButton>
          <ToolbarButton>Third</ToolbarButton>
        </Toolbar>
      );

      const firstButton = screen.getByRole('button', { name: 'First' });
      firstButton.focus();

      await user.keyboard('{End}');

      expect(screen.getByRole('button', { name: 'Third' })).toHaveFocus();
    });

    it('skips disabled items when navigating', async () => {
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton>First</ToolbarButton>
          <ToolbarButton disabled>Second (disabled)</ToolbarButton>
          <ToolbarButton>Third</ToolbarButton>
        </Toolbar>
      );

      const firstButton = screen.getByRole('button', { name: 'First' });
      firstButton.focus();

      await user.keyboard('{ArrowRight}');

      expect(screen.getByRole('button', { name: 'Third' })).toHaveFocus();
    });
  });

  describe('APG: Keyboard Interaction (Vertical)', () => {
    it('moves focus to next button with ArrowDown', async () => {
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar" orientation="vertical">
          <ToolbarButton>First</ToolbarButton>
          <ToolbarButton>Second</ToolbarButton>
          <ToolbarButton>Third</ToolbarButton>
        </Toolbar>
      );

      const firstButton = screen.getByRole('button', { name: 'First' });
      firstButton.focus();

      await user.keyboard('{ArrowDown}');

      expect(screen.getByRole('button', { name: 'Second' })).toHaveFocus();
    });

    it('moves focus to previous button with ArrowUp', async () => {
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar" orientation="vertical">
          <ToolbarButton>First</ToolbarButton>
          <ToolbarButton>Second</ToolbarButton>
          <ToolbarButton>Third</ToolbarButton>
        </Toolbar>
      );

      const secondButton = screen.getByRole('button', { name: 'Second' });
      secondButton.focus();

      await user.keyboard('{ArrowUp}');

      expect(screen.getByRole('button', { name: 'First' })).toHaveFocus();
    });

    it('ArrowLeft/Right are disabled in vertical toolbar', async () => {
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar" orientation="vertical">
          <ToolbarButton>First</ToolbarButton>
          <ToolbarButton>Second</ToolbarButton>
        </Toolbar>
      );

      const firstButton = screen.getByRole('button', { name: 'First' });
      firstButton.focus();

      await user.keyboard('{ArrowRight}');
      expect(firstButton).toHaveFocus();

      await user.keyboard('{ArrowLeft}');
      expect(firstButton).toHaveFocus();
    });

    it('stops at edge with ArrowDown (does not wrap)', async () => {
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar" orientation="vertical">
          <ToolbarButton>First</ToolbarButton>
          <ToolbarButton>Second</ToolbarButton>
        </Toolbar>
      );

      const secondButton = screen.getByRole('button', { name: 'Second' });
      secondButton.focus();

      await user.keyboard('{ArrowDown}');

      expect(secondButton).toHaveFocus();
    });
  });

  describe('APG: Focus Management', () => {
    it('first enabled item has tabIndex=0, others have tabIndex=-1 (Roving Tabindex)', () => {
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton>First</ToolbarButton>
          <ToolbarButton>Second</ToolbarButton>
        </Toolbar>
      );

      const buttons = screen.getAllByRole('button');
      expect(buttons[0]).toHaveAttribute('tabIndex', '0');
      expect(buttons[1]).toHaveAttribute('tabIndex', '-1');
    });

    it('updates focus position on click', async () => {
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton>First</ToolbarButton>
          <ToolbarButton>Second</ToolbarButton>
          <ToolbarButton>Third</ToolbarButton>
        </Toolbar>
      );

      await user.click(screen.getByRole('button', { name: 'Second' }));
      await user.keyboard('{ArrowRight}');

      expect(screen.getByRole('button', { name: 'Third' })).toHaveFocus();
    });
  });
});

describe('ToolbarButton', () => {
  describe('ARIA Attributes', () => {
    it('has implicit role="button"', () => {
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton>Click me</ToolbarButton>
        </Toolbar>
      );
      expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
    });

    it('has type="button"', () => {
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton>Click me</ToolbarButton>
        </Toolbar>
      );
      expect(screen.getByRole('button')).toHaveAttribute('type', 'button');
    });
  });

  describe('Functionality', () => {
    it('fires onClick on click', async () => {
      const handleClick = vi.fn();
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton onClick={handleClick}>Click me</ToolbarButton>
        </Toolbar>
      );

      await user.click(screen.getByRole('button'));

      expect(handleClick).toHaveBeenCalledTimes(1);
    });

    it('fires onClick on Enter', async () => {
      const handleClick = vi.fn();
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton onClick={handleClick}>Click me</ToolbarButton>
        </Toolbar>
      );

      const button = screen.getByRole('button');
      button.focus();
      await user.keyboard('{Enter}');

      expect(handleClick).toHaveBeenCalledTimes(1);
    });

    it('fires onClick on Space', async () => {
      const handleClick = vi.fn();
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton onClick={handleClick}>Click me</ToolbarButton>
        </Toolbar>
      );

      const button = screen.getByRole('button');
      button.focus();
      await user.keyboard(' ');

      expect(handleClick).toHaveBeenCalledTimes(1);
    });

    it('does not fire onClick when disabled', async () => {
      const handleClick = vi.fn();
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton onClick={handleClick} disabled>
            Click me
          </ToolbarButton>
        </Toolbar>
      );

      await user.click(screen.getByRole('button'));

      expect(handleClick).not.toHaveBeenCalled();
    });

    it('is not focusable when disabled (disabled attribute)', () => {
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton disabled>Click me</ToolbarButton>
        </Toolbar>
      );
      expect(screen.getByRole('button')).toBeDisabled();
    });
  });
});

describe('ToolbarToggleButton', () => {
  describe('ARIA Attributes', () => {
    it('has implicit role="button"', () => {
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarToggleButton>Toggle</ToolbarToggleButton>
        </Toolbar>
      );
      expect(screen.getByRole('button', { name: 'Toggle' })).toBeInTheDocument();
    });

    it('has type="button"', () => {
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarToggleButton>Toggle</ToolbarToggleButton>
        </Toolbar>
      );
      expect(screen.getByRole('button')).toHaveAttribute('type', 'button');
    });

    it('has aria-pressed="false" in initial state', () => {
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarToggleButton>Toggle</ToolbarToggleButton>
        </Toolbar>
      );
      expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'false');
    });

    it('has aria-pressed="true" when pressed', () => {
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarToggleButton defaultPressed>Toggle</ToolbarToggleButton>
        </Toolbar>
      );
      expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true');
    });
  });

  describe('Functionality', () => {
    it('toggles aria-pressed on click', async () => {
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarToggleButton>Toggle</ToolbarToggleButton>
        </Toolbar>
      );

      const button = screen.getByRole('button');
      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('toggles aria-pressed on Enter', async () => {
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarToggleButton>Toggle</ToolbarToggleButton>
        </Toolbar>
      );

      const button = screen.getByRole('button');
      button.focus();
      expect(button).toHaveAttribute('aria-pressed', 'false');

      await user.keyboard('{Enter}');
      expect(button).toHaveAttribute('aria-pressed', 'true');
    });

    it('toggles aria-pressed on Space', async () => {
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarToggleButton>Toggle</ToolbarToggleButton>
        </Toolbar>
      );

      const button = screen.getByRole('button');
      button.focus();
      expect(button).toHaveAttribute('aria-pressed', 'false');

      await user.keyboard(' ');
      expect(button).toHaveAttribute('aria-pressed', 'true');
    });

    it('fires onPressedChange', async () => {
      const handlePressedChange = vi.fn();
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarToggleButton onPressedChange={handlePressedChange}>Toggle</ToolbarToggleButton>
        </Toolbar>
      );

      await user.click(screen.getByRole('button'));

      expect(handlePressedChange).toHaveBeenCalledWith(true);

      await user.click(screen.getByRole('button'));

      expect(handlePressedChange).toHaveBeenCalledWith(false);
    });

    it('sets initial state with defaultPressed', () => {
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarToggleButton defaultPressed>Toggle</ToolbarToggleButton>
        </Toolbar>
      );
      expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true');
    });

    it('controlled state with pressed prop', async () => {
      const user = userEvent.setup();
      const Controlled = () => {
        const [pressed, setPressed] = React.useState(false);
        return (
          <Toolbar aria-label="Test toolbar">
            <ToolbarToggleButton pressed={pressed} onPressedChange={setPressed}>
              Toggle
            </ToolbarToggleButton>
          </Toolbar>
        );
      };

      render(<Controlled />);

      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('aria-pressed', 'false');

      await user.click(button);
      expect(button).toHaveAttribute('aria-pressed', 'true');
    });

    it('does not toggle when disabled', async () => {
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarToggleButton disabled>Toggle</ToolbarToggleButton>
        </Toolbar>
      );

      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('aria-pressed', 'false');

      await user.click(button);

      expect(button).toHaveAttribute('aria-pressed', 'false');
    });

    it('does not fire onPressedChange when disabled', async () => {
      const handlePressedChange = vi.fn();
      const user = userEvent.setup();
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarToggleButton disabled onPressedChange={handlePressedChange}>
            Toggle
          </ToolbarToggleButton>
        </Toolbar>
      );

      await user.click(screen.getByRole('button'));

      expect(handlePressedChange).not.toHaveBeenCalled();
    });

    it('is not focusable when disabled (disabled attribute)', () => {
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarToggleButton disabled>Toggle</ToolbarToggleButton>
        </Toolbar>
      );
      expect(screen.getByRole('button')).toBeDisabled();
    });
  });
});

describe('ToolbarSeparator', () => {
  describe('ARIA Attributes', () => {
    it('has role="separator"', () => {
      render(
        <Toolbar aria-label="Test toolbar">
          <ToolbarButton>Before</ToolbarButton>
          <ToolbarSeparator />
          <ToolbarButton>After</ToolbarButton>
        </Toolbar>
      );
      expect(screen.getByRole('separator')).toBeInTheDocument();
    });

    it('has aria-orientation="vertical" in horizontal toolbar', () => {
      render(
        <Toolbar aria-label="Test toolbar" orientation="horizontal">
          <ToolbarButton>Before</ToolbarButton>
          <ToolbarSeparator />
          <ToolbarButton>After</ToolbarButton>
        </Toolbar>
      );
      expect(screen.getByRole('separator')).toHaveAttribute('aria-orientation', 'vertical');
    });

    it('has aria-orientation="horizontal" in vertical toolbar', () => {
      render(
        <Toolbar aria-label="Test toolbar" orientation="vertical">
          <ToolbarButton>Before</ToolbarButton>
          <ToolbarSeparator />
          <ToolbarButton>After</ToolbarButton>
        </Toolbar>
      );
      expect(screen.getByRole('separator')).toHaveAttribute('aria-orientation', 'horizontal');
    });
  });
});

describe('Accessibility', () => {
  it('has no WCAG 2.1 AA violations', async () => {
    const { container } = render(
      <Toolbar aria-label="Text formatting">
        <ToolbarToggleButton>Bold</ToolbarToggleButton>
        <ToolbarToggleButton>Italic</ToolbarToggleButton>
        <ToolbarSeparator />
        <ToolbarButton>Copy</ToolbarButton>
        <ToolbarButton>Paste</ToolbarButton>
      </Toolbar>
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('has no WCAG 2.1 AA violations in vertical toolbar', async () => {
    const { container } = render(
      <Toolbar aria-label="Actions" orientation="vertical">
        <ToolbarButton>New</ToolbarButton>
        <ToolbarButton>Open</ToolbarButton>
        <ToolbarSeparator />
        <ToolbarButton>Save</ToolbarButton>
      </Toolbar>
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

describe('HTML Attribute Inheritance', () => {
  it('applies className to container', () => {
    render(
      <Toolbar aria-label="Test toolbar" className="custom-toolbar">
        <ToolbarButton>Button</ToolbarButton>
      </Toolbar>
    );
    expect(screen.getByRole('toolbar')).toHaveClass('custom-toolbar');
  });

  it('applies className to ToolbarButton', () => {
    render(
      <Toolbar aria-label="Test toolbar">
        <ToolbarButton className="custom-button">Button</ToolbarButton>
      </Toolbar>
    );
    expect(screen.getByRole('button')).toHaveClass('custom-button');
  });

  it('applies className to ToolbarToggleButton', () => {
    render(
      <Toolbar aria-label="Test toolbar">
        <ToolbarToggleButton className="custom-toggle">Toggle</ToolbarToggleButton>
      </Toolbar>
    );
    expect(screen.getByRole('button')).toHaveClass('custom-toggle');
  });

  it('applies className to ToolbarSeparator', () => {
    render(
      <Toolbar aria-label="Test toolbar">
        <ToolbarButton>Before</ToolbarButton>
        <ToolbarSeparator className="custom-separator" />
        <ToolbarButton>After</ToolbarButton>
      </Toolbar>
    );
    expect(screen.getByRole('separator')).toHaveClass('custom-separator');
  });
});

// Import React for the controlled component test
import React from 'react';

リソース