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 カスタムクラスがすべてのコンポーネントに適用される
テストツール
- Vitest (opens in new tab)
- テストランナー
- Testing Library (opens in new tab)
- フレームワーク固有のテストユーティリティ
- jest-axe (opens in new tab)
- 自動アクセシビリティテスト
詳細なドキュメントは testing-strategy.md (opens in new tab) を参照してください。
リソース
-
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