Tabs
A set of layered sections of content, known as tab panels, that display one panel of content at a time.
Demo
Automatic Activation (Default)
Tabs are activated automatically when focused with arrow keys.
Manual Activation
Tabs require Enter or Space to activate after focusing.
Vertical Orientation
Tabs arranged vertically with Up/Down arrow navigation.
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
tablist | Container | Container for tab elements |
tab | Each tab | Individual tab element |
tabpanel | Panel | Content area for each tab |
WAI-ARIA tablist role (opens in new tab)
WAI-ARIA Properties
| Attribute | Target | Values | Required | Configuration |
|---|---|---|---|---|
aria-orientation | tablist | "horizontal" | "vertical" | No | orientation prop |
aria-controls | tab | ID reference to associated panel | Yes | Auto-generated |
aria-labelledby | tabpanel | ID reference to associated tab | Yes | Auto-generated |
WAI-ARIA States
aria-selected
| Target | tab element |
| Values | true | false |
| Required | Yes |
| Change Trigger | Tab click, Arrow keys (automatic), Enter/Space (manual) |
| Reference | aria-selected (opens in new tab) |
Keyboard Support
| Key | Action |
|---|---|
| Tab | Move focus into/out of the tablist |
| ArrowRight | Move to next tab (loops at end) |
| ArrowLeft | Move to previous tab (loops at start) |
| Home | Move to first tab |
| End | Move to last tab |
| Enter / Space | Activate tab (manual mode only) |
| ArrowDown | Move to next tab (loops at end) |
| ArrowUp | Move to previous tab (loops at start) |
Focus Management
- Selected/focused tab: tabIndex="0"
- Other tabs: tabIndex="-1"
- Tabpanel: tabIndex="0" (focusable)
- Disabled tabs: Skipped during keyboard navigation
Implementation Notes
Activation Modes
Automatic (default)
- Arrow keys move focus AND select tab
- Panel content changes immediately
Manual
- Arrow keys move focus only
- Enter/Space required to select tab
- Panel content changes on explicit activation
Structure
┌─────────────────────────────────────────┐
│ [Tab 1] [Tab 2] [Tab 3] ← tablist │
├─────────────────────────────────────────┤
│ │
│ Panel content here ← tabpanel │
│ │
└─────────────────────────────────────────┘
ID Relationships:
- Tab: id="tab-1", aria-controls="panel-1"
- Panel: id="panel-1", aria-labelledby="tab-1" Tabs component structure with ID relationships
Source Code
---
/**
* APG Tabs Pattern - Astro Implementation
*
* A set of layered sections of content that display one panel at a time.
* Uses Web Components for client-side keyboard navigation and state management.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
*/
export interface TabItem {
id: string;
label: string;
content: string;
disabled?: boolean;
}
export interface Props {
/** Array of tab items */
tabs: TabItem[];
/** Initially selected tab ID */
defaultSelectedId?: string;
/** Orientation of the tabs */
orientation?: 'horizontal' | 'vertical';
/** Activation mode: 'automatic' selects on arrow key, 'manual' requires Enter/Space */
activation?: 'automatic' | 'manual';
/** Additional CSS class */
class?: string;
}
const {
tabs,
defaultSelectedId,
orientation = 'horizontal',
activation = 'automatic',
class: className = '',
} = Astro.props;
// Determine initial selected tab
const initialTab = defaultSelectedId
? tabs.find((tab) => tab.id === defaultSelectedId && !tab.disabled)
: tabs.find((tab) => !tab.disabled);
const selectedId = initialTab?.id || tabs[0]?.id;
// Generate unique ID for this instance
const instanceId = `tabs-${Math.random().toString(36).substring(2, 11)}`;
const containerClass = `apg-tabs ${
orientation === 'vertical' ? 'apg-tabs--vertical' : 'apg-tabs--horizontal'
} ${className}`.trim();
const tablistClass = `apg-tablist ${
orientation === 'vertical' ? 'apg-tablist--vertical' : 'apg-tablist--horizontal'
}`;
---
<apg-tabs class={containerClass} data-activation={activation} data-orientation={orientation}>
<div role="tablist" aria-orientation={orientation} class={tablistClass}>
{
tabs.map((tab) => {
const isSelected = tab.id === selectedId;
const tabClass = `apg-tab ${
orientation === 'vertical' ? 'apg-tab--vertical' : 'apg-tab--horizontal'
} ${isSelected ? 'apg-tab--selected' : ''} ${
tab.disabled ? 'apg-tab--disabled' : ''
}`.trim();
return (
<button
role="tab"
type="button"
id={`${instanceId}-tab-${tab.id}`}
aria-selected={isSelected}
aria-controls={isSelected ? `${instanceId}-panel-${tab.id}` : undefined}
tabindex={tab.disabled ? -1 : isSelected ? 0 : -1}
disabled={tab.disabled}
class={tabClass}
data-tab-id={tab.id}
>
<span class="apg-tab-label">{tab.label}</span>
</button>
);
})
}
</div>
<div class="apg-tabpanels">
{
tabs.map((tab) => {
const isSelected = tab.id === selectedId;
return (
<div
role="tabpanel"
id={`${instanceId}-panel-${tab.id}`}
aria-labelledby={`${instanceId}-tab-${tab.id}`}
hidden={!isSelected}
class={`apg-tabpanel ${isSelected ? 'apg-tabpanel--active' : 'apg-tabpanel--inactive'}`}
tabindex={isSelected ? 0 : -1}
data-panel-id={tab.id}
>
<Fragment set:html={tab.content} />
</div>
);
})
}
</div>
</apg-tabs>
<script>
class ApgTabs extends HTMLElement {
private tablist: HTMLElement | null = null;
private tabs: HTMLButtonElement[] = [];
private panels: HTMLElement[] = [];
private availableTabs: HTMLButtonElement[] = [];
private focusedIndex = 0;
private activation: 'automatic' | 'manual' = 'automatic';
private orientation: 'horizontal' | 'vertical' = 'horizontal';
private rafId: number | null = null;
connectedCallback() {
// Use requestAnimationFrame to ensure DOM is fully constructed
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.tablist = this.querySelector('[role="tablist"]');
if (!this.tablist) {
console.warn('apg-tabs: tablist element not found');
return;
}
this.tabs = Array.from(this.querySelectorAll('[role="tab"]'));
this.panels = Array.from(this.querySelectorAll('[role="tabpanel"]'));
if (this.tabs.length === 0 || this.panels.length === 0) {
console.warn('apg-tabs: tabs or panels not found');
return;
}
this.availableTabs = this.tabs.filter((tab) => !tab.disabled);
this.activation = (this.dataset.activation as 'automatic' | 'manual') || 'automatic';
this.orientation = (this.dataset.orientation as 'horizontal' | 'vertical') || 'horizontal';
// Find initial focused index
this.focusedIndex = this.availableTabs.findIndex(
(tab) => tab.getAttribute('aria-selected') === 'true'
);
if (this.focusedIndex === -1) this.focusedIndex = 0;
// Attach event listeners
this.tablist.addEventListener('click', this.handleClick);
this.tablist.addEventListener('keydown', this.handleKeyDown);
}
disconnectedCallback() {
// Cancel pending initialization
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
// Remove event listeners
this.tablist?.removeEventListener('click', this.handleClick);
this.tablist?.removeEventListener('keydown', this.handleKeyDown);
// Clean up references
this.tablist = null;
this.tabs = [];
this.panels = [];
this.availableTabs = [];
}
private selectTab(tabId: string) {
this.tabs.forEach((tab) => {
const isSelected = tab.dataset.tabId === tabId;
tab.setAttribute('aria-selected', String(isSelected));
tab.tabIndex = isSelected ? 0 : -1;
tab.classList.toggle('apg-tab--selected', isSelected);
// Update aria-controls
const panelId = tab.id.replace('-tab-', '-panel-');
if (isSelected) {
tab.setAttribute('aria-controls', panelId);
} else {
tab.removeAttribute('aria-controls');
}
});
this.panels.forEach((panel) => {
const isSelected = panel.dataset.panelId === tabId;
panel.hidden = !isSelected;
panel.tabIndex = isSelected ? 0 : -1;
panel.classList.toggle('apg-tabpanel--active', isSelected);
panel.classList.toggle('apg-tabpanel--inactive', !isSelected);
});
// Dispatch custom event
this.dispatchEvent(
new CustomEvent('tabchange', {
detail: { selectedId: tabId },
bubbles: true,
})
);
}
private focusTab(index: number) {
this.focusedIndex = index;
this.availableTabs[index]?.focus();
}
private handleClick = (e: Event) => {
const target = (e.target as HTMLElement).closest<HTMLButtonElement>('[role="tab"]');
if (target && !target.disabled) {
this.selectTab(target.dataset.tabId!);
// Update focused index
this.focusedIndex = this.availableTabs.indexOf(target);
}
};
private handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.getAttribute('role') !== 'tab') return;
let newIndex = this.focusedIndex;
let shouldPreventDefault = false;
// Determine navigation keys based on orientation
const nextKey = this.orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
const prevKey = this.orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
switch (e.key) {
case nextKey:
case 'ArrowRight':
case 'ArrowDown':
newIndex = (this.focusedIndex + 1) % this.availableTabs.length;
shouldPreventDefault = true;
break;
case prevKey:
case 'ArrowLeft':
case 'ArrowUp':
newIndex =
(this.focusedIndex - 1 + this.availableTabs.length) % this.availableTabs.length;
shouldPreventDefault = true;
break;
case 'Home':
newIndex = 0;
shouldPreventDefault = true;
break;
case 'End':
newIndex = this.availableTabs.length - 1;
shouldPreventDefault = true;
break;
case 'Enter':
case ' ':
if (this.activation === 'manual') {
const focusedTab = this.availableTabs[this.focusedIndex];
if (focusedTab) {
this.selectTab(focusedTab.dataset.tabId!);
}
}
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
e.preventDefault();
if (newIndex !== this.focusedIndex) {
this.focusTab(newIndex);
if (this.activation === 'automatic') {
const newTab = this.availableTabs[newIndex];
if (newTab) {
this.selectTab(newTab.dataset.tabId!);
}
}
}
}
};
}
// Register the custom element
if (!customElements.get('apg-tabs')) {
customElements.define('apg-tabs', ApgTabs);
}
</script> Usage
---
import Tabs from './Tabs.astro';
const tabs = [
{ id: 'tab1', label: 'First', content: 'First panel content' },
{ id: 'tab2', label: 'Second', content: 'Second panel content' },
{ id: 'tab3', label: 'Third', content: 'Third panel content' }
];
---
<Tabs
tabs={tabs}
defaultSelectedId="tab1"
/>
<script>
// Listen for tab change events
document.querySelector('apg-tabs')?.addEventListener('tabchange', (e) => {
console.log('Tab changed:', e.detail.selectedId);
});
</script> API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
tabs | TabItem[] | required | Array of tab items |
defaultSelectedId | string | first tab | ID of the initially selected tab |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Tab layout direction |
activation | 'automatic' | 'manual' | 'automatic' | How tabs are activated |
class | string | "" | Additional CSS class |
TabItem Interface
interface TabItem {
id: string;
label: string;
content: string;
disabled?: boolean;
} Custom Events
| Event | Detail | Description |
|---|---|---|
tabchange | { selectedId: string } | Fired when the selected tab changes |
This component uses a Web Component (<apg-tabs>) for client-side keyboard
navigation and state management.
Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. The Tabs 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-selected, aria-controls, aria-labelledby)
- 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 with looping
- Automatic and manual activation modes
- 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 tabs (horizontal) |
ArrowDown/Up | Moves focus between tabs (vertical orientation) |
Home/End | Moves focus to first/last tab |
Loop navigation | Arrow keys loop from last to first and vice versa |
Disabled skip | Skips disabled tabs during navigation |
Automatic activation | Tab panel changes on focus (default mode) |
Manual activation | Enter/Space required to activate tab |
High Priority : APG ARIA Attributes (Unit + E2E)
| Test | Description |
|---|---|
role="tablist" | Container has tablist role |
role="tab" | Each tab button has tab role |
role="tabpanel" | Content panel has tabpanel role |
aria-selected | Selected tab has aria-selected="true" |
aria-controls | Tab references its panel via aria-controls |
aria-labelledby | Panel references its tab via aria-labelledby |
aria-orientation | Reflects horizontal/vertical orientation |
High Priority : Click Interaction (Unit + E2E)
| Test | Description |
|---|---|
Click selects | Clicking tab selects it |
Click shows panel | Clicking tab shows corresponding panel |
Click hides others | Clicking tab hides other panels |
High Priority : Focus Management - Roving Tabindex (Unit + E2E)
| Test | Description |
|---|---|
tabIndex=0 | Selected tab has tabIndex=0 |
tabIndex=-1 | Non-selected tabs have tabIndex=-1 |
Panel focusable | Panel has tabIndex=0 for focus |
Medium Priority : Vertical Orientation (Unit + E2E)
| Test | Description |
|---|---|
ArrowDown moves next | ArrowDown moves to next tab in vertical mode |
ArrowUp moves prev | ArrowUp moves to previous tab in vertical mode |
Medium Priority : Disabled State (E2E)
| Test | Description |
|---|---|
Disabled no click | Clicking disabled tab does not select it |
Navigation skips | Arrow key navigation skips disabled tabs |
Medium Priority : Accessibility (Unit + E2E)
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations (via jest-axe/axe-core) |
Low Priority : Cross-framework Consistency (E2E)
| Test | Description |
|---|---|
All frameworks render | React, Vue, Svelte, Astro all render tabs |
Consistent click | All frameworks support click to select |
Consistent ARIA | All frameworks have consistent ARIA structure |
Example Test Code
The following is the actual E2E test file (e2e/tabs.spec.ts).
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
/**
* E2E Tests for Tabs Pattern
*
* A set of layered sections of content, known as tab panels, that display
* one panel of content at a time.
*
* APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
*/
const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;
// ============================================
// Helper Functions
// ============================================
const getTabs = (page: import('@playwright/test').Page) => {
return page.locator('.apg-tabs');
};
const getTablist = (page: import('@playwright/test').Page) => {
return page.getByRole('tablist');
};
const getTabButtons = (page: import('@playwright/test').Page) => {
return page.getByRole('tab');
};
const getTabPanels = (page: import('@playwright/test').Page) => {
return page.getByRole('tabpanel');
};
// ============================================
// Framework-specific Tests
// ============================================
for (const framework of frameworks) {
test.describe(`Tabs (${framework})`, () => {
test.beforeEach(async ({ page }) => {
await page.goto(`patterns/tabs/${framework}/demo/`);
await getTabs(page).first().waitFor();
// Wait for hydration - tabs should have proper aria-controls
const firstTab = getTabButtons(page).first();
await expect
.poll(async () => {
const id = await firstTab.getAttribute('id');
return id && id.length > 1 && !id.startsWith('-');
})
.toBe(true);
// Ensure first tab is interactive (hydration complete)
await expect(firstTab).toHaveAttribute('tabindex', '0');
await expect(firstTab).toHaveAttribute('aria-selected', 'true');
});
// ------------------------------------------
// 🔴 High Priority: APG ARIA Structure
// ------------------------------------------
test.describe('APG: ARIA Structure', () => {
test('tablist has role="tablist"', async ({ page }) => {
const tablist = getTablist(page).first();
await expect(tablist).toHaveRole('tablist');
});
test('tabs have role="tab"', async ({ page }) => {
const tabs = getTabButtons(page);
const firstTab = tabs.first();
await expect(firstTab).toHaveRole('tab');
});
test('panels have role="tabpanel"', async ({ page }) => {
const panel = getTabPanels(page).first();
await expect(panel).toHaveRole('tabpanel');
});
test('selected tab has aria-selected="true"', async ({ page }) => {
const tabs = getTabs(page).first();
const tabButtons = tabs.getByRole('tab');
const firstTab = tabButtons.first();
await expect(firstTab).toHaveAttribute('aria-selected', 'true');
});
test('non-selected tabs have aria-selected="false"', async ({ page }) => {
const tabs = getTabs(page).first();
const tabButtons = tabs.getByRole('tab');
const secondTab = tabButtons.nth(1);
await expect(secondTab).toHaveAttribute('aria-selected', 'false');
});
test('selected tab has aria-controls referencing panel', async ({ page }) => {
const tabs = getTabs(page).first();
const selectedTab = tabs.getByRole('tab', { selected: true });
await expect(selectedTab).toHaveAttribute('aria-controls', /.+/);
const controlsId = await selectedTab.getAttribute('aria-controls');
expect(controlsId).toBeTruthy();
const panel = page.locator(`#${controlsId}`);
await expect(panel).toHaveRole('tabpanel');
});
test('panel has aria-labelledby referencing tab', async ({ page }) => {
const tabs = getTabs(page).first();
const selectedTab = tabs.getByRole('tab', { selected: true });
const tabId = await selectedTab.getAttribute('id');
const controlsId = await selectedTab.getAttribute('aria-controls');
const panel = page.locator(`#${controlsId}`);
await expect(panel).toHaveAttribute('aria-labelledby', tabId!);
});
test('tablist has aria-orientation attribute', async ({ page }) => {
const tablist = getTablist(page).first();
const orientation = await tablist.getAttribute('aria-orientation');
// Should be either horizontal (default) or vertical
expect(['horizontal', 'vertical']).toContain(orientation);
});
});
// ------------------------------------------
// 🔴 High Priority: Click Interaction
// ------------------------------------------
test.describe('APG: Click Interaction', () => {
test('clicking tab selects it', async ({ page }) => {
const tabs = getTabs(page).first();
const tabButtons = tabs.getByRole('tab');
const secondTab = tabButtons.nth(1);
await expect(secondTab).toHaveAttribute('aria-selected', 'false');
await secondTab.click();
await expect(secondTab).toHaveAttribute('aria-selected', 'true');
});
test('clicking tab shows corresponding panel', async ({ page }) => {
const tabs = getTabs(page).first();
const tabButtons = tabs.getByRole('tab');
const secondTab = tabButtons.nth(1);
await secondTab.click();
const controlsId = await secondTab.getAttribute('aria-controls');
const panel = page.locator(`#${controlsId}`);
await expect(panel).not.toHaveAttribute('hidden');
});
test('clicking tab hides other panels', async ({ page }) => {
const tabs = getTabs(page).first();
const tabButtons = tabs.getByRole('tab');
const firstTab = tabButtons.first();
const secondTab = tabButtons.nth(1);
// Click second tab
await secondTab.click();
// First tab's panel should be hidden
const firstControlsId = await firstTab.getAttribute('aria-controls');
if (firstControlsId) {
const firstPanel = page.locator(`#${firstControlsId}`);
await expect(firstPanel).toHaveAttribute('hidden', '');
}
});
});
// ------------------------------------------
// 🔴 High Priority: Keyboard Interaction (Automatic Mode)
// ------------------------------------------
test.describe('APG: Keyboard Interaction (Automatic)', () => {
test('ArrowRight moves to next tab and selects it', async ({ page }) => {
const tabs = getTabs(page).first();
const tabButtons = tabs.getByRole('tab');
const firstTab = tabButtons.first();
const secondTab = tabButtons.nth(1);
await firstTab.focus();
await expect(firstTab).toBeFocused();
await firstTab.press('ArrowRight');
await expect(secondTab).toBeFocused();
// Automatic mode: arrow key selects tab
await expect(secondTab).toHaveAttribute('aria-selected', 'true');
});
test('ArrowLeft moves to previous tab and selects it', async ({ page }) => {
const tabs = getTabs(page).first();
const tabButtons = tabs.getByRole('tab');
const firstTab = tabButtons.first();
const secondTab = tabButtons.nth(1);
// Navigate to second tab using keyboard
await firstTab.focus();
await expect(firstTab).toBeFocused();
await firstTab.press('ArrowRight');
await expect(secondTab).toBeFocused();
// Now test ArrowLeft from second tab
await secondTab.press('ArrowLeft');
await expect(firstTab).toBeFocused();
await expect(firstTab).toHaveAttribute('aria-selected', 'true');
});
test('Home moves to first tab', async ({ page }) => {
const tabs = getTabs(page).first();
const tabButtons = tabs.getByRole('tab');
const firstTab = tabButtons.first();
const lastTab = tabButtons.last();
// Navigate to last tab using keyboard
await firstTab.focus();
await expect(firstTab).toBeFocused();
await firstTab.press('End');
await expect(lastTab).toBeFocused();
// Now test Home from last tab
await lastTab.press('Home');
await expect(firstTab).toBeFocused();
});
test('End moves to last tab', async ({ page }) => {
const tabs = getTabs(page).first();
const tabButtons = tabs.getByRole('tab');
const firstTab = tabButtons.first();
const lastTab = tabButtons.last();
await firstTab.focus();
await expect(firstTab).toBeFocused();
await firstTab.press('End');
await expect(lastTab).toBeFocused();
});
test('Arrow keys loop at boundaries', async ({ page }) => {
const tabs = getTabs(page).first();
const tabButtons = tabs.getByRole('tab');
const firstTab = tabButtons.first();
const lastTab = tabButtons.last();
// Navigate to last tab using keyboard
await firstTab.focus();
await expect(firstTab).toBeFocused();
await firstTab.press('End');
await expect(lastTab).toBeFocused();
// At last tab, ArrowRight should loop to first
await lastTab.press('ArrowRight');
await expect(firstTab).toBeFocused();
// At first tab, ArrowLeft should loop to last
await firstTab.press('ArrowLeft');
await expect(lastTab).toBeFocused();
});
});
// ------------------------------------------
// 🔴 High Priority: Manual Activation Mode
// ------------------------------------------
test.describe('APG: Manual Activation Mode', () => {
test('arrow keys move focus without selecting in manual mode', async ({ page }) => {
// Second tabs component uses manual activation
const manualTabs = getTabs(page).nth(1);
const tabButtons = manualTabs.getByRole('tab');
const firstTab = tabButtons.first();
const secondTab = tabButtons.nth(1);
await firstTab.focus();
await expect(firstTab).toBeFocused();
await expect(firstTab).toHaveAttribute('aria-selected', 'true');
await firstTab.press('ArrowRight');
// Focus should move
await expect(secondTab).toBeFocused();
// But selection should NOT change in manual mode
await expect(firstTab).toHaveAttribute('aria-selected', 'true');
await expect(secondTab).toHaveAttribute('aria-selected', 'false');
});
test('Enter activates focused tab in manual mode', async ({ page }) => {
const manualTabs = getTabs(page).nth(1);
const tabButtons = manualTabs.getByRole('tab');
const firstTab = tabButtons.first();
const secondTab = tabButtons.nth(1);
await firstTab.focus();
await expect(firstTab).toBeFocused();
await firstTab.press('ArrowRight');
await expect(secondTab).toBeFocused();
// Press Enter to activate
await secondTab.press('Enter');
await expect(secondTab).toHaveAttribute('aria-selected', 'true');
});
test('Space activates focused tab in manual mode', async ({ page }) => {
const manualTabs = getTabs(page).nth(1);
const tabButtons = manualTabs.getByRole('tab');
const firstTab = tabButtons.first();
const secondTab = tabButtons.nth(1);
await firstTab.focus();
await expect(firstTab).toBeFocused();
await firstTab.press('ArrowRight');
await expect(secondTab).toBeFocused();
// Press Space to activate
await secondTab.press('Space');
await expect(secondTab).toHaveAttribute('aria-selected', 'true');
});
});
// ------------------------------------------
// 🔴 High Priority: Roving Tabindex
// ------------------------------------------
test.describe('APG: Roving Tabindex', () => {
test('selected tab has tabindex="0"', async ({ page }) => {
const tabs = getTabs(page).first();
const selectedTab = tabs.getByRole('tab', { selected: true });
await expect(selectedTab).toHaveAttribute('tabindex', '0');
});
test('non-selected tabs have tabindex="-1"', async ({ page }) => {
const tabs = getTabs(page).first();
const tabButtons = tabs.getByRole('tab');
const count = await tabButtons.count();
for (let i = 0; i < count; i++) {
const tab = tabButtons.nth(i);
const isSelected = (await tab.getAttribute('aria-selected')) === 'true';
if (!isSelected) {
await expect(tab).toHaveAttribute('tabindex', '-1');
}
}
});
test('tabpanel is focusable', async ({ page }) => {
const tabs = getTabs(page).first();
const panel = tabs.getByRole('tabpanel');
// Panel should have tabindex="0"
await expect(panel).toHaveAttribute('tabindex', '0');
});
});
// ------------------------------------------
// 🟡 Medium Priority: Vertical Orientation
// ------------------------------------------
test.describe('Vertical Orientation', () => {
test('ArrowDown moves to next tab in vertical mode', async ({ page }) => {
// Third tabs component uses vertical orientation
const verticalTabs = getTabs(page).nth(2);
const tablist = verticalTabs.getByRole('tablist');
const tabButtons = verticalTabs.getByRole('tab');
const firstTab = tabButtons.first();
const secondTab = tabButtons.nth(1);
await expect(tablist).toHaveAttribute('aria-orientation', 'vertical');
await firstTab.focus();
await expect(firstTab).toBeFocused();
await firstTab.press('ArrowDown');
await expect(secondTab).toBeFocused();
});
test('ArrowUp moves to previous tab in vertical mode', async ({ page }) => {
const verticalTabs = getTabs(page).nth(2);
const tabButtons = verticalTabs.getByRole('tab');
const firstTab = tabButtons.first();
const secondTab = tabButtons.nth(1);
// Navigate to second tab using keyboard
await firstTab.focus();
await expect(firstTab).toBeFocused();
await firstTab.press('ArrowDown');
await expect(secondTab).toBeFocused();
// Now test ArrowUp from second tab
await secondTab.press('ArrowUp');
await expect(firstTab).toBeFocused();
});
});
// ------------------------------------------
// 🟡 Medium Priority: Disabled Tabs
// ------------------------------------------
test.describe('Disabled Tabs', () => {
test('disabled tab cannot be clicked', async ({ page }) => {
// Fourth tabs component has disabled tab
const disabledTabs = getTabs(page).nth(3);
const tabButtons = disabledTabs.getByRole('tab');
const disabledTab = tabButtons.filter({ has: page.locator('[disabled]') });
if ((await disabledTab.count()) > 0) {
const tab = disabledTab.first();
await tab.click({ force: true });
// Should not be selected
await expect(tab).toHaveAttribute('aria-selected', 'false');
}
});
test('arrow key navigation skips disabled tabs', async ({ page }) => {
const disabledTabs = getTabs(page).nth(3);
const tabButtons = disabledTabs.getByRole('tab');
const firstTab = tabButtons.first();
await firstTab.focus();
await expect(firstTab).toBeFocused();
await firstTab.press('ArrowRight');
// Should skip disabled tab and go to next enabled
const focusedTab = disabledTabs.locator('[role="tab"]:focus');
const isDisabled = await focusedTab.getAttribute('disabled');
expect(isDisabled).toBeNull();
});
});
// ------------------------------------------
// 🟢 Low Priority: Accessibility
// ------------------------------------------
test.describe('Accessibility', () => {
test('has no axe-core violations', async ({ page }) => {
const tabs = getTabs(page);
await tabs.first().waitFor();
const results = await new AxeBuilder({ page })
.include('.apg-tabs')
.disableRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
});
});
}
// ============================================
// Cross-framework Consistency Tests
// ============================================
test.describe('Tabs - Cross-framework Consistency', () => {
test('all frameworks have tabs', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/tabs/${framework}/demo/`);
await getTabs(page).first().waitFor();
const tabs = getTabs(page);
const count = await tabs.count();
expect(count).toBeGreaterThan(0);
}
});
test('all frameworks support click to select', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/tabs/${framework}/demo/`);
await getTabs(page).first().waitFor();
const tabs = getTabs(page).first();
const tabButtons = tabs.getByRole('tab');
const secondTab = tabButtons.nth(1);
await expect(secondTab).toHaveAttribute('aria-selected', 'false');
await secondTab.click();
await expect(secondTab).toHaveAttribute('aria-selected', 'true');
}
});
test('all frameworks have consistent ARIA structure', async ({ page }) => {
test.setTimeout(60000);
for (const framework of frameworks) {
await page.goto(`patterns/tabs/${framework}/demo/`);
await getTabs(page).first().waitFor();
const tabs = getTabs(page).first();
const tablist = tabs.getByRole('tablist');
const selectedTab = tabs.getByRole('tab', { selected: true });
const panel = tabs.getByRole('tabpanel');
// Verify structure
await expect(tablist).toBeAttached();
await expect(selectedTab).toBeAttached();
await expect(panel).toBeAttached();
// Verify linking
const controlsId = await selectedTab.getAttribute('aria-controls');
expect(controlsId).toBeTruthy();
await expect(panel).toHaveAttribute('id', controlsId!);
}
});
}); Running Tests
# Run unit tests for Tabs
npm run test -- tabs
# Run E2E tests for Tabs (all frameworks)
npm run test:e2e:pattern --pattern=tabs
# Run E2E tests for specific framework
npm run test:e2e:react:pattern --pattern=tabs
npm run test:e2e:vue:pattern --pattern=tabs
npm run test:e2e:svelte:pattern --pattern=tabs
npm run test:e2e:astro:pattern --pattern=tabs 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.
Resources
- WAI-ARIA APG: Tabs Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist