APG Patterns
English GitHub
English GitHub

Toolbar

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

🤖 AI Implementation Guide

デモ

テキスト書式設定 Toolbar

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

垂直 Toolbar

矢印の上/下キーで移動します。

無効な項目を含む Toolbar

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

制御されたトグルボタン

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

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

Sample text with applied formatting

デフォルトの押下状態

初期状態のための default-pressed を含むトグルボタン(無効状態を含む)。

アクセシビリティ

WAI-ARIA ロール

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

WAI-ARIA toolbar role (opens in new tab)

WAI-ARIA プロパティ

属性 対象 必須 設定方法
aria-label toolbar 文字列 Yes* aria-label prop
aria-labelledby toolbar ID参照 Yes* aria-labelledby prop
aria-orientation toolbar "horizontal" | "vertical" No orientation prop (デフォルト: horizontal)

* aria-label または aria-labelledby のいずれかが必須

WAI-ARIA ステート

aria-pressed

トグルボタンの押下状態を示します。

対象 ToolbarToggleButton
true | false
必須 Yes (トグルボタンの場合)
変更トリガー Click, Enter, Space
リファレンス aria-pressed (opens in new tab)

キーボードサポート

キー アクション
Tab ツールバーへ/からフォーカスを移動(単一のタブストップ)
Arrow Right / Arrow Left コントロール間を移動(水平ツールバー)
Arrow Down / Arrow Up コントロール間を移動(垂直ツールバー)
Home 最初のコントロールにフォーカスを移動
End 最後のコントロールにフォーカスを移動
Enter / Space ボタンを実行 / 押下状態をトグル

フォーカス管理

このコンポーネントは、フォーカス管理に Roving Tabindex パターンを使用します:

  • 一度に1つのコントロールのみが tabindex="0" を持つ
  • 他のコントロールは tabindex="-1" を持つ
  • 矢印キーでコントロール間を移動
  • 無効なコントロールとセパレータはスキップされる
  • フォーカスは端で折り返さない

ソースコード

Toolbar.vue
<script lang="ts">
export interface ToolbarProps {
  /** Direction of the toolbar */
  orientation?: 'horizontal' | 'vertical';
}

export { ToolbarContextKey, type ToolbarContext } from './toolbar-context';
</script>

<template>
  <div
    ref="toolbarRef"
    role="toolbar"
    :aria-orientation="orientation"
    class="apg-toolbar"
    v-bind="$attrs"
    @keydown="handleKeyDown"
    @focus.capture="handleFocus"
  >
    <slot />
  </div>
</template>

<script setup lang="ts">
import { ref, computed, provide, watch, onMounted, useSlots } from 'vue';
import { ToolbarContextKey, type ToolbarContext } from './toolbar-context';

defineOptions({
  inheritAttrs: false,
});

const props = withDefaults(
  defineProps<{
    /** Direction of the toolbar */
    orientation?: 'horizontal' | 'vertical';
  }>(),
  {
    orientation: 'horizontal',
  }
);

// Provide reactive context to child components
const orientationComputed = computed(() => props.orientation);
provide<ToolbarContext>(ToolbarContextKey, {
  orientation: orientationComputed,
});

const toolbarRef = ref<HTMLDivElement | null>(null);
const focusedIndex = ref(0);
const slots = useSlots();

const getButtons = (): HTMLButtonElement[] => {
  if (!toolbarRef.value) return [];
  return Array.from(toolbarRef.value.querySelectorAll<HTMLButtonElement>('button:not([disabled])'));
};

// Roving tabindex: only the focused button should have tabIndex=0
const updateTabIndices = () => {
  const buttons = getButtons();
  if (buttons.length === 0) return;

  // Clamp focusedIndex to valid range
  if (focusedIndex.value >= buttons.length) {
    focusedIndex.value = buttons.length - 1;
    return; // Will re-run with corrected index
  }

  buttons.forEach((btn, index) => {
    btn.tabIndex = index === focusedIndex.value ? 0 : -1;
  });
};

onMounted(updateTabIndices);
watch(focusedIndex, updateTabIndices);
watch(() => slots.default?.(), updateTabIndices, { flush: 'post' });

const handleFocus = (event: FocusEvent) => {
  const buttons = getButtons();
  const targetIndex = buttons.findIndex((btn) => btn === event.target);
  if (targetIndex !== -1) {
    focusedIndex.value = targetIndex;
  }
};

const 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 = props.orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
  const prevKey = props.orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
  const invalidKeys =
    props.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.value = newIndex;
    }
  }
};
</script>
ToolbarButton.vue
<script lang="ts">
export interface ToolbarButtonProps {
  /** Whether the button is disabled */
  disabled?: boolean;
}
</script>

<template>
  <button type="button" class="apg-toolbar-button" :disabled="disabled" v-bind="$attrs">
    <slot />
  </button>
</template>

<script setup lang="ts">
import { inject } from 'vue';
import { ToolbarContextKey } from './toolbar-context';

defineOptions({
  inheritAttrs: false,
});

withDefaults(
  defineProps<{
    /** Whether the button is disabled */
    disabled?: boolean;
  }>(),
  {
    disabled: false,
  }
);

// Verify we're inside a Toolbar
const context = inject(ToolbarContextKey);
if (!context) {
  console.warn('ToolbarButton must be used within a Toolbar');
}
</script>
ToolbarToggleButton.vue
<script lang="ts">
export interface ToolbarToggleButtonProps {
  /** Controlled pressed state */
  pressed?: boolean;
  /** Default pressed state (uncontrolled) */
  defaultPressed?: boolean;
  /** Whether the button is disabled */
  disabled?: boolean;
}
</script>

<template>
  <button
    type="button"
    class="apg-toolbar-button"
    :aria-pressed="currentPressed"
    :disabled="disabled"
    v-bind="$attrs"
    @click="handleClick"
  >
    <slot />
  </button>
</template>

<script setup lang="ts">
import { ref, inject, computed } from 'vue';
import { ToolbarContextKey } from './toolbar-context';

defineOptions({
  inheritAttrs: false,
});

const props = withDefaults(
  defineProps<{
    /** Controlled pressed state */
    pressed?: boolean;
    /** Default pressed state (uncontrolled) */
    defaultPressed?: boolean;
    /** Whether the button is disabled */
    disabled?: boolean;
  }>(),
  {
    pressed: undefined,
    defaultPressed: false,
    disabled: false,
  }
);

const emit = defineEmits<{
  'update:pressed': [pressed: boolean];
  'pressed-change': [pressed: boolean];
}>();

// Verify we're inside a Toolbar
const context = inject(ToolbarContextKey);
if (!context) {
  console.warn('ToolbarToggleButton must be used within a Toolbar');
}

const internalPressed = ref(props.defaultPressed);
const isControlled = computed(() => props.pressed !== undefined);
const currentPressed = computed(() => (isControlled.value ? props.pressed : internalPressed.value));

const handleClick = () => {
  if (props.disabled) return;

  const newPressed = !currentPressed.value;

  if (!isControlled.value) {
    internalPressed.value = newPressed;
  }

  emit('update:pressed', newPressed);
  emit('pressed-change', newPressed);
};
</script>
ToolbarSeparator.vue
<template>
  <div role="separator" :aria-orientation="separatorOrientation" class="apg-toolbar-separator" />
</template>

<script setup lang="ts">
import { inject, computed } from 'vue';
import { ToolbarContextKey } from './toolbar-context';

// Verify we're inside a Toolbar
const context = inject(ToolbarContextKey);
if (!context) {
  console.warn('ToolbarSeparator must be used within a Toolbar');
}

// Separator orientation is perpendicular to toolbar orientation
const separatorOrientation = computed(() =>
  context?.orientation.value === 'horizontal' ? 'vertical' : 'horizontal'
);
</script>

使い方

使用例
<script setup>
import Toolbar from '@patterns/toolbar/Toolbar.vue'
import ToolbarButton from '@patterns/toolbar/ToolbarButton.vue'
import ToolbarToggleButton from '@patterns/toolbar/ToolbarToggleButton.vue'
import ToolbarSeparator from '@patterns/toolbar/ToolbarSeparator.vue'
</script>

<template>
  <Toolbar aria-label="Text formatting">
    <ToolbarToggleButton>Bold</ToolbarToggleButton>
    <ToolbarToggleButton>Italic</ToolbarToggleButton>
    <ToolbarSeparator />
    <ToolbarButton>Copy</ToolbarButton>
    <ToolbarButton>Paste</ToolbarButton>
  </Toolbar>
</template>

API

Toolbar プロパティ

プロパティ デフォルト 説明
orientation 'horizontal' | 'vertical' 'horizontal' ツールバーの方向

ToolbarToggleButton イベント

イベント ペイロード 説明
update:pressed boolean 押下状態が変更されたときに発行されます(v-model)
pressed-change boolean 押下状態が変更されたときに発行されます

テスト

テストは、キーボード操作、ARIA属性、アクセシビリティ要件におけるAPG準拠を検証します。

テストカテゴリ

高優先度: APG キーボード操作

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

高優先度: APG ARIA 属性

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

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

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

中優先度: アクセシビリティ

テスト 説明
axe violations WCAG 2.1 AA 違反なし(jest-axeを使用)
Vertical toolbar 垂直の向きもaxeに合格

低優先度: HTML属性継承

テスト 説明
className カスタムクラスがすべてのコンポーネントに適用される

テストツール

詳細なドキュメントは testing-strategy.md (opens in new tab) を参照してください。

Toolbar.test.vue.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { h, ref } from 'vue';
import Toolbar from './Toolbar.vue';
import ToolbarButton from './ToolbarButton.vue';
import ToolbarToggleButton from './ToolbarToggleButton.vue';
import ToolbarSeparator from './ToolbarSeparator.vue';

// ヘルパー: Toolbar と子コンポーネントをレンダリング
function renderToolbar(props: Record<string, unknown> = {}, children: ReturnType<typeof h>[]) {
  return render(Toolbar, {
    props,
    slots: {
      default: () => children,
    },
    global: {
      components: {
        ToolbarButton,
        ToolbarToggleButton,
        ToolbarSeparator,
      },
    },
  });
}

describe('Toolbar (Vue)', () => {
  // 🔴 High Priority: APG 準拠の核心
  describe('APG: ARIA 属性', () => {
    it('role="toolbar" が設定される', () => {
      renderToolbar({ 'aria-label': 'Test toolbar' }, [h(ToolbarButton, null, () => 'Button')]);
      expect(screen.getByRole('toolbar')).toBeInTheDocument();
    });

    it('aria-orientation がデフォルトで "horizontal"', () => {
      renderToolbar({ 'aria-label': 'Test toolbar' }, [h(ToolbarButton, null, () => 'Button')]);
      expect(screen.getByRole('toolbar')).toHaveAttribute('aria-orientation', 'horizontal');
    });

    it('aria-orientation が orientation prop を反映する', () => {
      renderToolbar({ 'aria-label': 'Test toolbar', orientation: 'vertical' }, [
        h(ToolbarButton, null, () => 'Button'),
      ]);
      expect(screen.getByRole('toolbar')).toHaveAttribute('aria-orientation', 'vertical');
    });

    it('aria-label が透過される', () => {
      renderToolbar({ 'aria-label': 'Text formatting' }, [h(ToolbarButton, null, () => 'Button')]);
      expect(screen.getByRole('toolbar')).toHaveAttribute('aria-label', 'Text formatting');
    });
  });

  describe('APG: キーボード操作 (Horizontal)', () => {
    it('ArrowRight で次のボタンにフォーカス移動', async () => {
      const user = userEvent.setup();
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarButton, null, () => 'First'),
        h(ToolbarButton, null, () => 'Second'),
        h(ToolbarButton, null, () => 'Third'),
      ]);

      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();
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarButton, null, () => 'First'),
        h(ToolbarButton, null, () => 'Second'),
        h(ToolbarButton, null, () => 'Third'),
      ]);

      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();
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarButton, null, () => 'First'),
        h(ToolbarButton, null, () => 'Second'),
        h(ToolbarButton, null, () => 'Third'),
      ]);

      const thirdButton = screen.getByRole('button', { name: 'Third' });
      thirdButton.focus();

      await user.keyboard('{ArrowRight}');

      expect(thirdButton).toHaveFocus();
    });

    it('ArrowLeft で先頭から最後にラップしない(端で止まる)', async () => {
      const user = userEvent.setup();
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarButton, null, () => 'First'),
        h(ToolbarButton, null, () => 'Second'),
      ]);

      const firstButton = screen.getByRole('button', { name: 'First' });
      firstButton.focus();

      await user.keyboard('{ArrowLeft}');

      expect(firstButton).toHaveFocus();
    });

    it('ArrowUp/Down は水平ツールバーでは無効', async () => {
      const user = userEvent.setup();
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarButton, null, () => 'First'),
        h(ToolbarButton, null, () => 'Second'),
      ]);

      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();
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarButton, null, () => 'First'),
        h(ToolbarButton, null, () => 'Second'),
        h(ToolbarButton, null, () => 'Third'),
      ]);

      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();
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarButton, null, () => 'First'),
        h(ToolbarButton, null, () => 'Second'),
        h(ToolbarButton, null, () => 'Third'),
      ]);

      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();
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarButton, null, () => 'First'),
        h(ToolbarButton, { disabled: true }, () => 'Second (disabled)'),
        h(ToolbarButton, null, () => 'Third'),
      ]);

      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();
      renderToolbar({ 'aria-label': 'Test toolbar', orientation: 'vertical' }, [
        h(ToolbarButton, null, () => 'First'),
        h(ToolbarButton, null, () => 'Second'),
        h(ToolbarButton, null, () => 'Third'),
      ]);

      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();
      renderToolbar({ 'aria-label': 'Test toolbar', orientation: 'vertical' }, [
        h(ToolbarButton, null, () => 'First'),
        h(ToolbarButton, null, () => 'Second'),
        h(ToolbarButton, null, () => 'Third'),
      ]);

      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();
      renderToolbar({ 'aria-label': 'Test toolbar', orientation: 'vertical' }, [
        h(ToolbarButton, null, () => 'First'),
        h(ToolbarButton, null, () => 'Second'),
      ]);

      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 (Vue)', () => {
  describe('ARIA 属性', () => {
    it('role="button" が暗黙的に設定される', () => {
      renderToolbar({ 'aria-label': 'Test toolbar' }, [h(ToolbarButton, null, () => 'Click me')]);
      expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
    });

    it('type="button" が設定される', () => {
      renderToolbar({ 'aria-label': 'Test toolbar' }, [h(ToolbarButton, null, () => 'Click me')]);
      expect(screen.getByRole('button')).toHaveAttribute('type', 'button');
    });
  });

  describe('機能', () => {
    it('クリックで click イベントが発火', async () => {
      const handleClick = vi.fn();
      const user = userEvent.setup();
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarButton, { onClick: handleClick }, () => 'Click me'),
      ]);

      await user.click(screen.getByRole('button'));

      expect(handleClick).toHaveBeenCalledTimes(1);
    });

    it('disabled 時はフォーカス対象外(disabled属性で非フォーカス)', () => {
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarButton, { disabled: true }, () => 'Click me'),
      ]);
      expect(screen.getByRole('button')).toBeDisabled();
    });
  });
});

describe('ToolbarToggleButton (Vue)', () => {
  describe('ARIA 属性', () => {
    it('role="button" が暗黙的に設定される', () => {
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarToggleButton, null, () => 'Toggle'),
      ]);
      expect(screen.getByRole('button', { name: 'Toggle' })).toBeInTheDocument();
    });

    it('type="button" が設定される', () => {
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarToggleButton, null, () => 'Toggle'),
      ]);
      expect(screen.getByRole('button')).toHaveAttribute('type', 'button');
    });

    it('aria-pressed="false" が初期状態で設定される', () => {
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarToggleButton, null, () => 'Toggle'),
      ]);
      expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'false');
    });

    it('aria-pressed="true" が押下状態で設定される', () => {
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarToggleButton, { defaultPressed: true }, () => 'Toggle'),
      ]);
      expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true');
    });
  });

  describe('機能', () => {
    it('クリックで aria-pressed がトグル', async () => {
      const user = userEvent.setup();
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarToggleButton, null, () => 'Toggle'),
      ]);

      const button = screen.getByRole('button');
      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();
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarToggleButton, null, () => 'Toggle'),
      ]);

      const button = screen.getByRole('button');
      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();
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarToggleButton, null, () => 'Toggle'),
      ]);

      const button = screen.getByRole('button');
      button.focus();
      expect(button).toHaveAttribute('aria-pressed', 'false');

      await user.keyboard(' ');
      expect(button).toHaveAttribute('aria-pressed', 'true');
    });

    it('pressed-change イベントが発火', async () => {
      const handlePressedChange = vi.fn();
      const user = userEvent.setup();
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarToggleButton, { onPressedChange: handlePressedChange }, () => 'Toggle'),
      ]);

      await user.click(screen.getByRole('button'));
      expect(handlePressedChange).toHaveBeenCalledWith(true);

      await user.click(screen.getByRole('button'));
      expect(handlePressedChange).toHaveBeenCalledWith(false);
    });

    it('disabled 時はトグルしない', async () => {
      const user = userEvent.setup();
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarToggleButton, { disabled: true }, () => 'Toggle'),
      ]);

      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('aria-pressed', 'false');

      await user.click(button);

      expect(button).toHaveAttribute('aria-pressed', 'false');
    });

    it('disabled 時はフォーカス対象外(disabled属性で非フォーカス)', () => {
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarToggleButton, { disabled: true }, () => 'Toggle'),
      ]);
      expect(screen.getByRole('button')).toBeDisabled();
    });
  });
});

describe('ToolbarSeparator (Vue)', () => {
  describe('ARIA 属性', () => {
    it('role="separator" が設定される', () => {
      renderToolbar({ 'aria-label': 'Test toolbar' }, [
        h(ToolbarButton, null, () => 'Before'),
        h(ToolbarSeparator),
        h(ToolbarButton, null, () => 'After'),
      ]);
      expect(screen.getByRole('separator')).toBeInTheDocument();
    });

    it('horizontal toolbar 時に aria-orientation="vertical"', () => {
      renderToolbar({ 'aria-label': 'Test toolbar', orientation: 'horizontal' }, [
        h(ToolbarButton, null, () => 'Before'),
        h(ToolbarSeparator),
        h(ToolbarButton, null, () => 'After'),
      ]);
      expect(screen.getByRole('separator')).toHaveAttribute('aria-orientation', 'vertical');
    });

    it('vertical toolbar 時に aria-orientation="horizontal"', () => {
      renderToolbar({ 'aria-label': 'Test toolbar', orientation: 'vertical' }, [
        h(ToolbarButton, null, () => 'Before'),
        h(ToolbarSeparator),
        h(ToolbarButton, null, () => 'After'),
      ]);
      expect(screen.getByRole('separator')).toHaveAttribute('aria-orientation', 'horizontal');
    });
  });
});

describe('アクセシビリティ (Vue)', () => {
  it('axe による WCAG 2.1 AA 違反がない', async () => {
    const { container } = renderToolbar({ 'aria-label': 'Text formatting' }, [
      h(ToolbarToggleButton, null, () => 'Bold'),
      h(ToolbarToggleButton, null, () => 'Italic'),
      h(ToolbarSeparator),
      h(ToolbarButton, null, () => 'Copy'),
      h(ToolbarButton, null, () => 'Paste'),
    ]);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('vertical toolbar でも WCAG 2.1 AA 違反がない', async () => {
    const { container } = renderToolbar({ 'aria-label': 'Actions', orientation: 'vertical' }, [
      h(ToolbarButton, null, () => 'New'),
      h(ToolbarButton, null, () => 'Open'),
      h(ToolbarSeparator),
      h(ToolbarButton, null, () => 'Save'),
    ]);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

リソース