pfy.ch

The tables present on my games page are sortable and responsive on small displays. These are standard tables compiled with Pandoc combined with a little JS magic.

Coming from a predominantly React stack at work, responsive data driven tables have always been a nightmare, so I excepted this to be way more of a hassle than it actually was which was a pleasant surprise!

Dates Long Key Another Long Key Another Very Long Key Again, With Long Keys
2023/03/01 3245234 213342523 11 3
2023/03/09 324454325 21325321 12 1
2023/03/17 34345234 21 13 2

Responsiveness

Firstly, tables in HTML are not very responsive, so I like to wrap them in a div and let the div handle overflow, let the table always fit content.

The following function runs to ensure all tables have a wrapper:

export const wrapTables = () => {
  const tables = [...document.getElementsByTagName('table')];

  tables.forEach((table) => {
    const wrapperDiv = document.createElement('div');
    wrapperDiv.className = 'tableContainer';

    table.parentNode.insertBefore(wrapperDiv, table);
    wrapperDiv.appendChild(table);
  });
};

I then apply styles similar to the following:

.tableContainer {
  width: 100%;
  overflow: auto;
}

table {
  width: 100%;
  border: 1px solid var(--colour-background-sub);
  border-collapse: collapse;

  th,
  td {
    // Table cells dont ever wrap
    white-space: nowrap;
  }
  
  tr:nth-of-type(2n) {
    background: var(--colour-background-sub);
  }
}

Sortability

Not all tables should be sortable, that would be unnecessarily heavy. By taking advantage of some pandoc features we can wrap specific tables in a div with a class name.

:::{.sortable}

| Dates      | Long Key  |
|------------|-----------|
| 2023/03/01 | 3245234   |
| 2023/03/09 | 324454325 |
| 2023/03/17 | 34345234  |

:::

We can then fetch all tables that contain this class with a standard query selector:

 const scoreTables: Element[] = [
  ...(document.querySelectorAll('.sortable table') || []),
];

For each of these tables we can query the headers and set up click handlers which we’ll use to call our sorting function, we make the assumption here that only one thead will be present.

scoreTables.forEach((table) => {
  const header = table.querySelector('thead tr');
  const rows = [...(table.querySelectorAll('tbody tr') || [])];

  header.querySelectorAll('th').forEach((node) => {
    node.style.setProperty('cursor', 'pointer');
    node.addEventListener('click', () => {
      // Call our sorting function 
    });
  });
});

Our sorting function will take in the attribute we want to sort by, the Header element itself and the list of rows as an array of Elements.

let lastClicked = ''; // We'll use this later!
const sortRows = (sortBy: string, header: Element, rows: Element[]) => {};

Firstly we want to find the index of the header we’ve clicked. We can make the assumption that if the index of the header we clicked is 0 that the data will exist at index 0 if we query an array of td items in a row. We’ll also underline the header element we clicked.

const allHeaders = [...(header.querySelectorAll('th') || [])];
const indexOfSortBy = allHeaders
  .map((header) => header.innerText)
  .indexOf(sortBy);

allHeaders.forEach((header) => {
  header.style.textDecoration = 'none';
  header.style.userSelect = 'none';
});
allHeaders[indexOfSortBy].style.textDecoration = 'underline';

We can then sort the rows array by their innerText, doing a little bit of Regex to ensure formatted numbers sort correctly, we’ll use the last clicked variable to invert the sort if we click a header again:

const sortedRows = rows.sort((rowA, rowB) => {
  const rowAValue = rowA.querySelectorAll('td')[indexOfSortBy].innerText;
  const rowBValue = rowB.querySelectorAll('td')[indexOfSortBy].innerText;

  const rowAValueSortable = /^[0-9,.]*$/.test(rowAValue)
    ? parseInt(rowAValue.replace(/,/g, ''))
    : rowAValue;

  const rowBValueSortable = /^[0-9,.]*$/.test(rowBValue)
    ? parseInt(rowBValue.replace(/,/g, ''))
    : rowBValue;

  if (rowAValueSortable > rowBValueSortable) {
    return lastClicked === sortBy ? -1 : 1;
  }

  if (rowAValueSortable < rowBValueSortable) {
    return lastClicked === sortBy ? 1 : -1;
  }

  return 0;
});

Finally, we’ll set the lastClicked variable and set the tables tbody to the new sorted rows

lastClicked = lastClicked === sortBy ? '' : sortBy;

const tableBody = rows[0].parentElement;

tableBody.innerHTML = '';
tableBody.append(...sortedRows);

The final code snippet should look something like this:


const sortRows = (sortBy: string, header: Element, rows: Element[]) => {
  // Discover the selected header
  const allHeaders = [...(header.querySelectorAll('th') || [])];
  const indexOfSortBy = allHeaders
    .map((header) => header.innerText)
    .indexOf(sortBy);

  // Apply Styles to selected header
  allHeaders.forEach((header) => {
    header.style.textDecoration = 'none';
    header.style.userSelect = 'none';
  });
  allHeaders[indexOfSortBy].style.textDecoration = 'underline';

  // Sort the rows and update the parent
  const sortedRows = rows.sort((rowA, rowB) => {
    const rowAValue = rowA.querySelectorAll('td')[indexOfSortBy].innerText;
    const rowBValue = rowB.querySelectorAll('td')[indexOfSortBy].innerText;

    const rowAValueSortable = /^[0-9,.]*$/.test(rowAValue)
      ? parseInt(rowAValue.replace(/,/g, ''))
      : rowAValue;

    const rowBValueSortable = /^[0-9,.]*$/.test(rowBValue)
      ? parseInt(rowBValue.replace(/,/g, ''))
      : rowBValue;

    if (rowAValueSortable > rowBValueSortable) {
      return lastClicked === sortBy ? -1 : 1;
    }

    if (rowAValueSortable < rowBValueSortable) {
      return lastClicked === sortBy ? 1 : -1;
    }

    return 0;
  });

  lastClicked = lastClicked === sortBy ? '' : sortBy;

  const tableBody = rows[0].parentElement;

  // We wipe the tables content then append the array of elements as children
  tableBody.innerHTML = '';
  tableBody.append(...sortedRows);
};

// Discover score tables
const scoreTables: Element[] = [
  ...(document.querySelectorAll('.sortable table') || []),
];

if (scoreTables) {
  let lastClicked = '';

  // Set up the click handlers
  scoreTables.forEach((table) => {
    const header = table.querySelector('thead tr');
    const rows = [...(table.querySelectorAll('tbody tr') || [])];

    // Add Click handlers to headers
    header.querySelectorAll('th').forEach((node) => {
      node.style.setProperty('cursor', 'pointer');
      node.addEventListener('click', () =>
        sortRows(node.innerText, header, rows),
      );
    });
  });
}

This was not as complicated as first expected. I was really surprised to learn about accessors like .parentElement and that .append() can take an array of elements as input!

If you’d like to see these tables in action you can view them on my games page for any arcade-style game.


© 2024 Pfych 🏳️‍⚧️