APG Patterns

Accordion

A vertically stacked set of interactive headings that each reveal a section of content.

Demo

Single Expansion (Default)

Only one panel can be expanded at a time. Opening a new panel closes the previously open one.

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.

Multiple Expansion

Multiple panels can be expanded simultaneously using the allowMultiple prop.

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.

With Disabled Items

Individual accordion items can be disabled. Keyboard navigation automatically skips disabled items.

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.

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
heading Header wrapper (h2-h6) Contains the accordion trigger button
button Header trigger Interactive element that toggles panel visibility
region Panel (optional) Content area associated with header (omit for 6+ panels)

WAI-ARIA Accordion Pattern (opens in new tab)

WAI-ARIA Properties

Attribute Target Values Required Configuration
aria-level heading 2 - 6 Yes headingLevel prop
aria-controls button ID reference to associated panel Yes Auto-generated
aria-labelledby region (panel) ID reference to header button Yes (if region used) Auto-generated

WAI-ARIA States

aria-expanded

Indicates whether the accordion panel is expanded or collapsed.

Target button element
Values true | false
Required Yes
Change Trigger Click, Enter, Space
Reference aria-expanded (opens in new tab)

aria-disabled

Indicates whether the accordion header is disabled.

Target button element
Values true | false
Required No (only when disabled)
Reference aria-disabled (opens in new tab)

Keyboard Support

Key Action
Tab Move focus to the next focusable element
Space / Enter Toggle the expansion of the focused accordion header
Arrow Down Move focus to the next accordion header (optional)
Arrow Up Move focus to the previous accordion header (optional)
Home Move focus to the first accordion header (optional)
End Move focus to the last accordion header (optional)

Arrow key navigation is optional but recommended. Focus does not wrap around at the end of the list.

Source Code

Accordion.tsx
import { useCallback, useId, useRef, useState } from "react";

/**
 * Accordion item configuration
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
 */
export interface AccordionItem {
  /** Unique identifier for the item */
  id: string;
  /** Content displayed in the accordion header button */
  header: React.ReactNode;
  /** Content displayed in the collapsible panel */
  content: React.ReactNode;
  /** 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
 * ```tsx
 * const items = [
 *   { id: 'section1', header: 'Section 1', content: 'Content 1', defaultExpanded: true },
 *   { id: 'section2', header: 'Section 2', content: 'Content 2' },
 * ];
 *
 * <Accordion
 *   items={items}
 *   headingLevel={3}
 *   allowMultiple={false}
 *   onExpandedChange={(ids) => console.log('Expanded:', ids)}
 * />
 * ```
 */
export interface AccordionProps {
  /**
   * Array of accordion items to display
   * Each item requires an id, header, and content
   */
  items: AccordionItem[];
  /**
   * Allow multiple panels to be expanded simultaneously
   * @default false
   */
  allowMultiple?: boolean;
  /**
   * Heading level for accessibility (h2-h6)
   * Should match the document outline hierarchy
   * @default 3
   */
  headingLevel?: 2 | 3 | 4 | 5 | 6;
  /**
   * Enable arrow key navigation between accordion headers
   * When enabled: Arrow Up/Down, Home, End keys navigate between headers
   * @default true
   */
  enableArrowKeys?: boolean;
  /**
   * Callback fired when the expanded panels change
   * @param expandedIds - Array of currently expanded item IDs
   */
  onExpandedChange?: (expandedIds: string[]) => void;
  /**
   * Additional CSS class to apply to the accordion container
   * @default ""
   */
  className?: string;
}

export function Accordion({
  items,
  allowMultiple = false,
  headingLevel = 3,
  enableArrowKeys = true,
  onExpandedChange,
  className = "",
}: AccordionProps): React.ReactElement {
  const instanceId = useId();
  const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});

  // Initialize with defaultExpanded items
  const [expandedIds, setExpandedIds] = useState<string[]>(() =>
    items
      .filter((item) => item.defaultExpanded && !item.disabled)
      .map((item) => item.id)
  );

  const availableItems = items.filter((item) => !item.disabled);

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

      let newExpandedIds: string[];
      const isCurrentlyExpanded = expandedIds.includes(itemId);

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

      setExpandedIds(newExpandedIds);
      onExpandedChange?.(newExpandedIds);
    },
    [expandedIds, allowMultiple, items, onExpandedChange]
  );

  const handleKeyDown = useCallback(
    (event: React.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":
          // Move to next, but don't wrap (APG compliant)
          if (currentIndex < availableItems.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.length - 1;
          shouldPreventDefault = true;
          break;
      }

      if (shouldPreventDefault) {
        event.preventDefault();
        if (newIndex !== currentIndex) {
          const newItem = availableItems[newIndex];
          if (newItem && buttonRefs.current[newItem.id]) {
            buttonRefs.current[newItem.id]?.focus();
          }
        }
      }
    },
    [enableArrowKeys, availableItems]
  );

  // Use role="region" only for 6 or fewer panels (APG recommendation)
  const useRegion = items.length <= 6;

  // Dynamic heading component with proper typing
  const HeadingTag = `h${headingLevel}` as "h2" | "h3" | "h4" | "h5" | "h6";

  return (
    <div className={`apg-accordion ${className}`.trim()}>
      {items.map((item) => {
        const headerId = `${instanceId}-header-${item.id}`;
        const panelId = `${instanceId}-panel-${item.id}`;
        const isExpanded = expandedIds.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 key={item.id} className={itemClass}>
            <HeadingTag className="apg-accordion-header">
              <button
                ref={(el) => {
                  buttonRefs.current[item.id] = el;
                }}
                type="button"
                id={headerId}
                aria-expanded={isExpanded}
                aria-controls={panelId}
                aria-disabled={item.disabled || undefined}
                disabled={item.disabled}
                className={triggerClass}
                onClick={() => handleToggle(item.id)}
                onKeyDown={(e) => handleKeyDown(e, item.id)}
              >
                <span className="apg-accordion-trigger-content">
                  {item.header}
                </span>
                <span className={iconClass} aria-hidden="true">
                  <svg
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="2"
                  >
                    <polyline points="6 9 12 15 18 9" />
                  </svg>
                </span>
              </button>
            </HeadingTag>
            <div
              role={useRegion ? "region" : undefined}
              id={panelId}
              aria-labelledby={useRegion ? headerId : undefined}
              className={panelClass}
            >
              <div className="apg-accordion-panel-content">{item.content}</div>
            </div>
          </div>
        );
      })}
    </div>
  );
}

export default Accordion;

Usage

Example
import { Accordion } from './Accordion';

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 App() {
  return (
    <Accordion
      items={items}
      headingLevel={3}
      allowMultiple={false}
      onExpandedChange={(ids) => console.log('Expanded:', ids)}
    />
  );
}

API

AccordionProps

Prop Type Default Description
items AccordionItem[] required Array of accordion items
allowMultiple boolean false Allow multiple panels to be expanded
headingLevel 2 | 3 | 4 | 5 | 6 3 Heading level for accessibility
enableArrowKeys boolean true Enable arrow key navigation
onExpandedChange (ids: string[]) => void - Callback when expansion changes
className string "" Additional CSS class

AccordionItem

Property Type Required Description
id string Yes Unique identifier for the item
header ReactNode Yes Content for the accordion header
content ReactNode Yes Content for the accordion panel
disabled boolean No Disable the accordion item
defaultExpanded boolean No Initially expanded state

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements.

Test Categories

High Priority: APG Keyboard Interaction

Test Description
Enter key Expands/collapses the focused panel
Space key Expands/collapses the focused panel
ArrowDown Moves focus to next header
ArrowUp Moves focus to previous header
Home Moves focus to first header
End Moves focus to last header
No loop Focus stops at edges (no wrapping)
Disabled skip Skips disabled headers during navigation

High Priority: APG ARIA Attributes

Test Description
aria-expanded Header button reflects expand/collapse state
aria-controls Header references its panel via aria-controls
aria-labelledby Panel references its header via aria-labelledby
role="region" Panel has region role (6 or fewer panels)
No region (7+) Panel omits region role when 7+ panels
aria-disabled Disabled items have aria-disabled="true"

High Priority: Heading Structure

Test Description
headingLevel prop Uses correct heading element (h2, h3, etc.)

Medium Priority: Accessibility

Test Description
axe violations No WCAG 2.1 AA violations (via jest-axe)

Low Priority: Props & Behavior

Test Description
allowMultiple Controls single vs. multiple expansion
defaultExpanded Sets initial expansion state
className Custom classes are applied

Testing Tools

See testing-strategy.md (opens in new tab) for full documentation.

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

// ใƒ†ใ‚นใƒˆ็”จใฎใ‚ขใ‚ณใƒผใƒ‡ใ‚ฃใ‚ชใƒณใƒ‡ใƒผใ‚ฟ
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", () => {
  // ๐Ÿ”ด High Priority: APG ๆบ–ๆ‹ ใฎๆ ธๅฟƒ
  describe("APG: ใ‚ญใƒผใƒœใƒผใƒ‰ๆ“ไฝœ", () => {
    it("Enter ใงใƒ‘ใƒใƒซใ‚’้–‹้–‰ใ™ใ‚‹", async () => {
      const user = userEvent.setup();
      render(<Accordion 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 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 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 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 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 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 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 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 items={itemsWithDisabled} />);

      const button1 = screen.getByRole("button", { name: "Section 1" });
      button1.focus();

      await user.keyboard("{ArrowDown}");

      // Section 2 ใฏใ‚นใ‚ญใƒƒใƒ—ใ•ใ‚Œใ€Section 3 ใซ็งปๅ‹•
      const button3 = screen.getByRole("button", { name: "Section 3" });
      expect(button3).toHaveFocus();
    });

    it("enableArrowKeys=false ใง็Ÿขๅฐใ‚ญใƒผใƒŠใƒ“ใ‚ฒใƒผใ‚ทใƒงใƒณ็„กๅŠน", async () => {
      const user = userEvent.setup();
      render(<Accordion 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 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 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 items={defaultItems} />);
      const button = screen.getByRole("button", { name: "Section 1" });

      expect(button).toHaveAttribute("aria-expanded", "false");
    });

    it("ใƒ˜ใƒƒใƒ€ใƒผใฎ aria-controls ใŒใƒ‘ใƒใƒซ id ใจไธ€่‡ด", () => {
      render(<Accordion 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 items={defaultItems} />);
      const regions = screen.getAllByRole("region");

      expect(regions).toHaveLength(3);
    });

    it('7ๅ€‹ไปฅไธŠใฎใƒ‘ใƒใƒซใง role="region" ใ‚’ๆŒใŸใชใ„', () => {
      render(<Accordion items={manyItems} />);
      const regions = screen.queryAllByRole("region");

      expect(regions).toHaveLength(0);
    });

    it("ใƒ‘ใƒใƒซใฎ aria-labelledby ใŒใƒ˜ใƒƒใƒ€ใƒผ id ใจไธ€่‡ด", () => {
      render(<Accordion 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 items={itemsWithDisabled} />);
      const disabledButton = screen.getByRole("button", { name: "Section 2" });

      expect(disabledButton).toHaveAttribute("aria-disabled", "true");
    });
  });

  describe("APG: ่ฆ‹ๅ‡บใ—ๆง‹้€ ", () => {
    it("headingLevel=3 ใง h3 ่ฆ็ด ใ‚’ไฝฟ็”จ", () => {
      render(<Accordion items={defaultItems} headingLevel={3} />);
      const headings = document.querySelectorAll("h3");

      expect(headings).toHaveLength(3);
    });

    it("headingLevel=2 ใง h2 ่ฆ็ด ใ‚’ไฝฟ็”จ", () => {
      render(<Accordion 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 items={defaultItems} />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  describe("Props", () => {
    it("defaultExpanded ใงๅˆๆœŸๅฑ•้–‹็Šถๆ…‹ใ‚’ๆŒ‡ๅฎšใงใใ‚‹", () => {
      render(<Accordion 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 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 items={defaultItems} allowMultiple />);

      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 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 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 items={items} />);

      const button = screen.getByRole("button", { name: "Section 1" });
      expect(button).toHaveAttribute("aria-expanded", "false");
    });
  });

  // ๐ŸŸข Low Priority: ๆ‹กๅผตๆ€ง
  describe("HTML ๅฑžๆ€ง็ถ™ๆ‰ฟ", () => {
    it("className ใŒใ‚ณใƒณใƒ†ใƒŠใซ้ฉ็”จใ•ใ‚Œใ‚‹", () => {
      const { container } = render(
        <Accordion items={defaultItems} className="custom-accordion" />
      );
      const accordionContainer = container.firstChild as HTMLElement;
      expect(accordionContainer).toHaveClass("custom-accordion");
    });
  });
});

Resources