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 | カスタムクラスがすべてのコンポーネントに適用される |
テストツール
- Vitest (opens in new tab) - テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ
- jest-axe (opens in new tab) - 自動アクセシビリティテスト
詳細なドキュメントは 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'; リソース
- WAI-ARIA APG: Toolbar パターン (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