Skip to the code
cf.

Checkbox tree with indeterminate parents

MediumDOM

The page has a tree of checkboxes. Each item is an <li> whose <label> holds its checkbox, and an item with children has a nested <ul> of more items after its label:

<ul id="permissions">
  <li>
    <label><input type="checkbox" id="docs"> Documents</label>
    <ul>
      <li><label><input type="checkbox" id="view"> View</label></li>
      <li><label><input type="checkbox" id="edit"> Edit</label></li>
      <li>
        <label><input type="checkbox" id="share"> Share</label>
        <ul>
          <li><label><input type="checkbox" id="team"> With your team</label></li>
          <li><label><input type="checkbox" id="public"> With anyone</label></li>
        </ul>
      </li>
    </ul>
  </li>
  <li><label><input type="checkbox" id="billing"> Billing</label></li>
</ul>

Write setupCheckboxTree(root), where root is the outer <ul>. When the user checks or unchecks a box, every box below it takes the same state. Clicking an indeterminate box checks it: the browser clears its indeterminate and sets checked.

Then every box above it updates from its direct children: checked when they're all checked, unchecked when none is checked or indeterminate, and indeterminate otherwise. An indeterminate box has indeterminate set to true and checked set to false; checked and unchecked boxes have indeterminate set to false.

On setup, work out every parent box from the boxes below it by the same rule, so a tree that starts with some boxes checked shows the right parents.

Show the mixed state with the indeterminate property only, and don't add aria-checked. Browsers already report an indeterminate native checkbox to screen readers as mixed, and ARIA in HTML doesn't allow aria-checked on one.

setupCheckboxTree(document.getElementById("permissions"));
// check "With your team": Share and Documents become indeterminate
// check "With anyone": Share is checked; Documents stays indeterminate
// click Documents: it's checked, and so is everything below it

What the tests check

  1. checking a parent checks everything below it
    const box = (id) => document.getElementById(id);
    const state = (id) => (box(id).indeterminate ? (box(id).checked ? "checked and indeterminate" : "indeterminate") : box(id).checked ? "checked" : "unchecked");
    const all = ["docs", "view", "edit", "share", "team", "public", "billing"];
    setupCheckboxTree(document.getElementById("permissions"));
    fire(box("docs"), "click");
    expect(all.map(state)).toEqual(["checked", "checked", "checked", "checked", "checked", "checked", "unchecked"]);
    
  2. unchecking a parent unchecks everything below it
    const box = (id) => document.getElementById(id);
    const state = (id) => (box(id).indeterminate ? (box(id).checked ? "checked and indeterminate" : "indeterminate") : box(id).checked ? "checked" : "unchecked");
    const all = ["docs", "view", "edit", "share", "team", "public", "billing"];
    setupCheckboxTree(document.getElementById("permissions"));
    fire(box("docs"), "click");
    fire(box("docs"), "click");
    expect(all.map(state)).toEqual(["unchecked", "unchecked", "unchecked", "unchecked", "unchecked", "unchecked", "unchecked"]);
    
  3. a parent with only some children checked is indeterminate
    const box = (id) => document.getElementById(id);
    const state = (id) => (box(id).indeterminate ? (box(id).checked ? "checked and indeterminate" : "indeterminate") : box(id).checked ? "checked" : "unchecked");
    setupCheckboxTree(document.getElementById("permissions"));
    fire(box("view"), "click");
    expect(state("docs")).toBe("indeterminate");
    fire(box("edit"), "click");
    fire(box("share"), "click");
    expect(state("docs")).toBe("checked");
    fire(box("edit"), "click");
    expect(state("docs")).toBe("indeterminate");
    
  4. an indeterminate child makes every box above it indeterminate
    const box = (id) => document.getElementById(id);
    const state = (id) => (box(id).indeterminate ? (box(id).checked ? "checked and indeterminate" : "indeterminate") : box(id).checked ? "checked" : "unchecked");
    setupCheckboxTree(document.getElementById("permissions"));
    fire(box("team"), "click");
    expect(state("share")).toBe("indeterminate");
    expect(state("docs")).toBe("indeterminate");
    fire(box("public"), "click");
    expect(state("share")).toBe("checked");
    expect(state("docs")).toBe("indeterminate");
    
  5. unchecking the last checked child unchecks every box above it
    const box = (id) => document.getElementById(id);
    const state = (id) => (box(id).indeterminate ? (box(id).checked ? "checked and indeterminate" : "indeterminate") : box(id).checked ? "checked" : "unchecked");
    setupCheckboxTree(document.getElementById("permissions"));
    fire(box("team"), "click");
    fire(box("team"), "click");
    expect(state("share")).toBe("unchecked");
    expect(state("docs")).toBe("unchecked");
    
  6. clicking an indeterminate parent checks it and everything below it
    const box = (id) => document.getElementById(id);
    const state = (id) => (box(id).indeterminate ? (box(id).checked ? "checked and indeterminate" : "indeterminate") : box(id).checked ? "checked" : "unchecked");
    const below = ["view", "edit", "share", "team", "public"];
    setupCheckboxTree(document.getElementById("permissions"));
    fire(box("team"), "click");
    fire(box("docs"), "click");
    expect(state("docs")).toBe("checked");
    expect(below.map(state)).toEqual(["checked", "checked", "checked", "checked", "checked"]);
    
  7. works out every parent from the boxes checked before setup
    const box = (id) => document.getElementById(id);
    const state = (id) => (box(id).indeterminate ? (box(id).checked ? "checked and indeterminate" : "indeterminate") : box(id).checked ? "checked" : "unchecked");
    const all = ["docs", "view", "edit", "share", "team", "public", "billing"];
    for (const id of ["view", "edit", "team", "public"]) box(id).checked = true;
    setupCheckboxTree(document.getElementById("permissions"));
    expect(all.map(state)).toEqual(["checked", "checked", "checked", "checked", "checked", "checked", "unchecked"]);
    
  8. a parent checked before setup follows its children
    const box = (id) => document.getElementById(id);
    const state = (id) => (box(id).indeterminate ? (box(id).checked ? "checked and indeterminate" : "indeterminate") : box(id).checked ? "checked" : "unchecked");
    const all = ["docs", "view", "edit", "share", "team", "public", "billing"];
    for (const id of ["docs", "share", "view"]) box(id).checked = true;
    setupCheckboxTree(document.getElementById("permissions"));
    expect(all.map(state)).toEqual(["indeterminate", "checked", "unchecked", "unchecked", "unchecked", "unchecked", "unchecked"]);
    
  9. doesn't add aria-checked to the native checkboxes
    const root = document.getElementById("permissions");
    setupCheckboxTree(root);
    fire(document.getElementById("team"), "click");
    fire(document.getElementById("view"), "click");
    expect(root.querySelectorAll("[aria-checked]")).toHaveLength(0);
    

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