APG Patterns
English
English

Toolbar

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

デモ

テキスト書式設定ツールバー

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

垂直ツールバー

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

無効な項目あり

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

制御されたトグルボタン

制御された状態を持つトグルボタン。現在の状態が表示され、サンプルテキストに適用されます。

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

Sample text with applied formatting

デフォルト押下状態

初期状態のための 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.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}"
></div>

使い方

使用例
<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準拠を検証します。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) を参照してください。

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();
  });
});

リソース