APG Patterns
English
English

Toolbar

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

デモ

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

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

垂直ツールバー

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

無効な項目あり

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

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

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

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

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

デフォルトの押下状態

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

デモのみを開く →

アクセシビリティ

WAI-ARIA Roles

Role Target Element Description
toolbar Container Container for grouping controls
button Button elements Implicit role for <button> elements
separator Separator Visual and semantic separator between groups

WAI-ARIA toolbar role (opens in new tab)

WAI-ARIA Properties

Attribute Target Values Required Configuration
aria-label toolbar String Yes* aria-label prop
aria-labelledby toolbar ID reference Yes* aria-labelledby prop
aria-orientation toolbar "horizontal" | "vertical" No orientation prop (default: horizontal)

* Either aria-label or aria-labelledby is required

WAI-ARIA States

aria-pressed

Indicates the pressed state of toggle buttons.

Target ToolbarToggleButton
Values true | false
Required Yes (for toggle buttons)
Change Trigger Click, Enter, Space
Reference aria-pressed (opens in new tab)

Keyboard Support

Key Action
Tab Move focus into/out of the toolbar (single tab stop)
Arrow Right / Arrow Left Navigate between controls (horizontal toolbar)
Arrow Down / Arrow Up Navigate between controls (vertical toolbar)
Home Move focus to first control
End Move focus to last control
Enter / Space Activate button / toggle pressed state

Focus Management

This component uses the Roving Tabindex pattern for focus management:

  • Only one control has tabindex="0" at a time
  • Other controls have tabindex="-1"
  • Arrow keys move focus between controls
  • Disabled controls and separators are skipped
  • Focus does not wrap (stops at edges)

ソースコード

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準拠を検証します。Toolbarコンポーネントは2層のテスト戦略を使用しています。

テスト戦略

ユニットテスト(Testing Library)

フレームワーク固有のテストライブラリを使用して、コンポーネントの出力を検証します。正しいHTML構造とARIA属性を確認します。

  • ARIA属性(aria-pressed、aria-orientation)
  • キーボード操作(矢印キー、Home、End)
  • Roving tabindexの動作
  • jest-axeによるアクセシビリティ

E2Eテスト(Playwright)

実際のブラウザ環境で全フレームワークのコンポーネント動作を検証します。インタラクションとクロスフレームワークの一貫性をカバーします。

  • クリック操作
  • 矢印キーナビゲーション(水平・垂直)
  • トグルボタンの状態変更
  • 無効化アイテムの処理
  • ライブブラウザでのARIA構造検証
  • axe-coreアクセシビリティスキャン
  • クロスフレームワーク一貫性チェック

テストカテゴリ

高優先度 : APGキーボード操作(Unit + E2E)

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

高優先度 : APG ARIA属性(Unit + E2E)

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

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

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

高優先度 : トグルボタンの状態(Unit + E2E)

テスト 説明
aria-pressed トグルボタンがaria-pressed属性を持つ
Click toggles トグルボタンをクリックするとaria-pressedが変わる
defaultPressed defaultPressedを持つトグルは押下状態で開始

中優先度 : アクセシビリティ(Unit + E2E)

テスト 説明
axe violations WCAG 2.1 AA違反なし(jest-axe/axe-core経由)
Vertical toolbar 垂直方向もaxeに合格

低優先度 : クロスフレームワーク一貫性(E2E)

テスト 説明
All frameworks render React、Vue、Svelte、Astroすべてがツールバーをレンダリング
Consistent keyboard すべてのフレームワークがキーボードナビゲーションをサポート
Consistent ARIA すべてのフレームワークが一貫したARIA構造を持つ
Toggle buttons すべてのフレームワークがトグルボタン状態をサポート

E2Eテストコード例

以下は実際のE2Eテストファイルです (e2e/toolbar.spec.ts).

e2e/toolbar.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

/**
 * E2E Tests for Toolbar Pattern
 *
 * A container for grouping a set of controls, such as buttons, toggle buttons,
 * or menus. Toolbar uses roving tabindex for keyboard navigation.
 *
 * APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/
 */

const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

// ============================================
// Helper Functions
// ============================================

const getToolbar = (page: import('@playwright/test').Page) => {
  return page.getByRole('toolbar');
};

const getToolbarButtons = (page: import('@playwright/test').Page, toolbarIndex = 0) => {
  const toolbar = getToolbar(page).nth(toolbarIndex);
  return toolbar.getByRole('button');
};

const getSeparators = (page: import('@playwright/test').Page, toolbarIndex = 0) => {
  const toolbar = getToolbar(page).nth(toolbarIndex);
  return toolbar.getByRole('separator');
};

// ============================================
// Framework-specific Tests
// ============================================

for (const framework of frameworks) {
  test.describe(`Toolbar (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/toolbar/${framework}/demo/`);
      await getToolbar(page).first().waitFor();

      // Wait for hydration - first button should have tabindex="0"
      const firstButton = getToolbarButtons(page, 0).first();
      await expect
        .poll(async () => {
          const tabindex = await firstButton.getAttribute('tabindex');
          return tabindex === '0';
        })
        .toBe(true);
    });

    // ------------------------------------------
    // 🔴 High Priority: APG ARIA Structure
    // ------------------------------------------
    test.describe('APG: ARIA Structure', () => {
      test('container has role="toolbar"', async ({ page }) => {
        const toolbar = getToolbar(page).first();
        await expect(toolbar).toHaveRole('toolbar');
      });

      test('toolbar has aria-label for accessible name', async ({ page }) => {
        const toolbar = getToolbar(page).first();
        const ariaLabel = await toolbar.getAttribute('aria-label');
        expect(ariaLabel).toBeTruthy();
      });

      test('horizontal toolbar has aria-orientation="horizontal"', async ({ page }) => {
        const toolbar = getToolbar(page).first();
        await expect(toolbar).toHaveAttribute('aria-orientation', 'horizontal');
      });

      test('vertical toolbar has aria-orientation="vertical"', async ({ page }) => {
        // Second toolbar is vertical
        const toolbar = getToolbar(page).nth(1);
        await expect(toolbar).toHaveAttribute('aria-orientation', 'vertical');
      });

      test('buttons have implicit role="button"', async ({ page }) => {
        const buttons = getToolbarButtons(page, 0);
        const count = await buttons.count();
        expect(count).toBeGreaterThan(0);

        for (let i = 0; i < Math.min(3, count); i++) {
          await expect(buttons.nth(i)).toHaveRole('button');
        }
      });

      test('separator has role="separator"', async ({ page }) => {
        const separators = getSeparators(page, 0);
        const count = await separators.count();
        expect(count).toBeGreaterThan(0);

        await expect(separators.first()).toHaveRole('separator');
      });

      test('separator in horizontal toolbar has aria-orientation="vertical"', async ({ page }) => {
        const separator = getSeparators(page, 0).first();
        await expect(separator).toHaveAttribute('aria-orientation', 'vertical');
      });

      test('separator in vertical toolbar has aria-orientation="horizontal"', async ({ page }) => {
        // Second toolbar is vertical
        const separator = getSeparators(page, 1).first();
        await expect(separator).toHaveAttribute('aria-orientation', 'horizontal');
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Toggle Button ARIA
    // ------------------------------------------
    test.describe('APG: Toggle Button ARIA', () => {
      test('toggle button has aria-pressed attribute', async ({ page }) => {
        const buttons = getToolbarButtons(page, 0);
        const firstButton = buttons.first();

        const ariaPressed = await firstButton.getAttribute('aria-pressed');
        expect(ariaPressed === 'true' || ariaPressed === 'false').toBe(true);
      });

      test('clicking toggle button changes aria-pressed', async ({ page }) => {
        const buttons = getToolbarButtons(page, 0);
        const toggleButton = buttons.first();

        const initialPressed = await toggleButton.getAttribute('aria-pressed');

        await toggleButton.click();

        const newPressed = await toggleButton.getAttribute('aria-pressed');
        expect(newPressed).not.toBe(initialPressed);
      });

      test('toggle button with defaultPressed starts as pressed', async ({ page }) => {
        // Fifth toolbar has defaultPressed toggle buttons
        const toolbar = getToolbar(page).nth(4);
        const buttons = toolbar.getByRole('button');
        const firstButton = buttons.first();

        await expect(firstButton).toHaveAttribute('aria-pressed', 'true');
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Keyboard Interaction (Horizontal)
    // ------------------------------------------
    test.describe('APG: Keyboard Interaction (Horizontal)', () => {
      test('ArrowRight moves focus to next button', async ({ page }) => {
        const buttons = getToolbarButtons(page, 0);
        const firstButton = buttons.first();
        const secondButton = buttons.nth(1);

        await firstButton.click();
        await expect(firstButton).toBeFocused();

        await firstButton.press('ArrowRight');

        await expect(secondButton).toBeFocused();
      });

      test('ArrowLeft moves focus to previous button', async ({ page }) => {
        const buttons = getToolbarButtons(page, 0);
        const firstButton = buttons.first();
        const secondButton = buttons.nth(1);

        await secondButton.click();
        await expect(secondButton).toBeFocused();

        await secondButton.press('ArrowLeft');

        await expect(firstButton).toBeFocused();
      });

      test('Home moves focus to first button', async ({ page }) => {
        const buttons = getToolbarButtons(page, 0);
        const firstButton = buttons.first();
        const lastButton = buttons.last();

        await lastButton.click();
        await expect(lastButton).toBeFocused();

        await lastButton.press('Home');

        await expect(firstButton).toBeFocused();
      });

      test('End moves focus to last button', async ({ page }) => {
        const buttons = getToolbarButtons(page, 0);
        const firstButton = buttons.first();
        const lastButton = buttons.last();

        await firstButton.click();
        await expect(firstButton).toBeFocused();

        await firstButton.press('End');

        await expect(lastButton).toBeFocused();
      });

      test('focus does not wrap at end (stops at edge)', async ({ page }) => {
        const buttons = getToolbarButtons(page, 0);
        const lastButton = buttons.last();

        await lastButton.click();
        await expect(lastButton).toBeFocused();

        await lastButton.press('ArrowRight');

        // Should still be on last button (no wrap)
        await expect(lastButton).toBeFocused();
      });

      test('focus does not wrap at start (stops at edge)', async ({ page }) => {
        const buttons = getToolbarButtons(page, 0);
        const firstButton = buttons.first();

        await firstButton.click();
        await expect(firstButton).toBeFocused();

        await firstButton.press('ArrowLeft');

        // Should still be on first button (no wrap)
        await expect(firstButton).toBeFocused();
      });

      test('ArrowUp/Down are ignored in horizontal toolbar', async ({ page }) => {
        const buttons = getToolbarButtons(page, 0);
        const firstButton = buttons.first();

        await firstButton.click();
        await expect(firstButton).toBeFocused();

        await firstButton.press('ArrowDown');
        await expect(firstButton).toBeFocused();

        await expect(firstButton).toBeFocused();
        await firstButton.press('ArrowUp');
        await expect(firstButton).toBeFocused();
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Keyboard Interaction (Vertical)
    // ------------------------------------------
    test.describe('APG: Keyboard Interaction (Vertical)', () => {
      test('ArrowDown moves focus to next button in vertical toolbar', async ({ page }) => {
        // Second toolbar is vertical
        const buttons = getToolbarButtons(page, 1);
        const firstButton = buttons.first();
        const secondButton = buttons.nth(1);

        await firstButton.click();
        await expect(firstButton).toBeFocused();

        await firstButton.press('ArrowDown');

        await expect(secondButton).toBeFocused();
      });

      test('ArrowUp moves focus to previous button in vertical toolbar', async ({ page }) => {
        const buttons = getToolbarButtons(page, 1);
        const firstButton = buttons.first();
        const secondButton = buttons.nth(1);

        await secondButton.click();
        await expect(secondButton).toBeFocused();

        await secondButton.press('ArrowUp');

        await expect(firstButton).toBeFocused();
      });

      test('ArrowLeft/Right are ignored in vertical toolbar', async ({ page }) => {
        const buttons = getToolbarButtons(page, 1);
        const firstButton = buttons.first();

        await firstButton.click();
        await expect(firstButton).toBeFocused();

        await firstButton.press('ArrowRight');
        await expect(firstButton).toBeFocused();

        await expect(firstButton).toBeFocused();
        await firstButton.press('ArrowLeft');
        await expect(firstButton).toBeFocused();
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Disabled Items
    // ------------------------------------------
    test.describe('APG: Disabled Items', () => {
      test('disabled button has disabled attribute', async ({ page }) => {
        // Third toolbar has disabled items
        const toolbar = getToolbar(page).nth(2);
        const buttons = toolbar.getByRole('button');

        let foundDisabled = false;
        const count = await buttons.count();

        for (let i = 0; i < count; i++) {
          const isDisabled = await buttons.nth(i).isDisabled();
          if (isDisabled) {
            foundDisabled = true;
            break;
          }
        }

        expect(foundDisabled).toBe(true);
      });

      test('arrow key navigation skips disabled buttons', async ({ page }) => {
        // Third toolbar has disabled items: Undo, Redo(disabled), Cut, Copy, Paste(disabled)
        const toolbar = getToolbar(page).nth(2);
        const buttons = toolbar.getByRole('button');
        const undoButton = buttons.filter({ hasText: 'Undo' });

        await undoButton.click();
        await expect(undoButton).toBeFocused();

        // ArrowRight should skip Redo (disabled) and go to Cut
        await undoButton.press('ArrowRight');

        // Should be on Cut, not Redo
        const focusedButton = page.locator(':focus');
        const focusedText = await focusedButton.textContent();
        expect(focusedText).not.toContain('Redo');
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Roving Tabindex
    // ------------------------------------------
    test.describe('APG: Roving Tabindex', () => {
      test('first button has tabindex="0" initially', async ({ page }) => {
        const buttons = getToolbarButtons(page, 0);
        const firstButton = buttons.first();

        await expect(firstButton).toHaveAttribute('tabindex', '0');
      });

      test('other buttons have tabindex="-1" initially', async ({ page }) => {
        const buttons = getToolbarButtons(page, 0);
        const count = await buttons.count();

        for (let i = 1; i < count; i++) {
          await expect(buttons.nth(i)).toHaveAttribute('tabindex', '-1');
        }
      });

      test('focused button gets tabindex="0", previous loses it', async ({ page }) => {
        const buttons = getToolbarButtons(page, 0);
        const firstButton = buttons.first();
        const secondButton = buttons.nth(1);

        await firstButton.click();
        await expect(firstButton).toBeFocused();
        await firstButton.press('ArrowRight');

        // Second button should now have tabindex="0"
        await expect(secondButton).toHaveAttribute('tabindex', '0');
        // First button should have tabindex="-1"
        await expect(firstButton).toHaveAttribute('tabindex', '-1');
      });

      test('only one enabled button has tabindex="0" at a time', async ({ page }) => {
        const buttons = getToolbarButtons(page, 0);
        const count = await buttons.count();

        let tabbableCount = 0;
        for (let i = 0; i < count; i++) {
          const button = buttons.nth(i);
          const isDisabled = await button.isDisabled();
          if (isDisabled) continue;

          const tabindex = await button.getAttribute('tabindex');
          if (tabindex === '0') {
            tabbableCount++;
          }
        }

        expect(tabbableCount).toBe(1);
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Button Activation
    // ------------------------------------------
    test.describe('APG: Button Activation', () => {
      test('Enter activates button', async ({ page }) => {
        const buttons = getToolbarButtons(page, 0);
        const toggleButton = buttons.first();

        // Focus the button without clicking (to avoid changing state)
        await toggleButton.focus();
        await expect(toggleButton).toBeFocused();

        const initialPressed = await toggleButton.getAttribute('aria-pressed');

        await toggleButton.press('Enter');

        const newPressed = await toggleButton.getAttribute('aria-pressed');
        expect(newPressed).not.toBe(initialPressed);
      });

      test('Space activates button', async ({ page }) => {
        const buttons = getToolbarButtons(page, 0);
        const toggleButton = buttons.first();

        // Focus the button without clicking (to avoid changing state)
        await toggleButton.focus();
        await expect(toggleButton).toBeFocused();

        const initialPressed = await toggleButton.getAttribute('aria-pressed');

        await toggleButton.press('Space');

        const newPressed = await toggleButton.getAttribute('aria-pressed');
        expect(newPressed).not.toBe(initialPressed);
      });
    });

    // ------------------------------------------
    // 🟢 Low Priority: Accessibility
    // ------------------------------------------
    test.describe('Accessibility', () => {
      test('has no axe-core violations', async ({ page }) => {
        await getToolbar(page).first().waitFor();

        const results = await new AxeBuilder({ page })
          .include('[role="toolbar"]')
          .disableRules(['color-contrast'])
          .analyze();

        expect(results.violations).toEqual([]);
      });
    });
  });
}

// ============================================
// Cross-framework Consistency Tests
// ============================================

test.describe('Toolbar - Cross-framework Consistency', () => {
  test('all frameworks render toolbars', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/toolbar/${framework}/demo/`);
      await getToolbar(page).first().waitFor();

      const toolbars = getToolbar(page);
      const count = await toolbars.count();
      expect(count).toBeGreaterThan(0);
    }
  });

  test('all frameworks support keyboard navigation', async ({ page }) => {
    test.setTimeout(60000);

    for (const framework of frameworks) {
      await page.goto(`patterns/toolbar/${framework}/demo/`);
      await getToolbar(page).first().waitFor();

      const buttons = getToolbarButtons(page, 0);
      const firstButton = buttons.first();
      const secondButton = buttons.nth(1);

      await firstButton.click();
      await expect(firstButton).toBeFocused();

      await firstButton.press('ArrowRight');
      await expect(secondButton).toBeFocused();
    }
  });

  test('all frameworks have consistent ARIA structure', async ({ page }) => {
    test.setTimeout(60000);

    for (const framework of frameworks) {
      await page.goto(`patterns/toolbar/${framework}/demo/`);
      await getToolbar(page).first().waitFor();

      // Check toolbar role
      const toolbar = getToolbar(page).first();
      await expect(toolbar).toHaveRole('toolbar');

      // Check aria-label
      const ariaLabel = await toolbar.getAttribute('aria-label');
      expect(ariaLabel).toBeTruthy();

      // Check aria-orientation
      const orientation = await toolbar.getAttribute('aria-orientation');
      expect(orientation).toBe('horizontal');

      // Check buttons exist
      const buttons = getToolbarButtons(page, 0);
      const count = await buttons.count();
      expect(count).toBeGreaterThan(0);
    }
  });

  test('all frameworks support toggle buttons', async ({ page }) => {
    test.setTimeout(60000);

    for (const framework of frameworks) {
      await page.goto(`patterns/toolbar/${framework}/demo/`);
      await getToolbar(page).first().waitFor();

      const buttons = getToolbarButtons(page, 0);
      const toggleButton = buttons.first();

      // Should have aria-pressed
      const initialPressed = await toggleButton.getAttribute('aria-pressed');
      expect(initialPressed === 'true' || initialPressed === 'false').toBe(true);

      // Should toggle on click
      await toggleButton.click();
      const newPressed = await toggleButton.getAttribute('aria-pressed');
      expect(newPressed).not.toBe(initialPressed);
    }
  });
});

テストの実行

# Toolbarのユニットテストを実行
npm run test -- toolbar

# ToolbarのE2Eテストを実行(全フレームワーク)
npm run test:e2e:pattern --pattern=toolbar

# 特定のフレームワークでE2Eテストを実行
npm run test:e2e:react:pattern --pattern=toolbar

テストツール

詳細は testing-strategy.md (opens in new tab) を参照してください。

リソース