Toolbar
ボタン、トグルボタン、その他の入力要素などのコントロールをグループ化するコンテナ。
🤖 AI Implementation Guideデモ
テキスト書式設定ツールバー
トグルボタンと通常のボタンを含む水平ツールバー。
垂直ツールバー
上下矢印キーを使用してナビゲートします。
無効な項目あり
無効な項目は、キーボードナビゲーション中にスキップされます。
制御されたトグルボタン
制御された状態を持つトグルボタン。現在の状態が表示され、サンプルテキストに適用されます。
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.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { setToolbarContext } from './toolbar-context.svelte';
interface ToolbarProps {
children?: Snippet<[]>;
orientation?: 'horizontal' | 'vertical';
class?: string;
[key: string]: unknown;
}
let {
children,
orientation = 'horizontal',
class: className = '',
...restProps
}: ToolbarProps = $props();
let toolbarRef: HTMLDivElement | undefined = $state();
let focusedIndex = $state(0);
// Provide reactive context to child components
setToolbarContext(() => orientation);
function getButtons(): HTMLButtonElement[] {
if (!toolbarRef) return [];
return Array.from(toolbarRef.querySelectorAll<HTMLButtonElement>('button:not([disabled])'));
}
// Track DOM mutations to detect slot content changes
let mutationCount = $state(0);
$effect(() => {
if (!toolbarRef) return;
const observer = new MutationObserver(() => {
mutationCount++;
});
observer.observe(toolbarRef, { childList: true, subtree: true });
return () => observer.disconnect();
});
// Roving tabindex: only the focused button should have tabIndex=0
$effect(() => {
// Dependencies: focusedIndex and mutationCount (for slot content changes)
void mutationCount;
const buttons = getButtons();
if (buttons.length === 0) return;
// Clamp focusedIndex to valid range
if (focusedIndex >= buttons.length) {
focusedIndex = buttons.length - 1;
return; // Will re-run with corrected index
}
buttons.forEach((btn, index) => {
btn.tabIndex = index === focusedIndex ? 0 : -1;
});
});
function handleFocus(event: FocusEvent) {
const buttons = getButtons();
const targetIndex = buttons.findIndex((btn) => btn === event.target);
if (targetIndex !== -1) {
focusedIndex = targetIndex;
}
}
function handleKeyDown(event: KeyboardEvent) {
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();
focusedIndex = newIndex;
}
}
}
</script>
<div
bind:this={toolbarRef}
role="toolbar"
aria-orientation={orientation}
class="apg-toolbar {className}"
{...restProps}
onfocusin={handleFocus}
onkeydown={handleKeyDown}
>
{#if children}
{@render children()}
{/if}
</div> ToolbarButton.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { getToolbarContext } from './toolbar-context.svelte';
interface ToolbarButtonProps {
children?: Snippet<[]>;
disabled?: boolean;
class?: string;
onclick?: (event: MouseEvent) => void;
[key: string]: unknown;
}
let {
children,
disabled = false,
class: className = '',
onclick,
...restProps
}: ToolbarButtonProps = $props();
// Verify we're inside a Toolbar
const context = getToolbarContext();
if (!context) {
console.warn('ToolbarButton must be used within a Toolbar');
}
</script>
<button type="button" class="apg-toolbar-button {className}" {disabled} {onclick} {...restProps}>
{#if children}
{@render children()}
{/if}
</button> ToolbarToggleButton.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { untrack } from 'svelte';
import { getToolbarContext } from './toolbar-context.svelte';
interface ToolbarToggleButtonProps {
children?: Snippet<[]>;
/** Controlled pressed state */
pressed?: boolean;
/** Default pressed state (uncontrolled) */
defaultPressed?: boolean;
/** Callback when pressed state changes */
onPressedChange?: (pressed: boolean) => void;
disabled?: boolean;
class?: string;
onclick?: (event: MouseEvent) => void;
[key: string]: unknown;
}
let {
children,
pressed: controlledPressed = undefined,
defaultPressed = false,
onPressedChange,
disabled = false,
class: className = '',
onclick,
...restProps
}: ToolbarToggleButtonProps = $props();
// Verify we're inside a Toolbar
const context = getToolbarContext();
if (!context) {
console.warn('ToolbarToggleButton must be used within a Toolbar');
}
let internalPressed = $state(untrack(() => defaultPressed));
let isControlled = $derived(controlledPressed !== undefined);
let pressed = $derived(isControlled ? controlledPressed : internalPressed);
function handleClick(event: MouseEvent) {
if (disabled) return;
const newPressed = !pressed;
if (!isControlled) {
internalPressed = newPressed;
}
onPressedChange?.(newPressed);
onclick?.(event);
}
</script>
<button
type="button"
aria-pressed={pressed}
class="apg-toolbar-button {className}"
{disabled}
onclick={handleClick}
{...restProps}
>
{#if children}
{@render children()}
{/if}
</button> ToolbarSeparator.svelte
<script lang="ts">
import { getToolbarContext } from './toolbar-context.svelte';
interface ToolbarSeparatorProps {
class?: string;
}
let { class: className = '' }: ToolbarSeparatorProps = $props();
// Verify we're inside a Toolbar
const context = getToolbarContext();
if (!context) {
console.warn('ToolbarSeparator must be used within a Toolbar');
}
// Separator orientation is perpendicular to toolbar orientation
let separatorOrientation = $derived(
context?.orientation === 'horizontal' ? 'vertical' : 'horizontal'
);
</script>
<div
role="separator"
aria-orientation={separatorOrientation}
class="apg-toolbar-separator {className}"
/> 使い方
使用例
<script>
import Toolbar from '@patterns/toolbar/Toolbar.svelte';
import ToolbarButton from '@patterns/toolbar/ToolbarButton.svelte';
import ToolbarToggleButton from '@patterns/toolbar/ToolbarToggleButton.svelte';
import ToolbarSeparator from '@patterns/toolbar/ToolbarSeparator.svelte';
</script>
<Toolbar aria-label="Text formatting">
<ToolbarToggleButton>Bold</ToolbarToggleButton>
<ToolbarToggleButton>Italic</ToolbarToggleButton>
<ToolbarSeparator />
<ToolbarButton>Copy</ToolbarButton>
<ToolbarButton>Paste</ToolbarButton>
</Toolbar> API
Toolbar Props
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
orientation | 'horizontal' | 'vertical' | 'horizontal' | ツールバーの方向 |
ToolbarToggleButton Props
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
pressed | boolean | - | 制御された押下状態 |
defaultPressed | boolean | false | 初期押下状態 |
onPressedChange | (pressed: boolean) => void | - | 押下状態変更時のコールバック |
テスト
テストは、キーボード操作、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.svelte.ts
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
// Import test wrapper components
import ToolbarTestBasic from './test-wrappers/ToolbarTestBasic.svelte';
import ToolbarTestVertical from './test-wrappers/ToolbarTestVertical.svelte';
import ToolbarTestDisabled from './test-wrappers/ToolbarTestDisabled.svelte';
import ToolbarTestToggle from './test-wrappers/ToolbarTestToggle.svelte';
import ToolbarTestSeparator from './test-wrappers/ToolbarTestSeparator.svelte';
import ToolbarTestSeparatorVertical from './test-wrappers/ToolbarTestSeparatorVertical.svelte';
describe('Toolbar (Svelte)', () => {
// 🔴 High Priority: APG 準拠の核心
describe('APG: ARIA 属性', () => {
it('role="toolbar" が設定される', () => {
render(ToolbarTestBasic);
expect(screen.getByRole('toolbar')).toBeInTheDocument();
});
it('aria-orientation がデフォルトで "horizontal"', () => {
render(ToolbarTestBasic);
expect(screen.getByRole('toolbar')).toHaveAttribute('aria-orientation', 'horizontal');
});
it('aria-orientation が orientation prop を反映する', () => {
render(ToolbarTestVertical);
expect(screen.getByRole('toolbar')).toHaveAttribute('aria-orientation', 'vertical');
});
it('aria-label が透過される', () => {
render(ToolbarTestBasic);
expect(screen.getByRole('toolbar')).toHaveAttribute('aria-label', 'Test toolbar');
});
});
describe('APG: キーボード操作 (Horizontal)', () => {
it('ArrowRight で次のボタンにフォーカス移動', async () => {
const user = userEvent.setup();
render(ToolbarTestBasic);
const firstButton = screen.getByRole('button', { name: 'First' });
firstButton.focus();
await user.keyboard('{ArrowRight}');
expect(screen.getByRole('button', { name: 'Second' })).toHaveFocus();
});
it('ArrowLeft で前のボタンにフォーカス移動', async () => {
const user = userEvent.setup();
render(ToolbarTestBasic);
const secondButton = screen.getByRole('button', { name: 'Second' });
secondButton.focus();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('button', { name: 'First' })).toHaveFocus();
});
it('ArrowRight で最後から先頭にラップしない(端で止まる)', async () => {
const user = userEvent.setup();
render(ToolbarTestBasic);
const thirdButton = screen.getByRole('button', { name: 'Third' });
thirdButton.focus();
await user.keyboard('{ArrowRight}');
expect(thirdButton).toHaveFocus();
});
it('ArrowLeft で先頭から最後にラップしない(端で止まる)', async () => {
const user = userEvent.setup();
render(ToolbarTestBasic);
const firstButton = screen.getByRole('button', { name: 'First' });
firstButton.focus();
await user.keyboard('{ArrowLeft}');
expect(firstButton).toHaveFocus();
});
it('ArrowUp/Down は水平ツールバーでは無効', async () => {
const user = userEvent.setup();
render(ToolbarTestBasic);
const firstButton = screen.getByRole('button', { name: 'First' });
firstButton.focus();
await user.keyboard('{ArrowDown}');
expect(firstButton).toHaveFocus();
await user.keyboard('{ArrowUp}');
expect(firstButton).toHaveFocus();
});
it('Home で最初のボタンにフォーカス移動', async () => {
const user = userEvent.setup();
render(ToolbarTestBasic);
const thirdButton = screen.getByRole('button', { name: 'Third' });
thirdButton.focus();
await user.keyboard('{Home}');
expect(screen.getByRole('button', { name: 'First' })).toHaveFocus();
});
it('End で最後のボタンにフォーカス移動', async () => {
const user = userEvent.setup();
render(ToolbarTestBasic);
const firstButton = screen.getByRole('button', { name: 'First' });
firstButton.focus();
await user.keyboard('{End}');
expect(screen.getByRole('button', { name: 'Third' })).toHaveFocus();
});
it('disabled アイテムをスキップして移動', async () => {
const user = userEvent.setup();
render(ToolbarTestDisabled);
const firstButton = screen.getByRole('button', { name: 'First' });
firstButton.focus();
await user.keyboard('{ArrowRight}');
expect(screen.getByRole('button', { name: 'Third' })).toHaveFocus();
});
});
describe('APG: キーボード操作 (Vertical)', () => {
it('ArrowDown で次のボタンにフォーカス移動', async () => {
const user = userEvent.setup();
render(ToolbarTestVertical);
const firstButton = screen.getByRole('button', { name: 'First' });
firstButton.focus();
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('button', { name: 'Second' })).toHaveFocus();
});
it('ArrowUp で前のボタンにフォーカス移動', async () => {
const user = userEvent.setup();
render(ToolbarTestVertical);
const secondButton = screen.getByRole('button', { name: 'Second' });
secondButton.focus();
await user.keyboard('{ArrowUp}');
expect(screen.getByRole('button', { name: 'First' })).toHaveFocus();
});
it('ArrowLeft/Right は垂直ツールバーでは無効', async () => {
const user = userEvent.setup();
render(ToolbarTestVertical);
const firstButton = screen.getByRole('button', { name: 'First' });
firstButton.focus();
await user.keyboard('{ArrowRight}');
expect(firstButton).toHaveFocus();
await user.keyboard('{ArrowLeft}');
expect(firstButton).toHaveFocus();
});
});
});
describe('ToolbarButton (Svelte)', () => {
describe('ARIA 属性', () => {
it('role="button" が暗黙的に設定される', () => {
render(ToolbarTestBasic);
expect(screen.getByRole('button', { name: 'First' })).toBeInTheDocument();
});
it('type="button" が設定される', () => {
render(ToolbarTestBasic);
expect(screen.getByRole('button', { name: 'First' })).toHaveAttribute('type', 'button');
});
});
describe('機能', () => {
it('disabled 時はフォーカス対象外(disabled属性で非フォーカス)', () => {
render(ToolbarTestDisabled);
const disabledButton = screen.getByRole('button', { name: 'Second (disabled)' });
expect(disabledButton).toBeDisabled();
});
});
});
describe('ToolbarToggleButton (Svelte)', () => {
describe('ARIA 属性', () => {
it('aria-pressed="false" が初期状態で設定される', () => {
render(ToolbarTestToggle);
expect(screen.getByRole('button', { name: 'Toggle' })).toHaveAttribute(
'aria-pressed',
'false'
);
});
it('type="button" が設定される', () => {
render(ToolbarTestToggle);
expect(screen.getByRole('button', { name: 'Toggle' })).toHaveAttribute('type', 'button');
});
});
describe('機能', () => {
it('クリックで aria-pressed がトグル', async () => {
const user = userEvent.setup();
render(ToolbarTestToggle);
const button = screen.getByRole('button', { name: 'Toggle' });
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('Enter で aria-pressed がトグル', async () => {
const user = userEvent.setup();
render(ToolbarTestToggle);
const button = screen.getByRole('button', { name: 'Toggle' });
button.focus();
expect(button).toHaveAttribute('aria-pressed', 'false');
await user.keyboard('{Enter}');
expect(button).toHaveAttribute('aria-pressed', 'true');
});
it('Space で aria-pressed がトグル', async () => {
const user = userEvent.setup();
render(ToolbarTestToggle);
const button = screen.getByRole('button', { name: 'Toggle' });
button.focus();
expect(button).toHaveAttribute('aria-pressed', 'false');
await user.keyboard(' ');
expect(button).toHaveAttribute('aria-pressed', 'true');
});
});
});
describe('ToolbarSeparator (Svelte)', () => {
describe('ARIA 属性', () => {
it('role="separator" が設定される', () => {
render(ToolbarTestSeparator);
expect(screen.getByRole('separator')).toBeInTheDocument();
});
it('horizontal toolbar 時に aria-orientation="vertical"', () => {
render(ToolbarTestSeparator);
expect(screen.getByRole('separator')).toHaveAttribute('aria-orientation', 'vertical');
});
it('vertical toolbar 時に aria-orientation="horizontal"', () => {
render(ToolbarTestSeparatorVertical);
expect(screen.getByRole('separator')).toHaveAttribute('aria-orientation', 'horizontal');
});
});
});
describe('アクセシビリティ (Svelte)', () => {
it('axe による WCAG 2.1 AA 違反がない', async () => {
const { container } = render(ToolbarTestSeparator);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('vertical toolbar でも WCAG 2.1 AA 違反がない', async () => {
const { container } = render(ToolbarTestSeparatorVertical);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
}); リソース
- WAI-ARIA APG: Toolbar パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist