Skip to the code
cf.

Nested checkboxes

The tea shop's filter panel groups teas into kinds, and kinds into families. Build App: it takes nodes, a list of tree nodes { id, label, children? }, and defaultChecked, a list of the leaf ids that start checked (default none). When nodes isn't given, as in the Preview, show a tree of your own. A node with a non-empty children list is a parent; any other node is a leaf. The tree can be any depth.

Render the tree as nested lists: a <ul> of <li>s, each holding a <label> with a checkbox and the node's label, and a parent's <li> also holding a <ul> of its children.

Checking a parent checks every node under it, at every depth; unchecking it unchecks them all. Checking or unchecking a leaf changes only that leaf.

A parent's box follows the leaves under it: checked when all of them are, unchecked when none are, and indeterminate when some are. An indeterminate box is unchecked, with its indeterminate property set to true; at every other time indeterminate is false. Clicking an indeterminate parent checks everything under it.

<App nodes={[
  { id: "green", label: "Green", children: [
    { id: "sencha", label: "Sencha" },
    { id: "matcha", label: "Matcha" },
  ] },
]} defaultChecked={["sencha"]} />
 
// Green is indeterminate, Sencha checked, Matcha not.
// Checking Matcha too makes Green checked.

What the tests check

  1. renders a checkbox for every node, nested in lists, all unchecked
    const nodes = [
      {
        id: "all",
        label: "All teas",
        children: [
          { id: "green", label: "Green", children: [{ id: "sencha", label: "Sencha" }, { id: "matcha", label: "Matcha" }, { id: "gyokuro", label: "Gyokuro" }] },
          {
            id: "black",
            label: "Black",
            children: [
              { id: "assam", label: "Assam" },
              { id: "darjeeling", label: "Darjeeling", children: [{ id: "first-flush", label: "First flush" }, { id: "second-flush", label: "Second flush" }] },
            ],
          },
          { id: "rooibos", label: "Rooibos" },
        ],
      },
    ];
    const box = (name) => screen.getByRole("checkbox", { name });
    render(<App nodes={nodes} />);
    expect(screen.getAllByRole("checkbox")).toHaveLength(11);
    expect(screen.getAllByRole("checkbox").every((input) => !input.checked)).toBe(true);
    const black = box("Black").closest("li");
    expect(within(black).getAllByRole("checkbox").map((input) => input.labels[0].textContent.trim())).toEqual(["Black", "Assam", "Darjeeling", "First flush", "Second flush"]);
    expect(box("Assam").closest("ul").closest("li")).toBe(black);
    expect(box("First flush").closest("ul").closest("li")).toBe(box("Darjeeling").closest("li"));
    
  2. checking a parent checks everything under it
    const nodes = [
      {
        id: "all",
        label: "All teas",
        children: [
          { id: "green", label: "Green", children: [{ id: "sencha", label: "Sencha" }, { id: "matcha", label: "Matcha" }, { id: "gyokuro", label: "Gyokuro" }] },
          {
            id: "black",
            label: "Black",
            children: [
              { id: "assam", label: "Assam" },
              { id: "darjeeling", label: "Darjeeling", children: [{ id: "first-flush", label: "First flush" }, { id: "second-flush", label: "Second flush" }] },
            ],
          },
          { id: "rooibos", label: "Rooibos" },
        ],
      },
    ];
    const box = (name) => screen.getByRole("checkbox", { name });
    render(<App nodes={nodes} />);
    await user.click(box("Black"));
    for (const name of ["Black", "Assam", "Darjeeling", "First flush", "Second flush"]) {
      expect(box(name)).toBeChecked();
    }
    expect(box("Sencha")).not.toBeChecked();
    expect(box("Rooibos")).not.toBeChecked();
    
  3. unchecking a parent unchecks everything under it
    const nodes = [
      {
        id: "all",
        label: "All teas",
        children: [
          { id: "green", label: "Green", children: [{ id: "sencha", label: "Sencha" }, { id: "matcha", label: "Matcha" }, { id: "gyokuro", label: "Gyokuro" }] },
          {
            id: "black",
            label: "Black",
            children: [
              { id: "assam", label: "Assam" },
              { id: "darjeeling", label: "Darjeeling", children: [{ id: "first-flush", label: "First flush" }, { id: "second-flush", label: "Second flush" }] },
            ],
          },
          { id: "rooibos", label: "Rooibos" },
        ],
      },
    ];
    const box = (name) => screen.getByRole("checkbox", { name });
    render(<App nodes={nodes} />);
    await user.click(box("All teas"));
    expect(screen.getAllByRole("checkbox").every((input) => input.checked)).toBe(true);
    await user.click(box("Green"));
    for (const name of ["Green", "Sencha", "Matcha", "Gyokuro"]) {
      expect(box(name)).not.toBeChecked();
    }
    expect(box("Assam")).toBeChecked();
    
  4. some children checked makes the parent indeterminate and unchecked
    const nodes = [
      {
        id: "all",
        label: "All teas",
        children: [
          { id: "green", label: "Green", children: [{ id: "sencha", label: "Sencha" }, { id: "matcha", label: "Matcha" }, { id: "gyokuro", label: "Gyokuro" }] },
          {
            id: "black",
            label: "Black",
            children: [
              { id: "assam", label: "Assam" },
              { id: "darjeeling", label: "Darjeeling", children: [{ id: "first-flush", label: "First flush" }, { id: "second-flush", label: "Second flush" }] },
            ],
          },
          { id: "rooibos", label: "Rooibos" },
        ],
      },
    ];
    const box = (name) => screen.getByRole("checkbox", { name });
    render(<App nodes={nodes} />);
    await user.click(box("Sencha"));
    expect(box("Green").indeterminate).toBe(true);
    expect(box("Green")).not.toBeChecked();
    expect(box("All teas").indeterminate).toBe(true);
    expect(box("Black").indeterminate).toBe(false);
    
  5. checking every child checks the parent
    const nodes = [
      {
        id: "all",
        label: "All teas",
        children: [
          { id: "green", label: "Green", children: [{ id: "sencha", label: "Sencha" }, { id: "matcha", label: "Matcha" }, { id: "gyokuro", label: "Gyokuro" }] },
          {
            id: "black",
            label: "Black",
            children: [
              { id: "assam", label: "Assam" },
              { id: "darjeeling", label: "Darjeeling", children: [{ id: "first-flush", label: "First flush" }, { id: "second-flush", label: "Second flush" }] },
            ],
          },
          { id: "rooibos", label: "Rooibos" },
        ],
      },
    ];
    const box = (name) => screen.getByRole("checkbox", { name });
    render(<App nodes={nodes} />);
    await user.click(box("Sencha"));
    await user.click(box("Matcha"));
    await user.click(box("Gyokuro"));
    expect(box("Green")).toBeChecked();
    expect(box("Green").indeterminate).toBe(false);
    
  6. works at any depth
    const nodes = [
      {
        id: "all",
        label: "All teas",
        children: [
          { id: "green", label: "Green", children: [{ id: "sencha", label: "Sencha" }, { id: "matcha", label: "Matcha" }, { id: "gyokuro", label: "Gyokuro" }] },
          {
            id: "black",
            label: "Black",
            children: [
              { id: "assam", label: "Assam" },
              { id: "darjeeling", label: "Darjeeling", children: [{ id: "first-flush", label: "First flush" }, { id: "second-flush", label: "Second flush" }] },
            ],
          },
          { id: "rooibos", label: "Rooibos" },
        ],
      },
    ];
    const box = (name) => screen.getByRole("checkbox", { name });
    render(<App nodes={nodes} />);
    await user.click(box("First flush"));
    expect(box("Darjeeling").indeterminate).toBe(true);
    expect(box("Black").indeterminate).toBe(true);
    expect(box("All teas").indeterminate).toBe(true);
    await user.click(box("Second flush"));
    expect(box("Darjeeling")).toBeChecked();
    expect(box("Black").indeterminate).toBe(true);
    await user.click(box("Assam"));
    expect(box("Black")).toBeChecked();
    expect(box("Black").indeterminate).toBe(false);
    
  7. clicking an indeterminate parent checks everything under it
    const nodes = [
      {
        id: "all",
        label: "All teas",
        children: [
          { id: "green", label: "Green", children: [{ id: "sencha", label: "Sencha" }, { id: "matcha", label: "Matcha" }, { id: "gyokuro", label: "Gyokuro" }] },
          {
            id: "black",
            label: "Black",
            children: [
              { id: "assam", label: "Assam" },
              { id: "darjeeling", label: "Darjeeling", children: [{ id: "first-flush", label: "First flush" }, { id: "second-flush", label: "Second flush" }] },
            ],
          },
          { id: "rooibos", label: "Rooibos" },
        ],
      },
    ];
    const box = (name) => screen.getByRole("checkbox", { name });
    render(<App nodes={nodes} />);
    await user.click(box("Assam"));
    await user.click(box("Black"));
    for (const name of ["Black", "Assam", "Darjeeling", "First flush", "Second flush"]) {
      expect(box(name)).toBeChecked();
    }
    expect(box("Black").indeterminate).toBe(false);
    
  8. indeterminate clears when a parent's children are back to none
    const nodes = [
      {
        id: "all",
        label: "All teas",
        children: [
          { id: "green", label: "Green", children: [{ id: "sencha", label: "Sencha" }, { id: "matcha", label: "Matcha" }, { id: "gyokuro", label: "Gyokuro" }] },
          {
            id: "black",
            label: "Black",
            children: [
              { id: "assam", label: "Assam" },
              { id: "darjeeling", label: "Darjeeling", children: [{ id: "first-flush", label: "First flush" }, { id: "second-flush", label: "Second flush" }] },
            ],
          },
          { id: "rooibos", label: "Rooibos" },
        ],
      },
    ];
    const box = (name) => screen.getByRole("checkbox", { name });
    render(<App nodes={nodes} />);
    await user.click(box("Matcha"));
    await user.click(box("Matcha"));
    expect(box("Green").indeterminate).toBe(false);
    expect(box("Green")).not.toBeChecked();
    expect(box("All teas").indeterminate).toBe(false);
    
  9. starts from defaultChecked
    const nodes = [
      {
        id: "all",
        label: "All teas",
        children: [
          { id: "green", label: "Green", children: [{ id: "sencha", label: "Sencha" }, { id: "matcha", label: "Matcha" }, { id: "gyokuro", label: "Gyokuro" }] },
          {
            id: "black",
            label: "Black",
            children: [
              { id: "assam", label: "Assam" },
              { id: "darjeeling", label: "Darjeeling", children: [{ id: "first-flush", label: "First flush" }, { id: "second-flush", label: "Second flush" }] },
            ],
          },
          { id: "rooibos", label: "Rooibos" },
        ],
      },
    ];
    const box = (name) => screen.getByRole("checkbox", { name });
    render(<App nodes={nodes} defaultChecked={["sencha", "matcha", "gyokuro", "first-flush"]} />);
    expect(box("Green")).toBeChecked();
    expect(box("Darjeeling").indeterminate).toBe(true);
    expect(box("All teas").indeterminate).toBe(true);
    expect(box("Assam")).not.toBeChecked();
    

Learn more

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