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.

Open demo only โ†’

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. The Toolbar component uses a two-layer testing strategy.

Testing Strategy

Unit Tests (Testing Library)

Verify the component's rendered output using framework-specific testing libraries. These tests ensure correct HTML structure and ARIA attributes.

  • ARIA attributes (aria-pressed, aria-orientation)
  • Keyboard interaction (Arrow keys, Home, End)
  • Roving tabindex behavior
  • Accessibility via jest-axe

E2E Tests (Playwright)

Verify component behavior in a real browser environment across all frameworks. These tests cover interactions and cross-framework consistency.

  • Click interactions
  • Arrow key navigation (horizontal and vertical)
  • Toggle button state changes
  • Disabled item handling
  • ARIA structure validation in live browser
  • axe-core accessibility scanning
  • Cross-framework consistency checks

Test Categories

High Priority : APG Keyboard Interaction (Unit + E2E)

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 (Unit + E2E)

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 (Unit + E2E)

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

High Priority : Toggle Button State (Unit + E2E)

Test Description
aria-pressed Toggle button has aria-pressed attribute
Click toggles Clicking toggle button changes aria-pressed
defaultPressed Toggle with defaultPressed starts as pressed

Medium Priority : Accessibility (Unit + E2E)

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

Low Priority : Cross-framework Consistency (E2E)

Test Description
All frameworks render React, Vue, Svelte, Astro all render toolbars
Consistent keyboard All frameworks support keyboard navigation
Consistent ARIA All frameworks have consistent ARIA structure
Toggle buttons All frameworks support toggle button state

Example E2E Test Code

The following is the actual E2E test file (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);
    }
  });
});

Running Tests

# Run unit tests for Toolbar
npm run test -- toolbar

# Run E2E tests for Toolbar (all frameworks)
npm run test:e2e:pattern --pattern=toolbar

# Run E2E tests for specific framework
npm run test:e2e:react:pattern --pattern=toolbar

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