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.svelte
<script lang="ts">
import { onMount } from 'svelte';
export interface TabItem {
id: string;
label: string;
content?: string;
disabled?: boolean;
}
interface TabsProps {
tabs: TabItem[];
defaultSelectedId?: string;
orientation?: 'horizontal' | 'vertical';
activationMode?: 'automatic' | 'manual';
label?: string;
onSelectionChange?: (tabId: string) => void;
}
let {
tabs = [],
defaultSelectedId = undefined,
orientation = 'horizontal',
activationMode = 'automatic',
label = undefined,
onSelectionChange = () => {},
}: TabsProps = $props();
let selectedId = $state('');
let focusedIndex = $state(0);
let tablistElement: HTMLElement;
let tabRefs: Record<string, HTMLButtonElement> = {};
let tablistId = $state('');
onMount(() => {
tablistId = `tabs-${Math.random().toString(36).substr(2, 9)}`;
});
// Initialize selected tab
$effect(() => {
if (tabs.length > 0 && !selectedId) {
const initialTab = defaultSelectedId
? tabs.find((tab) => tab.id === defaultSelectedId && !tab.disabled)
: tabs.find((tab) => !tab.disabled);
selectedId = initialTab?.id || tabs[0]?.id;
}
});
// Derived values
let availableTabs = $derived(tabs.filter((tab) => !tab.disabled));
let containerClass = $derived(
`apg-tabs ${orientation === 'vertical' ? 'apg-tabs--vertical' : 'apg-tabs--horizontal'}`
);
let tablistClass = $derived(
`apg-tablist ${orientation === 'vertical' ? 'apg-tablist--vertical' : 'apg-tablist--horizontal'}`
);
function getTabClass(tab: TabItem): string {
const classes = ['apg-tab'];
classes.push(orientation === 'vertical' ? 'apg-tab--vertical' : 'apg-tab--horizontal');
if (tab.id === selectedId) classes.push('apg-tab--selected');
if (tab.disabled) classes.push('apg-tab--disabled');
return classes.join(' ');
}
function getPanelClass(tab: TabItem): string {
return `apg-tabpanel ${tab.id === selectedId ? 'apg-tabpanel--active' : 'apg-tabpanel--inactive'}`;
}
function handleTabSelection(tabId: string) {
selectedId = tabId;
onSelectionChange(tabId);
}
function handleTabFocus(index: number) {
focusedIndex = index;
const tab = availableTabs[index];
if (tab && tabRefs[tab.id]) {
tabRefs[tab.id].focus();
}
}
function handleKeyDown(event: KeyboardEvent) {
const target = event.target;
if (!tablistElement || !(target instanceof Node) || !tablistElement.contains(target)) {
return;
}
let newIndex = focusedIndex;
let shouldPreventDefault = false;
switch (event.key) {
case 'ArrowRight':
case 'ArrowDown':
newIndex = (focusedIndex + 1) % availableTabs.length;
shouldPreventDefault = true;
break;
case 'ArrowLeft':
case 'ArrowUp':
newIndex = (focusedIndex - 1 + availableTabs.length) % availableTabs.length;
shouldPreventDefault = true;
break;
case 'Home':
newIndex = 0;
shouldPreventDefault = true;
break;
case 'End':
newIndex = availableTabs.length - 1;
shouldPreventDefault = true;
break;
case 'Enter':
case ' ':
if (activationMode === 'manual') {
const focusedTab = availableTabs[focusedIndex];
if (focusedTab) {
handleTabSelection(focusedTab.id);
}
}
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
event.preventDefault();
if (newIndex !== focusedIndex) {
handleTabFocus(newIndex);
if (activationMode === 'automatic') {
const newTab = availableTabs[newIndex];
if (newTab) {
handleTabSelection(newTab.id);
}
}
}
}
}
// Update focused index when selected tab changes
$effect(() => {
const selectedIndex = availableTabs.findIndex((tab) => tab.id === selectedId);
if (selectedIndex >= 0) {
focusedIndex = selectedIndex;
}
});
</script>
<div class={containerClass}>
<div
bind:this={tablistElement}
role="tablist"
aria-orientation={orientation}
class={tablistClass}
onkeydown={handleKeyDown}
>
{#each tabs as tab}
{@const isSelected = tab.id === selectedId}
{@const tabIndex = tab.disabled ? -1 : isSelected ? 0 : -1}
<button
bind:this={tabRefs[tab.id]}
role="tab"
type="button"
id="{tablistId}-tab-{tab.id}"
aria-selected={isSelected}
aria-controls={isSelected ? `${tablistId}-panel-${tab.id}` : undefined}
tabindex={tabIndex}
disabled={tab.disabled}
class={getTabClass(tab)}
onclick={() => !tab.disabled && handleTabSelection(tab.id)}
>
<span class="apg-tab-label">{tab.label}</span>
</button>
{/each}
</div>
<div class="apg-tabpanels">
{#each tabs as tab}
{@const isSelected = tab.id === selectedId}
<div
role="tabpanel"
id="{tablistId}-panel-{tab.id}"
aria-labelledby="{tablistId}-tab-{tab.id}"
hidden={!isSelected}
class={getPanelClass(tab)}
tabindex={isSelected ? 0 : -1}
>
{#if tab.content}
{@html tab.content}
{/if}
</div>
{/each}
</div>
</div> 使い方
使用例
<script>
import Tabs from './Tabs.svelte';
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' }
];
function handleTabChange(event) {
console.log('Tab changed:', event.detail);
}
</script>
<Tabs
{tabs}
label="My tabs"
ontabchange={handleTabChange}
/> API
Props
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
tabs | TabItem[] | 必須 | id、label、content を持つタブアイテムの配列 |
label | string | - | タブリストのアクセシブルラベル |
defaultTab | string | 最初のタブ | 初期選択されるタブのID |
orientation | 'horizontal' | 'vertical' | 'horizontal' | タブの配置方向 |
activationMode | 'automatic' | 'manual' | 'automatic' | タブのアクティベーション方法 |
Events
| イベント | 詳細 | 説明 |
|---|---|---|
tabchange | string | アクティブなタブが変更されたときに発火 |
TabItem Interface
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.svelte.ts
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Tabs from './Tabs.svelte';
import type { TabItem } from './Tabs.svelte';
// テスト用のタブデータ
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 (Svelte)', () => {
// 🔴 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/Up でループする', 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();
});
});
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');
});
});
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('onSelectionChange がタブ選択時に呼び出される', 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 仕様、キーボード操作、テストチェックリスト