APG Patterns
English GitHub
English GitHub

Accordion

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

🤖 AI 実装ガイド

デモ

単一展開(デフォルト)

一度に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 最後のアコーディオンヘッダーにフォーカスを移動 (オプション)

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

ソースコード

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準拠を検証します。

テストカテゴリ

高優先度: APG キーボード操作

テスト 説明
Enter キー フォーカスされたパネルを展開/折り畳み
Space キー フォーカスされたパネルを展開/折り畳み
ArrowDown 次のヘッダーにフォーカスを移動
ArrowUp 前のヘッダーにフォーカスを移動
Home 最初のヘッダーにフォーカスを移動
End 最後のヘッダーにフォーカスを移動
ループなし フォーカスは端で停止 (ループしない)
無効化スキップ ナビゲーション中に無効化されたヘッダーをスキップ

高優先度: APG ARIA 属性

テスト 説明
aria-expanded ヘッダーボタンが展開/折り畳み状態を反映
aria-controls ヘッダーが aria-controls でパネルを参照
aria-labelledby パネルが aria-labelledby でヘッダーを参照
role="region" パネルに region ロール (6個以下のパネル)
region なし (7個以上) 7個以上のパネルの場合、region ロールを省略
aria-disabled 無効化された項目に aria-disabled="true"

高優先度: 見出し構造

テスト 説明
headingLevel プロパティ 正しい見出し要素を使用 (h2, h3, など)

中優先度: アクセシビリティ

テスト 説明
axe 違反 WCAG 2.1 AA 違反なし (jest-axe 経由)

低優先度: プロパティと振る舞い

テスト 説明
allowMultiple 単一または複数の展開を制御
defaultExpanded 初期展開状態を設定
className カスタムクラスが適用される

テストツール

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

実装上の注意

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

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

リソース