Skip to the code
cf.

Accordion with one panel open

Build App: the questions on the tea shop's help page, where only one answer is open at a time. It takes items, a list of { id, title, content } (ids are short strings like "delivery"), and shows each title as a button inside an <h3>, with its answer in a panel under it. When items isn't given, as in the Preview, show a few questions of your own.

Every answer starts closed. Clicking a title opens its answer and closes the one that was open. Clicking the title of the open answer closes it, so none is open.

Each title button has aria-expanded, "true" when its answer is open and "false" otherwise, and aria-controls set to the id of its panel. Each panel is a <div role="region"> with aria-labelledby set to its button's id. Panels are always in the page: a closed one has the hidden attribute.

A page can hold more than one of these lists, so every id must be unique on the whole page, not only inside one list. Each list's answers open and close on their own.

<App items={[{ id: "delivery", title: "How long does delivery take?", content: "Two to three working days." }]} />
 
// renders, with the answer open
// ("r1" stands for a prefix that's unique to this list):
<h3>
  <button id="r1-delivery-title" aria-expanded="true" aria-controls="r1-delivery-panel">
    How long does delivery take?
  </button>
</h3>
<div role="region" id="r1-delivery-panel" aria-labelledby="r1-delivery-title">
  Two to three working days.
</div>

What the tests check

  1. shows every title as a button inside a heading
    const items = [
      { id: "delivery", title: "How long does delivery take?", content: "Two to three working days." },
      { id: "returns", title: "Can I return an open tin?", content: "Yes, within 30 days." },
      { id: "storage", title: "How should I store loose tea?", content: "In a sealed tin, away from light." },
    ];
    render(<App items={items} />);
    const headings = screen.getAllByRole("heading", { level: 3 });
    expect(headings).toHaveLength(3);
    items.forEach((item, index) => {
      expect(within(headings[index]).getByRole("button", { name: item.title })).toBeInTheDocument();
    });
    
  2. starts with every answer closed
    const items = [
      { id: "delivery", title: "How long does delivery take?", content: "Two to three working days." },
      { id: "returns", title: "Can I return an open tin?", content: "Yes, within 30 days." },
      { id: "storage", title: "How should I store loose tea?", content: "In a sealed tin, away from light." },
    ];
    render(<App items={items} />);
    for (const item of items) {
      expect(screen.getByRole("button", { name: item.title })).toHaveAttribute("aria-expanded", "false");
      expect(screen.getByText(item.content)).not.toBeVisible();
    }
    
  3. clicking a title opens its answer
    const items = [
      { id: "delivery", title: "How long does delivery take?", content: "Two to three working days." },
      { id: "returns", title: "Can I return an open tin?", content: "Yes, within 30 days." },
      { id: "storage", title: "How should I store loose tea?", content: "In a sealed tin, away from light." },
    ];
    render(<App items={items} />);
    const button = screen.getByRole("button", { name: "Can I return an open tin?" });
    await user.click(button);
    expect(button).toHaveAttribute("aria-expanded", "true");
    expect(screen.getByText("Yes, within 30 days.")).toBeVisible();
    expect(screen.getByRole("region", { name: "Can I return an open tin?" })).toHaveTextContent("Yes, within 30 days.");
    
  4. opening another answer closes the first
    const items = [
      { id: "delivery", title: "How long does delivery take?", content: "Two to three working days." },
      { id: "returns", title: "Can I return an open tin?", content: "Yes, within 30 days." },
      { id: "storage", title: "How should I store loose tea?", content: "In a sealed tin, away from light." },
    ];
    render(<App items={items} />);
    const delivery = screen.getByRole("button", { name: "How long does delivery take?" });
    const storage = screen.getByRole("button", { name: "How should I store loose tea?" });
    await user.click(delivery);
    await user.click(storage);
    expect(delivery).toHaveAttribute("aria-expanded", "false");
    expect(screen.getByText("Two to three working days.")).not.toBeVisible();
    expect(storage).toHaveAttribute("aria-expanded", "true");
    expect(screen.getByText("In a sealed tin, away from light.")).toBeVisible();
    
  5. clicking the open title closes it
    const items = [
      { id: "delivery", title: "How long does delivery take?", content: "Two to three working days." },
      { id: "returns", title: "Can I return an open tin?", content: "Yes, within 30 days." },
    ];
    render(<App items={items} />);
    const delivery = screen.getByRole("button", { name: "How long does delivery take?" });
    await user.click(delivery);
    await user.click(delivery);
    expect(delivery).toHaveAttribute("aria-expanded", "false");
    expect(screen.getByText("Two to three working days.")).not.toBeVisible();
    expect(screen.getByRole("button", { name: "Can I return an open tin?" })).toHaveAttribute("aria-expanded", "false");
    
  6. each title controls its own panel, and the panel is labelled by it
    const items = [
      { id: "delivery", title: "How long does delivery take?", content: "Two to three working days." },
      { id: "returns", title: "Can I return an open tin?", content: "Yes, within 30 days." },
      { id: "storage", title: "How should I store loose tea?", content: "In a sealed tin, away from light." },
    ];
    render(<App items={items} />);
    for (const item of items) {
      const button = screen.getByRole("button", { name: item.title });
      const panel = document.getElementById(button.getAttribute("aria-controls"));
      expect(panel).toBeInTheDocument();
      expect(panel).toHaveAttribute("role", "region");
      expect(panel).toHaveTextContent(item.content);
      expect(button.id).toBeTruthy();
      expect(panel).toHaveAttribute("aria-labelledby", button.id);
    }
    
  7. two lists on one page have different ids and open on their own
    const items = [
      { id: "delivery", title: "How long does delivery take?", content: "Two to three working days." },
      { id: "returns", title: "Can I return an open tin?", content: "Yes, within 30 days." },
    ];
    render(
      <>
        <App items={items} />
        <App items={items} />
      </>
    );
    const ids = screen.getAllByRole("button").flatMap((button) => [button.id, button.getAttribute("aria-controls")]);
    expect(ids.every(Boolean)).toBe(true);
    expect(new Set(ids).size).toBe(8);
    const [first, second] = screen.getAllByRole("button", { name: "How long does delivery take?" });
    await user.click(first);
    expect(first).toHaveAttribute("aria-expanded", "true");
    expect(second).toHaveAttribute("aria-expanded", "false");
    expect(document.getElementById(second.getAttribute("aria-controls"))).not.toBeVisible();
    
  8. works from the keyboard
    const items = [
      { id: "delivery", title: "How long does delivery take?", content: "Two to three working days." },
      { id: "returns", title: "Can I return an open tin?", content: "Yes, within 30 days." },
    ];
    render(<App items={items} />);
    await user.tab();
    await user.tab();
    expect(screen.getByRole("button", { name: "Can I return an open tin?" })).toHaveFocus();
    await user.keyboard("{Enter}");
    expect(screen.getByRole("button", { name: "Can I return an open tin?" })).toHaveAttribute("aria-expanded", "true");
    expect(screen.getByText("Yes, within 30 days.")).toBeVisible();
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).