APG Patterns
English
English

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.tsx
import { useCallback, useId, useRef, useState } from 'react';

/**
 * Accordion item configuration
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
 */
export interface AccordionItem {
  /** Unique identifier for the item */
  id: string;
  /** Content displayed in the accordion header button */
  header: React.ReactNode;
  /** Content displayed in the collapsible panel */
  content: React.ReactNode;
  /** 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
 * ```tsx
 * const items = [
 *   { id: 'section1', header: 'Section 1', content: 'Content 1', defaultExpanded: true },
 *   { id: 'section2', header: 'Section 2', content: 'Content 2' },
 * ];
 *
 * <Accordion
 *   items={items}
 *   headingLevel={3}
 *   allowMultiple={false}
 *   onExpandedChange={(ids) => console.log('Expanded:', ids)}
 * />
 * ```
 */
export interface AccordionProps {
  /**
   * Array of accordion items to display
   * Each item requires an id, header, and content
   */
  items: AccordionItem[];
  /**
   * Allow multiple panels to be expanded simultaneously
   * @default false
   */
  allowMultiple?: boolean;
  /**
   * Heading level for accessibility (h2-h6)
   * Should match the document outline hierarchy
   * @default 3
   */
  headingLevel?: 2 | 3 | 4 | 5 | 6;
  /**
   * Enable arrow key navigation between accordion headers
   * When enabled: Arrow Up/Down, Home, End keys navigate between headers
   * @default true
   */
  enableArrowKeys?: boolean;
  /**
   * Callback fired when the expanded panels change
   * @param expandedIds - Array of currently expanded item IDs
   */
  onExpandedChange?: (expandedIds: string[]) => void;
  /**
   * Additional CSS class to apply to the accordion container
   * @default ""
   */
  className?: string;
}

export function Accordion({
  items,
  allowMultiple = false,
  headingLevel = 3,
  enableArrowKeys = true,
  onExpandedChange,
  className = '',
}: AccordionProps): React.ReactElement {
  const instanceId = useId();
  const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});

  // Initialize with defaultExpanded items
  const [expandedIds, setExpandedIds] = useState<string[]>(() =>
    items.filter((item) => item.defaultExpanded && !item.disabled).map((item) => item.id)
  );

  const availableItems = items.filter((item) => !item.disabled);

  const handleToggle = useCallback(
    (itemId: string) => {
      const item = items.find((i) => i.id === itemId);
      if (item?.disabled) return;

      let newExpandedIds: string[];
      const isCurrentlyExpanded = expandedIds.includes(itemId);

      if (isCurrentlyExpanded) {
        newExpandedIds = expandedIds.filter((id) => id !== itemId);
      } else {
        if (allowMultiple) {
          newExpandedIds = [...expandedIds, itemId];
        } else {
          newExpandedIds = [itemId];
        }
      }

      setExpandedIds(newExpandedIds);
      onExpandedChange?.(newExpandedIds);
    },
    [expandedIds, allowMultiple, items, onExpandedChange]
  );

  const handleKeyDown = useCallback(
    (event: React.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':
          // Move to next, but don't wrap (APG compliant)
          if (currentIndex < availableItems.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.length - 1;
          shouldPreventDefault = true;
          break;
      }

      if (shouldPreventDefault) {
        event.preventDefault();
        if (newIndex !== currentIndex) {
          const newItem = availableItems[newIndex];
          if (newItem && buttonRefs.current[newItem.id]) {
            buttonRefs.current[newItem.id]?.focus();
          }
        }
      }
    },
    [enableArrowKeys, availableItems]
  );

  // Use role="region" only for 6 or fewer panels (APG recommendation)
  const useRegion = items.length <= 6;

  // Dynamic heading component with proper typing
  const headingTagMap = {
    2: 'h2',
    3: 'h3',
    4: 'h4',
    5: 'h5',
    6: 'h6',
  } as const;
  const HeadingTag = headingTagMap[headingLevel];

  return (
    <div className={`apg-accordion ${className}`.trim()}>
      {items.map((item) => {
        const headerId = `${instanceId}-header-${item.id}`;
        const panelId = `${instanceId}-panel-${item.id}`;
        const isExpanded = expandedIds.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 key={item.id} className={itemClass}>
            <HeadingTag className="apg-accordion-header">
              <button
                ref={(el) => {
                  buttonRefs.current[item.id] = el;
                }}
                type="button"
                id={headerId}
                aria-expanded={isExpanded}
                aria-controls={panelId}
                aria-disabled={item.disabled || undefined}
                disabled={item.disabled}
                className={triggerClass}
                onClick={() => handleToggle(item.id)}
                onKeyDown={(e) => handleKeyDown(e, item.id)}
              >
                <span className="apg-accordion-trigger-content">{item.header}</span>
                <span className={iconClass} aria-hidden="true">
                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
                    <polyline points="6 9 12 15 18 9" />
                  </svg>
                </span>
              </button>
            </HeadingTag>
            <div
              role={useRegion ? 'region' : undefined}
              id={panelId}
              aria-labelledby={useRegion ? headerId : undefined}
              className={panelClass}
            >
              <div className="apg-accordion-panel-content">{item.content}</div>
            </div>
          </div>
        );
      })}
    </div>
  );
}

export default Accordion;

使い方

Example
import { Accordion } from './Accordion';

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 App() {
  return (
    <Accordion
      items={items}
      headingLevel={3}
      allowMultiple={false}
      onExpandedChange={(ids) => console.log('Expanded:', ids)}
    />
  );
}

API

AccordionProps

プロパティ デフォルト 説明
items AccordionItem[] 必須 アコーディオンアイテムの配列
allowMultiple boolean false 複数のパネルの同時展開を許可
headingLevel 2 | 3 | 4 | 5 | 6 3 アクセシビリティ用の見出しレベル
enableArrowKeys boolean true 矢印キーナビゲーションを有効化
onExpandedChange (ids: string[]) => void - 展開状態が変更されたときのコールバック
className string "" 追加のCSSクラス

AccordionItem

プロパティ 必須 説明
id string はい アイテムの一意の識別子
header ReactNode はい アコーディオンヘッダーのコンテンツ
content ReactNode はい アコーディオンパネルのコンテンツ
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

テストツール

詳細なドキュメントについては、 testing-strategy.md (opens in new tab) を参照してください。

Accordion.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 { Accordion, type AccordionItem } from './Accordion';

// Test accordion data
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+ items (for region role test)
const manyItems: AccordionItem[] = Array.from({ length: 7 }, (_, i) => ({
  id: `section${i + 1}`,
  header: `Section ${i + 1}`,
  content: `Content ${i + 1}`,
}));

describe('Accordion', () => {
  // 🔴 High Priority: APG Core Compliance
  describe('APG: Keyboard Interaction', () => {
    it('toggles panel with Enter', async () => {
      const user = userEvent.setup();
      render(<Accordion 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('toggles panel with Space', async () => {
      const user = userEvent.setup();
      render(<Accordion 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('moves focus to next header with ArrowDown', async () => {
      const user = userEvent.setup();
      render(<Accordion 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('moves focus to previous header with ArrowUp', async () => {
      const user = userEvent.setup();
      render(<Accordion 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('does not move focus when at last header with ArrowDown (no loop)', async () => {
      const user = userEvent.setup();
      render(<Accordion items={defaultItems} />);

      const button3 = screen.getByRole('button', { name: 'Section 3' });
      button3.focus();

      await user.keyboard('{ArrowDown}');

      // Focus does not move
      expect(button3).toHaveFocus();
    });

    it('does not move focus when at first header with ArrowUp (no loop)', async () => {
      const user = userEvent.setup();
      render(<Accordion items={defaultItems} />);

      const button1 = screen.getByRole('button', { name: 'Section 1' });
      button1.focus();

      await user.keyboard('{ArrowUp}');

      // Focus does not move
      expect(button1).toHaveFocus();
    });

    it('moves focus to first header with Home', async () => {
      const user = userEvent.setup();
      render(<Accordion 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('moves focus to last header with End', async () => {
      const user = userEvent.setup();
      render(<Accordion 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('skips disabled headers when navigating', async () => {
      const user = userEvent.setup();
      render(<Accordion items={itemsWithDisabled} />);

      const button1 = screen.getByRole('button', { name: 'Section 1' });
      button1.focus();

      await user.keyboard('{ArrowDown}');

      // Section 2 is skipped, moves to Section 3
      const button3 = screen.getByRole('button', { name: 'Section 3' });
      expect(button3).toHaveFocus();
    });

    it('disables arrow key navigation when enableArrowKeys=false', async () => {
      const user = userEvent.setup();
      render(<Accordion items={defaultItems} enableArrowKeys={false} />);

      const button1 = screen.getByRole('button', { name: 'Section 1' });
      button1.focus();

      await user.keyboard('{ArrowDown}');

      // Focus does not move
      expect(button1).toHaveFocus();
    });
  });

  describe('APG: ARIA Attributes', () => {
    it('header buttons have aria-expanded', () => {
      render(<Accordion items={defaultItems} />);
      const buttons = screen.getAllByRole('button');

      buttons.forEach((button) => {
        expect(button).toHaveAttribute('aria-expanded');
      });
    });

    it('has aria-expanded="true" on open panel', async () => {
      const user = userEvent.setup();
      render(<Accordion items={defaultItems} />);

      const button = screen.getByRole('button', { name: 'Section 1' });
      await user.click(button);

      expect(button).toHaveAttribute('aria-expanded', 'true');
    });

    it('has aria-expanded="false" on closed panel', () => {
      render(<Accordion items={defaultItems} />);
      const button = screen.getByRole('button', { name: 'Section 1' });

      expect(button).toHaveAttribute('aria-expanded', 'false');
    });

    it('header aria-controls matches panel id', () => {
      render(<Accordion 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('panels have role="region" when 6 or fewer', () => {
      render(<Accordion items={defaultItems} />);
      const regions = screen.getAllByRole('region');

      expect(regions).toHaveLength(3);
    });

    it('panels do not have role="region" when 7 or more', () => {
      render(<Accordion items={manyItems} />);
      const regions = screen.queryAllByRole('region');

      expect(regions).toHaveLength(0);
    });

    it('panel aria-labelledby matches header id', () => {
      render(<Accordion 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 item has aria-disabled="true"', () => {
      render(<Accordion items={itemsWithDisabled} />);
      const disabledButton = screen.getByRole('button', { name: 'Section 2' });

      expect(disabledButton).toHaveAttribute('aria-disabled', 'true');
    });
  });

  describe('APG: Heading Structure', () => {
    it('uses h3 element when headingLevel=3', () => {
      render(<Accordion items={defaultItems} headingLevel={3} />);
      const headings = document.querySelectorAll('h3');

      expect(headings).toHaveLength(3);
    });

    it('uses h2 element when headingLevel=2', () => {
      render(<Accordion items={defaultItems} headingLevel={2} />);
      const headings = document.querySelectorAll('h2');

      expect(headings).toHaveLength(3);
    });
  });

  // 🟡 Medium Priority: Accessibility Validation
  describe('Accessibility', () => {
    it('has no WCAG 2.1 AA violations', async () => {
      const { container } = render(<Accordion items={defaultItems} />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  describe('Props', () => {
    it('can specify initial expanded state with defaultExpanded', () => {
      render(<Accordion items={itemsWithDefaultExpanded} />);
      const button = screen.getByRole('button', { name: 'Section 1' });

      expect(button).toHaveAttribute('aria-expanded', 'true');
    });

    it('only one panel expanded when allowMultiple=false (default)', async () => {
      const user = userEvent.setup();
      render(<Accordion 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('multiple panels can be expanded when allowMultiple=true', async () => {
      const user = userEvent.setup();
      render(<Accordion items={defaultItems} allowMultiple />);

      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('calls onExpandedChange when expanded state changes', async () => {
      const handleExpandedChange = vi.fn();
      const user = userEvent.setup();
      render(<Accordion items={defaultItems} onExpandedChange={handleExpandedChange} />);

      await user.click(screen.getByRole('button', { name: 'Section 1' }));

      expect(handleExpandedChange).toHaveBeenCalledWith(['section1']);
    });
  });

  describe('Edge Cases', () => {
    it('disabled item does not toggle on click', async () => {
      const user = userEvent.setup();
      render(<Accordion 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 item with defaultExpanded is not expanded', () => {
      const items: AccordionItem[] = [
        {
          id: 'section1',
          header: 'Section 1',
          content: 'Content 1',
          disabled: true,
          defaultExpanded: true,
        },
      ];
      render(<Accordion items={items} />);

      const button = screen.getByRole('button', { name: 'Section 1' });
      expect(button).toHaveAttribute('aria-expanded', 'false');
    });
  });

  // 🟢 Low Priority: Extensibility
  describe('HTML Attribute Inheritance', () => {
    it('applies className to container', () => {
      const { container } = render(<Accordion items={defaultItems} className="custom-accordion" />);
      const accordionContainer = container.firstChild as HTMLElement;
      expect(accordionContainer).toHaveClass('custom-accordion');
    });
  });
});

リソース