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 Properties

aria-orientation

orientation prop

Values
horizontal | vertical
Required
No

aria-controls

Auto-generated

Values
ID reference to associated panel
Required
Yes

aria-labelledby

Auto-generated

Values
ID reference to associated tab
Required
Yes

WAI-ARIA States

aria-selected

Target Element
tab element
Values
true | false
Required
Yes
Change Trigger

Tab click, Arrow keys (automatic), Enter/Space (manual)

Keyboard Support

Horizontal Orientation

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)

Vertical Orientation

Key Action
ArrowDown Move to next tab (loops at end)
ArrowUp Move to previous tab (loops at start)

Focus Management

Event Behavior
Selected/focused tab tabIndex="0"
Other tabs tabIndex="-1"
Tabpanel tabIndex="0" (focusable)
Disabled tabs Skipped during keyboard navigation

Implementation Notes

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
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ [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

References

Source Code

Tabs.vue
<template>
  <div :class="containerClass">
    <div
      ref="tablistRef"
      role="tablist"
      :aria-label="label"
      :aria-orientation="orientation"
      :class="tablistClass"
      @keydown="handleKeyDown"
    >
      <button
        v-for="tab in tabs"
        :key="tab.id"
        :ref="(el) => setTabRef(tab.id, el)"
        role="tab"
        type="button"
        :id="`${tablistId}-tab-${tab.id}`"
        :aria-selected="tab.id === selectedId"
        :aria-controls="tab.id === selectedId ? `${tablistId}-panel-${tab.id}` : undefined"
        :tabindex="tab.disabled ? -1 : tab.id === selectedId ? 0 : -1"
        :disabled="tab.disabled"
        :class="getTabClass(tab)"
        @click="!tab.disabled && handleTabSelection(tab.id)"
      >
        <span class="apg-tab-label">{{ tab.label }}</span>
      </button>
    </div>

    <div class="apg-tabpanels">
      <div
        v-for="tab in tabs"
        :key="tab.id"
        role="tabpanel"
        :id="`${tablistId}-panel-${tab.id}`"
        :aria-labelledby="`${tablistId}-tab-${tab.id}`"
        :hidden="tab.id !== selectedId"
        :class="getPanelClass(tab)"
        :tabindex="tab.id === selectedId ? 0 : -1"
      >
        <div v-if="tab.content" v-html="tab.content" />
        <slot v-else :name="tab.id" />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue';

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

export interface TabsProps {
  tabs: TabItem[];
  defaultSelectedId?: string;
  orientation?: 'horizontal' | 'vertical';
  activationMode?: 'automatic' | 'manual';
  label?: string;
}

const props = withDefaults(defineProps<TabsProps>(), {
  orientation: 'horizontal',
  activationMode: 'automatic',
  label: undefined,
});

const emit = defineEmits<{
  selectionChange: [tabId: string];
}>();

const selectedId = ref<string>('');
const focusedIndex = ref<number>(0);
const tablistRef = ref<HTMLElement>();
const tabRefs = ref<Record<string, HTMLButtonElement>>({});
const tablistId = ref('');

onMounted(() => {
  tablistId.value = `tabs-${Math.random().toString(36).substr(2, 9)}`;
});

const setTabRef = (id: string, el: unknown) => {
  if (el instanceof HTMLButtonElement) {
    tabRefs.value[id] = el;
  }
};

const initializeSelectedTab = () => {
  if (props.tabs.length > 0) {
    const initialTab = props.defaultSelectedId
      ? props.tabs.find((tab) => tab.id === props.defaultSelectedId && !tab.disabled)
      : props.tabs.find((tab) => !tab.disabled);
    selectedId.value = initialTab?.id || props.tabs[0]?.id;

    // focusedIndex ใ‚’้ธๆŠžใ•ใ‚ŒใŸใ‚ฟใƒ–ใฎใ‚คใƒณใƒ‡ใƒƒใ‚ฏใ‚นใซๅŒๆœŸ
    const selectedIndex = availableTabs.value.findIndex((tab) => tab.id === selectedId.value);
    if (selectedIndex >= 0) {
      focusedIndex.value = selectedIndex;
    }
  }
};

const availableTabs = computed(() => props.tabs.filter((tab) => !tab.disabled));

const containerClass = computed(() => {
  return `apg-tabs ${props.orientation === 'vertical' ? 'apg-tabs--vertical' : 'apg-tabs--horizontal'}`;
});

const tablistClass = computed(() => {
  return `apg-tablist ${props.orientation === 'vertical' ? 'apg-tablist--vertical' : 'apg-tablist--horizontal'}`;
});

const getTabClass = (tab: TabItem) => {
  const classes = ['apg-tab'];
  classes.push(props.orientation === 'vertical' ? 'apg-tab--vertical' : 'apg-tab--horizontal');
  if (tab.id === selectedId.value) classes.push('apg-tab--selected');
  if (tab.disabled) classes.push('apg-tab--disabled');
  return classes.join(' ');
};

const getPanelClass = (tab: TabItem) => {
  return `apg-tabpanel ${tab.id === selectedId.value ? 'apg-tabpanel--active' : 'apg-tabpanel--inactive'}`;
};

const handleTabSelection = (tabId: string) => {
  selectedId.value = tabId;
  emit('selectionChange', tabId);
};

const handleTabFocus = async (index: number) => {
  focusedIndex.value = index;
  const tab = availableTabs.value[index];
  if (tab && tabRefs.value[tab.id]) {
    await nextTick();
    tabRefs.value[tab.id].focus();
  }
};

const handleKeyDown = async (event: KeyboardEvent) => {
  const target = event.target;
  if (!tablistRef.value || !(target instanceof Node) || !tablistRef.value.contains(target)) {
    return;
  }

  let newIndex = focusedIndex.value;
  let shouldPreventDefault = false;

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

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

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

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

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

  if (shouldPreventDefault) {
    event.preventDefault();

    if (newIndex !== focusedIndex.value) {
      await handleTabFocus(newIndex);

      if (props.activationMode === 'automatic') {
        const newTab = availableTabs.value[newIndex];
        if (newTab) {
          handleTabSelection(newTab.id);
        }
      }
    }
  }
};

watch(() => props.tabs, initializeSelectedTab, { immediate: true });

watch(selectedId, (newSelectedId) => {
  const selectedIndex = availableTabs.value.findIndex((tab) => tab.id === newSelectedId);
  if (selectedIndex >= 0) {
    focusedIndex.value = selectedIndex;
  }
});
</script>

Usage

Example
<script setup>
import Tabs from './Tabs.vue';

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

<template>
  <Tabs
    :tabs="tabs"
    label="My tabs"
    @tab-change="(id) => console.log('Tab changed:', id)"
  />
</template>

API

PropTypeDefaultDescription
tabsTabItem[]requiredArray of tab items with id, label, content
labelstring-Accessible label for the tablist
defaultTabstringfirst tabID of the initially selected tab
orientation'horizontal' | 'vertical''horizontal'Tab layout direction
activationMode'automatic' | 'manual''automatic'How tabs are activated

Custom Events

EventDetailDescription
tab-changestringEmitted when the active tab changes

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)

TestDescription
ArrowRight/LeftMoves focus between tabs (horizontal)
ArrowDown/UpMoves focus between tabs (vertical orientation)
Home/EndMoves focus to first/last tab
Loop navigationArrow keys loop from last to first and vice versa
Disabled skipSkips disabled tabs during navigation
Automatic activationTab panel changes on focus (default mode)
Manual activationEnter/Space required to activate tab

High Priority: APG ARIA Attributes (Unit + E2E)

TestDescription
role="tablist"Container has tablist role
role="tab"Each tab button has tab role
role="tabpanel"Content panel has tabpanel role
aria-selectedSelected tab has aria-selected="true"
aria-controlsTab references its panel via aria-controls
aria-labelledbyPanel references its tab via aria-labelledby
aria-orientationReflects horizontal/vertical orientation

High Priority: Click Interaction (Unit + E2E)

TestDescription
Click selectsClicking tab selects it
Click shows panelClicking tab shows corresponding panel
Click hides othersClicking tab hides other panels

High Priority: Focus Management - Roving Tabindex (Unit + E2E)

TestDescription
tabIndex=0Selected tab has tabIndex=0
tabIndex=-1Non-selected tabs have tabIndex=-1
Panel focusablePanel has tabIndex=0 for focus

Medium Priority: Vertical Orientation (Unit + E2E)

TestDescription
ArrowDown moves nextArrowDown moves to next tab in vertical mode
ArrowUp moves prevArrowUp moves to previous tab in vertical mode

Medium Priority: Disabled State (E2E)

TestDescription
Disabled no clickClicking disabled tab does not select it
Navigation skipsArrow key navigation skips disabled tabs

Medium Priority: Accessibility (Unit + E2E)

TestDescription
axe violationsNo WCAG 2.1 AA violations (via jest-axe/axe-core)

Low Priority: Cross-framework Consistency (E2E)

TestDescription
All frameworks renderReact, Vue, Svelte, Astro all render tabs
Consistent clickAll frameworks support click to select
Consistent ARIAAll 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);

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

See the Testing Strategy guide for details.

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

// ใƒ†ใ‚นใƒˆ็”จใฎใ‚ฟใƒ–ใƒ‡ใƒผใ‚ฟ
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 (Vue)', () => {
  // ๐Ÿ”ด High Priority: APG ๆบ–ๆ‹ ใฎๆ ธๅฟƒ
  describe('APG: ใ‚ญใƒผใƒœใƒผใƒ‰ๆ“ไฝœ (Horizontal)', () => {
    describe('Automatic Activation', () => {
      it('ArrowRight ใงๆฌกใฎใ‚ฟใƒ–ใซ็งปๅ‹•ใƒป้ธๆŠžใ™ใ‚‹', async () => {
        const user = userEvent.setup();
        render(Tabs, { props: { 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('ArrowLeft ใงๅ‰ใฎใ‚ฟใƒ–ใซ็งปๅ‹•ใƒป้ธๆŠžใ™ใ‚‹', async () => {
        const user = userEvent.setup();
        render(Tabs, { props: { 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('ArrowRight ใงๆœ€ๅพŒใ‹ใ‚‰ๆœ€ๅˆใซใƒซใƒผใƒ—ใ™ใ‚‹', async () => {
        const user = userEvent.setup();
        render(Tabs, { props: { 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('ArrowLeft ใงๆœ€ๅˆใ‹ใ‚‰ๆœ€ๅพŒใซใƒซใƒผใƒ—ใ™ใ‚‹', async () => {
        const user = userEvent.setup();
        render(Tabs, { props: { 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('Home ใงๆœ€ๅˆใฎใ‚ฟใƒ–ใซ็งปๅ‹•ใƒป้ธๆŠžใ™ใ‚‹', async () => {
        const user = userEvent.setup();
        render(Tabs, { props: { 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('End ใงๆœ€ๅพŒใฎใ‚ฟใƒ–ใซ็งปๅ‹•ใƒป้ธๆŠžใ™ใ‚‹', async () => {
        const user = userEvent.setup();
        render(Tabs, { props: { 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('disabled ใ‚ฟใƒ–ใ‚’ใ‚นใ‚ญใƒƒใƒ—ใ—ใฆๆฌกใฎๆœ‰ๅŠนใชใ‚ฟใƒ–ใซ็งปๅ‹•ใ™ใ‚‹', async () => {
        const user = userEvent.setup();
        render(Tabs, { props: { tabs: tabsWithDisabled } });

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

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

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

    describe('Manual Activation', () => {
      it('็Ÿขๅฐใ‚ญใƒผใงใƒ•ใ‚ฉใƒผใ‚ซใ‚น็งปๅ‹•ใ™ใ‚‹ใŒใƒ‘ใƒใƒซใฏๅˆ‡ใ‚Šๆ›ฟใ‚ใ‚‰ใชใ„', async () => {
        const user = userEvent.setup();
        render(Tabs, { props: { tabs: defaultTabs, activationMode: '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();
        expect(tab1).toHaveAttribute('aria-selected', 'true');
        expect(tab2).toHaveAttribute('aria-selected', 'false');
        expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 1');
      });

      it('Enter ใงใƒ•ใ‚ฉใƒผใ‚ซใ‚นไธญใฎใ‚ฟใƒ–ใ‚’้ธๆŠžใ™ใ‚‹', async () => {
        const user = userEvent.setup();
        render(Tabs, { props: { tabs: defaultTabs, activationMode: '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('Space ใงใƒ•ใ‚ฉใƒผใ‚ซใ‚นไธญใฎใ‚ฟใƒ–ใ‚’้ธๆŠžใ™ใ‚‹', async () => {
        const user = userEvent.setup();
        render(Tabs, { props: { tabs: defaultTabs, activationMode: '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: ใ‚ญใƒผใƒœใƒผใƒ‰ๆ“ไฝœ (Vertical)', () => {
    it('ArrowDown ใงๆฌกใฎใ‚ฟใƒ–ใซ็งปๅ‹•ใƒป้ธๆŠžใ™ใ‚‹', async () => {
      const user = userEvent.setup();
      render(Tabs, { props: { 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('ArrowUp ใงๅ‰ใฎใ‚ฟใƒ–ใซ็งปๅ‹•ใƒป้ธๆŠžใ™ใ‚‹', async () => {
      const user = userEvent.setup();
      render(Tabs, {
        props: { 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('ArrowDown ใงๆœ€ๅพŒใ‹ใ‚‰ๆœ€ๅˆใซใƒซใƒผใƒ—ใ™ใ‚‹', async () => {
      const user = userEvent.setup();
      render(Tabs, {
        props: { 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();
      expect(tab1).toHaveAttribute('aria-selected', 'true');
    });
  });

  describe('APG: ARIA ๅฑžๆ€ง', () => {
    it('tablist ใŒ role="tablist" ใ‚’ๆŒใค', () => {
      render(Tabs, { props: { tabs: defaultTabs } });
      expect(screen.getByRole('tablist')).toBeInTheDocument();
    });

    it('ๅ„ใ‚ฟใƒ–ใŒ role="tab" ใ‚’ๆŒใค', () => {
      render(Tabs, { props: { tabs: defaultTabs } });
      const tabs = screen.getAllByRole('tab');
      expect(tabs).toHaveLength(3);
    });

    it('ๅ„ใƒ‘ใƒใƒซใŒ role="tabpanel" ใ‚’ๆŒใค', () => {
      render(Tabs, { props: { tabs: defaultTabs } });
      expect(screen.getByRole('tabpanel')).toBeInTheDocument();
    });

    it('้ธๆŠžไธญใ‚ฟใƒ–ใŒ aria-selected="true"ใ€้ž้ธๆŠžใŒ "false"', () => {
      render(Tabs, { props: { 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('้ธๆŠžไธญใ‚ฟใƒ–ใฎ aria-controls ใŒใƒ‘ใƒใƒซ id ใจไธ€่‡ด', () => {
      render(Tabs, { props: { 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('ใƒ‘ใƒใƒซใฎ aria-labelledby ใŒใ‚ฟใƒ– id ใจไธ€่‡ด', () => {
      render(Tabs, { props: { 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 ใŒ orientation prop ใ‚’ๅๆ˜ ใ™ใ‚‹', () => {
      render(Tabs, { props: { tabs: defaultTabs } });
      expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'horizontal');
    });

    it('vertical orientation ใง aria-orientation=vertical', () => {
      render(Tabs, { props: { tabs: defaultTabs, orientation: 'vertical' } });
      expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'vertical');
    });
  });

  describe('APG: ใƒ•ใ‚ฉใƒผใ‚ซใ‚น็ฎก็† (Roving Tabindex)', () => {
    it('Automatic: ้ธๆŠžไธญใ‚ฟใƒ–ใฎใฟ tabIndex=0', () => {
      render(Tabs, { props: { 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('Tab ใ‚ญใƒผใง tabpanel ใซ็งปๅ‹•ใงใใ‚‹', async () => {
      const user = userEvent.setup();
      render(Tabs, { props: { tabs: defaultTabs } });

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

      await user.tab();

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

    it('tabpanel ใŒ tabIndex=0 ใงใƒ•ใ‚ฉใƒผใ‚ซใ‚นๅฏ่ƒฝ', () => {
      render(Tabs, { props: { tabs: defaultTabs } });
      const tabpanel = screen.getByRole('tabpanel');
      expect(tabpanel).toHaveAttribute('tabIndex', '0');
    });
  });

  // ๐ŸŸก Medium Priority: ใ‚ขใ‚ฏใ‚ปใ‚ทใƒ“ใƒชใƒ†ใ‚ฃๆคœ่จผ
  describe('ใ‚ขใ‚ฏใ‚ปใ‚ทใƒ“ใƒชใƒ†ใ‚ฃ', () => {
    it('axe ใซใ‚ˆใ‚‹ WCAG 2.1 AA ้•ๅใŒใชใ„', async () => {
      const { container } = render(Tabs, { props: { tabs: defaultTabs } });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  describe('Props', () => {
    it('defaultSelectedId ใงๅˆๆœŸ้ธๆŠžใ‚ฟใƒ–ใ‚’ๆŒ‡ๅฎšใงใใ‚‹', () => {
      render(Tabs, { props: { 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('@selectionChange ใŒใ‚ฟใƒ–้ธๆŠžๆ™‚ใซ็™บ็ซใ™ใ‚‹', async () => {
      const handleSelectionChange = vi.fn();
      const user = userEvent.setup();
      render(Tabs, {
        props: { tabs: defaultTabs, onSelectionChange: handleSelectionChange },
      });

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

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

  describe('็•ฐๅธธ็ณป', () => {
    it('defaultSelectedId ใŒๅญ˜ๅœจใ—ใชใ„ๅ ดๅˆใ€ๆœ€ๅˆใฎใ‚ฟใƒ–ใŒ้ธๆŠžใ•ใ‚Œใ‚‹', () => {
      render(Tabs, { props: { tabs: defaultTabs, defaultSelectedId: 'nonexistent' } });

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

Resources