Tabs
一度に1つのパネルを表示する、タブパネルと呼ばれる階層化されたコンテンツのセクション。
デモ
自動アクティベーション(デフォルト)
矢印キーでフォーカスが当たると、自動的にタブがアクティブになります。
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 | タブリスト内にフォーカスを移動、またはタブリストから移動 |
| 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.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準拠を検証します。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 テストツール
- Vitest (opens in new tab) - ユニットテスト用テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ(React, Vue, Svelte)
- Playwright (opens in new tab) - E2Eテスト用ブラウザ自動化
- axe-core/playwright (opens in new tab) - E2Eでの自動アクセシビリティテスト
詳細なドキュメントについては、 testing-strategy.md (opens in new tab) を参照してください。
リソース
- WAI-ARIA APG: Tabs パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist