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つのセクションすべてを同時に展開できます。

無効なアイテムを含む

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

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

ソースコード

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

テストカテゴリ

高優先度: 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.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');
    });
  });
});

リソース