APG Patterns
English
English

Tabs

タブパネルと呼ばれる階層化されたコンテンツのセットで、一度に1つのパネルを表示します。

デモ

自動アクティベーション(デフォルト)

矢印キーでフォーカスすると自動的にタブがアクティブになります。

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

手動アクティベーション

フォーカス後、EnterまたはSpaceキーでタブをアクティブにする必要があります。

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

垂直方向

上下矢印キーでナビゲーションする垂直配置のタブです。

Configure your application settings here.

デモのみを開く →

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
tablist コンテナ タブ要素のコンテナ
tab 各タブ 個々のタブ要素
tabpanel パネル 各タブのコンテンツエリア

WAI-ARIA tablist role (opens in new tab)

WAI-ARIA プロパティ

属性 対象 必須 設定
aria-orientation tablist "horizontal" | "vertical" いいえ orientation プロパティ
aria-controls tab 関連するパネルへのID参照 はい 自動生成
aria-labelledby tabpanel 関連するタブへのID参照 はい 自動生成

WAI-ARIA ステート

aria-selected

対象 tab 要素
true | false
必須 はい
変更トリガー タブクリック、矢印キー(自動)、Enter/Space(手動)
リファレンス aria-selected (opens in new tab)

キーボードサポート

キー アクション
Tab タブリスト内にフォーカスを移動、またはタブリストから移動
ArrowRight 次のタブに移動(末尾でループ)
ArrowLeft 前のタブに移動(先頭でループ)
Home 最初のタブに移動
End 最後のタブに移動
Enter / Space タブをアクティブ化(手動モードのみ)
ArrowDown 次のタブに移動(末尾でループ)
ArrowUp 前のタブに移動(先頭でループ)

フォーカス管理

  • 選択中/フォーカス中のタブ: tabIndex="0"
  • 他のタブ: tabIndex="-1"
  • タブパネル: tabIndex="0"(フォーカス可能)
  • 無効化されたタブ: キーボードナビゲーションでスキップ

実装ノート

アクティベーションモード

自動モード(デフォルト)
  • 矢印キーでフォーカス移動とタブ選択を同時に行う
  • パネルの内容が即座に変更される
手動モード
  • 矢印キーはフォーカス移動のみ
  • タブ選択にはEnter/Spaceが必要
  • 明示的なアクティベーションでパネル内容が変更される

構造

┌─────────────────────────────────────────┐
│ [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"

ID関連を含むTabsコンポーネントの構造

ソースコード

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>

使い方

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

Props

プロパティ デフォルト 説明
tabs TabItem[] 必須 id、label、contentを含むタブアイテムの配列
label string - タブリストのアクセシブルなラベル
defaultTab string 最初のタブ 初期選択されるタブのID
orientation 'horizontal' | 'vertical' 'horizontal' タブのレイアウト方向
activationMode 'automatic' | 'manual' 'automatic' タブのアクティベーション方法

Events

イベント ペイロード 説明
tab-change string アクティブなタブが変更されたときに発火

TabItem インターフェース

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

テスト

テストは、キーボード操作、ARIA属性、アクセシビリティ要件全体にわたってAPG準拠を検証します。Tabsコンポーネントは2層のテスト戦略を使用しています。

テスト戦略

ユニットテスト(Testing Library)

フレームワーク固有のテストライブラリを使用して、コンポーネントのレンダリング出力を検証します。これらのテストは正しいHTML構造とARIA属性を確認します。

  • ARIA属性(aria-selected, aria-controls, aria-labelledby)
  • キーボード操作(矢印キー、Home、End)
  • ローヴィングタブインデックスの動作
  • jest-axeによるアクセシビリティ

E2Eテスト(Playwright)

すべてのフレームワークにわたって実際のブラウザ環境でコンポーネントの動作を検証します。これらのテストは操作とフレームワーク間の一貫性をカバーします。

  • クリック操作
  • ループを含む矢印キーナビゲーション
  • 自動および手動アクティベーションモード
  • 実ブラウザでのARIA構造検証
  • axe-coreアクセシビリティスキャン
  • フレームワーク間の一貫性チェック

テストカテゴリ

高優先度 : APGキーボード操作(Unit + E2E)

テスト 説明
ArrowRight/Left タブ間でフォーカスを移動(水平方向)
ArrowDown/Up タブ間でフォーカスを移動(垂直方向)
Home/End 最初/最後のタブにフォーカスを移動
Loop navigation 矢印キーで最後から最初へ、またはその逆にループ
Disabled skip ナビゲーション中に無効なタブをスキップ
Automatic activation フォーカス時にタブパネルが変更される(デフォルトモード)
Manual activation タブをアクティブ化するにはEnter/Spaceが必要

高優先度 : APG ARIA属性(Unit + E2E)

テスト 説明
role="tablist" コンテナにtablistロールがある
role="tab" 各タブボタンにtabロールがある
role="tabpanel" コンテンツパネルにtabpanelロールがある
aria-selected 選択されたタブにaria-selected="true"がある
aria-controls タブがaria-controls経由でパネルを参照
aria-labelledby パネルがaria-labelledby経由でタブを参照
aria-orientation 水平/垂直の向きを反映

高優先度 : クリック操作(Unit + E2E)

テスト 説明
Click selects タブをクリックすると選択される
Click shows panel タブをクリックすると対応するパネルが表示される
Click hides others タブをクリックすると他のパネルが非表示になる

高優先度 : フォーカス管理 - ローヴィングタブインデックス(Unit + E2E)

テスト 説明
tabIndex=0 選択されたタブにtabIndex=0がある
tabIndex=-1 選択されていないタブにtabIndex=-1がある
Panel focusable パネルがフォーカスのためにtabIndex=0を持つ

中優先度 : 垂直方向(Unit + E2E)

テスト 説明
ArrowDown moves next 垂直モードでArrowDownで次のタブに移動
ArrowUp moves prev 垂直モードでArrowUpで前のタブに移動

中優先度 : 無効状態(E2E)

テスト 説明
Disabled no click 無効なタブをクリックしても選択されない
Navigation skips 矢印キーナビゲーションで無効なタブをスキップ

中優先度 : アクセシビリティ(Unit + E2E)

テスト 説明
axe violations WCAG 2.1 AA違反なし(jest-axe/axe-core経由)

低優先度 : フレームワーク間一貫性(E2E)

テスト 説明
All frameworks render React、Vue、Svelte、Astroすべてでタブがレンダリングされる
Consistent click すべてのフレームワークでクリックによる選択をサポート
Consistent ARIA すべてのフレームワークで一貫したARIA構造

テストコード例

以下は実際のE2Eテストファイルです (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!);
    }
  });
});

テストの実行

# Tabsのユニットテストを実行
npm run test -- tabs

# TabsのE2Eテストを実行(全フレームワーク)
npm run test:e2e:pattern --pattern=tabs

# 特定フレームワークのE2Eテストを実行
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-strategy.md (opens in new tab) を参照してください。

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

リソース