Accordion
縦に積み重ねられたインタラクティブな見出しのセットで、それぞれがコンテンツのセクションを表示します。
🤖 AI 実装ガイドデモ
単一展開(デフォルト)
一度に1つのパネルのみ展開できます。新しいパネルを開くと、以前に開いていたパネルが閉じます。
コンテンツを折りたたみ可能なセクションに整理する必要がある場合に Accordion を使用します。これにより、情報をアクセス可能に保ちながら視覚的な混雑を減らすことができます。FAQ、設定パネル、ナビゲーションメニューに特に有用です。
Accordion はキーボードでアクセス可能であり、展開/折りたたみ状態をスクリーンリーダーに適切にアナウンスする必要があります。各ヘッダーは適切な見出し要素であり、パネルは aria-controls と aria-labelledby を介してヘッダーと関連付けられている必要があります。
複数展開
allowMultiple プロパティを使用して、複数のパネルを同時に展開できます。
セクション1のコンテンツ。allowMultiple を有効にすると、複数のセクションを同時に開くことができます。
セクション2のコンテンツ。セクション1が開いている状態でこれを開いてみてください。
セクション3のコンテンツ。3つのセクションすべてを同時に展開できます。
無効なアイテムを含む
個々の Accordion アイテムを無効にできます。キーボードナビゲーションは無効なアイテムを自動的にスキップします。
このセクションは通常通り展開および折りたたみできます。
このコンテンツは、セクションが無効になっているためアクセスできません。
このセクションも展開できます。矢印キーナビゲーションが無効なセクションをスキップすることに注意してください。
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
heading | ヘッダーラッパー (h2-h6) | アコーディオントリガーボタンを含む |
button | ヘッダートリガー | パネルの表示/非表示を切り替える対話要素 |
region | パネル (オプション) | ヘッダーと関連付けられたコンテンツエリア (6個以上のパネルでは省略) |
WAI-ARIA Accordion Pattern (opens in new tab)
WAI-ARIA プロパティ
| 属性 | 対象 | 値 | 必須 | 設定方法 |
|---|---|---|---|---|
aria-level | heading | 2 - 6 | はい | headingLevel プロパティ |
aria-controls | button | 関連付けられたパネルへのID参照 | はい | 自動生成 |
aria-labelledby | region (パネル) | ヘッダーボタンへのID参照 | はい (regionを使用する場合) | 自動生成 |
WAI-ARIA ステート
aria-expanded
アコーディオンパネルが展開されているか折り畳まれているかを示します。
| 対象 | button 要素 |
| 値 | true | false |
| 必須 | はい |
| 変更トリガー | クリック、Enter、Space |
| リファレンス | aria-expanded (opens in new tab) |
aria-disabled
アコーディオンヘッダーが無効化されているかどうかを示します。
| 対象 | button 要素 |
| 値 | true | false |
| 必須 | いいえ (無効化する場合のみ) |
| リファレンス | aria-disabled (opens in new tab) |
キーボードサポート
| キー | アクション |
|---|---|
| Tab | 次のフォーカス可能な要素にフォーカスを移動 |
| Space / Enter | フォーカスされたアコーディオンヘッダーの展開/折り畳みを切り替え |
| Arrow Down | 次のアコーディオンヘッダーにフォーカスを移動 (オプション) |
| Arrow Up | 前のアコーディオンヘッダーにフォーカスを移動 (オプション) |
| Home | 最初のアコーディオンヘッダーにフォーカスを移動 (オプション) |
| End | 最後のアコーディオンヘッダーにフォーカスを移動 (オプション) |
矢印キーによるナビゲーションはオプションですが推奨されます。フォーカスはリストの端でループしません。
ソースコード
Accordion.vue
<template>
<div :class="`apg-accordion ${className}`.trim()">
<div v-for="item in items" :key="item.id" :class="getItemClass(item)">
<component :is="`h${headingLevel}`" class="apg-accordion-header">
<button
:ref="(el) => setButtonRef(item.id, el)"
type="button"
:id="`${instanceId}-header-${item.id}`"
:aria-expanded="isExpanded(item.id)"
:aria-controls="`${instanceId}-panel-${item.id}`"
:aria-disabled="item.disabled || undefined"
:disabled="item.disabled"
:class="getTriggerClass(item)"
@click="handleToggle(item.id)"
@keydown="handleKeyDown($event, item.id)"
>
<span class="apg-accordion-trigger-content">{{ item.header }}</span>
<span :class="getIconClass(item)" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
</component>
<div
:role="useRegion ? 'region' : undefined"
:id="`${instanceId}-panel-${item.id}`"
:aria-labelledby="useRegion ? `${instanceId}-header-${item.id}` : undefined"
:class="getPanelClass(item)"
>
<div class="apg-accordion-panel-content">
<div v-if="item.content" v-html="item.content" />
<slot v-else :name="item.id" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* APG Accordion Pattern - Vue Implementation
*
* A vertically stacked set of interactive headings that each reveal a section of content.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
*/
import { ref, computed } from 'vue';
/**
* Accordion item configuration
*/
export interface AccordionItem {
/** Unique identifier for the item */
id: string;
/** Content displayed in the accordion header button */
header: string;
/** Content displayed in the collapsible panel (HTML string) */
content?: string;
/** When true, the item cannot be expanded/collapsed */
disabled?: boolean;
/** When true, the panel is expanded on initial render */
defaultExpanded?: boolean;
}
/**
* Props for the Accordion component
*
* @example
* ```vue
* <Accordion
* :items="[
* { id: 'section1', header: 'Section 1', content: 'Content 1', defaultExpanded: true },
* { id: 'section2', header: 'Section 2', content: 'Content 2' },
* ]"
* :heading-level="3"
* :allow-multiple="false"
* @expanded-change="(ids) => console.log('Expanded:', ids)"
* />
* ```
*/
export interface AccordionProps {
/** Array of accordion items to display */
items: AccordionItem[];
/** Allow multiple panels to be expanded simultaneously @default false */
allowMultiple?: boolean;
/** Heading level for accessibility (h2-h6) @default 3 */
headingLevel?: 2 | 3 | 4 | 5 | 6;
/** Enable arrow key navigation @default true */
enableArrowKeys?: boolean;
/** Additional CSS class @default "" */
className?: string;
}
const props = withDefaults(defineProps<AccordionProps>(), {
allowMultiple: false,
headingLevel: 3,
enableArrowKeys: true,
className: '',
});
const emit = defineEmits<{
expandedChange: [expandedIds: string[]];
}>();
// Initialize with defaultExpanded items immediately
const getInitialExpandedIds = () => {
return props.items
.filter((item) => item.defaultExpanded && !item.disabled)
.map((item) => item.id);
};
const expandedIds = ref<string[]>(getInitialExpandedIds());
const instanceId = ref(`accordion-${Math.random().toString(36).substr(2, 9)}`);
const buttonRefs = ref<Record<string, HTMLButtonElement>>({});
const setButtonRef = (id: string, el: unknown) => {
if (el instanceof HTMLButtonElement) {
buttonRefs.value[id] = el;
}
};
const availableItems = computed(() => props.items.filter((item) => !item.disabled));
// Use role="region" only for 6 or fewer panels (APG recommendation)
const useRegion = computed(() => props.items.length <= 6);
const isExpanded = (itemId: string) => expandedIds.value.includes(itemId);
const getItemClass = (item: AccordionItem) => {
const classes = ['apg-accordion-item'];
if (isExpanded(item.id)) classes.push('apg-accordion-item--expanded');
if (item.disabled) classes.push('apg-accordion-item--disabled');
return classes.join(' ');
};
const getTriggerClass = (item: AccordionItem) => {
const classes = ['apg-accordion-trigger'];
if (isExpanded(item.id)) classes.push('apg-accordion-trigger--expanded');
return classes.join(' ');
};
const getIconClass = (item: AccordionItem) => {
const classes = ['apg-accordion-icon'];
if (isExpanded(item.id)) classes.push('apg-accordion-icon--expanded');
return classes.join(' ');
};
const getPanelClass = (item: AccordionItem) => {
return `apg-accordion-panel ${isExpanded(item.id) ? 'apg-accordion-panel--expanded' : 'apg-accordion-panel--collapsed'}`;
};
const handleToggle = (itemId: string) => {
const item = props.items.find((i) => i.id === itemId);
if (item?.disabled) return;
const isCurrentlyExpanded = expandedIds.value.includes(itemId);
if (isCurrentlyExpanded) {
expandedIds.value = expandedIds.value.filter((id) => id !== itemId);
} else {
if (props.allowMultiple) {
expandedIds.value = [...expandedIds.value, itemId];
} else {
expandedIds.value = [itemId];
}
}
emit('expandedChange', expandedIds.value);
};
const handleKeyDown = (event: KeyboardEvent, currentItemId: string) => {
if (!props.enableArrowKeys) return;
const currentIndex = availableItems.value.findIndex((item) => item.id === currentItemId);
if (currentIndex === -1) return;
let newIndex = currentIndex;
let shouldPreventDefault = false;
switch (event.key) {
case 'ArrowDown':
// Move to next, but don't wrap (APG compliant)
if (currentIndex < availableItems.value.length - 1) {
newIndex = currentIndex + 1;
}
shouldPreventDefault = true;
break;
case 'ArrowUp':
// Move to previous, but don't wrap (APG compliant)
if (currentIndex > 0) {
newIndex = currentIndex - 1;
}
shouldPreventDefault = true;
break;
case 'Home':
newIndex = 0;
shouldPreventDefault = true;
break;
case 'End':
newIndex = availableItems.value.length - 1;
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
event.preventDefault();
if (newIndex !== currentIndex) {
const newItem = availableItems.value[newIndex];
if (newItem && buttonRefs.value[newItem.id]) {
buttonRefs.value[newItem.id].focus();
}
}
}
};
</script> 使い方
使用例
<script setup>
import Accordion from './Accordion.vue';
const items = [
{
id: 'section1',
header: '最初のセクション',
content: '最初のセクションのコンテンツ...',
defaultExpanded: true,
},
{
id: 'section2',
header: '2番目のセクション',
content: '2番目のセクションのコンテンツ...',
},
];
</script>
<template>
<Accordion
:items="items"
:heading-level="3"
:allow-multiple="false"
@expanded-change="(ids) => console.log('展開:', ids)"
/>
</template> API
プロパティ
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
items | AccordionItem[] | required | Accordion アイテムの配列 |
allowMultiple | boolean | false | 複数のパネルの展開を許可 |
headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | アクセシビリティのための見出しレベル |
enableArrowKeys | boolean | true | 矢印キーナビゲーションを有効化 |
イベント
| イベント | ペイロード | 説明 |
|---|---|---|
expanded-change | string[] | 展開されたパネルが変更されたときに発行 |
AccordionItem インターフェース
型定義
interface AccordionItem {
id: string;
header: string;
content?: string;
disabled?: boolean;
defaultExpanded?: boolean;
} テスト
テストは、キーボード操作、ARIA属性、アクセシビリティ要件におけるAPG準拠を検証します。
テストカテゴリ
高優先度: APG キーボード操作
| テスト | 説明 |
|---|---|
Enter キー | フォーカスされたパネルを展開/折り畳み |
Space キー | フォーカスされたパネルを展開/折り畳み |
ArrowDown | 次のヘッダーにフォーカスを移動 |
ArrowUp | 前のヘッダーにフォーカスを移動 |
Home | 最初のヘッダーにフォーカスを移動 |
End | 最後のヘッダーにフォーカスを移動 |
ループなし | フォーカスは端で停止 (ループしない) |
無効化スキップ | ナビゲーション中に無効化されたヘッダーをスキップ |
高優先度: APG ARIA 属性
| テスト | 説明 |
|---|---|
aria-expanded | ヘッダーボタンが展開/折り畳み状態を反映 |
aria-controls | ヘッダーが aria-controls でパネルを参照 |
aria-labelledby | パネルが aria-labelledby でヘッダーを参照 |
role="region" | パネルに region ロール (6個以下のパネル) |
region なし (7個以上) | 7個以上のパネルの場合、region ロールを省略 |
aria-disabled | 無効化された項目に aria-disabled="true" |
高優先度: 見出し構造
| テスト | 説明 |
|---|---|
headingLevel プロパティ | 正しい見出し要素を使用 (h2, h3, など) |
中優先度: アクセシビリティ
| テスト | 説明 |
|---|---|
axe 違反 | WCAG 2.1 AA 違反なし (jest-axe 経由) |
低優先度: プロパティと振る舞い
| テスト | 説明 |
|---|---|
allowMultiple | 単一または複数の展開を制御 |
defaultExpanded | 初期展開状態を設定 |
className | カスタムクラスが適用される |
テストツール
- Vitest (opens in new tab) - テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ
- jest-axe (opens in new tab) - 自動アクセシビリティテスト
詳細なドキュメントは testing-strategy.md (opens in new tab) を参照してください。
Accordion.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 Accordion from './Accordion.vue';
import type { AccordionItem } from './Accordion.vue';
// テスト用のアコーディオンデータ
const defaultItems: AccordionItem[] = [
{ id: 'section1', header: 'Section 1', content: 'Content 1' },
{ id: 'section2', header: 'Section 2', content: 'Content 2' },
{ id: 'section3', header: 'Section 3', content: 'Content 3' },
];
const itemsWithDisabled: AccordionItem[] = [
{ id: 'section1', header: 'Section 1', content: 'Content 1' },
{ id: 'section2', header: 'Section 2', content: 'Content 2', disabled: true },
{ id: 'section3', header: 'Section 3', content: 'Content 3' },
];
const itemsWithDefaultExpanded: AccordionItem[] = [
{ id: 'section1', header: 'Section 1', content: 'Content 1', defaultExpanded: true },
{ id: 'section2', header: 'Section 2', content: 'Content 2' },
{ id: 'section3', header: 'Section 3', content: 'Content 3' },
];
// 7個以上のアイテム(region role テスト用)
const manyItems: AccordionItem[] = Array.from({ length: 7 }, (_, i) => ({
id: `section${i + 1}`,
header: `Section ${i + 1}`,
content: `Content ${i + 1}`,
}));
describe('Accordion (Vue)', () => {
// 🔴 High Priority: APG 準拠の核心
describe('APG: キーボード操作', () => {
it('Enter でパネルを開閉する', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button = screen.getByRole('button', { name: 'Section 1' });
button.focus();
expect(button).toHaveAttribute('aria-expanded', 'false');
await user.keyboard('{Enter}');
expect(button).toHaveAttribute('aria-expanded', 'true');
await user.keyboard('{Enter}');
expect(button).toHaveAttribute('aria-expanded', 'false');
});
it('Space でパネルを開閉する', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button = screen.getByRole('button', { name: 'Section 1' });
button.focus();
expect(button).toHaveAttribute('aria-expanded', 'false');
await user.keyboard(' ');
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('ArrowDown で次のヘッダーにフォーカス移動', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button1 = screen.getByRole('button', { name: 'Section 1' });
button1.focus();
await user.keyboard('{ArrowDown}');
const button2 = screen.getByRole('button', { name: 'Section 2' });
expect(button2).toHaveFocus();
});
it('ArrowUp で前のヘッダーにフォーカス移動', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button2 = screen.getByRole('button', { name: 'Section 2' });
button2.focus();
await user.keyboard('{ArrowUp}');
const button1 = screen.getByRole('button', { name: 'Section 1' });
expect(button1).toHaveFocus();
});
it('ArrowDown で最後のヘッダーにいる場合、移動しない(ループなし)', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button3 = screen.getByRole('button', { name: 'Section 3' });
button3.focus();
await user.keyboard('{ArrowDown}');
expect(button3).toHaveFocus();
});
it('ArrowUp で最初のヘッダーにいる場合、移動しない(ループなし)', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button1 = screen.getByRole('button', { name: 'Section 1' });
button1.focus();
await user.keyboard('{ArrowUp}');
expect(button1).toHaveFocus();
});
it('Home で最初のヘッダーに移動', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button3 = screen.getByRole('button', { name: 'Section 3' });
button3.focus();
await user.keyboard('{Home}');
const button1 = screen.getByRole('button', { name: 'Section 1' });
expect(button1).toHaveFocus();
});
it('End で最後のヘッダーに移動', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button1 = screen.getByRole('button', { name: 'Section 1' });
button1.focus();
await user.keyboard('{End}');
const button3 = screen.getByRole('button', { name: 'Section 3' });
expect(button3).toHaveFocus();
});
it('disabled ヘッダーをスキップして移動', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: itemsWithDisabled } });
const button1 = screen.getByRole('button', { name: 'Section 1' });
button1.focus();
await user.keyboard('{ArrowDown}');
const button3 = screen.getByRole('button', { name: 'Section 3' });
expect(button3).toHaveFocus();
});
it('enableArrowKeys=false で矢印キーナビゲーション無効', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems, enableArrowKeys: false } });
const button1 = screen.getByRole('button', { name: 'Section 1' });
button1.focus();
await user.keyboard('{ArrowDown}');
expect(button1).toHaveFocus();
});
});
describe('APG: ARIA 属性', () => {
it('ヘッダーボタンが aria-expanded を持つ', () => {
render(Accordion, { props: { items: defaultItems } });
const buttons = screen.getAllByRole('button');
buttons.forEach((button) => {
expect(button).toHaveAttribute('aria-expanded');
});
});
it('開いたパネルで aria-expanded="true"', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button = screen.getByRole('button', { name: 'Section 1' });
await user.click(button);
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('閉じたパネルで aria-expanded="false"', () => {
render(Accordion, { props: { items: defaultItems } });
const button = screen.getByRole('button', { name: 'Section 1' });
expect(button).toHaveAttribute('aria-expanded', 'false');
});
it('ヘッダーの aria-controls がパネル id と一致', () => {
render(Accordion, { props: { items: defaultItems } });
const button = screen.getByRole('button', { name: 'Section 1' });
const ariaControls = button.getAttribute('aria-controls');
expect(ariaControls).toBeTruthy();
expect(document.getElementById(ariaControls!)).toBeInTheDocument();
});
it('6個以下のパネルで role="region" を持つ', () => {
render(Accordion, { props: { items: defaultItems } });
const regions = screen.getAllByRole('region');
expect(regions).toHaveLength(3);
});
it('7個以上のパネルで role="region" を持たない', () => {
render(Accordion, { props: { items: manyItems } });
const regions = screen.queryAllByRole('region');
expect(regions).toHaveLength(0);
});
it('パネルの aria-labelledby がヘッダー id と一致', () => {
render(Accordion, { props: { items: defaultItems } });
const button = screen.getByRole('button', { name: 'Section 1' });
const regions = screen.getAllByRole('region');
expect(regions[0]).toHaveAttribute('aria-labelledby', button.id);
});
it('disabled 項目が aria-disabled="true" を持つ', () => {
render(Accordion, { props: { items: itemsWithDisabled } });
const disabledButton = screen.getByRole('button', { name: 'Section 2' });
expect(disabledButton).toHaveAttribute('aria-disabled', 'true');
});
});
describe('APG: 見出し構造', () => {
it('headingLevel=3 で h3 要素を使用', () => {
render(Accordion, { props: { items: defaultItems, headingLevel: 3 } });
const headings = document.querySelectorAll('h3');
expect(headings).toHaveLength(3);
});
it('headingLevel=2 で h2 要素を使用', () => {
render(Accordion, { props: { items: defaultItems, headingLevel: 2 } });
const headings = document.querySelectorAll('h2');
expect(headings).toHaveLength(3);
});
});
// 🟡 Medium Priority: アクセシビリティ検証
describe('アクセシビリティ', () => {
it('axe による WCAG 2.1 AA 違反がない', async () => {
const { container } = render(Accordion, { props: { items: defaultItems } });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
describe('Props', () => {
it('defaultExpanded で初期展開状態を指定できる', () => {
render(Accordion, { props: { items: itemsWithDefaultExpanded } });
const button = screen.getByRole('button', { name: 'Section 1' });
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('allowMultiple=false で1つのみ展開(デフォルト)', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button1 = screen.getByRole('button', { name: 'Section 1' });
const button2 = screen.getByRole('button', { name: 'Section 2' });
await user.click(button1);
expect(button1).toHaveAttribute('aria-expanded', 'true');
await user.click(button2);
expect(button1).toHaveAttribute('aria-expanded', 'false');
expect(button2).toHaveAttribute('aria-expanded', 'true');
});
it('allowMultiple=true で複数展開可能', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems, allowMultiple: true } });
const button1 = screen.getByRole('button', { name: 'Section 1' });
const button2 = screen.getByRole('button', { name: 'Section 2' });
await user.click(button1);
await user.click(button2);
expect(button1).toHaveAttribute('aria-expanded', 'true');
expect(button2).toHaveAttribute('aria-expanded', 'true');
});
it('@expandedChange が展開状態変化時に発火する', async () => {
const handleExpandedChange = vi.fn();
const user = userEvent.setup();
render(Accordion, {
props: { items: defaultItems, onExpandedChange: handleExpandedChange },
});
await user.click(screen.getByRole('button', { name: 'Section 1' }));
expect(handleExpandedChange).toHaveBeenCalledWith(['section1']);
});
});
describe('異常系', () => {
it('disabled 項目はクリックで開閉しない', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: itemsWithDisabled } });
const disabledButton = screen.getByRole('button', { name: 'Section 2' });
expect(disabledButton).toHaveAttribute('aria-expanded', 'false');
await user.click(disabledButton);
expect(disabledButton).toHaveAttribute('aria-expanded', 'false');
});
it('disabled かつ defaultExpanded の項目は展開されない', () => {
const items: AccordionItem[] = [
{
id: 'section1',
header: 'Section 1',
content: 'Content 1',
disabled: true,
defaultExpanded: true,
},
];
render(Accordion, { props: { items } });
const button = screen.getByRole('button', { name: 'Section 1' });
expect(button).toHaveAttribute('aria-expanded', 'false');
});
});
}); リソース
- WAI-ARIA APG: Accordion パターン (opens in new tab)
- AI 実装ガイド (llm.md) (opens in new tab) - ARIA 仕様、キーボード操作、テストチェックリスト