diff --git a/public/table-sort.mjs b/public/table-sort.mjs new file mode 100644 index 0000000..6bfb922 --- /dev/null +++ b/public/table-sort.mjs @@ -0,0 +1,620 @@ +/* +table-sort-js +Author: Lee Wannacott +Licence: MIT License Copyright (c) 2021 Lee Wannacott + +GitHub Repository: https://github.com/LeeWannacott/table-sort-js +npm package: https://www.npmjs.com/package/table-sort-js +Demo: https://leewannacott.github.io/Portfolio/#/GitHub +Install: +Frontend: or +Download this file and add to your HTML +Backend: npm install table-sort-js and use require("../node_modules/table-sort-js/table-sort.js") +Instructions: + Add class="table-sort" to tables you'd like to make sortable + Click on the table headers to sort them. +*/ + +export 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 or exists below the browser will make + 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"), + tableArrows: sortableTable.classList.contains("table-arrows"), + rememberSort: sortableTable.classList.contains("remember-sort"), + }; + 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(arrowUp, arrowDown, initialArrow = "↕") { + th.innerHTML = th.innerHTML.replace(initialArrow, ""); + th.innerHTML = th.innerHTML.replace(arrowUp, ""); + th.innerHTML = th.innerHTML.replace(arrowDown, ""); + } + + if (column.toBeSorted[0] === undefined) { + return; + } + + function changeTableArrow(arrowDirection) { + if (table.hasClass.tableArrows) { + clearArrows(arrow.up, arrow.down); + th.insertAdjacentText("beforeend", arrowDirection); + } + } + + function sortColumn(sortDirection) { + column.toBeSorted.sort(sortDirection, { + numeric: !isAlphaSort, + ignorePunctuation: !isPunctSort, + }); + } + + if (timesClickedColumn === 1) { + if (desc) { + changeTableArrow(arrow.down); + sortColumn(sortDescending); + } else { + changeTableArrow(arrow.up); + sortColumn(sortAscending); + } + } else if (timesClickedColumn === 2) { + timesClickedColumn = 0; + if (desc) { + changeTableArrow(arrow.up); + sortColumn(sortAscending); + } else { + changeTableArrow(arrow.down); + sortColumn(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"); + const initialArrow = " ↕"; + const arrow = { up: " ↑", down: " ↓" }; + const fillValue = "!X!Y!Z!"; + + if (table.hasClass.tableArrows) { + th.insertAdjacentText("beforeend", initialArrow); + } + + 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(); + } + } +}