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.vue
<template>
<div :class="containerClass">
<div
ref="tablistRef"
role="tablist"
:aria-label="label"
:aria-orientation="orientation"
:class="tablistClass"
@keydown="handleKeyDown"
>
<button
v-for="tab in tabs"
:key="tab.id"
:ref="(el) => setTabRef(tab.id, el)"
role="tab"
type="button"
:id="`${tablistId}-tab-${tab.id}`"
:aria-selected="tab.id === selectedId"
:aria-controls="tab.id === selectedId ? `${tablistId}-panel-${tab.id}` : undefined"
:tabindex="tab.disabled ? -1 : tab.id === selectedId ? 0 : -1"
:disabled="tab.disabled"
:class="getTabClass(tab)"
@click="!tab.disabled && handleTabSelection(tab.id)"
>
<span class="apg-tab-label">{{ tab.label }}</span>
</button>
</div>
<div class="apg-tabpanels">
<div
v-for="tab in tabs"
:key="tab.id"
role="tabpanel"
:id="`${tablistId}-panel-${tab.id}`"
:aria-labelledby="`${tablistId}-tab-${tab.id}`"
:hidden="tab.id !== selectedId"
:class="getPanelClass(tab)"
:tabindex="tab.id === selectedId ? 0 : -1"
>
<div v-if="tab.content" v-html="tab.content" />
<slot v-else :name="tab.id" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue';
export interface TabItem {
id: string;
label: string;
content?: string;
disabled?: boolean;
}
export interface TabsProps {
tabs: TabItem[];
defaultSelectedId?: string;
orientation?: 'horizontal' | 'vertical';
activationMode?: 'automatic' | 'manual';
label?: string;
}
const props = withDefaults(defineProps<TabsProps>(), {
orientation: 'horizontal',
activationMode: 'automatic',
label: undefined,
});
const emit = defineEmits<{
selectionChange: [tabId: string];
}>();
const selectedId = ref<string>('');
const focusedIndex = ref<number>(0);
const tablistRef = ref<HTMLElement>();
const tabRefs = ref<Record<string, HTMLButtonElement>>({});
const tablistId = ref('');
onMounted(() => {
tablistId.value = `tabs-${Math.random().toString(36).substr(2, 9)}`;
});
const setTabRef = (id: string, el: unknown) => {
if (el instanceof HTMLButtonElement) {
tabRefs.value[id] = el;
}
};
const initializeSelectedTab = () => {
if (props.tabs.length > 0) {
const initialTab = props.defaultSelectedId
? props.tabs.find((tab) => tab.id === props.defaultSelectedId && !tab.disabled)
: props.tabs.find((tab) => !tab.disabled);
selectedId.value = initialTab?.id || props.tabs[0]?.id;
// focusedIndex を選択されたタブのインデックスに同期
const selectedIndex = availableTabs.value.findIndex((tab) => tab.id === selectedId.value);
if (selectedIndex >= 0) {
focusedIndex.value = selectedIndex;
}
}
};
const availableTabs = computed(() => props.tabs.filter((tab) => !tab.disabled));
const containerClass = computed(() => {
return `apg-tabs ${props.orientation === 'vertical' ? 'apg-tabs--vertical' : 'apg-tabs--horizontal'}`;
});
const tablistClass = computed(() => {
return `apg-tablist ${props.orientation === 'vertical' ? 'apg-tablist--vertical' : 'apg-tablist--horizontal'}`;
});
const getTabClass = (tab: TabItem) => {
const classes = ['apg-tab'];
classes.push(props.orientation === 'vertical' ? 'apg-tab--vertical' : 'apg-tab--horizontal');
if (tab.id === selectedId.value) classes.push('apg-tab--selected');
if (tab.disabled) classes.push('apg-tab--disabled');
return classes.join(' ');
};
const getPanelClass = (tab: TabItem) => {
return `apg-tabpanel ${tab.id === selectedId.value ? 'apg-tabpanel--active' : 'apg-tabpanel--inactive'}`;
};
const handleTabSelection = (tabId: string) => {
selectedId.value = tabId;
emit('selectionChange', tabId);
};
const handleTabFocus = async (index: number) => {
focusedIndex.value = index;
const tab = availableTabs.value[index];
if (tab && tabRefs.value[tab.id]) {
await nextTick();
tabRefs.value[tab.id].focus();
}
};
const handleKeyDown = async (event: KeyboardEvent) => {
const target = event.target;
if (!tablistRef.value || !(target instanceof Node) || !tablistRef.value.contains(target)) {
return;
}
let newIndex = focusedIndex.value;
let shouldPreventDefault = false;
switch (event.key) {
case 'ArrowRight':
case 'ArrowDown':
newIndex = (focusedIndex.value + 1) % availableTabs.value.length;
shouldPreventDefault = true;
break;
case 'ArrowLeft':
case 'ArrowUp':
newIndex = (focusedIndex.value - 1 + availableTabs.value.length) % availableTabs.value.length;
shouldPreventDefault = true;
break;
case 'Home':
newIndex = 0;
shouldPreventDefault = true;
break;
case 'End':
newIndex = availableTabs.value.length - 1;
shouldPreventDefault = true;
break;
case 'Enter':
case ' ':
if (props.activationMode === 'manual') {
const focusedTab = availableTabs.value[focusedIndex.value];
if (focusedTab) {
handleTabSelection(focusedTab.id);
}
}
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
event.preventDefault();
if (newIndex !== focusedIndex.value) {
await handleTabFocus(newIndex);
if (props.activationMode === 'automatic') {
const newTab = availableTabs.value[newIndex];
if (newTab) {
handleTabSelection(newTab.id);
}
}
}
}
};
watch(() => props.tabs, initializeSelectedTab, { immediate: true });
watch(selectedId, (newSelectedId) => {
const selectedIndex = availableTabs.value.findIndex((tab) => tab.id === newSelectedId);
if (selectedIndex >= 0) {
focusedIndex.value = selectedIndex;
}
});
</script> 使い方
Example
<script setup>
import Tabs from './Tabs.vue';
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' }
];
</script>
<template>
<Tabs
:tabs="tabs"
label="My tabs"
@tab-change="(id) => console.log('Tab changed:', id)"
/>
</template> API
Props
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
tabs | TabItem[] | 必須 | id、label、contentを含むタブアイテムの配列 |
label | string | - | タブリストのアクセシブルなラベル |
defaultTab | string | 最初のタブ | 初期選択されるタブのID |
orientation | 'horizontal' | 'vertical' | 'horizontal' | タブのレイアウト方向 |
activationMode | 'automatic' | 'manual' | 'automatic' | タブのアクティベーション方法 |
Events
| イベント | ペイロード | 説明 |
|---|---|---|
tab-change | string | アクティブなタブが変更されたときに発火 |
TabItem インターフェース
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経由) |
テストツール
- Vitest (opens in new tab) - テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ
- jest-axe (opens in new tab) - 自動アクセシビリティテスト
詳細なドキュメントについては、 testing-strategy.md (opens in new tab) を参照してください。
Tabs.test.vue.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Tabs from './Tabs.vue';
import type { TabItem } from './Tabs.vue';
// テスト用のタブデータ
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 (Vue)', () => {
// 🔴 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 で最後から最初にループする', 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();
expect(tab1).toHaveAttribute('aria-selected', 'true');
});
});
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');
});
it('vertical orientation で aria-orientation=vertical', () => {
render(Tabs, { props: { tabs: defaultTabs, orientation: 'vertical' } });
expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'vertical');
});
});
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('@selectionChange がタブ選択時に発火する', 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');
});
});
}); リソース
- WAI-ARIA APG: Tabs パターン (opens in new tab)
- AI 実装ガイド (llm.md) (opens in new tab) - ARIA 仕様、キーボードサポート、テストチェックリスト