APG Patterns
English
English

Accordion

複数のセクションを縦に並べ、各セクションのヘッダーをクリックすることで内容を表示/非表示できるコンポーネント。

デモ

単一展開(デフォルト)

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

Accordion は、複数のセクションを縦に並べ、各セクションのヘッダーをクリックすることで内容を表示/非表示できるコンポーネントです。1つのページに複数のコンテンツセクションを表示する際、スクロールの必要性を減らすために使用されます。

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

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

複数展開

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

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

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

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

無効化されたアイテム

個別のアコーディオンアイテムを無効化できます。キーボードナビゲーションは無効化されたアイテムを自動的にスキップします。

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

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

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

デモのみを開く →

アクセシビリティ

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.astro
---
/**
 * 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).

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) を参照してください。

実装上の注意

この Astro 実装は、クライアント側のインタラクティビティに Web Components(customElements.define)を使用しています。アコーディオンはサーバー上で静的 HTML としてレンダリングされ、Web Component がキーボードナビゲーションと状態管理で機能を強化します。

  • クライアント側で JavaScript フレームワークは不要
  • SSG(静的サイト生成)で動作
  • プログレッシブエンハンスメント - JavaScript なしでも基本機能が動作
  • 最小限の JavaScript バンドルサイズ

リソース