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

使い方

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

プロパティ デフォルト 説明
tabs TabItem[] 必須 タブアイテムの配列
defaultSelectedId string 最初のタブ 初期選択されるタブのID
orientation 'horizontal' | 'vertical' 'horizontal' タブの配置方向
activation 'automatic' | 'manual' 'automatic' タブのアクティベーション方法
onSelectionChange (tabId: string) => void - タブが変更されたときのコールバック

TabItem Interface

Types
interface TabItem {
  id: string;
  label: string;
  content: React.ReactNode;
  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.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');
    });
  });
});

リソース