Tabs
一度に1つのパネルを表示する、タブパネルと呼ばれる階層化されたコンテンツのセクション。
🤖 AI 実装ガイドデモ
自動アクティベーション(デフォルト)
矢印キーでフォーカスが当たると、自動的にタブがアクティブになります。
This is the overview panel content. It provides a general introduction to the product or service.
Keyboard navigation support, ARIA compliant, Automatic and manual activation modes.
Pricing information would be displayed here.
手動アクティベーション
フォーカス後、EnterキーまたはSpaceキーでタブをアクティブにする必要があります。
Content for tab one. Press Enter or Space to activate tabs.
Content for tab two.
Content for tab three.
縦方向
縦に配置されたタブで、上下矢印キーでナビゲーションします。
Configure your application settings here.
Manage your profile information.
Set your notification preferences.
アクセシビリティ
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経由) |
テストツール
- Vitest (opens in new tab) - テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ
- jest-axe (opens in new tab) - 自動アクセシビリティテスト
詳細なドキュメントについては、 testing-strategy.md (opens in new tab) を参照してください。
リソース
- WAI-ARIA APG: Tabs パターン (opens in new tab)
- AI 実装ガイド (llm.md) (opens in new tab) - ARIA 仕様、キーボード操作、テストチェックリスト