Accordion
複数のセクションを縦に並べ、各セクションのヘッダーをクリックすることで内容を表示/非表示できるコンポーネント。
デモ
単一展開(デフォルト)
一度に1つのパネルのみ展開できます。新しいパネルを開くと、以前開いていたパネルは閉じます。
複数展開
allowMultiple プロパティを使用すると、複数のパネルを同時に展開できます。
無効化されたアイテム
個別のアコーディオンアイテムを無効化できます。キーボードナビゲーションは無効化されたアイテムを自動的にスキップします。
アクセシビリティ
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関連を含むアコーディオンコンポーネントの構造
ソースコード
---
/**
* APG Accordion Pattern - Astro Implementation
*
* A vertically stacked set of interactive headings that each reveal a section of content.
* Uses Web Components for client-side keyboard navigation and state management.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
*/
export interface AccordionItem {
id: string;
header: string;
content: string;
disabled?: boolean;
defaultExpanded?: boolean;
}
export interface Props {
/** Array of accordion items */
items: AccordionItem[];
/** Allow multiple panels to be expanded simultaneously */
allowMultiple?: boolean;
/** Heading level for accessibility (2-6) */
headingLevel?: 2 | 3 | 4 | 5 | 6;
/** Enable arrow key navigation between headers */
enableArrowKeys?: boolean;
/** Additional CSS class */
class?: string;
}
const {
items,
allowMultiple = false,
headingLevel = 3,
enableArrowKeys = true,
class: className = '',
} = Astro.props;
// Generate unique ID for this instance
const instanceId = `accordion-${Math.random().toString(36).substring(2, 11)}`;
// Determine initially expanded items
const initialExpanded = items
.filter((item) => item.defaultExpanded && !item.disabled)
.map((item) => item.id);
// Use role="region" only for 6 or fewer panels (APG recommendation)
const useRegion = items.length <= 6;
// Dynamic heading tag
const HeadingTag = `h${headingLevel}`;
---
<apg-accordion
class={`apg-accordion ${className}`.trim()}
data-allow-multiple={allowMultiple}
data-enable-arrow-keys={enableArrowKeys}
data-expanded={JSON.stringify(initialExpanded)}
>
{
items.map((item) => {
const headerId = `${instanceId}-header-${item.id}`;
const panelId = `${instanceId}-panel-${item.id}`;
const isExpanded = initialExpanded.includes(item.id);
const itemClass = `apg-accordion-item ${
isExpanded ? 'apg-accordion-item--expanded' : ''
} ${item.disabled ? 'apg-accordion-item--disabled' : ''}`.trim();
const triggerClass = `apg-accordion-trigger ${
isExpanded ? 'apg-accordion-trigger--expanded' : ''
}`.trim();
const iconClass = `apg-accordion-icon ${
isExpanded ? 'apg-accordion-icon--expanded' : ''
}`.trim();
const panelClass = `apg-accordion-panel ${
isExpanded ? 'apg-accordion-panel--expanded' : 'apg-accordion-panel--collapsed'
}`.trim();
return (
<div class={itemClass} data-item-id={item.id}>
<Fragment set:html={`<${HeadingTag} class="apg-accordion-header">`} />
<button
type="button"
id={headerId}
aria-expanded={isExpanded}
aria-controls={panelId}
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
class={triggerClass}
data-item-id={item.id}
>
<span class="apg-accordion-trigger-content">{item.header}</span>
<span class={iconClass} 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>
<Fragment set:html={`</${HeadingTag}>`} />
<div
role={useRegion ? 'region' : undefined}
id={panelId}
aria-labelledby={useRegion ? headerId : undefined}
class={panelClass}
data-panel-id={item.id}
>
<div class="apg-accordion-panel-content">
<Fragment set:html={item.content} />
</div>
</div>
</div>
);
})
}
</apg-accordion>
<script>
class ApgAccordion extends HTMLElement {
private buttons: HTMLButtonElement[] = [];
private panels: HTMLElement[] = [];
private availableButtons: HTMLButtonElement[] = [];
private expandedIds: string[] = [];
private allowMultiple = false;
private enableArrowKeys = true;
private rafId: number | null = null;
connectedCallback() {
// Use requestAnimationFrame to ensure DOM is fully constructed
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.buttons = Array.from(this.querySelectorAll('.apg-accordion-trigger'));
this.panels = Array.from(this.querySelectorAll('.apg-accordion-panel'));
if (this.buttons.length === 0 || this.panels.length === 0) {
console.warn('apg-accordion: buttons or panels not found');
return;
}
this.availableButtons = this.buttons.filter((btn) => !btn.disabled);
this.allowMultiple = this.dataset.allowMultiple === 'true';
this.enableArrowKeys = this.dataset.enableArrowKeys !== 'false';
this.expandedIds = JSON.parse(this.dataset.expanded || '[]');
// Attach event listeners
this.buttons.forEach((button) => {
button.addEventListener('click', this.handleClick);
});
if (this.enableArrowKeys) {
this.addEventListener('keydown', this.handleKeyDown);
}
}
disconnectedCallback() {
// Cancel pending initialization
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
// Remove event listeners
this.buttons.forEach((button) => {
button.removeEventListener('click', this.handleClick);
});
this.removeEventListener('keydown', this.handleKeyDown);
// Clean up references
this.buttons = [];
this.panels = [];
this.availableButtons = [];
}
private togglePanel(itemId: string) {
const isCurrentlyExpanded = this.expandedIds.includes(itemId);
if (isCurrentlyExpanded) {
this.expandedIds = this.expandedIds.filter((id) => id !== itemId);
} else {
if (this.allowMultiple) {
this.expandedIds = [...this.expandedIds, itemId];
} else {
this.expandedIds = [itemId];
}
}
this.updateDOM();
// Dispatch custom event
this.dispatchEvent(
new CustomEvent('expandedchange', {
detail: { expandedIds: this.expandedIds },
bubbles: true,
})
);
}
private updateDOM() {
this.buttons.forEach((button) => {
const itemId = button.dataset.itemId!;
const isExpanded = this.expandedIds.includes(itemId);
const panel = this.panels.find((p) => p.dataset.panelId === itemId);
const item = button.closest('.apg-accordion-item');
const icon = button.querySelector('.apg-accordion-icon');
// Update button
button.setAttribute('aria-expanded', String(isExpanded));
button.classList.toggle('apg-accordion-trigger--expanded', isExpanded);
// Update icon
icon?.classList.toggle('apg-accordion-icon--expanded', isExpanded);
// Update panel visibility via CSS classes (not hidden attribute)
if (panel) {
panel.classList.toggle('apg-accordion-panel--expanded', isExpanded);
panel.classList.toggle('apg-accordion-panel--collapsed', !isExpanded);
}
// Update item
item?.classList.toggle('apg-accordion-item--expanded', isExpanded);
});
}
private handleClick = (e: Event) => {
const button = e.currentTarget as HTMLButtonElement;
if (button.disabled) return;
this.togglePanel(button.dataset.itemId!);
};
private handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (!target.classList.contains('apg-accordion-trigger')) return;
const currentIndex = this.availableButtons.indexOf(target as HTMLButtonElement);
if (currentIndex === -1) return;
let newIndex = currentIndex;
let shouldPreventDefault = false;
switch (e.key) {
case 'ArrowDown':
// Move to next, but don't wrap (APG compliant)
if (currentIndex < this.availableButtons.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 = this.availableButtons.length - 1;
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
e.preventDefault();
if (newIndex !== currentIndex) {
this.availableButtons[newIndex]?.focus();
}
}
};
}
// Register the custom element
if (!customElements.get('apg-accordion')) {
customElements.define('apg-accordion', ApgAccordion);
}
</script> 使い方
---
import Accordion from './Accordion.astro';
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...',
},
];
---
<Accordion
items={items}
headingLevel={3}
allowMultiple={false}
/> API
プロパティ
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
items | AccordionItem[] | 必須 | アコーディオンアイテムの配列 |
allowMultiple | boolean | false | 複数のパネルの展開を許可 |
headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | アクセシビリティのための見出しレベル |
enableArrowKeys | boolean | true | 矢印キーナビゲーションを有効化 |
class | string | "" | 追加の CSS クラス |
イベント
| イベント | 詳細 | 説明 |
|---|---|---|
expandedchange | { expandedIds: string[] } | 展開されたパネルが変更されたときに発火 |
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).
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) を参照してください。
実装上の注意
この Astro 実装は、クライアント側のインタラクティビティに Web Components(customElements.define)を使用しています。アコーディオンはサーバー上で静的 HTML としてレンダリングされ、Web
Component がキーボードナビゲーションと状態管理で機能を強化します。
- クライアント側で JavaScript フレームワークは不要
- SSG(静的サイト生成)で動作
- プログレッシブエンハンスメント - JavaScript なしでも基本機能が動作
- 最小限の JavaScript バンドルサイズ
リソース
- WAI-ARIA APG: Accordion パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist