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')];
.forEach((table) => {
tablesconst wrapperDiv = document.createElement('div');
.className = 'tableContainer';
wrapperDiv
.parentNode.insertBefore(wrapperDiv, table);
table.appendChild(table);
wrapperDiv;
}); }
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;
}
:nth-of-type(2n) {
trbackground: 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.
.forEach((table) => {
scoreTablesconst header = table.querySelector('thead tr');
const rows = [...(table.querySelectorAll('tbody tr') || [])];
.querySelectorAll('th').forEach((node) => {
header.style.setProperty('cursor', 'pointer');
node.addEventListener('click', () => {
node// 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);
.forEach((header) => {
allHeaders.style.textDecoration = 'none';
header.style.userSelect = 'none';
header;
}).style.textDecoration = 'underline'; allHeaders[indexOfSortBy]
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 === sortBy ? '' : sortBy;
lastClicked
const tableBody = rows[0].parentElement;
.innerHTML = '';
tableBody.append(...sortedRows); tableBody
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
.forEach((header) => {
allHeaders.style.textDecoration = 'none';
header.style.userSelect = 'none';
header;
}).style.textDecoration = 'underline';
allHeaders[indexOfSortBy]
// 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 === sortBy ? '' : sortBy;
lastClicked
const tableBody = rows[0].parentElement;
// We wipe the tables content then append the array of elements as children
.innerHTML = '';
tableBody.append(...sortedRows);
tableBody;
}
// Discover score tables
const scoreTables: Element[] = [
...(document.querySelectorAll('.sortable table') || []),
;
]
if (scoreTables) {
let lastClicked = '';
// Set up the click handlers
.forEach((table) => {
scoreTablesconst header = table.querySelector('thead tr');
const rows = [...(table.querySelectorAll('tbody tr') || [])];
// Add Click handlers to headers
.querySelectorAll('th').forEach((node) => {
header.style.setProperty('cursor', 'pointer');
node.addEventListener('click', () =>
nodesortRows(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.