APG Patterns
English GitHub
English GitHub

Accordion

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

🤖 AI 実装ガイド

デモ

シングル展開(デフォルト)

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

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

ソースコード

Accordion.svelte
<script lang="ts">
  /**
   * APG Accordion Pattern - Svelte 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 { onMount } from 'svelte';

  /**
   * 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
   */
  interface AccordionProps {
    items: AccordionItem[];
    allowMultiple?: boolean;
    headingLevel?: 2 | 3 | 4 | 5 | 6;
    enableArrowKeys?: boolean;
    onExpandedChange?: (expandedIds: string[]) => void;
    className?: string;
  }

  let {
    items = [],
    allowMultiple = false,
    headingLevel = 3,
    enableArrowKeys = true,
    onExpandedChange = () => {},
    className = '',
  }: AccordionProps = $props();

  let expandedIds = $state<string[]>([]);
  let instanceId = $state('');
  let buttonRefs = $state<Record<string, HTMLButtonElement | undefined>>({});

  onMount(() => {
    instanceId = `accordion-${Math.random().toString(36).substring(2, 11)}`;
    // Initialize with defaultExpanded items
    if (Array.isArray(items)) {
      expandedIds = items
        .filter((item) => item.defaultExpanded && !item.disabled)
        .map((item) => item.id);
    }
  });

  // Derived values
  let safeItems = $derived(Array.isArray(items) ? items : []);
  let availableItems = $derived(safeItems.filter((item) => !item.disabled));
  let useRegion = $derived(safeItems.length <= 6);

  function isExpanded(itemId: string): boolean {
    return expandedIds.includes(itemId);
  }

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

    const isCurrentlyExpanded = expandedIds.includes(itemId);

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

    onExpandedChange(expandedIds);
  }

  function handleKeyDown(event: 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':
        if (currentIndex < availableItems.length - 1) {
          newIndex = currentIndex + 1;
        }
        shouldPreventDefault = true;
        break;

      case 'ArrowUp':
        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[newItem.id]?.focus();
        }
      }
    }
  }

  function getItemClass(item: AccordionItem): string {
    let cls = 'apg-accordion-item';
    if (isExpanded(item.id)) cls += ' apg-accordion-item--expanded';
    if (item.disabled) cls += ' apg-accordion-item--disabled';
    return cls;
  }

  function getTriggerClass(itemId: string): string {
    return isExpanded(itemId)
      ? 'apg-accordion-trigger apg-accordion-trigger--expanded'
      : 'apg-accordion-trigger';
  }

  function getIconClass(itemId: string): string {
    return isExpanded(itemId)
      ? 'apg-accordion-icon apg-accordion-icon--expanded'
      : 'apg-accordion-icon';
  }

  function getPanelClass(itemId: string): string {
    return isExpanded(itemId)
      ? 'apg-accordion-panel apg-accordion-panel--expanded'
      : 'apg-accordion-panel apg-accordion-panel--collapsed';
  }
</script>

{#if safeItems.length > 0}
  <div class="apg-accordion {className}">
    {#each safeItems as item (item.id)}
      {@const headerId = `${instanceId}-header-${item.id}`}
      {@const panelId = `${instanceId}-panel-${item.id}`}

      <div class={getItemClass(item)}>
        {#if headingLevel === 2}
          <h2 class="apg-accordion-header">
            <button
              bind:this={buttonRefs[item.id]}
              type="button"
              id={headerId}
              aria-expanded={isExpanded(item.id)}
              aria-controls={panelId}
              aria-disabled={item.disabled || undefined}
              disabled={item.disabled}
              class={getTriggerClass(item.id)}
              onclick={() => handleToggle(item.id)}
              onkeydown={(e) => handleKeyDown(e, item.id)}
            >
              <span class="apg-accordion-trigger-content">{item.header}</span>
              <span class={getIconClass(item.id)} 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>
          </h2>
        {:else if headingLevel === 3}
          <h3 class="apg-accordion-header">
            <button
              bind:this={buttonRefs[item.id]}
              type="button"
              id={headerId}
              aria-expanded={isExpanded(item.id)}
              aria-controls={panelId}
              aria-disabled={item.disabled || undefined}
              disabled={item.disabled}
              class={getTriggerClass(item.id)}
              onclick={() => handleToggle(item.id)}
              onkeydown={(e) => handleKeyDown(e, item.id)}
            >
              <span class="apg-accordion-trigger-content">{item.header}</span>
              <span class={getIconClass(item.id)} 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>
          </h3>
        {:else if headingLevel === 4}
          <h4 class="apg-accordion-header">
            <button
              bind:this={buttonRefs[item.id]}
              type="button"
              id={headerId}
              aria-expanded={isExpanded(item.id)}
              aria-controls={panelId}
              aria-disabled={item.disabled || undefined}
              disabled={item.disabled}
              class={getTriggerClass(item.id)}
              onclick={() => handleToggle(item.id)}
              onkeydown={(e) => handleKeyDown(e, item.id)}
            >
              <span class="apg-accordion-trigger-content">{item.header}</span>
              <span class={getIconClass(item.id)} 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>
          </h4>
        {:else if headingLevel === 5}
          <h5 class="apg-accordion-header">
            <button
              bind:this={buttonRefs[item.id]}
              type="button"
              id={headerId}
              aria-expanded={isExpanded(item.id)}
              aria-controls={panelId}
              aria-disabled={item.disabled || undefined}
              disabled={item.disabled}
              class={getTriggerClass(item.id)}
              onclick={() => handleToggle(item.id)}
              onkeydown={(e) => handleKeyDown(e, item.id)}
            >
              <span class="apg-accordion-trigger-content">{item.header}</span>
              <span class={getIconClass(item.id)} 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>
          </h5>
        {:else}
          <h6 class="apg-accordion-header">
            <button
              bind:this={buttonRefs[item.id]}
              type="button"
              id={headerId}
              aria-expanded={isExpanded(item.id)}
              aria-controls={panelId}
              aria-disabled={item.disabled || undefined}
              disabled={item.disabled}
              class={getTriggerClass(item.id)}
              onclick={() => handleToggle(item.id)}
              onkeydown={(e) => handleKeyDown(e, item.id)}
            >
              <span class="apg-accordion-trigger-content">{item.header}</span>
              <span class={getIconClass(item.id)} 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>
          </h6>
        {/if}
        <div
          role={useRegion ? 'region' : undefined}
          id={panelId}
          aria-labelledby={useRegion ? headerId : undefined}
          class={getPanelClass(item.id)}
        >
          <div class="apg-accordion-panel-content">
            {#if item.content}
              {@html item.content}
            {/if}
          </div>
        </div>
      </div>
    {/each}
  </div>
{/if}

使い方

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

  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 handleExpandedChange(ids) {
    console.log('Expanded:', ids);
  }
</script>

<Accordion
  {items}
  headingLevel={3}
  allowMultiple={false}
  onExpandedChange={handleExpandedChange}
/>

API

プロパティ

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

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

Accordion.test.svelte.ts
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Accordion from './Accordion.svelte';
import type { AccordionItem } from './Accordion.svelte';

// テスト用のアコーディオンデータ
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 (Svelte)', () => {
  // 🔴 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('onExpandedChange が展開状態変化時に呼び出される', 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');
    });
  });
});

リソース