Skip to the code
cf.

Accordion with one open panel

MediumDOM

The page has an accordion. Each header is a <button> whose aria-controls holds the id of its panel, and whose aria-expanded says whether that panel is open. Closed panels have the hidden attribute.

<div id="faq">
  <h3><button id="q1" aria-expanded="false" aria-controls="a1"><span>Shipping</span></button></h3>
  <div id="a1" role="region" aria-labelledby="q1" hidden>We ship <strong>worldwide</strong>.</div>
  <h3><button id="q2" aria-expanded="false" aria-controls="a2"><span>Returns</span></button></h3>
  <div id="a2" role="region" aria-labelledby="q2" hidden>Within 30 days.</div>
  <!-- more of the same -->
</div>

Write setupAccordion(root). Clicking a header, or anything inside it, toggles its panel. Opening sets the header's aria-expanded to "true" and removes the panel's hidden; closing sets aria-expanded to "false" and adds hidden back.

At most one panel is open: opening one closes any other, including one that was already open before setup. Clicking the open header closes it, leaving them all closed.

Find each panel through aria-controls, not by where it sits in the page. Use one listener on root, so headers added later work too. Clicks anywhere else, such as inside a panel, change nothing.

What the tests check

  1. clicking a header opens its panel
    const expanded = () => ["q1", "q2", "q3"].map((id) => document.getElementById(id).getAttribute("aria-expanded"));
    const hidden = () => ["a1", "a2", "a3"].map((id) => document.getElementById(id).hidden);
    setupAccordion(document.getElementById("faq"));
    fire(document.getElementById("q2"), "click");
    expect(expanded()).toEqual(["false", "true", "false"]);
    expect(hidden()).toEqual([true, false, true]);
    
  2. a click on the text inside a header works too
    setupAccordion(document.getElementById("faq"));
    fire(document.querySelector("#q1 span"), "click");
    expect(document.getElementById("q1").getAttribute("aria-expanded")).toBe("true");
    expect(document.getElementById("a1").hidden).toBe(false);
    
  3. opening a panel closes the open one
    const expanded = () => ["q1", "q2", "q3"].map((id) => document.getElementById(id).getAttribute("aria-expanded"));
    const hidden = () => ["a1", "a2", "a3"].map((id) => document.getElementById(id).hidden);
    setupAccordion(document.getElementById("faq"));
    fire(document.getElementById("q1"), "click");
    fire(document.getElementById("q3"), "click");
    expect(expanded()).toEqual(["false", "false", "true"]);
    expect(hidden()).toEqual([true, true, false]);
    
  4. clicking the open header closes it
    const expanded = () => ["q1", "q2", "q3"].map((id) => document.getElementById(id).getAttribute("aria-expanded"));
    const hidden = () => ["a1", "a2", "a3"].map((id) => document.getElementById(id).hidden);
    setupAccordion(document.getElementById("faq"));
    fire(document.getElementById("q2"), "click");
    fire(document.getElementById("q2"), "click");
    expect(expanded()).toEqual(["false", "false", "false"]);
    expect(hidden()).toEqual([true, true, true]);
    
  5. closes a panel that was open before setup
    document.getElementById("q2").setAttribute("aria-expanded", "true");
    document.getElementById("a2").hidden = false;
    setupAccordion(document.getElementById("faq"));
    fire(document.getElementById("q1"), "click");
    expect(document.getElementById("q2").getAttribute("aria-expanded")).toBe("false");
    expect(document.getElementById("a2").hidden).toBe(true);
    expect(document.getElementById("a1").hidden).toBe(false);
    
  6. finds each panel through aria-controls
    const faq = document.getElementById("faq");
    faq.prepend(document.getElementById("a3"));
    setupAccordion(faq);
    fire(document.getElementById("q3"), "click");
    expect(document.getElementById("a3").hidden).toBe(false);
    expect(document.getElementById("a1").hidden).toBe(true);
    expect(document.getElementById("a2").hidden).toBe(true);
    
  7. clicks inside a panel change nothing
    setupAccordion(document.getElementById("faq"));
    fire(document.getElementById("q1"), "click");
    fire(document.querySelector("#a1 strong"), "click");
    expect(document.getElementById("q1").getAttribute("aria-expanded")).toBe("true");
    expect(document.getElementById("a1").hidden).toBe(false);
    
  8. works for headers added after setup
    const faq = document.getElementById("faq");
    setupAccordion(faq);
    fire(document.getElementById("q1"), "click");
    const heading = document.createElement("h3");
    const header = document.createElement("button");
    header.id = "q4";
    header.textContent = "Payments";
    header.setAttribute("aria-expanded", "false");
    header.setAttribute("aria-controls", "a4");
    heading.append(header);
    const panel = document.createElement("div");
    panel.id = "a4";
    panel.hidden = true;
    panel.textContent = "Cards and bank transfers.";
    faq.append(heading, panel);
    fire(header, "click");
    expect(header.getAttribute("aria-expanded")).toBe("true");
    expect(panel.hidden).toBe(false);
    expect(document.getElementById("a1").hidden).toBe(true);
    

Learn more

solution.jsSaved 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).