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.tsx
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
export interface TabItem {
id: string;
label: string;
content: React.ReactNode;
disabled?: boolean;
}
export interface TabsProps {
/** Array of tab items */
tabs: TabItem[];
/** Initially selected tab ID */
defaultSelectedId?: string;
/** Orientation of the tabs */
orientation?: 'horizontal' | 'vertical';
/** Activation mode */
activation?: 'automatic' | 'manual';
/** Callback when tab selection changes */
onSelectionChange?: (tabId: string) => void;
/** Additional CSS class */
className?: string;
}
export function Tabs({
tabs,
defaultSelectedId,
orientation = 'horizontal',
activation = 'automatic',
onSelectionChange,
className = '',
}: TabsProps): React.ReactElement {
// availableTabsの安定化(パフォーマンス最適化)
const availableTabs = useMemo(() => tabs.filter((tab) => !tab.disabled), [tabs]);
const initialTab = defaultSelectedId
? availableTabs.find((tab) => tab.id === defaultSelectedId)
: availableTabs[0];
const [selectedId, setSelectedId] = useState(initialTab?.id || availableTabs[0]?.id);
const [focusedIndex, setFocusedIndex] = useState(() => {
const index = availableTabs.findIndex((tab) => tab.id === initialTab?.id);
return index >= 0 ? index : 0;
});
const tablistRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const tablistId = useId();
const handleTabSelection = useCallback(
(tabId: string) => {
setSelectedId(tabId);
onSelectionChange?.(tabId);
},
[onSelectionChange]
);
const handleTabFocus = useCallback(
(index: number) => {
setFocusedIndex(index);
const tab = availableTabs[index];
if (tab) {
tabRefs.current.get(tab.id)?.focus();
}
},
[availableTabs]
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
const { key } = event;
const target = event.target;
if (
!tablistRef.current ||
!(target instanceof Node) ||
!tablistRef.current.contains(target)
) {
return;
}
let newIndex = focusedIndex;
let shouldPreventDefault = false;
switch (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 (activation === 'manual') {
const focusedTab = availableTabs[focusedIndex];
if (focusedTab) {
handleTabSelection(focusedTab.id);
}
}
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
event.preventDefault();
if (newIndex !== focusedIndex) {
handleTabFocus(newIndex);
if (activation === 'automatic') {
const newTab = availableTabs[newIndex];
if (newTab) {
handleTabSelection(newTab.id);
}
}
}
}
},
[focusedIndex, availableTabs, activation, handleTabSelection, handleTabFocus]
);
// フォーカス同期(Activation mode考慮)
useEffect(() => {
if (activation === 'manual') {
// Manual: tabsの変更により範囲外になった場合のみ修正
if (focusedIndex >= availableTabs.length) {
setFocusedIndex(Math.max(0, availableTabs.length - 1));
}
return;
}
// Automatic: 選択に追従
const selectedIndex = availableTabs.findIndex((tab) => tab.id === selectedId);
if (selectedIndex >= 0 && selectedIndex !== focusedIndex) {
setFocusedIndex(selectedIndex);
}
}, [selectedId, availableTabs, activation, focusedIndex]);
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'
}`;
return (
<div className={containerClass}>
<div
ref={tablistRef}
role="tablist"
aria-orientation={orientation}
className={tablistClass}
onKeyDown={handleKeyDown}
>
{tabs.map((tab) => {
const isSelected = tab.id === selectedId;
// APG準拠: Manual Activationではフォーカス位置でtabIndexを制御
const isFocusTarget =
activation === 'manual' ? tab.id === availableTabs[focusedIndex]?.id : isSelected;
const tabIndex = tab.disabled ? -1 : isFocusTarget ? 0 : -1;
const tabPanelId = `${tablistId}-panel-${tab.id}`;
const tabClass = `apg-tab ${
orientation === 'vertical' ? 'apg-tab--vertical' : 'apg-tab--horizontal'
} ${isSelected ? 'apg-tab--selected' : ''} ${
tab.disabled ? 'apg-tab--disabled' : ''
}`.trim();
return (
<button
key={tab.id}
ref={(el) => {
if (el) {
tabRefs.current.set(tab.id, el);
} else {
tabRefs.current.delete(tab.id);
}
}}
role="tab"
type="button"
id={`${tablistId}-tab-${tab.id}`}
aria-selected={isSelected}
aria-controls={isSelected ? tabPanelId : undefined}
tabIndex={tabIndex}
disabled={tab.disabled}
className={tabClass}
onClick={() => !tab.disabled && handleTabSelection(tab.id)}
>
<span className="apg-tab-label">{tab.label}</span>
</button>
);
})}
</div>
<div className="apg-tabpanels">
{tabs.map((tab) => {
const isSelected = tab.id === selectedId;
const tabPanelId = `${tablistId}-panel-${tab.id}`;
return (
<div
key={tab.id}
role="tabpanel"
id={tabPanelId}
aria-labelledby={`${tablistId}-tab-${tab.id}`}
hidden={!isSelected}
className={`apg-tabpanel ${
isSelected ? 'apg-tabpanel--active' : 'apg-tabpanel--inactive'
}`}
tabIndex={isSelected ? 0 : -1}
>
{tab.content}
</div>
);
})}
</div>
</div>
);
}
export default Tabs; 使い方
Example
import { Tabs } from './Tabs';
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 App() {
return (
<Tabs
tabs={tabs}
defaultSelectedId="tab1"
onSelectionChange={(id) => console.log('Tab changed:', id)}
/>
);
} API
Tabs Props
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
tabs | TabItem[] | 必須 | タブアイテムの配列 |
defaultSelectedId | string | 最初のタブ | 初期選択されるタブのID |
orientation | 'horizontal' | 'vertical' | 'horizontal' | タブの配置方向 |
activation | 'automatic' | 'manual' | 'automatic' | タブのアクティベーション方法 |
onSelectionChange | (tabId: string) => void | - | タブが変更されたときのコールバック |
TabItem Interface
Types
interface TabItem {
id: string;
label: string;
content: React.ReactNode;
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.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Tabs, type TabItem } from './Tabs';
// Test tab data
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', () => {
// 🔴 High Priority: APG Core Compliance
describe('APG: Keyboard Interaction (Horizontal)', () => {
describe('Automatic Activation', () => {
it('moves to and selects next tab with ArrowRight', async () => {
const user = userEvent.setup();
render(<Tabs 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('moves to and selects previous tab with ArrowLeft', async () => {
const user = userEvent.setup();
render(<Tabs 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('loops from last to first with ArrowRight', async () => {
const user = userEvent.setup();
render(<Tabs 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('loops from first to last with ArrowLeft', async () => {
const user = userEvent.setup();
render(<Tabs 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('moves to and selects first tab with Home', async () => {
const user = userEvent.setup();
render(<Tabs 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('moves to and selects last tab with End', async () => {
const user = userEvent.setup();
render(<Tabs 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('skips disabled tab and moves to next enabled tab', async () => {
const user = userEvent.setup();
render(<Tabs tabs={tabsWithDisabled} />);
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
tab1.focus();
await user.keyboard('{ArrowRight}');
// Tab 2 is skipped, moves to Tab 3
const tab3 = screen.getByRole('tab', { name: 'Tab 3' });
expect(tab3).toHaveFocus();
expect(tab3).toHaveAttribute('aria-selected', 'true');
});
});
describe('Manual Activation', () => {
it('moves focus with arrow keys but does not switch panel', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} activation="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();
// Panel does not switch
expect(tab1).toHaveAttribute('aria-selected', 'true');
expect(tab2).toHaveAttribute('aria-selected', 'false');
expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 1');
});
it('selects focused tab with Enter', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} activation="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('selects focused tab with Space', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} activation="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: Keyboard Interaction (Vertical)', () => {
it('moves to and selects next tab with ArrowDown', async () => {
const user = userEvent.setup();
render(<Tabs 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('moves to and selects previous tab with ArrowUp', async () => {
const user = userEvent.setup();
render(<Tabs 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('loops with ArrowDown/Up', async () => {
const user = userEvent.setup();
render(<Tabs 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 Attributes', () => {
it('tablist has role="tablist"', () => {
render(<Tabs tabs={defaultTabs} />);
expect(screen.getByRole('tablist')).toBeInTheDocument();
});
it('each tab has role="tab"', () => {
render(<Tabs tabs={defaultTabs} />);
const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(3);
});
it('each panel has role="tabpanel"', () => {
render(<Tabs tabs={defaultTabs} />);
// Only selected panel is displayed
expect(screen.getByRole('tabpanel')).toBeInTheDocument();
});
it('selected tab has aria-selected="true", unselected have "false"', () => {
render(<Tabs 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('selected tab aria-controls matches panel id', () => {
render(<Tabs 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('panel aria-labelledby matches tab id', () => {
render(<Tabs 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 reflects orientation prop', () => {
const { rerender } = render(<Tabs tabs={defaultTabs} />);
expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'horizontal');
rerender(<Tabs tabs={defaultTabs} orientation="vertical" />);
expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'vertical');
});
});
describe('APG: Focus Management (Roving Tabindex)', () => {
it('Automatic: only selected tab has tabIndex=0', () => {
render(<Tabs 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('Manual: focused tab has tabIndex=0', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} activation="manual" />);
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
tab1.focus();
await user.keyboard('{ArrowRight}');
const tabs = screen.getAllByRole('tab');
// In Manual, focused tab (not selected) has tabIndex=0
expect(tabs[0]).toHaveAttribute('tabIndex', '-1');
expect(tabs[1]).toHaveAttribute('tabIndex', '0');
expect(tabs[2]).toHaveAttribute('tabIndex', '-1');
});
it('can move to tabpanel with Tab key', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} />);
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
tab1.focus();
await user.tab();
expect(screen.getByRole('tabpanel')).toHaveFocus();
});
it('tabpanel is focusable with tabIndex=0', () => {
render(<Tabs tabs={defaultTabs} />);
const tabpanel = screen.getByRole('tabpanel');
expect(tabpanel).toHaveAttribute('tabIndex', '0');
});
});
// 🟡 Medium Priority: Accessibility Validation
describe('Accessibility', () => {
it('has no WCAG 2.1 AA violations', async () => {
const { container } = render(<Tabs tabs={defaultTabs} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
describe('Props', () => {
it('can specify initial selected tab with defaultSelectedId', () => {
render(<Tabs 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('calls onSelectionChange when tab is selected', async () => {
const handleSelectionChange = vi.fn();
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} onSelectionChange={handleSelectionChange} />);
await user.click(screen.getByRole('tab', { name: 'Tab 2' }));
expect(handleSelectionChange).toHaveBeenCalledWith('tab2');
});
});
describe('Edge Cases', () => {
it('selects first tab when defaultSelectedId does not exist', () => {
render(<Tabs tabs={defaultTabs} defaultSelectedId="nonexistent" />);
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
expect(tab1).toHaveAttribute('aria-selected', 'true');
});
});
// 🟢 Low Priority: Extensibility
describe('HTML Attribute Inheritance', () => {
it('applies className to container', () => {
const { container } = render(<Tabs tabs={defaultTabs} className="custom-tabs" />);
const tabsContainer = container.firstChild as HTMLElement;
expect(tabsContainer).toHaveClass('custom-tabs');
});
});
}); リソース
- WAI-ARIA APG: Tabs パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist