#615 Accessible Accordion

medium
javascript
accessibility
dom

An FAQ list where each question opens its answer is one of the most common widgets on the web. Built from plain divs with click handlers, it tells a screen reader nothing about which answers are open and gives keyboard users no quick way to move between questions. The WAI-ARIA accordion pattern describes how it should behave.

Write a function createAccordion(root, options) that takes the markup below and turns it into an accessible accordion.

<div id="faq">
  <h3><button data-trigger="returns">Can I return my order?</button></h3>
  <div data-content="returns">Yes, within 30 days of delivery.</div>

  <h3><button data-trigger="gift" aria-expanded="true">Do you offer gift wrap?</button></h3>
  <div data-content="gift">Add it at checkout for free.</div>

  <h3><button data-trigger="pickup" disabled>Can I pick up in store?</button></h3>
  <div data-content="pickup">Coming soon.</div>

  <h3><button data-trigger="track">How do I track a package?</button></h3>
  <div data-content="track">Use the link in your shipping email.</div>
</div>

A trigger and its content share the same key (data-trigger="gift" goes with data-content="gift"). The triggers are real <button>s inside headings, so Enter and Space already fire click for you.

Roles and relationships

  • Every [data-trigger] gets aria-controls pointing at its content's id.
  • Every [data-content] gets role="region" and aria-labelledby pointing at its trigger's id.
  • If a trigger or content has no id, give it <key>-trigger or <key>-content. Keep ids that already exist.

Expanding and collapsing

  • Every trigger always has aria-expanded set to "true" or "false". A collapsed section's content has the hidden attribute, an expanded one does not.
  • On start, sections whose trigger already has aria-expanded="true" are open, and everything else is closed.
  • Clicking an enabled trigger toggles its section. Clicking inside the content does nothing.
  • By default only one section can be open at a time: opening one closes the rest. If several triggers start with aria-expanded="true", keep only the first one open. Closing the open section is allowed, so everything can be collapsed.
  • With { multiple: true }, sections open and close independently.

Keyboard (when a trigger has focus)

  • ArrowDown / ArrowUp move focus to the next / previous trigger, wrapping around at the ends. Moving focus does not open or close anything.
  • Home / End move focus to the first / last trigger.
  • Disabled triggers are skipped.
  • Call preventDefault() for the keys you handle, and leave every other key alone.

createAccordion returns a function that removes every listener it added.

const destroy = createAccordion(document.getElementById("faq"));

// "Do you offer gift wrap?" is open, the rest are closed.
// Click "Can I return my order?": it opens and the gift wrap answer closes.
// Focus it and press ArrowDown twice: focus skips the disabled
// "pick up in store" question and lands on "How do I track a package?".

destroy(); // clicks and key presses do nothing now