Accordion
複数のセクションを縦に並べ、各セクションのヘッダーをクリックすることで内容を表示/非表示できるコンポーネント。
🤖 AI 実装ガイドデモ
単一展開(デフォルト)
一度に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 | カスタムクラスが適用される |
テストツール
- Vitest (opens in new tab) - テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ
- jest-axe (opens in new tab) - 自動アクセシビリティテスト
詳細なドキュメントは testing-strategy.md (opens in new tab) を参照してください。
実装上の注意
この Astro 実装は、クライアント側のインタラクティビティに Web Components(customElements.define)を使用しています。アコーディオンはサーバー上で静的 HTML としてレンダリングされ、Web
Component がキーボードナビゲーションと状態管理で機能を強化します。
- クライアント側で JavaScript フレームワークは不要
- SSG(静的サイト生成)で動作
- プログレッシブエンハンスメント - JavaScript なしでも基本機能が動作
- 最小限の JavaScript バンドルサイズ
リソース
- WAI-ARIA APG: Accordion パターン (opens in new tab)
- AI 実装ガイド (llm.md) (opens in new tab) - ARIA 仕様、キーボード操作、テストチェックリスト