table-sort-js
Version:
A JavaScript client-side HTML table sorting library with no dependencies required.
630 lines (589 loc) • 20.7 kB
JavaScript
/*
table-sort-js
Author: Lee Wannacott
Licence: MIT License Copyright (c) 2021-2024 Lee Wannacott
GitHub Repository: https://github.com/LeeWannacott/table-sort-js
Instructions:
Load as script:
<script src="https://cdn.jsdelivr.net/npm/table-sort-js/table-sort.min.js"></script>
Add class="table-sort" to tables you'd like to make sortable
Click on the table headers to sort them.
*/
function tableSortJs(testingTableSortJS = false, domDocumentWindow = document) {
function getHTMLTables() {
const getTagTable = !testingTableSortJS
? document.getElementsByTagName("table")
: domDocumentWindow.getElementsByTagName("table");
return [getTagTable];
}
const [getTagTable] = getHTMLTables();
const columnIndexAndTableRow = {};
for (let table of getTagTable) {
if (
table.classList.contains("table-sort") &&
!table.classList.contains("table-processed")
) {
makeTableSortable(table);
}
}
function createMissingTableHead(sortableTable) {
let createTableHead = !testingTableSortJS
? document.createElement("thead")
: domDocumentWindow.createElement("thead");
createTableHead.appendChild(sortableTable.rows[0]);
sortableTable.insertBefore(createTableHead, sortableTable.firstChild);
}
function getTableBodies(sortableTable) {
if (sortableTable.getElementsByTagName("thead").length === 0) {
createMissingTableHead(sortableTable);
if (sortableTable.querySelectorAll("tbody").length > 1) {
// don't select empty tbody that the browser creates
return sortableTable.querySelectorAll("tbody:not(:nth-child(2))");
} else {
return sortableTable.querySelectorAll("tbody");
}
} else {
// if <tr> or <td> exists below <thead> the browser will make <tbody>
return sortableTable.querySelectorAll("tbody");
}
}
function inferSortClasses(tableRows, columnIndex, column, th) {
try {
const runtimeRegex = /^(\d+h)?\s?(\d+m)?\s?(\d+s)?$/i;
const fileSizeRegex = /^([.0-9]+)\s?(B|KB|KiB|MB|MiB|GB|GiB|TB|TiB)/i;
// Don't infer dates with delimiter "."; as could capture semantic version numbers.
const dmyRegex = /^(\d\d?)[/-](\d\d?)[/-]((\d\d)?\d\d)/;
const ymdRegex = /^(\d\d\d\d)[/-](\d\d?)[/-](\d\d?)/;
const numericRegex =
/^-?(?:[$£€¥₩₽₺₣฿₿Ξξ¤¿\u20A1\uFFE0]\d{1,3}(?:[',]\d{3})*(?:\.\d+)?|\d+(?:\.\d+)?(?:[',]\d{3})*?)(?:%?)$/;
const inferableClasses = {
runtime: { regexp: runtimeRegex, class: "runtime-sort", count: 0 },
filesize: { regexp: fileSizeRegex, class: "file-size-sort", count: 0 },
dmyDates: { regexp: dmyRegex, class: "dates-dmy-sort", count: 0 },
ymdDates: { regexp: ymdRegex, class: "dates-ymd-sort", count: 0 },
numericRegex: { regexp: numericRegex, class: "numeric-sort", count: 0 },
};
let classNameAdded = false;
let regexNotFoundCount = 0;
const threshold = Math.ceil(tableRows.length / 2);
for (let tr of tableRows) {
if (regexNotFoundCount >= threshold) {
break;
}
const tableColumn = tr
.querySelectorAll("* > th , * > td")
.item(
column.span[columnIndex] === 1
? column.spanSum[columnIndex] - 1
: column.spanSum[columnIndex] - column.span[columnIndex]
);
let foundMatch = false;
for (let key of Object.keys(inferableClasses)) {
let classRegexp = inferableClasses[key].regexp;
let columnOfTd = testingTableSortJS
? tableColumn.textContent
: tableColumn.innerText;
if (columnOfTd !== undefined && columnOfTd.match(classRegexp)) {
foundMatch = true;
inferableClasses[key].count++;
}
if (inferableClasses[key].count >= threshold) {
th.classList.add(inferableClasses[key].class);
classNameAdded = true;
break;
}
}
if (classNameAdded) {
break;
}
if (!foundMatch) {
regexNotFoundCount++;
continue;
}
}
} catch (e) {
console.log(e);
}
}
function makeTableSortable(sortableTable) {
sortableTable.classList.add("table-processed");
const table = {
bodies: getTableBodies(sortableTable),
theads: sortableTable.querySelectorAll("thead"),
rows: [],
headers: [],
};
for (let index of table.theads.keys()) {
table.headers.push(
table.theads.item(index).querySelectorAll("* > th , * > td")
);
}
for (let index of table.bodies.keys()) {
if (table.bodies.item(index) == null) {
return;
}
table.rows.push(table.bodies.item(index).querySelectorAll("tr"));
}
table.hasClass = {
noClassInfer: sortableTable.classList.contains("no-class-infer"),
cellsSort: sortableTable.classList.contains("cells-sort"),
rememberSort: sortableTable.classList.contains("remember-sort"),
tableArrows: Array.from(sortableTable.classList).filter((item) =>
item.includes("table-arrows")
),
};
for (
let headerIndex = 0;
headerIndex < table.theads.length;
headerIndex++
) {
let columnIndexesClicked = [];
const column = { span: {}, spanSum: {} };
getColSpanData(table.headers[headerIndex], column);
for (let [columnIndex, th] of table.headers[headerIndex].entries()) {
if (!th.classList.contains("disable-sort")) {
th.style.cursor = "pointer";
if (!table.hasClass.noClassInfer) {
inferSortClasses(table.rows[headerIndex], columnIndex, column, th);
}
makeEachColumnSortable(
th,
headerIndex,
columnIndex,
table,
columnIndexesClicked
);
}
}
}
}
function cellsOrRows(table, tr) {
if (table.hasClass.cellsSort) {
return tr.innerHTML;
} else {
return tr.outerHTML;
}
}
function sortDataAttributes(table, column) {
for (let [i, tr] of table.visibleRows.entries()) {
let dataAttributeTd = column.getColumn(tr, column.spanSum, column.span)
.dataset.sort;
column.toBeSorted.push(`${dataAttributeTd}#${i}`);
columnIndexAndTableRow[column.toBeSorted[i]] = cellsOrRows(table, tr);
}
}
function sortFileSize(table, column, columnIndex) {
let unitToMultiplier = {
b: 1,
kb: 1000,
kib: 2 ** 10,
mb: 1e6,
mib: 2 ** 20,
gb: 1e9,
gib: 2 ** 30,
tb: 1e12,
tib: 2 ** 40,
};
const numberWithUnitType = /([.0-9]+)\s?(B|KB|KiB|MB|MiB|GB|GiB|TB|TiB)/i;
for (let [i, tr] of table.visibleRows.entries()) {
let fileSizeTd = tr
.querySelectorAll("* > th , * > td")
.item(columnIndex).textContent;
let match = fileSizeTd.match(numberWithUnitType);
if (match) {
let number = parseFloat(match[1]);
let unit = match[2].toLowerCase();
let multiplier = unitToMultiplier[unit];
column.toBeSorted.push(`${number * multiplier}#${i}`);
columnIndexAndTableRow[column.toBeSorted[i]] = cellsOrRows(table, tr);
}
}
}
function sortDates(datesFormat, table, column) {
try {
for (let [i, tr] of table.visibleRows.entries()) {
let columnOfTd, datesRegex;
if (datesFormat === "mdy" || datesFormat === "dmy") {
datesRegex = /^(\d\d?)[./-](\d\d?)[./-]((\d\d)?\d\d)/;
} else if (datesFormat === "ymd") {
datesRegex = /^(\d\d\d\d)[./-](\d\d?)[./-](\d\d?)/;
}
columnOfTd = column.getColumn(
tr,
column.spanSum,
column.span
).textContent;
let match = columnOfTd.match(datesRegex);
let [years, days, months] = [0, 0, 0];
let numberToSort = columnOfTd;
if (match) {
const [regPos1, regPos2, regPos3] = [match[1], match[2], match[3]];
if (regPos1 && regPos2 && regPos3) {
if (datesFormat === "mdy") {
[months, days, years] = [regPos1, regPos2, regPos3];
} else if (datesFormat === "ymd") {
[years, months, days] = [regPos1, regPos2, regPos3];
} else {
[days, months, years] = [regPos1, regPos2, regPos3];
}
}
numberToSort = Number(
years +
String(months).padStart(2, "0") +
String(days).padStart(2, "0")
);
}
column.toBeSorted.push(`${numberToSort}#${i}`);
columnIndexAndTableRow[column.toBeSorted[i]] = cellsOrRows(table, tr);
}
} catch (e) {
console.log(e);
}
}
function sortByRuntime(table, column) {
try {
for (let [i, tr] of table.visibleRows.entries()) {
const regexMinutesAndSeconds = /^(\d+h)?\s?(\d+m)?\s?(\d+s)?$/i;
let columnOfTd = "";
// TODO: github actions runtime didn't like textContent, tests didn't like innerText?
columnOfTd = column.getColumn(tr, column.spanSum, column.span);
columnOfTd = testingTableSortJS
? columnOfTd.textContent
: columnOfTd.innerText;
let match = columnOfTd.match(regexMinutesAndSeconds);
let [minutesInSeconds, hours, seconds] = [0, 0, 0];
let timeinSeconds = columnOfTd;
if (match) {
const regexHours = match[1];
if (regexHours) {
hours = Number(regexHours.replace("h", "")) * 60 * 60;
}
const regexMinutes = match[2];
if (regexMinutes) {
minutesInSeconds = Number(regexMinutes.replace("m", "")) * 60;
}
const regexSeconds = match[3];
if (regexSeconds) {
seconds = Number(regexSeconds.replace("s", ""));
}
timeinSeconds = hours + minutesInSeconds + seconds;
}
column.toBeSorted.push(`${timeinSeconds}#${i}`);
columnIndexAndTableRow[column.toBeSorted[i]] = cellsOrRows(table, tr);
}
} catch (e) {
console.log(e);
}
}
function getTableData(tableProperties, timesClickedColumn) {
const {
table,
tableRows,
fillValue,
column,
th,
hasThClass,
isSortDates,
desc,
arrow,
} = tableProperties;
for (let [i, tr] of tableRows.entries()) {
let tdTextContent = column.getColumn(
tr,
column.spanSum,
column.span
).textContent;
if (tdTextContent.length === 0) {
tdTextContent = "";
}
if (tdTextContent.trim() !== "") {
if (
!hasThClass.fileSize &&
!hasThClass.dataSort &&
!hasThClass.runtime &&
!hasThClass.filesize &&
!isSortDates.dayMonthYear &&
!isSortDates.yearMonthDay &&
!isSortDates.monthDayYear
) {
column.toBeSorted.push(`${tdTextContent}#${i}`);
columnIndexAndTableRow[`${tdTextContent}#${i}`] = cellsOrRows(
table,
tr
);
}
} else {
// Fill in blank table cells dict key with filler value.
column.toBeSorted.push(`${fillValue}#${i}`);
columnIndexAndTableRow[`${fillValue}#${i}`] = cellsOrRows(table, tr);
}
}
const isPunctSort = th.classList.contains("punct-sort");
const isAlphaSort = th.classList.contains("alpha-sort");
const isNumericSort = th.classList.contains("numeric-sort");
function parseNumberFromString(str) {
let num;
str = str.slice(0, str.indexOf("#"));
if (str.match(/^\(-?(\d+(?:\.\d+)?)\)$/)) {
num = -1 * Number(str.slice(1, -1));
} else {
num = Number(str);
}
return num;
}
function strLocaleCompare(str1, str2) {
return str1.localeCompare(
str2,
navigator.languages[0] || navigator.language,
{ numeric: !isAlphaSort, ignorePunctuation: !isPunctSort }
);
}
function handleNumbers(str1, str2) {
const matchCurrencyCommaAndPercent = /[$£€¥₩₽₺₣฿₿Ξξ¤¿\u20A1\uFFE0,% ]/g;
str1 = str1.replace(matchCurrencyCommaAndPercent, "");
str2 = str2.replace(matchCurrencyCommaAndPercent, "");
const [num1, num2] = [
parseNumberFromString(str1),
parseNumberFromString(str2),
];
if (!isNaN(num1) && !isNaN(num2)) {
return num1 - num2;
} else {
return strLocaleCompare(str1, str2);
}
}
function sortAscending(a, b) {
if (a.includes(`${fillValue}#`)) {
return 1;
} else if (b.includes(`${fillValue}#`)) {
return -1;
} else if (isNumericSort) {
return handleNumbers(a, b);
} else {
return strLocaleCompare(a, b);
}
}
function sortDescending(a, b) {
return sortAscending(b, a);
}
function clearArrows(arrow) {
th.innerHTML = th.innerHTML.replace(arrow.neutral, "");
th.innerHTML = th.innerHTML.replace(arrow.up, "");
th.innerHTML = th.innerHTML.replace(arrow.down, "");
}
if (column.toBeSorted[0] === undefined) {
return;
}
function changeArrowAndSort(arrowDirection, sortDirection) {
if (table.hasClass.tableArrows[0]) {
clearArrows(arrow);
th.insertAdjacentText("beforeend", arrowDirection);
}
column.toBeSorted.sort(sortDirection, {
numeric: !isAlphaSort,
ignorePunctuation: !isPunctSort,
});
}
if (timesClickedColumn === 1) {
desc
? changeArrowAndSort(arrow.down, sortDescending)
: changeArrowAndSort(arrow.up, sortAscending);
} else if (timesClickedColumn === 2) {
timesClickedColumn = 0;
desc
? changeArrowAndSort(arrow.up, sortAscending)
: changeArrowAndSort(arrow.down, sortDescending);
}
return timesClickedColumn;
}
function updateFilesize(i, table, tr, column, columnIndex) {
if (table.hasClass.cellsSort) {
tr.innerHTML = columnIndexAndTableRow[column.toBeSorted[i]];
} else {
// We do this to sort rows rather than cells:
const template = document.createElement("template");
template.innerHTML = columnIndexAndTableRow[column.toBeSorted[i]];
tr = template.content.firstChild;
}
let getColumnTd = column.getColumn(tr, column.spanSum, column.span);
let fileSizeInBytesHTML = getColumnTd.outerHTML;
const fileSizeInBytesText = getColumnTd.textContent;
const fileSize = column.toBeSorted[i].replace(/#[0-9]*/, "");
let prefixes = ["", "Ki", "Mi", "Gi", "Ti", "Pi"];
let replaced = false;
for (let i = 0; i < prefixes.length; ++i) {
let nextPrefixMultiplier = 2 ** (10 * (i + 1));
if (fileSize < nextPrefixMultiplier) {
let prefixMultiplier = 2 ** (10 * i);
fileSizeInBytesHTML = fileSizeInBytesHTML.replace(
fileSizeInBytesText,
`${(fileSize / prefixMultiplier).toFixed(2)} ${prefixes[i]}B`
);
replaced = true;
break;
}
}
if (!replaced) {
fileSizeInBytesHTML = fileSizeInBytesHTML.replace(
fileSizeInBytesText,
"NaN"
);
}
tr.querySelectorAll("* > th , * > td").item(columnIndex).innerHTML =
fileSizeInBytesHTML;
return table.hasClass.cellsSort ? tr.innerHTML : tr.outerHTML;
}
function updateTable(tableProperties) {
const { column, table, columnIndex, hasThClass } = tableProperties;
for (let [i, tr] of table.visibleRows.entries()) {
if (hasThClass.fileSize) {
if (table.hasClass.cellsSort) {
tr.innerHTML = updateFilesize(i, table, tr, column, columnIndex);
} else {
tr.outerHTML = updateFilesize(i, table, tr, column, columnIndex);
}
} else if (!hasThClass.fileSize) {
if (table.hasClass.cellsSort) {
tr.innerHTML = columnIndexAndTableRow[column.toBeSorted[i]];
} else {
tr.outerHTML = columnIndexAndTableRow[column.toBeSorted[i]];
}
}
}
}
function getColSpanData(headers, column) {
headers.forEach((th, index) => {
column.span[index] = th.colSpan;
if (index === 0) column.spanSum[index] = th.colSpan;
else column.spanSum[index] = column.spanSum[index - 1] + th.colSpan;
});
}
function rememberSort(columnIndexesClicked, timesClickedColumn, columnIndex) {
// if user clicked different column from first column reset times clicked.
columnIndexesClicked.push(columnIndex);
if (timesClickedColumn === 1 && columnIndexesClicked.length > 1) {
const lastColumnClicked =
columnIndexesClicked[columnIndexesClicked.length - 1];
const secondLastColumnClicked =
columnIndexesClicked[columnIndexesClicked.length - 2];
if (lastColumnClicked !== secondLastColumnClicked) {
columnIndexesClicked.shift();
timesClickedColumn = 0;
}
}
return timesClickedColumn;
}
function makeEachColumnSortable(
th,
headerIndex,
columnIndex,
table,
columnIndexesClicked
) {
const desc = th.classList.contains("order-by-desc");
let fillValue = "!X!Y!Z!";
let arrow = { up: " ↑", neutral: " ↕", down: " ↓" };
if (table.hasClass.tableArrows[0]) {
if (table.hasClass.tableArrows[0].split("-").length > 2) {
// Array.from to support utf-8 strings e.g emojis
let customArrow = Array.from(
table.hasClass.tableArrows[0].split("-")[2]
);
customArrow = customArrow.map((i) => " " + i);
console.log(customArrow);
if (customArrow.length === 3) {
[arrow.up, arrow.neutral, arrow.down] = [...customArrow];
}
}
th.insertAdjacentText("beforeend", arrow.neutral);
}
let timesClickedColumn = 0;
const column = {
getColumn: function getColumn(tr, colSpanSum, colSpanData) {
return tr
.querySelectorAll("* > th , * > td")
.item(
colSpanData[columnIndex] === 1
? colSpanSum[columnIndex] - 1
: colSpanSum[columnIndex] - colSpanData[columnIndex]
);
},
};
th.addEventListener("click", function () {
column.toBeSorted = [];
column.span = {};
column.spanSum = {};
getColSpanData(table.headers[headerIndex], column);
table.visibleRows = Array.prototype.filter.call(
table.bodies.item(headerIndex).querySelectorAll("tr"),
(tr) => {
return tr.style.display !== "none";
}
);
if (!table.hasClass.rememberSort) {
timesClickedColumn = rememberSort(
columnIndexesClicked,
timesClickedColumn,
columnIndex
);
}
timesClickedColumn += 1;
const hasThClass = {
dataSort: th.classList.contains("data-sort"),
fileSize: th.classList.contains("file-size-sort"),
runtime: th.classList.contains("runtime-sort"),
};
if (hasThClass.dataSort) {
sortDataAttributes(table, column);
}
if (hasThClass.fileSize) {
sortFileSize(table, column, columnIndex, fillValue);
}
if (hasThClass.runtime) {
sortByRuntime(table, column);
}
const isSortDates = {
dayMonthYear: th.classList.contains("dates-dmy-sort"),
monthDayYear: th.classList.contains("dates-mdy-sort"),
yearMonthDay: th.classList.contains("dates-ymd-sort"),
};
// pick mdy first to override the inferred default class which is dmy.
if (isSortDates.monthDayYear) {
sortDates("mdy", table, column);
} else if (isSortDates.yearMonthDay) {
sortDates("ymd", table, column);
} else if (isSortDates.dayMonthYear) {
sortDates("dmy", table, column);
}
const tableProperties = {
table,
tableRows: table.visibleRows,
fillValue,
column,
columnIndex,
th,
hasThClass,
isSortDates,
desc,
timesClickedColumn,
arrow,
};
timesClickedColumn = getTableData(tableProperties, timesClickedColumn);
updateTable(tableProperties);
});
if (th.classList.contains("onload-sort")) {
th.click();
}
}
}
if (
document.readyState === "complete" ||
document.readyState === "interactive"
) {
tableSortJs();
} else if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", tableSortJs, false);
}
if (typeof module == "object") {
module.exports = tableSortJs;
}