APG Patterns
English GitHub
English GitHub

Tabs

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

🤖 AI 実装ガイド

デモ

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

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

手動アクティベーション

フォーカス後、Enter または Space キーでタブをアクティブにします。

垂直方向

上下矢印キーで操作する垂直配置のタブ。

アクセシビリティ

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 タブリスト内にフォーカスを移動、またはタブリストから移動
Arrow Right / Arrow Left タブ間を移動(水平方向)
Arrow Down / Arrow Up タブ間を移動(垂直方向)
Home 最初のタブにフォーカスを移動
End 最後のタブにフォーカスを移動
Enter / Space タブをアクティブ化(手動モードのみ)

ソースコード

Tabs.svelte
<script lang="ts">
  import { onMount } from 'svelte';

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

  interface TabsProps {
    tabs: TabItem[];
    defaultSelectedId?: string;
    orientation?: 'horizontal' | 'vertical';
    activationMode?: 'automatic' | 'manual';
    label?: string;
    onSelectionChange?: (tabId: string) => void;
  }

  let {
    tabs = [],
    defaultSelectedId = undefined,
    orientation = 'horizontal',
    activationMode = 'automatic',
    label = undefined,
    onSelectionChange = () => {},
  }: TabsProps = $props();

  let selectedId = $state('');
  let focusedIndex = $state(0);
  let tablistElement: HTMLElement;
  let tabRefs: Record<string, HTMLButtonElement> = {};
  let tablistId = $state('');

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

  // Initialize selected tab
  $effect(() => {
    if (tabs.length > 0 && !selectedId) {
      const initialTab = defaultSelectedId
        ? tabs.find((tab) => tab.id === defaultSelectedId && !tab.disabled)
        : tabs.find((tab) => !tab.disabled);
      selectedId = initialTab?.id || tabs[0]?.id;
    }
  });

  // Derived values
  let availableTabs = $derived(tabs.filter((tab) => !tab.disabled));

  let containerClass = $derived(
    `apg-tabs ${orientation === 'vertical' ? 'apg-tabs--vertical' : 'apg-tabs--horizontal'}`
  );

  let tablistClass = $derived(
    `apg-tablist ${orientation === 'vertical' ? 'apg-tablist--vertical' : 'apg-tablist--horizontal'}`
  );

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

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

  function handleTabSelection(tabId: string) {
    selectedId = tabId;
    onSelectionChange(tabId);
  }

  function handleTabFocus(index: number) {
    focusedIndex = index;
    const tab = availableTabs[index];
    if (tab && tabRefs[tab.id]) {
      tabRefs[tab.id].focus();
    }
  }

  function handleKeyDown(event: KeyboardEvent) {
    const target = event.target;
    if (!tablistElement || !(target instanceof Node) || !tablistElement.contains(target)) {
      return;
    }

    let newIndex = focusedIndex;
    let shouldPreventDefault = false;

    switch (event.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 (activationMode === 'manual') {
          const focusedTab = availableTabs[focusedIndex];
          if (focusedTab) {
            handleTabSelection(focusedTab.id);
          }
        }
        shouldPreventDefault = true;
        break;
    }

    if (shouldPreventDefault) {
      event.preventDefault();

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

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

  // Update focused index when selected tab changes
  $effect(() => {
    const selectedIndex = availableTabs.findIndex((tab) => tab.id === selectedId);
    if (selectedIndex >= 0) {
      focusedIndex = selectedIndex;
    }
  });
</script>

<div class={containerClass}>
  <div
    bind:this={tablistElement}
    role="tablist"
    aria-orientation={orientation}
    class={tablistClass}
    onkeydown={handleKeyDown}
  >
    {#each tabs as tab}
      {@const isSelected = tab.id === selectedId}
      {@const tabIndex = tab.disabled ? -1 : isSelected ? 0 : -1}

      <button
        bind:this={tabRefs[tab.id]}
        role="tab"
        type="button"
        id="{tablistId}-tab-{tab.id}"
        aria-selected={isSelected}
        aria-controls={isSelected ? `${tablistId}-panel-${tab.id}` : undefined}
        tabindex={tabIndex}
        disabled={tab.disabled}
        class={getTabClass(tab)}
        onclick={() => !tab.disabled && handleTabSelection(tab.id)}
      >
        <span class="apg-tab-label">{tab.label}</span>
      </button>
    {/each}
  </div>

  <div class="apg-tabpanels">
    {#each tabs as tab}
      {@const isSelected = tab.id === selectedId}

      <div
        role="tabpanel"
        id="{tablistId}-panel-{tab.id}"
        aria-labelledby="{tablistId}-tab-{tab.id}"
        hidden={!isSelected}
        class={getPanelClass(tab)}
        tabindex={isSelected ? 0 : -1}
      >
        {#if tab.content}
          {@html tab.content}
        {/if}
      </div>
    {/each}
  </div>
</div>

使い方

使用例
<script>
  import Tabs from './Tabs.svelte';

  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 handleTabChange(event) {
    console.log('Tab changed:', event.detail);
  }
</script>

<Tabs
  {tabs}
  label="My tabs"
  ontabchange={handleTabChange}
/>

API

Props

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

Events

イベント 詳細 説明
tabchange string アクティブなタブが変更されたときに発火

TabItem Interface

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

テスト

テストは、キーボード操作、ARIA属性、アクセシビリティ要件全体にわたってAPG準拠を検証します。

テストカテゴリ

高優先度: APGキーボード操作

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

高優先度: APG ARIA属性

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

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

テスト 説明
tabIndex=0 選択されたタブにtabIndex=0がある
tabIndex=-1 選択されていないタブにtabIndex=-1がある
パネルへのTab移動 Tabキーでタブリストからパネルにフォーカスを移動
パネルフォーカス可能 パネルがフォーカスのためにtabIndex=0を持つ

中優先度: アクセシビリティ

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

テストツール

詳細なドキュメントについては、 testing-strategy.md (opens in new tab) を参照してください。

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

// テスト用のタブデータ
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 (Svelte)', () => {
  // 🔴 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/Up でループする', 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();
    });
  });

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

  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('onSelectionChange がタブ選択時に呼び出される', 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');
    });
  });
});

リソース