Skip to the code
cf.

Event delegation helper

MediumDOM

Write delegate(root, type, selector, handler). Its arguments, in that order, are the container, the event type (such as "click"), a CSS selector for the items, and the function to call.

It adds one type listener to root. When an event arrives, find the closest element matching selector, starting at event.target and going up. If there is one and it's inside root, call handler with this set to that element and the arguments (event, matchedElement). Ignore the event otherwise. Items added inside root after delegate was called work too.

Return a function that removes the listener.

// <ul id="list"><li><span>One</span></li><li>Two</li></ul>
const list = document.getElementById("list");
const off = delegate(list, "click", "li", function (event, item) {
  item.classList.toggle("done"); // this === item too
});
 
list.querySelector("span").click(); // the first <li> gets the class "done"
const three = document.createElement("li");
list.append(three);
three.click();                      // added later, and it gets "done" too
off();
three.click();                      // nothing: the listener is gone

What the tests check

  1. calls the handler when a matching element is clicked
    const handler = spy();
    delegate(document.getElementById("list"), "click", "li", handler);
    fire(document.getElementById("first"), "click");
    expect(handler).toHaveBeenCalledTimes(1);
    
  2. finds the match from a click on something inside it
    const first = document.getElementById("first");
    const handler = spy();
    delegate(document.getElementById("list"), "click", "li", handler);
    fire(first.querySelector(".label"), "click");
    expect(handler).toHaveBeenCalledTimes(1);
    expect(handler.calls[0].args[1]).toBe(first);
    
  3. sets this to the matched element and passes the event
    const first = document.getElementById("first");
    const handler = spy();
    delegate(document.getElementById("list"), "click", "li", handler);
    const event = fire(first.querySelector(".label"), "click");
    expect(handler).toHaveBeenCalledTimes(1);
    expect(handler.calls[0].this).toBe(first);
    expect(handler.calls[0].args[0]).toBe(event);
    
  4. ignores events that match nothing
    const list = document.getElementById("list");
    const handler = spy();
    delegate(list, "click", "button.remove", handler);
    fire(document.querySelector(".label"), "click");
    fire(list, "click");
    expect(handler).not.toHaveBeenCalled();
    
  5. ignores matches outside root
    const list = document.getElementById("list");
    const handler = spy();
    delegate(list, "click", ".item", handler);
    fire(list, "click");
    expect(handler).not.toHaveBeenCalled();
    
  6. handles elements added after delegate was called
    const list = document.getElementById("list");
    const handler = spy();
    delegate(list, "click", "li", handler);
    const item = document.createElement("li");
    item.textContent = "Three";
    list.append(item);
    fire(item, "click");
    expect(handler).toHaveBeenCalledTimes(1);
    expect(handler.calls[0].this).toBe(item);
    
  7. the returned function removes the listener
    const handler = spy();
    const off = delegate(document.getElementById("list"), "click", "li", handler);
    off();
    fire(document.getElementById("first"), "click");
    expect(handler).not.toHaveBeenCalled();
    

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