Event delegation means handling an event for many elements with one listener on a parent they share, instead of a listener on each. A list of 500 items gets one click listener on the <ul>, not 500 on the <li>s. When a click arrives, the listener looks at where it started and works out which item it was meant for.
Picture an office building with one front desk. Instead of someone waiting at every office door, the desk takes in every parcel and reads the label to see whose it is. An office that opens next month gets its parcels from the first day, with nobody new to hire.
It works because most events bubble: after an event fires on the element where it happened, called its target, it fires again on that element's parent, then on the parent's parent, and so on up to document and window. Here's a click on the word One in <ul><li><span>One</span></li><li>Two</li></ul>, with a listener on the <ul> only:
| Step | The event reaches | What happens |
|---|---|---|
| 1 | <span> | This is where the click happened, so the <span> is event.target. There's no listener here. |
| 2 | <li> | The event bubbles up to the parent. No listener here either. |
| 3 | <ul> | The listener runs. event.target is still the <span>, and event.currentTarget is the <ul>, the element the listener is on. |
| 4 | <body>, <html>, document, window | It keeps bubbling, unless a listener calls event.stopPropagation(). |
The target is often something inside an item, like this <span>, an icon, or bold text, so the listener's job is to work out which item the target sits in. And because the listener is on the parent, items added later are covered with no extra work: a new <li> is inside the <ul>, so its clicks bubble to the same listener.
A few events don't bubble, such as focus, blur, mouseenter, and mouseleave, so this doesn't work for them as it is; focusin and focusout are versions of focus and blur that do bubble. You meet delegation in long lists, tables, menus, and accordions. React uses it too: it attaches its listeners to the root element your app renders into (before version 17, to document), not to each element.
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.
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]);
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);
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]);
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]);
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);
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);
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);
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);
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).
I'm here to help with Accordion with one open panel. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.