Skip to the code
cf.

Sortable table

MediumDOM

The page has a table. Each column that can be sorted has a <button> in its header, and a header with data-type="number" holds numbers:

<table id="team">
  <caption>Team</caption>
  <thead>
    <tr>
      <th><button type="button">Name</button></th>
      <th data-type="number"><button type="button">Commits</button></th>
      <th>Email</th>
    </tr>
  </thead>
  <tbody>
    <tr><td>Zoe</td><td>9</td><td>zoe@example.com</td></tr>
    <tr><td>adam</td><td>41</td><td>adam@example.com</td></tr>
    <tr><td>Émile</td><td>100</td><td>emile@example.com</td></tr>
    <tr><td>Bea</td><td>41</td><td>bea@example.com</td></tr>
  </tbody>
</table>

Write makeSortable(table). Clicking a header's button sorts the rows of the table's <tbody> by the text of their cells in that column. The first click on a column sorts it ascending, and each click on the sorted column flips it between ascending and descending.

Number columns compare as numbers, so 9 comes before 41. Other columns compare their text the way a.localeCompare(b) does, so adam and Émile sort among the capitalized names. Rows that tie keep the order they were in.

Set aria-sort on the sorted column's <th> to "ascending" or "descending", and remove aria-sort from every other header, so screen readers hear about the sort in effect and no other.

Move the existing <tr> elements rather than building new ones, so anything attached to them stays. If a header already has aria-sort when you set up, its column counts as sorted that way: the first click on it flips the direction.

Clicks anywhere else, such as on a header without a button, change nothing.

makeSortable(document.getElementById("team"));
// click "Commits": the rows go 9, 41 (adam), 41 (Bea), 100, and that header has aria-sort="ascending"
// click "Commits" again: 100, 41 (adam), 41 (Bea), 9, with aria-sort="descending"
// click "Name": adam, Bea, Émile, Zoe; only the Name header has aria-sort

What the tests check

  1. clicking a header's button sorts whole rows by that column, ascending
    const table = document.getElementById("team");
    const column = (index) => [...table.tBodies[0].rows].map((row) => row.cells[index].textContent);
    const name = table.tHead.rows[0].cells[0];
    makeSortable(table);
    fire(name.querySelector("button"), "click");
    expect(column(0)).toEqual(["adam", "Bea", "Émile", "Zoe"]);
    expect(column(2)).toEqual(["adam@example.com", "bea@example.com", "emile@example.com", "zoe@example.com"]);
    expect(name.getAttribute("aria-sort")).toBe("ascending");
    
  2. clicking the sorted column again flips the direction
    const table = document.getElementById("team");
    const column = (index) => [...table.tBodies[0].rows].map((row) => row.cells[index].textContent);
    const name = table.tHead.rows[0].cells[0];
    makeSortable(table);
    fire(name.querySelector("button"), "click");
    fire(name.querySelector("button"), "click");
    expect(column(0)).toEqual(["Zoe", "Émile", "Bea", "adam"]);
    expect(name.getAttribute("aria-sort")).toBe("descending");
    fire(name.querySelector("button"), "click");
    expect(column(0)).toEqual(["adam", "Bea", "Émile", "Zoe"]);
    expect(name.getAttribute("aria-sort")).toBe("ascending");
    
  3. sorts number columns as numbers
    const table = document.getElementById("team");
    const column = (index) => [...table.tBodies[0].rows].map((row) => row.cells[index].textContent);
    makeSortable(table);
    fire(table.tHead.rows[0].cells[1].querySelector("button"), "click");
    expect(column(1)).toEqual(["9", "41", "41", "100"]);
    fire(table.tHead.rows[0].cells[1].querySelector("button"), "click");
    expect(column(1)).toEqual(["100", "41", "41", "9"]);
    
  4. keeps aria-sort on the sorted column only
    const table = document.getElementById("team");
    const headers = [...table.tHead.rows[0].cells];
    makeSortable(table);
    fire(headers[0].querySelector("button"), "click");
    fire(headers[1].querySelector("button"), "click");
    expect(headers.map((th) => th.getAttribute("aria-sort"))).toEqual([null, "ascending", null]);
    
  5. rows that tie keep their order, in both directions
    const table = document.getElementById("team");
    const column = (index) => [...table.tBodies[0].rows].map((row) => row.cells[index].textContent);
    const [name, commits] = table.tHead.rows[0].cells;
    makeSortable(table);
    fire(name.querySelector("button"), "click");
    fire(name.querySelector("button"), "click");
    fire(commits.querySelector("button"), "click");
    expect(column(0)).toEqual(["Zoe", "Bea", "adam", "Émile"]);
    fire(commits.querySelector("button"), "click");
    expect(column(0)).toEqual(["Émile", "Bea", "adam", "Zoe"]);
    
  6. moves the existing rows instead of making new ones
    const table = document.getElementById("team");
    const before = [...table.tBodies[0].rows];
    makeSortable(table);
    fire(table.tHead.rows[0].cells[1].querySelector("button"), "click");
    const after = [...table.tBodies[0].rows];
    expect(after).toHaveLength(4);
    expect(after.filter((row) => !before.includes(row))).toHaveLength(0);
    expect(after[0]).toBe(before[0]);
    expect(after[3]).toBe(before[2]);
    
  7. a column already marked as sorted flips on the first click
    const table = document.getElementById("team");
    const column = (index) => [...table.tBodies[0].rows].map((row) => row.cells[index].textContent);
    const name = table.tHead.rows[0].cells[0];
    name.setAttribute("aria-sort", "ascending");
    makeSortable(table);
    fire(name.querySelector("button"), "click");
    expect(column(0)).toEqual(["Zoe", "Émile", "Bea", "adam"]);
    expect(name.getAttribute("aria-sort")).toBe("descending");
    
  8. clicks outside the header buttons change nothing
    const table = document.getElementById("team");
    const column = (index) => [...table.tBodies[0].rows].map((row) => row.cells[index].textContent);
    const headers = [...table.tHead.rows[0].cells];
    makeSortable(table);
    fire(headers[2], "click");
    fire(table.tBodies[0].rows[0].cells[0], "click");
    fire(table.caption, "click");
    expect(column(0)).toEqual(["Zoe", "adam", "Émile", "Bea"]);
    expect(headers.map((th) => th.getAttribute("aria-sort"))).toEqual([null, null, null]);
    

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