Skip to the code
cf.

Todo list

MediumDOM

The page has a form for adding todos, an empty list, and a counter:

<form id="new-todo">
  <label for="todo-text">New todo</label>
  <input id="todo-text" name="text" autocomplete="off">
  <button>Add</button>
</form>
<ul id="todos"></ul>
<p id="todo-count">0 items left</p>

Write setupTodoList(form, list, counter) to make it work. Listen for the form's submit event, which fires whether the user presses Enter in the field or clicks Add. In the handler, call preventDefault() so the page doesn't reload, then add a todo with the field's text, trimmed, and clear the field. Ignore text that's empty after trimming. The field is form.elements.text.

Each todo is an <li> holding, in order, an <input type="checkbox">, a <span> with the text, and a <button type="button"> reading Delete. Give the checkbox an aria-label of the todo's text and the button an aria-label of Delete followed by the text, so screen reader users can tell the rows apart. Insert text as text, never as HTML.

Checking a todo's checkbox toggles the class done on its <li>. Clicking Delete removes its <li>. counter always shows how many todos aren't done: 0 items left, 1 item left, 2 items left.

setupTodoList(
  document.getElementById("new-todo"),
  document.getElementById("todos"),
  document.getElementById("todo-count"),
);
// type "Buy milk" and press Enter:
// <li><input type="checkbox" aria-label="Buy milk"><span>Buy milk</span><button type="button" aria-label="Delete Buy milk">Delete</button></li>
// the counter reads "1 item left"

What the tests check

  1. submitting the form adds a todo
    const form = document.getElementById("new-todo");
    const list = document.getElementById("todos");
    setupTodoList(form, list, document.getElementById("todo-count"));
    document.getElementById("todo-text").value = "Buy milk";
    fire(form, "submit");
    expect(list.children).toHaveLength(1);
    const item = list.children[0];
    expect([...item.children].map((child) => child.tagName)).toEqual(["INPUT", "SPAN", "BUTTON"]);
    expect(item.children[0].type).toBe("checkbox");
    expect(item.children[1].textContent).toBe("Buy milk");
    expect(item.children[2].textContent).toBe("Delete");
    expect(item.children[2].type).toBe("button");
    
  2. stops the page from reloading
    const form = document.getElementById("new-todo");
    setupTodoList(form, document.getElementById("todos"), document.getElementById("todo-count"));
    document.getElementById("todo-text").value = "Walk";
    expect(fire(form, "submit").defaultPrevented).toBe(true);
    
  3. trims the text, clears the field, and ignores empty text
    const form = document.getElementById("new-todo");
    const list = document.getElementById("todos");
    const field = document.getElementById("todo-text");
    setupTodoList(form, list, document.getElementById("todo-count"));
    field.value = "  Walk the dog  ";
    fire(form, "submit");
    expect(list.querySelector("span").textContent).toBe("Walk the dog");
    expect(field.value).toBe("");
    field.value = "   ";
    fire(form, "submit");
    expect(list.children).toHaveLength(1);
    
  4. labels the checkbox and the Delete button
    const form = document.getElementById("new-todo");
    const list = document.getElementById("todos");
    setupTodoList(form, list, document.getElementById("todo-count"));
    document.getElementById("todo-text").value = "Buy milk";
    fire(form, "submit");
    expect(list.querySelector("input").getAttribute("aria-label")).toBe("Buy milk");
    expect(list.querySelector("button").getAttribute("aria-label")).toBe("Delete Buy milk");
    
  5. checking a todo marks it done, and unchecking unmarks it
    const form = document.getElementById("new-todo");
    const list = document.getElementById("todos");
    setupTodoList(form, list, document.getElementById("todo-count"));
    document.getElementById("todo-text").value = "A";
    fire(form, "submit");
    const checkbox = list.querySelector("input");
    fire(checkbox, "click");
    expect(list.children[0].classList.contains("done")).toBe(true);
    fire(checkbox, "click");
    expect(list.children[0].classList.contains("done")).toBe(false);
    
  6. Delete removes only its own todo
    const form = document.getElementById("new-todo");
    const list = document.getElementById("todos");
    const field = document.getElementById("todo-text");
    setupTodoList(form, list, document.getElementById("todo-count"));
    field.value = "A";
    fire(form, "submit");
    field.value = "B";
    fire(form, "submit");
    fire(list.children[0].querySelector("button"), "click");
    expect([...list.querySelectorAll("span")].map((span) => span.textContent)).toEqual(["B"]);
    
  7. keeps the counter in sync
    const form = document.getElementById("new-todo");
    const list = document.getElementById("todos");
    const field = document.getElementById("todo-text");
    const counter = document.getElementById("todo-count");
    setupTodoList(form, list, counter);
    field.value = "A";
    fire(form, "submit");
    field.value = "B";
    fire(form, "submit");
    expect(counter.textContent).toBe("2 items left");
    fire(list.children[0].querySelector("input"), "click");
    expect(counter.textContent).toBe("1 item left");
    fire(list.children[1].querySelector("button"), "click");
    expect(counter.textContent).toBe("0 items left");
    fire(list.children[0].querySelector("input"), "click");
    expect(counter.textContent).toBe("1 item left");
    
  8. shows text as text, never as HTML
    const form = document.getElementById("new-todo");
    const list = document.getElementById("todos");
    setupTodoList(form, list, document.getElementById("todo-count"));
    document.getElementById("todo-text").value = "<img src=x onerror=alert(1)>";
    fire(form, "submit");
    expect(list.querySelector("img")).toBeNull();
    expect(list.querySelector("span").textContent).toBe("<img src=x onerror=alert(1)>");
    

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