APG Patterns
English
English

Accordion

縦に積み重ねられたインタラクティブな見出しのセットで、それぞれがコンテンツのセクションを表示します。

デモ

単一展開(デフォルト)

一度に1つのパネルのみ展開できます。新しいパネルを開くと、以前に開いていたパネルが閉じます。

Accordion は、縦に積み重ねられたインタラクティブな見出しのセットで、それぞれがコンテンツのセクションを表示します。1つのページに複数のコンテンツセクションを表示する際のスクロールを減らすためによく使用されます。

コンテンツを折りたたみ可能なセクションに整理する必要がある場合に Accordion を使用します。これにより、情報をアクセス可能に保ちながら視覚的な混雑を減らすことができます。FAQ、設定パネル、ナビゲーションメニューに特に有用です。

Accordion はキーボードでアクセス可能であり、展開/折りたたみ状態をスクリーンリーダーに適切にアナウンスする必要があります。各ヘッダーは適切な見出し要素であり、パネルは aria-controls と aria-labelledby を介してヘッダーと関連付けられている必要があります。

複数展開

allowMultiple プロパティを使用して、複数のパネルを同時に展開できます。

セクション1のコンテンツ。allowMultiple を有効にすると、複数のセクションを同時に開くことができます。

セクション2のコンテンツ。セクション1が開いている状態でこれを開いてみてください。

セクション3のコンテンツ。3つのセクションすべてを同時に展開できます。

無効なアイテムを含む

個々の Accordion アイテムを無効にできます。キーボードナビゲーションは無効なアイテムを自動的にスキップします。

このセクションは通常通り展開および折りたたみできます。

このコンテンツは、セクションが無効になっているためアクセスできません。

このセクションも展開できます。矢印キーナビゲーションが無効なセクションをスキップすることに注意してください。

デモのみを開く →

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
heading ヘッダーラッパー (h2-h6) アコーディオントリガーボタンを含む
button ヘッダートリガー パネルの表示/非表示を切り替える対話要素
region パネル (オプション) ヘッダーと関連付けられたコンテンツエリア (6個以上のパネルでは省略)

WAI-ARIA Accordion Pattern (opens in new tab)

WAI-ARIA プロパティ

属性 対象 必須 設定
aria-level heading 2 - 6 はい headingLevel プロパティ
aria-controls button 関連付けられたパネルへのID参照 はい 自動生成
aria-labelledby region (パネル) ヘッダーボタンへのID参照 はい (regionを使用する場合) 自動生成

WAI-ARIA ステート

aria-expanded

対象 button 要素
true | false
必須 はい
変更トリガー クリック、Enter、Space
リファレンス aria-expanded (opens in new tab)

aria-disabled

対象 button 要素
true | false
必須 いいえ
変更トリガー 無効化する場合のみ
リファレンス aria-disabled (opens in new tab)

キーボードサポート

キー アクション
Tab 次のフォーカス可能な要素にフォーカスを移動
Space / Enter フォーカスされたアコーディオンヘッダーの展開/折り畳みを切り替え
Arrow Down 次のアコーディオンヘッダーにフォーカスを移動 (オプション)
Arrow Up 前のアコーディオンヘッダーにフォーカスを移動 (オプション)
Home 最初のアコーディオンヘッダーにフォーカスを移動 (オプション)
End 最後のアコーディオンヘッダーにフォーカスを移動 (オプション)

矢印キーによるナビゲーションはオプションですが推奨されます。フォーカスはリストの端でループしません。

実装ノート

構造

┌─────────────────────────────────────┐
│ [▼] 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.vue
<template>
  <div :class="`apg-accordion ${className}`.trim()">
    <div v-for="item in items" :key="item.id" :class="getItemClass(item)">
      <component :is="`h${headingLevel}`" class="apg-accordion-header">
        <button
          :ref="(el) => setButtonRef(item.id, el)"
          type="button"
          :id="`${instanceId}-header-${item.id}`"
          :aria-expanded="isExpanded(item.id)"
          :aria-controls="`${instanceId}-panel-${item.id}`"
          :aria-disabled="item.disabled || undefined"
          :disabled="item.disabled"
          :class="getTriggerClass(item)"
          @click="handleToggle(item.id)"
          @keydown="handleKeyDown($event, item.id)"
        >
          <span class="apg-accordion-trigger-content">{{ item.header }}</span>
          <span :class="getIconClass(item)" aria-hidden="true">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
              <polyline points="6 9 12 15 18 9" />
            </svg>
          </span>
        </button>
      </component>
      <div
        :role="useRegion ? 'region' : undefined"
        :id="`${instanceId}-panel-${item.id}`"
        :aria-labelledby="useRegion ? `${instanceId}-header-${item.id}` : undefined"
        :class="getPanelClass(item)"
      >
        <div class="apg-accordion-panel-content">
          <div v-if="item.content" v-html="item.content" />
          <slot v-else :name="item.id" />
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
/**
 * APG Accordion Pattern - Vue Implementation
 *
 * A vertically stacked set of interactive headings that each reveal a section of content.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
 */
import { ref, computed } from 'vue';

/**
 * Accordion item configuration
 */
export interface AccordionItem {
  /** Unique identifier for the item */
  id: string;
  /** Content displayed in the accordion header button */
  header: string;
  /** Content displayed in the collapsible panel (HTML string) */
  content?: string;
  /** When true, the item cannot be expanded/collapsed */
  disabled?: boolean;
  /** When true, the panel is expanded on initial render */
  defaultExpanded?: boolean;
}

/**
 * Props for the Accordion component
 *
 * @example
 * ```vue
 * <Accordion
 *   :items="[
 *     { id: 'section1', header: 'Section 1', content: 'Content 1', defaultExpanded: true },
 *     { id: 'section2', header: 'Section 2', content: 'Content 2' },
 *   ]"
 *   :heading-level="3"
 *   :allow-multiple="false"
 *   @expanded-change="(ids) => console.log('Expanded:', ids)"
 * />
 * ```
 */
export interface AccordionProps {
  /** Array of accordion items to display */
  items: AccordionItem[];
  /** Allow multiple panels to be expanded simultaneously @default false */
  allowMultiple?: boolean;
  /** Heading level for accessibility (h2-h6) @default 3 */
  headingLevel?: 2 | 3 | 4 | 5 | 6;
  /** Enable arrow key navigation @default true */
  enableArrowKeys?: boolean;
  /** Additional CSS class @default "" */
  className?: string;
}

const props = withDefaults(defineProps<AccordionProps>(), {
  allowMultiple: false,
  headingLevel: 3,
  enableArrowKeys: true,
  className: '',
});

const emit = defineEmits<{
  expandedChange: [expandedIds: string[]];
}>();

// Initialize with defaultExpanded items immediately
const getInitialExpandedIds = () => {
  return props.items
    .filter((item) => item.defaultExpanded && !item.disabled)
    .map((item) => item.id);
};

const expandedIds = ref<string[]>(getInitialExpandedIds());
const instanceId = ref(`accordion-${Math.random().toString(36).substr(2, 9)}`);
const buttonRefs = ref<Record<string, HTMLButtonElement>>({});

const setButtonRef = (id: string, el: unknown) => {
  if (el instanceof HTMLButtonElement) {
    buttonRefs.value[id] = el;
  }
};

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

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

const isExpanded = (itemId: string) => expandedIds.value.includes(itemId);

const getItemClass = (item: AccordionItem) => {
  const classes = ['apg-accordion-item'];
  if (isExpanded(item.id)) classes.push('apg-accordion-item--expanded');
  if (item.disabled) classes.push('apg-accordion-item--disabled');
  return classes.join(' ');
};

const getTriggerClass = (item: AccordionItem) => {
  const classes = ['apg-accordion-trigger'];
  if (isExpanded(item.id)) classes.push('apg-accordion-trigger--expanded');
  return classes.join(' ');
};

const getIconClass = (item: AccordionItem) => {
  const classes = ['apg-accordion-icon'];
  if (isExpanded(item.id)) classes.push('apg-accordion-icon--expanded');
  return classes.join(' ');
};

const getPanelClass = (item: AccordionItem) => {
  return `apg-accordion-panel ${isExpanded(item.id) ? 'apg-accordion-panel--expanded' : 'apg-accordion-panel--collapsed'}`;
};

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

  const isCurrentlyExpanded = expandedIds.value.includes(itemId);

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

  emit('expandedChange', expandedIds.value);
};

const handleKeyDown = (event: KeyboardEvent, currentItemId: string) => {
  if (!props.enableArrowKeys) return;

  const currentIndex = availableItems.value.findIndex((item) => item.id === currentItemId);
  if (currentIndex === -1) return;

  let newIndex = currentIndex;
  let shouldPreventDefault = false;

  switch (event.key) {
    case 'ArrowDown':
      // Move to next, but don't wrap (APG compliant)
      if (currentIndex < availableItems.value.length - 1) {
        newIndex = currentIndex + 1;
      }
      shouldPreventDefault = true;
      break;

    case 'ArrowUp':
      // Move to previous, but don't wrap (APG compliant)
      if (currentIndex > 0) {
        newIndex = currentIndex - 1;
      }
      shouldPreventDefault = true;
      break;

    case 'Home':
      newIndex = 0;
      shouldPreventDefault = true;
      break;

    case 'End':
      newIndex = availableItems.value.length - 1;
      shouldPreventDefault = true;
      break;
  }

  if (shouldPreventDefault) {
    event.preventDefault();
    if (newIndex !== currentIndex) {
      const newItem = availableItems.value[newIndex];
      if (newItem && buttonRefs.value[newItem.id]) {
        buttonRefs.value[newItem.id].focus();
      }
    }
  }
};
</script>

使い方

使用例
<script setup>
import Accordion from './Accordion.vue';

const items = [
  {
    id: 'section1',
    header: '最初のセクション',
    content: '最初のセクションのコンテンツ...',
    defaultExpanded: true,
  },
  {
    id: 'section2',
    header: '2番目のセクション',
    content: '2番目のセクションのコンテンツ...',
  },
];
</script>

<template>
  <Accordion
    :items="items"
    :heading-level="3"
    :allow-multiple="false"
    @expanded-change="(ids) => console.log('展開:', ids)"
  />
</template>

API

プロパティ

プロパティ デフォルト 説明
items AccordionItem[] required Accordion アイテムの配列
allowMultiple boolean false 複数のパネルの展開を許可
headingLevel 2 | 3 | 4 | 5 | 6 3 アクセシビリティのための見出しレベル
enableArrowKeys boolean true 矢印キーナビゲーションを有効化

イベント

イベント ペイロード 説明
expanded-change string[] 展開されたパネルが変更されたときに発行

AccordionItem インターフェース

型定義
interface AccordionItem {
  id: string;
  header: string;
  content?: string;
  disabled?: boolean;
  defaultExpanded?: boolean;
}

テスト

テストは、キーボード操作、ARIA属性、アクセシビリティ要件におけるAPG準拠を検証します。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.vue.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Accordion from './Accordion.vue';
import type { AccordionItem } from './Accordion.vue';

// テスト用のアコーディオンデータ
const defaultItems: AccordionItem[] = [
  { id: 'section1', header: 'Section 1', content: 'Content 1' },
  { id: 'section2', header: 'Section 2', content: 'Content 2' },
  { id: 'section3', header: 'Section 3', content: 'Content 3' },
];

const itemsWithDisabled: AccordionItem[] = [
  { id: 'section1', header: 'Section 1', content: 'Content 1' },
  { id: 'section2', header: 'Section 2', content: 'Content 2', disabled: true },
  { id: 'section3', header: 'Section 3', content: 'Content 3' },
];

const itemsWithDefaultExpanded: AccordionItem[] = [
  { id: 'section1', header: 'Section 1', content: 'Content 1', defaultExpanded: true },
  { id: 'section2', header: 'Section 2', content: 'Content 2' },
  { id: 'section3', header: 'Section 3', content: 'Content 3' },
];

// 7個以上のアイテム(region role テスト用)
const manyItems: AccordionItem[] = Array.from({ length: 7 }, (_, i) => ({
  id: `section${i + 1}`,
  header: `Section ${i + 1}`,
  content: `Content ${i + 1}`,
}));

describe('Accordion (Vue)', () => {
  // 🔴 High Priority: APG 準拠の核心
  describe('APG: キーボード操作', () => {
    it('Enter でパネルを開閉する', async () => {
      const user = userEvent.setup();
      render(Accordion, { props: { items: defaultItems } });

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

      expect(button).toHaveAttribute('aria-expanded', 'false');
      await user.keyboard('{Enter}');
      expect(button).toHaveAttribute('aria-expanded', 'true');
      await user.keyboard('{Enter}');
      expect(button).toHaveAttribute('aria-expanded', 'false');
    });

    it('Space でパネルを開閉する', async () => {
      const user = userEvent.setup();
      render(Accordion, { props: { items: defaultItems } });

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

      expect(button).toHaveAttribute('aria-expanded', 'false');
      await user.keyboard(' ');
      expect(button).toHaveAttribute('aria-expanded', 'true');
    });

    it('ArrowDown で次のヘッダーにフォーカス移動', async () => {
      const user = userEvent.setup();
      render(Accordion, { props: { items: defaultItems } });

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

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

      const button2 = screen.getByRole('button', { name: 'Section 2' });
      expect(button2).toHaveFocus();
    });

    it('ArrowUp で前のヘッダーにフォーカス移動', async () => {
      const user = userEvent.setup();
      render(Accordion, { props: { items: defaultItems } });

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

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

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

    it('ArrowDown で最後のヘッダーにいる場合、移動しない(ループなし)', async () => {
      const user = userEvent.setup();
      render(Accordion, { props: { items: defaultItems } });

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

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

      expect(button3).toHaveFocus();
    });

    it('ArrowUp で最初のヘッダーにいる場合、移動しない(ループなし)', async () => {
      const user = userEvent.setup();
      render(Accordion, { props: { items: defaultItems } });

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

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

      expect(button1).toHaveFocus();
    });

    it('Home で最初のヘッダーに移動', async () => {
      const user = userEvent.setup();
      render(Accordion, { props: { items: defaultItems } });

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

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

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

    it('End で最後のヘッダーに移動', async () => {
      const user = userEvent.setup();
      render(Accordion, { props: { items: defaultItems } });

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

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

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

    it('disabled ヘッダーをスキップして移動', async () => {
      const user = userEvent.setup();
      render(Accordion, { props: { items: itemsWithDisabled } });

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

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

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

    it('enableArrowKeys=false で矢印キーナビゲーション無効', async () => {
      const user = userEvent.setup();
      render(Accordion, { props: { items: defaultItems, enableArrowKeys: false } });

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

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

      expect(button1).toHaveFocus();
    });
  });

  describe('APG: ARIA 属性', () => {
    it('ヘッダーボタンが aria-expanded を持つ', () => {
      render(Accordion, { props: { items: defaultItems } });
      const buttons = screen.getAllByRole('button');

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

    it('開いたパネルで aria-expanded="true"', async () => {
      const user = userEvent.setup();
      render(Accordion, { props: { items: defaultItems } });

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

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

    it('閉じたパネルで aria-expanded="false"', () => {
      render(Accordion, { props: { items: defaultItems } });
      const button = screen.getByRole('button', { name: 'Section 1' });

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

    it('ヘッダーの aria-controls がパネル id と一致', () => {
      render(Accordion, { props: { items: defaultItems } });
      const button = screen.getByRole('button', { name: 'Section 1' });
      const ariaControls = button.getAttribute('aria-controls');

      expect(ariaControls).toBeTruthy();
      expect(document.getElementById(ariaControls!)).toBeInTheDocument();
    });

    it('6個以下のパネルで role="region" を持つ', () => {
      render(Accordion, { props: { items: defaultItems } });
      const regions = screen.getAllByRole('region');

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

    it('7個以上のパネルで role="region" を持たない', () => {
      render(Accordion, { props: { items: manyItems } });
      const regions = screen.queryAllByRole('region');

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

    it('パネルの aria-labelledby がヘッダー id と一致', () => {
      render(Accordion, { props: { items: defaultItems } });
      const button = screen.getByRole('button', { name: 'Section 1' });
      const regions = screen.getAllByRole('region');

      expect(regions[0]).toHaveAttribute('aria-labelledby', button.id);
    });

    it('disabled 項目が aria-disabled="true" を持つ', () => {
      render(Accordion, { props: { items: itemsWithDisabled } });
      const disabledButton = screen.getByRole('button', { name: 'Section 2' });

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

  describe('APG: 見出し構造', () => {
    it('headingLevel=3 で h3 要素を使用', () => {
      render(Accordion, { props: { items: defaultItems, headingLevel: 3 } });
      const headings = document.querySelectorAll('h3');

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

    it('headingLevel=2 で h2 要素を使用', () => {
      render(Accordion, { props: { items: defaultItems, headingLevel: 2 } });
      const headings = document.querySelectorAll('h2');

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

  // 🟡 Medium Priority: アクセシビリティ検証
  describe('アクセシビリティ', () => {
    it('axe による WCAG 2.1 AA 違反がない', async () => {
      const { container } = render(Accordion, { props: { items: defaultItems } });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  describe('Props', () => {
    it('defaultExpanded で初期展開状態を指定できる', () => {
      render(Accordion, { props: { items: itemsWithDefaultExpanded } });
      const button = screen.getByRole('button', { name: 'Section 1' });

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

    it('allowMultiple=false で1つのみ展開(デフォルト)', async () => {
      const user = userEvent.setup();
      render(Accordion, { props: { items: defaultItems } });

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

      await user.click(button1);
      expect(button1).toHaveAttribute('aria-expanded', 'true');

      await user.click(button2);
      expect(button1).toHaveAttribute('aria-expanded', 'false');
      expect(button2).toHaveAttribute('aria-expanded', 'true');
    });

    it('allowMultiple=true で複数展開可能', async () => {
      const user = userEvent.setup();
      render(Accordion, { props: { items: defaultItems, allowMultiple: true } });

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

      await user.click(button1);
      await user.click(button2);

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

    it('@expandedChange が展開状態変化時に発火する', async () => {
      const handleExpandedChange = vi.fn();
      const user = userEvent.setup();
      render(Accordion, {
        props: { items: defaultItems, onExpandedChange: handleExpandedChange },
      });

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

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

  describe('異常系', () => {
    it('disabled 項目はクリックで開閉しない', async () => {
      const user = userEvent.setup();
      render(Accordion, { props: { items: itemsWithDisabled } });

      const disabledButton = screen.getByRole('button', { name: 'Section 2' });

      expect(disabledButton).toHaveAttribute('aria-expanded', 'false');
      await user.click(disabledButton);
      expect(disabledButton).toHaveAttribute('aria-expanded', 'false');
    });

    it('disabled かつ defaultExpanded の項目は展開されない', () => {
      const items: AccordionItem[] = [
        {
          id: 'section1',
          header: 'Section 1',
          content: 'Content 1',
          disabled: true,
          defaultExpanded: true,
        },
      ];
      render(Accordion, { props: { items } });

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

リソース