APG Patterns
日本語
日本語

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.

This is the overview panel content. It provides a general introduction to the product or service.

Manual Activation

Tabs require Enter or Space to activate after focusing.

Content for tab one. Press Enter or Space to activate tabs.

Vertical Orientation

Tabs arranged vertically with Up/Down arrow navigation.

Configure your application settings here.

Open demo only →

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

Tabs.tsx
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';

export interface TabItem {
  id: string;
  label: string;
  content: React.ReactNode;
  disabled?: boolean;
}

export interface TabsProps {
  /** Array of tab items */
  tabs: TabItem[];
  /** Initially selected tab ID */
  defaultSelectedId?: string;
  /** Orientation of the tabs */
  orientation?: 'horizontal' | 'vertical';
  /** Activation mode */
  activation?: 'automatic' | 'manual';
  /** Callback when tab selection changes */
  onSelectionChange?: (tabId: string) => void;
  /** Additional CSS class */
  className?: string;
}

export function Tabs({
  tabs,
  defaultSelectedId,
  orientation = 'horizontal',
  activation = 'automatic',
  onSelectionChange,
  className = '',
}: TabsProps): React.ReactElement {
  // availableTabsの安定化(パフォーマンス最適化)
  const availableTabs = useMemo(() => tabs.filter((tab) => !tab.disabled), [tabs]);

  const initialTab = defaultSelectedId
    ? availableTabs.find((tab) => tab.id === defaultSelectedId)
    : availableTabs[0];

  const [selectedId, setSelectedId] = useState(initialTab?.id || availableTabs[0]?.id);
  const [focusedIndex, setFocusedIndex] = useState(() => {
    const index = availableTabs.findIndex((tab) => tab.id === initialTab?.id);
    return index >= 0 ? index : 0;
  });

  const tablistRef = useRef<HTMLDivElement>(null);
  const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());

  const tablistId = useId();

  const handleTabSelection = useCallback(
    (tabId: string) => {
      setSelectedId(tabId);
      onSelectionChange?.(tabId);
    },
    [onSelectionChange]
  );

  const handleTabFocus = useCallback(
    (index: number) => {
      setFocusedIndex(index);
      const tab = availableTabs[index];
      if (tab) {
        tabRefs.current.get(tab.id)?.focus();
      }
    },
    [availableTabs]
  );

  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent) => {
      const { key } = event;
      const target = event.target;
      if (
        !tablistRef.current ||
        !(target instanceof Node) ||
        !tablistRef.current.contains(target)
      ) {
        return;
      }

      let newIndex = focusedIndex;
      let shouldPreventDefault = false;

      switch (key) {
        case 'ArrowRight':
        case 'ArrowDown':
          newIndex = (focusedIndex + 1) % availableTabs.length;
          shouldPreventDefault = true;
          break;

        case 'ArrowLeft':
        case 'ArrowUp':
          newIndex = (focusedIndex - 1 + availableTabs.length) % availableTabs.length;
          shouldPreventDefault = true;
          break;

        case 'Home':
          newIndex = 0;
          shouldPreventDefault = true;
          break;

        case 'End':
          newIndex = availableTabs.length - 1;
          shouldPreventDefault = true;
          break;

        case 'Enter':
        case ' ':
          if (activation === 'manual') {
            const focusedTab = availableTabs[focusedIndex];
            if (focusedTab) {
              handleTabSelection(focusedTab.id);
            }
          }
          shouldPreventDefault = true;
          break;
      }

      if (shouldPreventDefault) {
        event.preventDefault();

        if (newIndex !== focusedIndex) {
          handleTabFocus(newIndex);

          if (activation === 'automatic') {
            const newTab = availableTabs[newIndex];
            if (newTab) {
              handleTabSelection(newTab.id);
            }
          }
        }
      }
    },
    [focusedIndex, availableTabs, activation, handleTabSelection, handleTabFocus]
  );

  // フォーカス同期(Activation mode考慮)
  useEffect(() => {
    if (activation === 'manual') {
      // Manual: tabsの変更により範囲外になった場合のみ修正
      if (focusedIndex >= availableTabs.length) {
        setFocusedIndex(Math.max(0, availableTabs.length - 1));
      }
      return;
    }

    // Automatic: 選択に追従
    const selectedIndex = availableTabs.findIndex((tab) => tab.id === selectedId);
    if (selectedIndex >= 0 && selectedIndex !== focusedIndex) {
      setFocusedIndex(selectedIndex);
    }
  }, [selectedId, availableTabs, activation, focusedIndex]);

  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'
  }`;

  return (
    <div className={containerClass}>
      {/* tablist capture child elements keydown events */}
      {/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */}
      <div
        ref={tablistRef}
        role="tablist"
        aria-orientation={orientation}
        className={tablistClass}
        onKeyDown={handleKeyDown}
      >
        {tabs.map((tab) => {
          const isSelected = tab.id === selectedId;
          // APG準拠: Manual Activationではフォーカス位置でtabIndexを制御
          const isFocusTarget =
            activation === 'manual' ? tab.id === availableTabs[focusedIndex]?.id : isSelected;
          const tabIndex = tab.disabled ? -1 : isFocusTarget ? 0 : -1;
          const tabPanelId = `${tablistId}-panel-${tab.id}`;

          const tabClass = `apg-tab ${
            orientation === 'vertical' ? 'apg-tab--vertical' : 'apg-tab--horizontal'
          } ${isSelected ? 'apg-tab--selected' : ''} ${
            tab.disabled ? 'apg-tab--disabled' : ''
          }`.trim();

          return (
            <button
              key={tab.id}
              ref={(el) => {
                if (el) {
                  tabRefs.current.set(tab.id, el);
                } else {
                  tabRefs.current.delete(tab.id);
                }
              }}
              role="tab"
              type="button"
              id={`${tablistId}-tab-${tab.id}`}
              aria-selected={isSelected}
              aria-controls={isSelected ? tabPanelId : undefined}
              tabIndex={tabIndex}
              disabled={tab.disabled}
              className={tabClass}
              onClick={() => !tab.disabled && handleTabSelection(tab.id)}
            >
              <span className="apg-tab-label">{tab.label}</span>
            </button>
          );
        })}
      </div>

      <div className="apg-tabpanels">
        {tabs.map((tab) => {
          const isSelected = tab.id === selectedId;
          const tabPanelId = `${tablistId}-panel-${tab.id}`;

          return (
            <div
              key={tab.id}
              role="tabpanel"
              id={tabPanelId}
              aria-labelledby={`${tablistId}-tab-${tab.id}`}
              hidden={!isSelected}
              className={`apg-tabpanel ${
                isSelected ? 'apg-tabpanel--active' : 'apg-tabpanel--inactive'
              }`}
              tabIndex={isSelected ? 0 : -1}
            >
              {tab.content}
            </div>
          );
        })}
      </div>
    </div>
  );
}

export default Tabs;

Usage

Example
import { Tabs } from './Tabs';

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' }
];

function App() {
  return (
    <Tabs
      tabs={tabs}
      defaultSelectedId="tab1"
      onSelectionChange={(id) => console.log('Tab changed:', id)}
    />
  );
}

API

Tabs 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
onSelectionChange (tabId: string) => void - Callback when tab changes

TabItem Interface

Types
interface TabItem {
  id: string;
  label: string;
  content: React.ReactNode;
  disabled?: boolean;
}

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).

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

    // ------------------------------------------
    // 🔴 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.click();
        await expect(firstTab).toBeFocused();

        await page.keyboard.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);

        // Start from second tab
        await secondTab.click();
        await expect(secondTab).toBeFocused();

        await page.keyboard.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();

        await lastTab.click();
        await expect(lastTab).toBeFocused();

        await page.keyboard.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.click();
        await expect(firstTab).toBeFocused();

        await page.keyboard.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();

        // At last tab, ArrowRight should loop to first
        await lastTab.click();
        // Wait for selection state to be updated before pressing arrow key
        await expect(lastTab).toHaveAttribute('aria-selected', 'true');
        await expect(lastTab).toBeFocused();

        await page.keyboard.press('ArrowRight');
        await expect(firstTab).toBeFocused();

        // At first tab, ArrowLeft should loop to last
        await page.keyboard.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.click();
        await expect(firstTab).toHaveAttribute('aria-selected', 'true');

        await page.keyboard.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.click();
        await page.keyboard.press('ArrowRight');

        // Press Enter to activate
        await page.keyboard.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.click();
        await page.keyboard.press('ArrowRight');

        // Press Space to activate
        await page.keyboard.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.click();
        await page.keyboard.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);

        await secondTab.click();
        await page.keyboard.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.click();
        await page.keyboard.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

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

Tabs.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Tabs, type TabItem } from './Tabs';

// Test tab data
const defaultTabs: TabItem[] = [
  { id: 'tab1', label: 'Tab 1', content: 'Content 1' },
  { id: 'tab2', label: 'Tab 2', content: 'Content 2' },
  { id: 'tab3', label: 'Tab 3', content: 'Content 3' },
];

const tabsWithDisabled: TabItem[] = [
  { id: 'tab1', label: 'Tab 1', content: 'Content 1' },
  { id: 'tab2', label: 'Tab 2', content: 'Content 2', disabled: true },
  { id: 'tab3', label: 'Tab 3', content: 'Content 3' },
];

describe('Tabs', () => {
  // 🔴 High Priority: APG Core Compliance
  describe('APG: Keyboard Interaction (Horizontal)', () => {
    describe('Automatic Activation', () => {
      it('moves to and selects next tab with ArrowRight', async () => {
        const user = userEvent.setup();
        render(<Tabs tabs={defaultTabs} />);

        const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
        tab1.focus();

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

        const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
        expect(tab2).toHaveFocus();
        expect(tab2).toHaveAttribute('aria-selected', 'true');
        expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 2');
      });

      it('moves to and selects previous tab with ArrowLeft', async () => {
        const user = userEvent.setup();
        render(<Tabs tabs={defaultTabs} defaultSelectedId="tab2" />);

        const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
        tab2.focus();

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

        const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
        expect(tab1).toHaveFocus();
        expect(tab1).toHaveAttribute('aria-selected', 'true');
      });

      it('loops from last to first with ArrowRight', async () => {
        const user = userEvent.setup();
        render(<Tabs tabs={defaultTabs} defaultSelectedId="tab3" />);

        const tab3 = screen.getByRole('tab', { name: 'Tab 3' });
        tab3.focus();

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

        const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
        expect(tab1).toHaveFocus();
        expect(tab1).toHaveAttribute('aria-selected', 'true');
      });

      it('loops from first to last with ArrowLeft', async () => {
        const user = userEvent.setup();
        render(<Tabs tabs={defaultTabs} />);

        const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
        tab1.focus();

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

        const tab3 = screen.getByRole('tab', { name: 'Tab 3' });
        expect(tab3).toHaveFocus();
        expect(tab3).toHaveAttribute('aria-selected', 'true');
      });

      it('moves to and selects first tab with Home', async () => {
        const user = userEvent.setup();
        render(<Tabs tabs={defaultTabs} defaultSelectedId="tab3" />);

        const tab3 = screen.getByRole('tab', { name: 'Tab 3' });
        tab3.focus();

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

        const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
        expect(tab1).toHaveFocus();
        expect(tab1).toHaveAttribute('aria-selected', 'true');
      });

      it('moves to and selects last tab with End', async () => {
        const user = userEvent.setup();
        render(<Tabs tabs={defaultTabs} />);

        const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
        tab1.focus();

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

        const tab3 = screen.getByRole('tab', { name: 'Tab 3' });
        expect(tab3).toHaveFocus();
        expect(tab3).toHaveAttribute('aria-selected', 'true');
      });

      it('skips disabled tab and moves to next enabled tab', async () => {
        const user = userEvent.setup();
        render(<Tabs tabs={tabsWithDisabled} />);

        const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
        tab1.focus();

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

        // Tab 2 is skipped, moves to Tab 3
        const tab3 = screen.getByRole('tab', { name: 'Tab 3' });
        expect(tab3).toHaveFocus();
        expect(tab3).toHaveAttribute('aria-selected', 'true');
      });
    });

    describe('Manual Activation', () => {
      it('moves focus with arrow keys but does not switch panel', async () => {
        const user = userEvent.setup();
        render(<Tabs tabs={defaultTabs} activation="manual" />);

        const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
        tab1.focus();

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

        const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
        expect(tab2).toHaveFocus();
        // Panel does not switch
        expect(tab1).toHaveAttribute('aria-selected', 'true');
        expect(tab2).toHaveAttribute('aria-selected', 'false');
        expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 1');
      });

      it('selects focused tab with Enter', async () => {
        const user = userEvent.setup();
        render(<Tabs tabs={defaultTabs} activation="manual" />);

        const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
        tab1.focus();

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

        const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
        expect(tab2).toHaveAttribute('aria-selected', 'true');
        expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 2');
      });

      it('selects focused tab with Space', async () => {
        const user = userEvent.setup();
        render(<Tabs tabs={defaultTabs} activation="manual" />);

        const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
        tab1.focus();

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

        const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
        expect(tab2).toHaveAttribute('aria-selected', 'true');
        expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 2');
      });
    });
  });

  describe('APG: Keyboard Interaction (Vertical)', () => {
    it('moves to and selects next tab with ArrowDown', async () => {
      const user = userEvent.setup();
      render(<Tabs tabs={defaultTabs} orientation="vertical" />);

      const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
      tab1.focus();

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

      const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
      expect(tab2).toHaveFocus();
      expect(tab2).toHaveAttribute('aria-selected', 'true');
    });

    it('moves to and selects previous tab with ArrowUp', async () => {
      const user = userEvent.setup();
      render(<Tabs tabs={defaultTabs} orientation="vertical" defaultSelectedId="tab2" />);

      const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
      tab2.focus();

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

      const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
      expect(tab1).toHaveFocus();
      expect(tab1).toHaveAttribute('aria-selected', 'true');
    });

    it('loops with ArrowDown/Up', async () => {
      const user = userEvent.setup();
      render(<Tabs tabs={defaultTabs} orientation="vertical" defaultSelectedId="tab3" />);

      const tab3 = screen.getByRole('tab', { name: 'Tab 3' });
      tab3.focus();

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

      const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
      expect(tab1).toHaveFocus();
    });
  });

  describe('APG: ARIA Attributes', () => {
    it('tablist has role="tablist"', () => {
      render(<Tabs tabs={defaultTabs} />);
      expect(screen.getByRole('tablist')).toBeInTheDocument();
    });

    it('each tab has role="tab"', () => {
      render(<Tabs tabs={defaultTabs} />);
      const tabs = screen.getAllByRole('tab');
      expect(tabs).toHaveLength(3);
    });

    it('each panel has role="tabpanel"', () => {
      render(<Tabs tabs={defaultTabs} />);
      // Only selected panel is displayed
      expect(screen.getByRole('tabpanel')).toBeInTheDocument();
    });

    it('selected tab has aria-selected="true", unselected have "false"', () => {
      render(<Tabs tabs={defaultTabs} />);
      const tabs = screen.getAllByRole('tab');

      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
      expect(tabs[1]).toHaveAttribute('aria-selected', 'false');
      expect(tabs[2]).toHaveAttribute('aria-selected', 'false');
    });

    it('selected tab aria-controls matches panel id', () => {
      render(<Tabs tabs={defaultTabs} />);
      const selectedTab = screen.getByRole('tab', { name: 'Tab 1' });
      const tabpanel = screen.getByRole('tabpanel');

      const ariaControls = selectedTab.getAttribute('aria-controls');
      expect(ariaControls).toBe(tabpanel.id);
    });

    it('panel aria-labelledby matches tab id', () => {
      render(<Tabs tabs={defaultTabs} />);
      const selectedTab = screen.getByRole('tab', { name: 'Tab 1' });
      const tabpanel = screen.getByRole('tabpanel');

      const ariaLabelledby = tabpanel.getAttribute('aria-labelledby');
      expect(ariaLabelledby).toBe(selectedTab.id);
    });

    it('aria-orientation reflects orientation prop', () => {
      const { rerender } = render(<Tabs tabs={defaultTabs} />);
      expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'horizontal');

      rerender(<Tabs tabs={defaultTabs} orientation="vertical" />);
      expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'vertical');
    });
  });

  describe('APG: Focus Management (Roving Tabindex)', () => {
    it('Automatic: only selected tab has tabIndex=0', () => {
      render(<Tabs tabs={defaultTabs} />);
      const tabs = screen.getAllByRole('tab');

      expect(tabs[0]).toHaveAttribute('tabIndex', '0');
      expect(tabs[1]).toHaveAttribute('tabIndex', '-1');
      expect(tabs[2]).toHaveAttribute('tabIndex', '-1');
    });

    it('Manual: focused tab has tabIndex=0', async () => {
      const user = userEvent.setup();
      render(<Tabs tabs={defaultTabs} activation="manual" />);

      const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
      tab1.focus();

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

      const tabs = screen.getAllByRole('tab');
      // In Manual, focused tab (not selected) has tabIndex=0
      expect(tabs[0]).toHaveAttribute('tabIndex', '-1');
      expect(tabs[1]).toHaveAttribute('tabIndex', '0');
      expect(tabs[2]).toHaveAttribute('tabIndex', '-1');
    });

    it('can move to tabpanel with Tab key', async () => {
      const user = userEvent.setup();
      render(<Tabs tabs={defaultTabs} />);

      const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
      tab1.focus();

      await user.tab();

      expect(screen.getByRole('tabpanel')).toHaveFocus();
    });

    it('tabpanel is focusable with tabIndex=0', () => {
      render(<Tabs tabs={defaultTabs} />);
      const tabpanel = screen.getByRole('tabpanel');
      expect(tabpanel).toHaveAttribute('tabIndex', '0');
    });
  });

  // 🟡 Medium Priority: Accessibility Validation
  describe('Accessibility', () => {
    it('has no WCAG 2.1 AA violations', async () => {
      const { container } = render(<Tabs tabs={defaultTabs} />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  describe('Props', () => {
    it('can specify initial selected tab with defaultSelectedId', () => {
      render(<Tabs tabs={defaultTabs} defaultSelectedId="tab2" />);

      const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
      expect(tab2).toHaveAttribute('aria-selected', 'true');
      expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 2');
    });

    it('calls onSelectionChange when tab is selected', async () => {
      const handleSelectionChange = vi.fn();
      const user = userEvent.setup();
      render(<Tabs tabs={defaultTabs} onSelectionChange={handleSelectionChange} />);

      await user.click(screen.getByRole('tab', { name: 'Tab 2' }));

      expect(handleSelectionChange).toHaveBeenCalledWith('tab2');
    });
  });

  describe('Edge Cases', () => {
    it('selects first tab when defaultSelectedId does not exist', () => {
      render(<Tabs tabs={defaultTabs} defaultSelectedId="nonexistent" />);

      const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
      expect(tab1).toHaveAttribute('aria-selected', 'true');
    });
  });

  // 🟢 Low Priority: Extensibility
  describe('HTML Attribute Inheritance', () => {
    it('applies className to container', () => {
      const { container } = render(<Tabs tabs={defaultTabs} className="custom-tabs" />);
      const tabsContainer = container.firstChild as HTMLElement;
      expect(tabsContainer).toHaveClass('custom-tabs');
    });
  });
});

Resources