APG Patterns
English
English

Toolbar

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

デモ

テキスト書式ツールバー

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

垂直ツールバー

上下矢印キーで操作します。

無効な項目付き

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

イベント処理付きトグルボタン

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

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

Sample text with applied formatting

デフォルトの押下状態

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

デモのみ表示 →

アクセシビリティ

WAI-ARIA ロール

ロール対象要素説明
toolbarコンテナコントロールをグループ化するコンテナ
buttonボタン要素<button>要素の暗黙的なロール
separatorセパレーターグループ間の視覚的およびセマンティックなセパレーター

WAI-ARIA プロパティ

aria-label

ツールバーのアクセシブルな名前

String
必須
はい*

aria-labelledby

aria-labelの代替(優先される)

ID参照
必須
はい*

aria-orientation

ツールバーの方向(デフォルト: horizontal)

horizontal | vertical
必須
いいえ

WAI-ARIA ステート

aria-pressed

対象要素
ToolbarToggleButton
true | false
必須
はい
変更トリガー
Click、Enter、Space

キーボードサポート

キーアクション
Tabツールバーへのフォーカス移動(単一Tabストップ)
Arrow Right / Arrow Leftコントロール間のナビゲーション(水平ツールバー)
Arrow Down / Arrow Upコントロール間のナビゲーション(垂直ツールバー)
Home最初のコントロールにフォーカスを移動
End最後のコントロールにフォーカスを移動
Enter / Spaceボタンをアクティブ化 / 押下状態を切り替え
  • ツールバーコンテナにはaria-labelまたはaria-labelledbyのいずれかが必須です。

フォーカス管理

イベント振る舞い
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>

使い方

Example
---
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

プロパティ デフォルト 説明
orientation 'horizontal' | 'vertical' 'horizontal' ツールバーの方向
aria-label string - ツールバーのアクセシブルラベル
aria-labelledby string - ツールバーをラベル付けする要素の ID
class string '' 追加の CSS クラス
このコンポーネントは、クライアント側のキーボードナビゲーションと状態管理のために Web Components(<apg-toolbar><apg-toolbar-toggle-button><apg-toolbar-separator>)を使用しています。

Custom Events

イベント Detail 説明
pressed-change { pressed: boolean } トグルボタンの状態が変更されたときに発行される

テスト

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

リソース