APG Patterns

Toolbar

A container for grouping a set of controls, such as buttons, toggle buttons, or other input elements.

Demo

Text Formatting Toolbar

A horizontal toolbar with toggle buttons and regular buttons.

Vertical Toolbar

Use arrow up/down keys to navigate.

With Disabled Items

Disabled items are skipped during keyboard navigation.

Controlled Toggle Buttons

Toggle buttons with controlled state using v-model. The current state is displayed and applied to the sample text.

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

Sample text with applied formatting

Default Pressed States

Toggle buttons with default-pressed for initial state, including disabled states.

Accessibility

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)

Source Code

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>

Usage

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

Prop Type Default Description
orientation 'horizontal' | 'vertical' 'horizontal' Direction of the toolbar

ToolbarToggleButton Events

Event Payload Description
update:pressed boolean Emitted when pressed state changes (v-model)
pressed-change boolean Emitted when pressed state changes

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements.

Test Categories

High Priority: APG Keyboard Interaction

Test Description
ArrowRight/Left Moves focus between items (horizontal)
ArrowDown/Up Moves focus between items (vertical)
Home Moves focus to first item
End Moves focus to last item
No wrap Focus stops at edges (no looping)
Disabled skip Skips disabled items during navigation
Enter/Space Activates button or toggles toggle button

High Priority: APG ARIA Attributes

Test Description
role="toolbar" Container has toolbar role
aria-orientation Reflects horizontal/vertical orientation
aria-label/labelledby Toolbar has accessible name
aria-pressed Toggle buttons reflect pressed state
role="separator" Separator has correct role and orientation
type="button" Buttons have explicit type attribute

High Priority: Focus Management (Roving Tabindex)

Test Description
tabIndex=0 First enabled item has tabIndex=0
tabIndex=-1 Other items have tabIndex=-1
Click updates focus Clicking an item updates roving focus position

Medium Priority: Accessibility

Test Description
axe violations No WCAG 2.1 AA violations (via jest-axe)
Vertical toolbar Vertical orientation also passes axe

Low Priority: HTML Attribute Inheritance

Test Description
className Custom classes applied to all components

Testing Tools

See testing-strategy.md (opens in new tab) for full documentation.

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

Resources