Tabs
A set of layered sections of content, known as tab panels, that display one panel of content at a time.
🤖 AI Implementation GuideDemo
Automatic Activation (Default)
Tabs are activated automatically when focused with arrow keys.
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.
Manual Activation
Tabs require Enter or Space to activate after focusing.
Content for tab one. Press Enter or Space to activate tabs.
Content for tab two.
Content for tab three.
Vertical Orientation
Tabs arranged vertically with Up/Down arrow navigation.
Configure your application settings here.
Manage your profile information.
Set your notification preferences.
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
tablist | Container | Container for tab elements |
tab | Each tab | Individual tab element |
tabpanel | Panel | Content area for each tab |
WAI-ARIA tablist role (opens in new tab)
WAI-ARIA Properties
| Attribute | Target | Values | Required | Configuration |
|---|---|---|---|---|
aria-orientation | tablist | "horizontal" | "vertical" | No | orientation prop |
aria-controls | tab | ID reference to associated panel | Yes | Auto-generated |
aria-labelledby | tabpanel | ID reference to associated tab | Yes | Auto-generated |
WAI-ARIA States
aria-selected
Indicates the currently active tab.
| Target | tab element |
| Values | true | false |
| Required | Yes |
| Change Trigger | Tab click, Arrow keys (automatic), Enter/Space (manual) |
| Reference | aria-selected (opens in new tab) |
Keyboard Support
| Key | Action |
|---|---|
| Tab | Move focus into/out of the tablist |
| Arrow Right / Arrow Left | Navigate between tabs (horizontal) |
| Arrow Down / Arrow Up | Navigate between tabs (vertical) |
| Home | Move focus to first tab |
| End | Move focus to last tab |
| Enter / Space | Activate tab (manual mode only) |
Source Code
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> Usage
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
| Prop | Type | Default | Description |
|---|---|---|---|
tabs | TabItem[] | required | Array of tab items with id, label, content |
label | string | - | Accessible label for the tablist |
defaultTab | string | first tab | ID of the initially selected tab |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Tab layout direction |
activationMode | 'automatic' | 'manual' | 'automatic' | How tabs are activated |
Events
| Event | Payload | Description |
|---|---|---|
tab-change | string | Emitted when the active tab changes |
TabItem Interface
Types
interface TabItem {
id: string;
label: string;
content: string;
disabled?: boolean;
} Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements.
Test Categories
High Priority: APG Keyboard Interaction
| Test | Description |
|---|---|
ArrowRight/Left | Moves focus between tabs (horizontal) |
ArrowDown/Up | Moves focus between tabs (vertical orientation) |
Home/End | Moves focus to first/last tab |
Loop navigation | Arrow keys loop from last to first and vice versa |
Disabled skip | Skips disabled tabs during navigation |
Automatic activation | Tab panel changes on focus (default mode) |
Manual activation | Enter/Space required to activate tab |
High Priority: APG ARIA Attributes
| Test | Description |
|---|---|
role="tablist" | Container has tablist role |
role="tab" | Each tab button has tab role |
role="tabpanel" | Content panel has tabpanel role |
aria-selected | Selected tab has aria-selected="true" |
aria-controls | Tab references its panel via aria-controls |
aria-labelledby | Panel references its tab via aria-labelledby |
aria-orientation | Reflects horizontal/vertical orientation |
High Priority: Focus Management (Roving Tabindex)
| Test | Description |
|---|---|
tabIndex=0 | Selected tab has tabIndex=0 |
tabIndex=-1 | Non-selected tabs have tabIndex=-1 |
Tab to panel | Tab key moves focus from tablist to panel |
Panel focusable | Panel has tabIndex=0 for focus |
Medium Priority: Accessibility
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations (via jest-axe) |
Testing Tools
- Vitest (opens in new tab) - Test runner
- Testing Library (opens in new tab) - Framework-specific testing utilities
- jest-axe (opens in new tab) - Automated accessibility testing
See testing-strategy.md (opens in new tab) for full documentation.
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');
});
});
}); Resources
- WAI-ARIA APG: Tabs Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist