Accordion
垂直に積み重ねられたインタラクティブな見出しのセットで、それぞれがコンテンツのセクションを表示します。
デモ
シングル展開(デフォルト)
一度に1つのパネルのみを展開できます。新しいパネルを開くと、以前に開いていたパネルが閉じます。
An accordion is a vertically stacked set of interactive headings that each reveal a section of content. They are commonly used to reduce the need to scroll when presenting multiple sections of content on a single page.
Use accordions when you need to organize content into collapsible sections. This helps reduce visual clutter while keeping information accessible. They are particularly useful for FAQs, settings panels, and navigation menus.
Accordions must be keyboard accessible and properly announce their expanded/collapsed state to screen readers. Each header should be a proper heading element, and the panel should be associated with its header via aria-controls and aria-labelledby.
マルチ展開
allowMultiple プロパティを使用して、複数のパネルを同時に展開できます。
Content for section one. With allowMultiple enabled, multiple sections can be open at the same time.
Content for section two. Try opening this while section one is still open.
Content for section three. All three sections can be expanded simultaneously.
無効化されたアイテム
個別のアコーディオンアイテムを無効化できます。キーボードナビゲーションは自動的に無効化されたアイテムをスキップします。
This section can be expanded and collapsed normally.
This content is not accessible because the section is disabled.
This section can also be expanded. Notice that arrow key navigation skips the disabled section.
アクセシビリティ
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 | 最後のアコーディオンヘッダーにフォーカスを移動 (オプション) |
矢印キーによるナビゲーションはオプションですが推奨されます。フォーカスはリストの端でループしません。
実装ノート
構造
┌─────────────────────────────────────┐
│ [▼] Section 1 │ ← button (aria-expanded="true")
├─────────────────────────────────────┤
│ Panel 1 content... │ ← region (aria-labelledby)
├─────────────────────────────────────┤
│ [▶] Section 2 │ ← button (aria-expanded="false")
├─────────────────────────────────────┤
│ [▶] Section 3 │ ← button (aria-expanded="false")
└─────────────────────────────────────┘
ID Relationships:
- Button: id="header-1", aria-controls="panel-1"
- Panel: id="panel-1", aria-labelledby="header-1"
Region Role Rule:
- ≤6 panels: use role="region" on panels
- >6 panels: omit role="region" (too many landmarks) ID関連を含むアコーディオンコンポーネントの構造
ソースコード
Accordion.svelte
<script lang="ts">
/**
* APG Accordion Pattern - Svelte 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 { onMount } from 'svelte';
/**
* 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
*/
interface AccordionProps {
items: AccordionItem[];
allowMultiple?: boolean;
headingLevel?: 2 | 3 | 4 | 5 | 6;
enableArrowKeys?: boolean;
onExpandedChange?: (expandedIds: string[]) => void;
className?: string;
}
let {
items = [],
allowMultiple = false,
headingLevel = 3,
enableArrowKeys = true,
onExpandedChange = () => {},
className = '',
}: AccordionProps = $props();
let expandedIds = $state<string[]>([]);
let instanceId = $state('');
let buttonRefs = $state<Record<string, HTMLButtonElement | undefined>>({});
onMount(() => {
instanceId = `accordion-${Math.random().toString(36).substring(2, 11)}`;
// Initialize with defaultExpanded items
if (Array.isArray(items)) {
expandedIds = items
.filter((item) => item.defaultExpanded && !item.disabled)
.map((item) => item.id);
}
});
// Derived values
let safeItems = $derived(Array.isArray(items) ? items : []);
let availableItems = $derived(safeItems.filter((item) => !item.disabled));
let useRegion = $derived(safeItems.length <= 6);
function isExpanded(itemId: string): boolean {
return expandedIds.includes(itemId);
}
function handleToggle(itemId: string) {
const item = safeItems.find((i) => i.id === itemId);
if (item?.disabled) return;
const isCurrentlyExpanded = expandedIds.includes(itemId);
if (isCurrentlyExpanded) {
expandedIds = expandedIds.filter((id) => id !== itemId);
} else {
if (allowMultiple) {
expandedIds = [...expandedIds, itemId];
} else {
expandedIds = [itemId];
}
}
onExpandedChange(expandedIds);
}
function handleKeyDown(event: KeyboardEvent, currentItemId: string) {
if (!enableArrowKeys) return;
const currentIndex = availableItems.findIndex((item) => item.id === currentItemId);
if (currentIndex === -1) return;
let newIndex = currentIndex;
let shouldPreventDefault = false;
switch (event.key) {
case 'ArrowDown':
if (currentIndex < availableItems.length - 1) {
newIndex = currentIndex + 1;
}
shouldPreventDefault = true;
break;
case 'ArrowUp':
if (currentIndex > 0) {
newIndex = currentIndex - 1;
}
shouldPreventDefault = true;
break;
case 'Home':
newIndex = 0;
shouldPreventDefault = true;
break;
case 'End':
newIndex = availableItems.length - 1;
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
event.preventDefault();
if (newIndex !== currentIndex) {
const newItem = availableItems[newIndex];
if (newItem) {
buttonRefs[newItem.id]?.focus();
}
}
}
}
function getItemClass(item: AccordionItem): string {
let cls = 'apg-accordion-item';
if (isExpanded(item.id)) cls += ' apg-accordion-item--expanded';
if (item.disabled) cls += ' apg-accordion-item--disabled';
return cls;
}
function getTriggerClass(itemId: string): string {
return isExpanded(itemId)
? 'apg-accordion-trigger apg-accordion-trigger--expanded'
: 'apg-accordion-trigger';
}
function getIconClass(itemId: string): string {
return isExpanded(itemId)
? 'apg-accordion-icon apg-accordion-icon--expanded'
: 'apg-accordion-icon';
}
function getPanelClass(itemId: string): string {
return isExpanded(itemId)
? 'apg-accordion-panel apg-accordion-panel--expanded'
: 'apg-accordion-panel apg-accordion-panel--collapsed';
}
</script>
{#if safeItems.length > 0}
<div class="apg-accordion {className}">
{#each safeItems as item (item.id)}
{@const headerId = `${instanceId}-header-${item.id}`}
{@const panelId = `${instanceId}-panel-${item.id}`}
<div class={getItemClass(item)}>
{#if headingLevel === 2}
<h2 class="apg-accordion-header">
<button
bind:this={buttonRefs[item.id]}
type="button"
id={headerId}
aria-expanded={isExpanded(item.id)}
aria-controls={panelId}
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
class={getTriggerClass(item.id)}
onclick={() => handleToggle(item.id)}
onkeydown={(e) => handleKeyDown(e, item.id)}
>
<span class="apg-accordion-trigger-content">{item.header}</span>
<span class={getIconClass(item.id)} 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>
</h2>
{:else if headingLevel === 3}
<h3 class="apg-accordion-header">
<button
bind:this={buttonRefs[item.id]}
type="button"
id={headerId}
aria-expanded={isExpanded(item.id)}
aria-controls={panelId}
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
class={getTriggerClass(item.id)}
onclick={() => handleToggle(item.id)}
onkeydown={(e) => handleKeyDown(e, item.id)}
>
<span class="apg-accordion-trigger-content">{item.header}</span>
<span class={getIconClass(item.id)} 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>
</h3>
{:else if headingLevel === 4}
<h4 class="apg-accordion-header">
<button
bind:this={buttonRefs[item.id]}
type="button"
id={headerId}
aria-expanded={isExpanded(item.id)}
aria-controls={panelId}
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
class={getTriggerClass(item.id)}
onclick={() => handleToggle(item.id)}
onkeydown={(e) => handleKeyDown(e, item.id)}
>
<span class="apg-accordion-trigger-content">{item.header}</span>
<span class={getIconClass(item.id)} 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>
</h4>
{:else if headingLevel === 5}
<h5 class="apg-accordion-header">
<button
bind:this={buttonRefs[item.id]}
type="button"
id={headerId}
aria-expanded={isExpanded(item.id)}
aria-controls={panelId}
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
class={getTriggerClass(item.id)}
onclick={() => handleToggle(item.id)}
onkeydown={(e) => handleKeyDown(e, item.id)}
>
<span class="apg-accordion-trigger-content">{item.header}</span>
<span class={getIconClass(item.id)} 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>
</h5>
{:else}
<h6 class="apg-accordion-header">
<button
bind:this={buttonRefs[item.id]}
type="button"
id={headerId}
aria-expanded={isExpanded(item.id)}
aria-controls={panelId}
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
class={getTriggerClass(item.id)}
onclick={() => handleToggle(item.id)}
onkeydown={(e) => handleKeyDown(e, item.id)}
>
<span class="apg-accordion-trigger-content">{item.header}</span>
<span class={getIconClass(item.id)} 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>
</h6>
{/if}
<div
role={useRegion ? 'region' : undefined}
id={panelId}
aria-labelledby={useRegion ? headerId : undefined}
class={getPanelClass(item.id)}
>
<div class="apg-accordion-panel-content">
{#if item.content}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Content is provided by the consuming application -->
{@html item.content}
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if} 使い方
使用例
<script>
import Accordion from './Accordion.svelte';
const items = [
{
id: 'section1',
header: 'First Section',
content: 'Content for the first section...',
defaultExpanded: true,
},
{
id: 'section2',
header: 'Second Section',
content: 'Content for the second section...',
},
];
function handleExpandedChange(ids) {
console.log('Expanded:', ids);
}
</script>
<Accordion
{items}
headingLevel={3}
allowMultiple={false}
onExpandedChange={handleExpandedChange}
/> API
プロパティ
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
items | AccordionItem[] | 必須 | アコーディオンアイテムの配列 |
allowMultiple | boolean | false | 複数のパネルの展開を許可 |
headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | アクセシビリティのための見出しレベル |
enableArrowKeys | boolean | true | 矢印キーナビゲーションを有効化 |
onExpandedChange | (ids: string[]) => void | - | 展開状態変更時のコールバック |
AccordionItem インターフェース
型定義
interface AccordionItem {
id: string;
header: string;
content?: string;
disabled?: boolean;
defaultExpanded?: boolean;
} テスト
テストは、キーボード操作、ARIA属性、アクセシビリティ要件におけるAPG準拠を検証します。Accordion コンポーネントは2層のテスト戦略を使用しています。
テスト戦略
ユニットテスト(Testing Library)
フレームワーク固有のテストライブラリを使用して、コンポーネントの出力を検証します。これらのテストは正しいHTML構造とARIA属性を確保します。
- ARIA 属性 (aria-expanded, aria-controls, aria-labelledby)
- キーボード操作 (Enter, Space, 矢印キー)
- 展開/折り畳み動作
- jest-axe によるアクセシビリティ
E2E テスト (Playwright)
実際のブラウザ環境でコンポーネントの動作を全フレームワークにわたって検証します。これらのテストはインタラクションとクロスフレームワークの一貫性をカバーします。
- クリック操作
- 矢印キーナビゲーション
- Home/End キーナビゲーション
- ライブブラウザでの ARIA 構造検証
- axe-core アクセシビリティスキャン
- クロスフレームワーク一貫性チェック
テストカテゴリ
高優先度 : APG キーボード操作(Unit + E2E)
| テスト | 説明 |
|---|---|
Enter key | フォーカスされたパネルを展開/折り畳み |
Space key | フォーカスされたパネルを展開/折り畳み |
ArrowDown | 次のヘッダーにフォーカスを移動 |
ArrowUp | 前のヘッダーにフォーカスを移動 |
Home | 最初のヘッダーにフォーカスを移動 |
End | 最後のヘッダーにフォーカスを移動 |
No loop | フォーカスは端で停止 (ループしない) |
Disabled skip | ナビゲーション中に無効化されたヘッダーをスキップ |
高優先度 : APG ARIA 属性(Unit + E2E)
| テスト | 説明 |
|---|---|
aria-expanded | ヘッダーボタンが展開/折り畳み状態を反映 |
aria-controls | ヘッダーが aria-controls でパネルを参照 |
aria-labelledby | パネルが aria-labelledby でヘッダーを参照 |
role="region" | パネルに region ロール (6個以下のパネル) |
No region (7+) | 7個以上のパネルの場合、region ロールを省略 |
aria-disabled | 無効化された項目に aria-disabled="true" |
高優先度 : クリック操作(Unit + E2E)
| テスト | 説明 |
|---|---|
Click expands | ヘッダーをクリックするとパネルが展開 |
Click collapses | 展開されたヘッダーをクリックするとパネルが折り畳み |
Single expansion | パネルを開くと他のパネルが閉じる(デフォルト) |
Multiple expansion | allowMultiple で複数のパネルを開ける |
高優先度 : 見出し構造(Unit + E2E)
| テスト | 説明 |
|---|---|
headingLevel prop | 正しい見出し要素を使用 (h2, h3, など) |
中優先度 : 無効状態(Unit + E2E)
| テスト | 説明 |
|---|---|
Disabled no click | 無効化されたヘッダーをクリックしても展開しない |
Disabled no keyboard | Enter/Space で無効化されたヘッダーが動作しない |
中優先度 : アクセシビリティ(Unit + E2E)
| テスト | 説明 |
|---|---|
axe violations | WCAG 2.1 AA 違反なし (jest-axe/axe-core 経由) |
低優先度 : クロスフレームワーク一貫性(E2E)
| テスト | 説明 |
|---|---|
All frameworks render | React, Vue, Svelte, Astro で全てアコーディオンがレンダリングされる |
Consistent ARIA | 全フレームワークで一貫した ARIA 構造 |
テストコード例
以下は実際の E2E テストファイルです (e2e/accordion.spec.ts).
e2e/accordion.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
/**
* E2E Tests for Accordion Pattern
*
* A vertically stacked set of interactive headings that each reveal
* a section of content.
*
* APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
*/
const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;
// ============================================
// Helper Functions
// ============================================
const getAccordion = (page: import('@playwright/test').Page) => {
return page.locator('.apg-accordion');
};
const getAccordionHeaders = (page: import('@playwright/test').Page) => {
return page.locator('.apg-accordion-trigger');
};
// ============================================
// Framework-specific Tests
// ============================================
for (const framework of frameworks) {
test.describe(`Accordion (${framework})`, () => {
test.beforeEach(async ({ page }) => {
await page.goto(`patterns/accordion/${framework}/demo/`);
await getAccordion(page).first().waitFor();
// Wait for hydration to complete - aria-controls should have a proper ID (not starting with hyphen)
const firstHeader = getAccordionHeaders(page).first();
await expect
.poll(async () => {
const id = await firstHeader.getAttribute('aria-controls');
// ID should be non-empty and not start with hyphen (Svelte pre-hydration)
return id && id.length > 1 && !id.startsWith('-');
})
.toBe(true);
});
// ------------------------------------------
// 🔴 High Priority: APG ARIA Structure
// ------------------------------------------
test.describe('APG: ARIA Structure', () => {
test('accordion headers have aria-expanded attribute', async ({ page }) => {
const headers = getAccordionHeaders(page);
const firstHeader = headers.first();
// Should have aria-expanded (either true or false)
const expanded = await firstHeader.getAttribute('aria-expanded');
expect(['true', 'false']).toContain(expanded);
});
test('accordion headers have aria-controls referencing panel', async ({ page }) => {
const headers = getAccordionHeaders(page);
const firstHeader = headers.first();
// Wait for aria-controls to be set
await expect(firstHeader).toHaveAttribute('aria-controls', /.+/);
const controlsId = await firstHeader.getAttribute('aria-controls');
expect(controlsId).toBeTruthy();
// Panel with that ID should exist
const panel = page.locator(`[id="${controlsId}"]`);
await expect(panel).toBeAttached();
});
test('panels have role="region" when 6 or fewer items', async ({ page }) => {
const accordion = getAccordion(page).first();
const headers = accordion.locator('.apg-accordion-trigger');
const count = await headers.count();
if (count <= 6) {
const panels = accordion.locator('.apg-accordion-panel');
const firstPanel = panels.first();
await expect(firstPanel).toHaveRole('region');
}
});
test('panels have aria-labelledby referencing header', async ({ page }) => {
const accordion = getAccordion(page).first();
const headers = accordion.locator('.apg-accordion-trigger');
const count = await headers.count();
// Only check aria-labelledby when role="region" is present (≤6 items)
if (count <= 6) {
const firstHeader = headers.first();
// Wait for aria-controls to be set
await expect(firstHeader).toHaveAttribute('aria-controls', /.+/);
const headerId = await firstHeader.getAttribute('id');
const controlsId = await firstHeader.getAttribute('aria-controls');
const panel = page.locator(`[id="${controlsId}"]`);
await expect(panel).toHaveAttribute('aria-labelledby', headerId!);
}
});
});
// ------------------------------------------
// 🔴 High Priority: Click Interaction
// ------------------------------------------
test.describe('APG: Click Interaction', () => {
test('clicking header toggles panel expansion', async ({ page }) => {
const accordion = getAccordion(page).first();
// Use second header which is not expanded by default
const header = accordion.locator('.apg-accordion-trigger').nth(1);
// Wait for component to be interactive (hydration complete)
await expect(header).toHaveAttribute('aria-expanded', 'false');
await header.click();
await expect(header).toHaveAttribute('aria-expanded', 'true');
});
test('single expansion mode: opening one panel closes others', async ({ page }) => {
// First accordion uses single expansion mode
const accordion = getAccordion(page).first();
const headers = accordion.locator('.apg-accordion-trigger');
// Wait for hydration - first header should be expanded by default
const firstHeader = headers.first();
await expect(firstHeader).toHaveAttribute('aria-expanded', 'true');
// Click second header
const secondHeader = headers.nth(1);
await secondHeader.click();
// Second should be open, first should be closed
await expect(secondHeader).toHaveAttribute('aria-expanded', 'true');
await expect(firstHeader).toHaveAttribute('aria-expanded', 'false');
});
});
// ------------------------------------------
// 🔴 High Priority: Keyboard Interaction
// ------------------------------------------
test.describe('APG: Keyboard Interaction', () => {
test('Enter/Space toggles panel expansion', async ({ page }) => {
const accordion = getAccordion(page).first();
// Use second header which is collapsed by default
const header = accordion.locator('.apg-accordion-trigger').nth(1);
// Wait for component to be ready
await expect(header).toHaveAttribute('aria-expanded', 'false');
// Click to set focus (this also opens the panel)
await header.click();
await expect(header).toBeFocused();
await expect(header).toHaveAttribute('aria-expanded', 'true');
// Press Enter to toggle (should collapse)
await expect(header).toBeFocused();
await header.press('Enter');
await expect(header).toHaveAttribute('aria-expanded', 'false');
// Press Space to toggle (should expand)
await expect(header).toBeFocused();
await header.press('Space');
await expect(header).toHaveAttribute('aria-expanded', 'true');
});
test('ArrowDown moves focus to next header', async ({ page }) => {
const accordion = getAccordion(page).first();
const headers = accordion.locator('.apg-accordion-trigger');
// Click to set focus
const firstHeader = headers.first();
await firstHeader.click();
await expect(firstHeader).toBeFocused();
await firstHeader.press('ArrowDown');
await expect(headers.nth(1)).toBeFocused();
});
test('ArrowUp moves focus to previous header', async ({ page }) => {
const accordion = getAccordion(page).first();
const headers = accordion.locator('.apg-accordion-trigger');
// Click to ensure focus is properly set
const secondHeader = headers.nth(1);
await secondHeader.click();
await expect(secondHeader).toBeFocused();
await secondHeader.press('ArrowUp');
await expect(headers.first()).toBeFocused();
});
test('Home moves focus to first header', async ({ page }) => {
const accordion = getAccordion(page).first();
const headers = accordion.locator('.apg-accordion-trigger');
// Click to set focus
const thirdHeader = headers.nth(2);
await thirdHeader.click();
await expect(thirdHeader).toBeFocused();
await thirdHeader.press('Home');
await expect(headers.first()).toBeFocused();
});
test('End moves focus to last header', async ({ page }) => {
const accordion = getAccordion(page).first();
const headers = accordion.locator('.apg-accordion-trigger');
const count = await headers.count();
// Click to set focus
const firstHeader = headers.first();
await firstHeader.click();
await expect(firstHeader).toBeFocused();
await firstHeader.press('End');
await expect(headers.nth(count - 1)).toBeFocused();
});
});
// ------------------------------------------
// 🟡 Medium Priority: Disabled State
// ------------------------------------------
test.describe('Disabled State', () => {
test('disabled header cannot be clicked to expand', async ({ page }) => {
// Third accordion has disabled items
const accordions = getAccordion(page);
const count = await accordions.count();
// Find accordion with disabled item
for (let i = 0; i < count; i++) {
const accordion = accordions.nth(i);
const disabledHeader = accordion.locator('.apg-accordion-trigger[disabled]');
if ((await disabledHeader.count()) > 0) {
const header = disabledHeader.first();
const initialExpanded = await header.getAttribute('aria-expanded');
await header.click({ force: true });
// State should not change
await expect(header).toHaveAttribute('aria-expanded', initialExpanded!);
break;
}
}
});
test('arrow key navigation skips disabled headers', async ({ page }) => {
// Find accordion with disabled item (third accordion)
const accordions = getAccordion(page);
const count = await accordions.count();
for (let i = 0; i < count; i++) {
const accordion = accordions.nth(i);
const disabledHeader = accordion.locator('.apg-accordion-trigger[disabled]');
if ((await disabledHeader.count()) > 0) {
const headers = accordion.locator('.apg-accordion-trigger:not([disabled])');
const firstEnabled = headers.first();
// Click to set focus reliably
await firstEnabled.click();
await expect(firstEnabled).toBeFocused();
await firstEnabled.press('ArrowDown');
// Should skip disabled and go to next enabled
const secondEnabled = headers.nth(1);
await expect(secondEnabled).toBeFocused();
break;
}
}
});
});
// ------------------------------------------
// 🟢 Low Priority: Accessibility
// ------------------------------------------
test.describe('Accessibility', () => {
test('has no axe-core violations', async ({ page }) => {
const accordion = getAccordion(page);
await accordion.first().waitFor();
const results = await new AxeBuilder({ page })
.include('.apg-accordion')
.disableRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
});
});
}
// ============================================
// Cross-framework Consistency Tests
// ============================================
test.describe('Accordion - Cross-framework Consistency', () => {
test('all frameworks have accordions', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/accordion/${framework}/demo/`);
await getAccordion(page).first().waitFor();
const accordions = getAccordion(page);
const count = await accordions.count();
expect(count).toBeGreaterThan(0);
}
});
test('all frameworks support click to expand', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/accordion/${framework}/demo/`);
await getAccordion(page).first().waitFor();
const accordion = getAccordion(page).first();
// Use second header which is not expanded by default
const header = accordion.locator('.apg-accordion-trigger').nth(1);
// Wait for the component to be interactive (not expanded by default)
await expect(header).toHaveAttribute('aria-expanded', 'false');
// Click to toggle
await header.click();
// State should change to expanded
await expect(header).toHaveAttribute('aria-expanded', 'true');
}
});
test('all frameworks have consistent ARIA structure', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/accordion/${framework}/demo/`);
await getAccordion(page).first().waitFor();
const accordion = getAccordion(page).first();
const header = accordion.locator('.apg-accordion-trigger').first();
// Wait for hydration - aria-controls should be set
await expect(header).toHaveAttribute('aria-controls', /.+/);
// Check aria-expanded exists
const expanded = await header.getAttribute('aria-expanded');
expect(['true', 'false']).toContain(expanded);
// Check aria-controls exists and references valid panel
const controlsId = await header.getAttribute('aria-controls');
expect(controlsId).toBeTruthy();
const panel = page.locator(`[id="${controlsId}"]`);
await expect(panel).toBeAttached();
}
});
}); テストの実行
# Accordion のユニットテストを実行
npm run test -- accordion
# Accordion の E2E テストを実行(全フレームワーク)
npm run test:e2e:pattern --pattern=accordion
# 特定フレームワークの E2E テストを実行
npm run test:e2e:react:pattern --pattern=accordion
npm run test:e2e:vue:pattern --pattern=accordion
npm run test:e2e:svelte:pattern --pattern=accordion
npm run test:e2e:astro:pattern --pattern=accordion テストツール
- Vitest (opens in new tab) - ユニットテスト用テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ (React, Vue, Svelte)
- Playwright (opens in new tab) - E2E テスト用ブラウザ自動化
- axe-core/playwright (opens in new tab) - E2E での自動アクセシビリティテスト
詳細なドキュメントについては、 testing-strategy.md (opens in new tab) を参照してください。
Accordion.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 Accordion from './Accordion.svelte';
import type { AccordionItem } from './Accordion.svelte';
// テスト用のアコーディオンデータ
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 (Svelte)', () => {
// 🔴 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('onExpandedChange が展開状態変化時に呼び出される', 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 Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist