APG Patterns
English GitHub
English GitHub

Tabs

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

🤖 AI 実装ガイド

デモ

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

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

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

ソースコード

Tabs.astro
---
/**
 * APG Tabs Pattern - Astro Implementation
 *
 * A set of layered sections of content that display one panel at a time.
 * Uses Web Components for client-side keyboard navigation and state management.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
 */

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

export interface Props {
  /** Array of tab items */
  tabs: TabItem[];
  /** Initially selected tab ID */
  defaultSelectedId?: string;
  /** Orientation of the tabs */
  orientation?: 'horizontal' | 'vertical';
  /** Activation mode: 'automatic' selects on arrow key, 'manual' requires Enter/Space */
  activation?: 'automatic' | 'manual';
  /** Additional CSS class */
  class?: string;
}

const {
  tabs,
  defaultSelectedId,
  orientation = 'horizontal',
  activation = 'automatic',
  class: className = '',
} = Astro.props;

// Determine initial selected tab
const initialTab = defaultSelectedId
  ? tabs.find((tab) => tab.id === defaultSelectedId && !tab.disabled)
  : tabs.find((tab) => !tab.disabled);
const selectedId = initialTab?.id || tabs[0]?.id;

// Generate unique ID for this instance
const instanceId = `tabs-${Math.random().toString(36).substring(2, 11)}`;

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

<apg-tabs class={containerClass} data-activation={activation} data-orientation={orientation}>
  <div role="tablist" aria-orientation={orientation} class={tablistClass}>
    {
      tabs.map((tab) => {
        const isSelected = tab.id === selectedId;
        const tabClass = `apg-tab ${
          orientation === 'vertical' ? 'apg-tab--vertical' : 'apg-tab--horizontal'
        } ${isSelected ? 'apg-tab--selected' : ''} ${
          tab.disabled ? 'apg-tab--disabled' : ''
        }`.trim();

        return (
          <button
            role="tab"
            type="button"
            id={`${instanceId}-tab-${tab.id}`}
            aria-selected={isSelected}
            aria-controls={isSelected ? `${instanceId}-panel-${tab.id}` : undefined}
            tabindex={tab.disabled ? -1 : isSelected ? 0 : -1}
            disabled={tab.disabled}
            class={tabClass}
            data-tab-id={tab.id}
          >
            <span class="apg-tab-label">{tab.label}</span>
          </button>
        );
      })
    }
  </div>

  <div class="apg-tabpanels">
    {
      tabs.map((tab) => {
        const isSelected = tab.id === selectedId;

        return (
          <div
            role="tabpanel"
            id={`${instanceId}-panel-${tab.id}`}
            aria-labelledby={`${instanceId}-tab-${tab.id}`}
            hidden={!isSelected}
            class={`apg-tabpanel ${isSelected ? 'apg-tabpanel--active' : 'apg-tabpanel--inactive'}`}
            tabindex={isSelected ? 0 : -1}
            data-panel-id={tab.id}
          >
            <Fragment set:html={tab.content} />
          </div>
        );
      })
    }
  </div>
</apg-tabs>

<script>
  class ApgTabs extends HTMLElement {
    private tablist: HTMLElement | null = null;
    private tabs: HTMLButtonElement[] = [];
    private panels: HTMLElement[] = [];
    private availableTabs: HTMLButtonElement[] = [];
    private focusedIndex = 0;
    private activation: 'automatic' | 'manual' = 'automatic';
    private orientation: 'horizontal' | 'vertical' = 'horizontal';
    private rafId: number | null = null;

    connectedCallback() {
      // Use requestAnimationFrame to ensure DOM is fully constructed
      this.rafId = requestAnimationFrame(() => this.initialize());
    }

    private initialize() {
      this.rafId = null;
      this.tablist = this.querySelector('[role="tablist"]');
      if (!this.tablist) {
        console.warn('apg-tabs: tablist element not found');
        return;
      }

      this.tabs = Array.from(this.querySelectorAll('[role="tab"]'));
      this.panels = Array.from(this.querySelectorAll('[role="tabpanel"]'));

      if (this.tabs.length === 0 || this.panels.length === 0) {
        console.warn('apg-tabs: tabs or panels not found');
        return;
      }

      this.availableTabs = this.tabs.filter((tab) => !tab.disabled);
      this.activation = (this.dataset.activation as 'automatic' | 'manual') || 'automatic';
      this.orientation = (this.dataset.orientation as 'horizontal' | 'vertical') || 'horizontal';

      // Find initial focused index
      this.focusedIndex = this.availableTabs.findIndex(
        (tab) => tab.getAttribute('aria-selected') === 'true'
      );
      if (this.focusedIndex === -1) this.focusedIndex = 0;

      // Attach event listeners
      this.tablist.addEventListener('click', this.handleClick);
      this.tablist.addEventListener('keydown', this.handleKeyDown);
    }

    disconnectedCallback() {
      // Cancel pending initialization
      if (this.rafId !== null) {
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
      }
      // Remove event listeners
      this.tablist?.removeEventListener('click', this.handleClick);
      this.tablist?.removeEventListener('keydown', this.handleKeyDown);
      // Clean up references
      this.tablist = null;
      this.tabs = [];
      this.panels = [];
      this.availableTabs = [];
    }

    private selectTab(tabId: string) {
      this.tabs.forEach((tab) => {
        const isSelected = tab.dataset.tabId === tabId;
        tab.setAttribute('aria-selected', String(isSelected));
        tab.tabIndex = isSelected ? 0 : -1;
        tab.classList.toggle('apg-tab--selected', isSelected);

        // Update aria-controls
        const panelId = tab.id.replace('-tab-', '-panel-');
        if (isSelected) {
          tab.setAttribute('aria-controls', panelId);
        } else {
          tab.removeAttribute('aria-controls');
        }
      });

      this.panels.forEach((panel) => {
        const isSelected = panel.dataset.panelId === tabId;
        panel.hidden = !isSelected;
        panel.tabIndex = isSelected ? 0 : -1;
        panel.classList.toggle('apg-tabpanel--active', isSelected);
        panel.classList.toggle('apg-tabpanel--inactive', !isSelected);
      });

      // Dispatch custom event
      this.dispatchEvent(
        new CustomEvent('tabchange', {
          detail: { selectedId: tabId },
          bubbles: true,
        })
      );
    }

    private focusTab(index: number) {
      this.focusedIndex = index;
      this.availableTabs[index]?.focus();
    }

    private handleClick = (e: Event) => {
      const target = (e.target as HTMLElement).closest<HTMLButtonElement>('[role="tab"]');
      if (target && !target.disabled) {
        this.selectTab(target.dataset.tabId!);
        // Update focused index
        this.focusedIndex = this.availableTabs.indexOf(target);
      }
    };

    private handleKeyDown = (e: KeyboardEvent) => {
      const target = e.target as HTMLElement;
      if (target.getAttribute('role') !== 'tab') return;

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

      // Determine navigation keys based on orientation
      const nextKey = this.orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
      const prevKey = this.orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';

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

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

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

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

        case 'Enter':
        case ' ':
          if (this.activation === 'manual') {
            const focusedTab = this.availableTabs[this.focusedIndex];
            if (focusedTab) {
              this.selectTab(focusedTab.dataset.tabId!);
            }
          }
          shouldPreventDefault = true;
          break;
      }

      if (shouldPreventDefault) {
        e.preventDefault();

        if (newIndex !== this.focusedIndex) {
          this.focusTab(newIndex);

          if (this.activation === 'automatic') {
            const newTab = this.availableTabs[newIndex];
            if (newTab) {
              this.selectTab(newTab.dataset.tabId!);
            }
          }
        }
      }
    };
  }

  // Register the custom element
  if (!customElements.get('apg-tabs')) {
    customElements.define('apg-tabs', ApgTabs);
  }
</script>

使い方

使用例
---
import Tabs from './Tabs.astro';

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

<Tabs
  tabs={tabs}
  defaultSelectedId="tab1"
/>

<script>
  // Listen for tab change events
  document.querySelector('apg-tabs')?.addEventListener('tabchange', (e) => {
    console.log('Tab changed:', e.detail.selectedId);
  });
</script>

API

プロパティ

プロパティ デフォルト 説明
tabs TabItem[] 必須 タブアイテムの配列
defaultSelectedId string 最初のタブ 初期選択されるタブのID
orientation 'horizontal' | 'vertical' 'horizontal' タブのレイアウト方向
activation 'automatic' | 'manual' 'automatic' タブのアクティベーション方法
class string "" 追加のCSSクラス

TabItem インターフェース

型定義
interface TabItem {
  id: string;
  label: string;
  content: string;
  disabled?: boolean;
}

カスタムイベント

イベント 詳細 説明
tabchange { selectedId: string } 選択されたタブが変更されたときに発火

このコンポーネントは、クライアントサイドのキーボードナビゲーションと状態管理のためにWeb Component(<apg-tabs>)を使用しています。

テスト

テストは、キーボード操作、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) を参照してください。

リソース