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}
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
<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> <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> <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> <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).
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
- Vitest (opens in new tab) - Test runner for unit tests
- Testing Library (opens in new tab) - Framework-specific testing utilities (React, Vue, Svelte)
- Playwright (opens in new tab) - Browser automation for E2E tests
- axe-core/playwright (opens in new tab) - Automated accessibility testing in E2E
See testing-strategy.md (opens in new tab) for full documentation.
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
- WAI-ARIA APG: Toolbar Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist