/* SortTable version 2 (Modernized) Original: 7th April 2007, Stuart Langridge, http://www.kryogenix.org/code/browser/sorttable/ Modernized: [Current Date] Instructions: Download this file Add to your HTML Add class="sortable" to any table you'd like to make sortable Click on the headers to sort Licenced as X11: http://www.kryogenix.org/code/browser/licence.html This basically means: do what you want with it. */ const sorttable = { DATE_RE: /^(\d{1,2})[\/\.-](\d{1,2})[\/\.-]((\d{2})?\d{2})$/, // Regex for date parsing dd/mm/yyyy or mm/dd/yyyy SORT_COLUMN_INDEX: 'sorttable_columnindex', // Custom attribute to store column index init: function() { // quit if this function has already been called if (this.initialized) return; this.initialized = true; if (!document.createElement || !document.getElementsByTagName) return; document.querySelectorAll('table.sortable').forEach(table => { this.makeSortable(table); }); }, makeSortable: function(table) { if (!table.tHead) { // table doesn't have a tHead. Create one and put the first row in it. const the = document.createElement('thead'); if (table.rows.length > 0) { the.appendChild(table.rows[0]); table.insertBefore(the, table.firstChild); } else { // Cannot make an empty table sortable return; } } // Ensure tHead is correctly referenced (needed for some older browser compatibility, safe otherwise) if (table.tHead == null) table.tHead = table.getElementsByTagName('thead')[0]; if (table.tHead.rows.length !== 1) return; // Can't cope with multiple header rows // Handle backwards compatibility for "sortbottom" class (move to tfoot) const sortbottomrows = []; // Use Array.from for iterating HTMLCollection Array.from(table.rows).forEach(row => { if (row.classList.contains('sortbottom')) { sortbottomrows.push(row); } }); if (sortbottomrows.length > 0) { let tfo = table.tFoot; if (!tfo) { // table doesn't have a tfoot. Create one. tfo = document.createElement('tfoot'); table.appendChild(tfo); } sortbottomrows.forEach(row => { tfo.appendChild(row); }); } // Work through each header cell const headrow = table.tHead.rows[0].cells; for (let i = 0; i < headrow.length; i++) { const cell = headrow[i]; // Skip columns with 'sorttable_nosort' class if (!cell.classList.contains('sorttable_nosort')) { let sortFunc; // Check for manual override sorttable_type const match = cell.className.match(/\bsorttable_([a-z0-9]+)\b/); const override = match ? match[1] : null; if (override && typeof this[`sort_${override}`] === 'function') { sortFunc = this[`sort_${override}`]; } else { sortFunc = this.guessType(table, i); } // Make header clickable cell.sorttable_sortfunction = sortFunc; cell.setAttribute(this.SORT_COLUMN_INDEX, i); // Store index using attribute // Use standard addEventListener cell.addEventListener('click', (e) => this.headerClick(e)); // Add visual cue for sortable columns cell.style.cursor = 'pointer'; } } }, headerClick: function(e) { const cell = e.currentTarget; // The header cell that was clicked const table = cell.closest('table'); const columnIndex = parseInt(cell.getAttribute(this.SORT_COLUMN_INDEX), 10); const tbody = table.tBodies[0]; if (!tbody || isNaN(columnIndex)) return; // Safety check const sortFunction = cell.sorttable_sortfunction; const isSorted = cell.classList.contains('sorttable_sorted'); const isSortedReverse = cell.classList.contains('sorttable_sorted_reverse'); // Function to update sort indicators const updateIndicator = (targetCell, direction) => { // Remove existing indicators targetCell.querySelectorAll('.sorttable_sortindicator').forEach(span => span.remove()); // Add new indicator const indicator = document.createElement('span'); indicator.className = 'sorttable_sortindicator'; indicator.innerHTML = direction === 'forward' ? ' ▾' : ' ▴'; // Down / Up arrow targetCell.appendChild(indicator); }; // Remove sorting classes and indicators from all headers in this row cell.parentNode.querySelectorAll('th, td').forEach(siblingCell => { siblingCell.classList.remove('sorttable_sorted', 'sorttable_sorted_reverse'); siblingCell.querySelectorAll('.sorttable_sortindicator').forEach(span => span.remove()); }); if (isSorted) { // If already sorted by this column, just reverse the table body this.reverse(tbody); cell.classList.add('sorttable_sorted_reverse'); updateIndicator(cell, 'reverse'); } else if (isSortedReverse) { // If sorted reverse, sort forward again (effectively re-reversing) // This requires a full sort, not just reversing the current order this.fullSort(tbody, columnIndex, sortFunction); cell.classList.add('sorttable_sorted'); updateIndicator(cell, 'forward'); } else { // New sort this.fullSort(tbody, columnIndex, sortFunction); cell.classList.add('sorttable_sorted'); updateIndicator(cell, 'forward'); } }, fullSort: function(tbody, columnIndex, sortFunction) { // Build an array to sort (Schwartzian transform) const rowArray = []; Array.from(tbody.rows).forEach(row => { const cell = row.cells[columnIndex]; const sortKey = this.getInnerText(cell); rowArray.push([sortKey, row]); }); // Sort the array using the determined sort function rowArray.sort(sortFunction); // Append rows back to the tbody in the new order rowArray.forEach(item => { tbody.appendChild(item[1]); }); }, guessType: function(table, column) { // Guess the type of a column based on its first non-blank row let sortfn = this.sort_alpha; // Default to alpha sort if (!table.tBodies || !table.tBodies[0]) return sortfn; // No body to guess from const tbody = table.tBodies[0]; for (let i = 0; i < tbody.rows.length; i++) { const cell = tbody.rows[i].cells[column]; if (!cell) continue; // Skip if cell doesn't exist const text = this.getInnerText(cell); if (text !== '') { // Check for numeric types (including currency and percentages) if (text.match(/^-?[\£$¤]?[\d,.]+%?$/)) { return this.sort_numeric; } // Check for a date: dd/mm/yyyy or dd/mm/yy or mm/dd/yyyy etc. const possdate = text.match(this.DATE_RE); if (possdate) { // Looks like a date const first = parseInt(possdate[1], 10); const second = parseInt(possdate[2], 10); if (first > 12) { // Definitely dd/mm return this.sort_ddmm; } else if (second > 12) { // Definitely mm/dd return this.sort_mmdd; } else { // Ambiguous (e.g., 01/02/2023). Default to dd/mm but continue checking other rows. // If a later row is unambiguously mm/dd, that will take precedence. sortfn = this.sort_ddmm; } } } } return sortfn; }, getInnerText: function(node) { // Gets the text we want to use for sorting for a cell. // Strips leading and trailing whitespace. // Special handling for custom key attribute and input fields. if (!node) return ""; // Check for custom sort key attribute first const customKey = node.getAttribute("sorttable_customkey"); if (customKey != null) { return customKey; } // Handle input fields if (node.tagName === 'INPUT' && node.value) { return node.value.trim(); } // Use textContent for modern browsers (strips tags) if (typeof node.textContent !== 'undefined') { return node.textContent.trim(); } // Fallback (might include tags in older environments, less likely needed now) if (typeof node.innerText !== 'undefined') { return node.innerText.trim(); } // Recursive fallback for complex node structures (rarely needed with textContent) let innerText = ''; Array.from(node.childNodes).forEach(child => { innerText += this.getInnerText(child); }); return innerText.trim(); }, reverse: function(tbody) { // Reverse the rows in a tbody const rows = Array.from(tbody.rows); rows.reverse().forEach(row => tbody.appendChild(row)); }, /* === Sort Functions === Each sort function takes two parameters, a and b (arrays from fullSort: [sortKey, rowElement]) Compare a[0] and b[0] */ sort_numeric: function(a, b) { // Clean string, parse float, default to 0 if NaN const aa = parseFloat(String(a[0]).replace(/[^0-9.-]/g, '')) || 0; const bb = parseFloat(String(b[0]).replace(/[^0-9.-]/g, '')) || 0; return aa - bb; }, sort_alpha: function(a, b) { const strA = String(a[0]).toLowerCase(); const strB = String(b[0]).toLowerCase(); if (strA === strB) return 0; if (strA < strB) return -1; return 1; }, // Helper for date sorting _parseDate: function(text, format) { const match = text.match(sorttable.DATE_RE); if (!match) return 0; // Or handle as invalid date let year = parseInt(match[3], 10); let month, day; if (format === 'ddmm') { day = parseInt(match[1], 10); month = parseInt(match[2], 10); } else { // mmdd month = parseInt(match[1], 10); day = parseInt(match[2], 10); } // Handle 2-digit years (assume 20xx or 19xx) if (match[4]) { // If year had only 2 digits initially year += (year < 70 ? 2000 : 1900); // Adjust century (adjust threshold if needed) } // Pad month and day for consistent string comparison YYYYMMDD const mm = String(month).padStart(2, '0'); const dd = String(day).padStart(2, '0'); return parseInt(`${year}${mm}${dd}`, 10); }, sort_ddmm: function(a, b) { const dt1 = sorttable._parseDate(a[0], 'ddmm'); const dt2 = sorttable._parseDate(b[0], 'ddmm'); return dt1 - dt2; }, sort_mmdd: function(a, b) { const dt1 = sorttable._parseDate(a[0], 'mmdd'); const dt2 = sorttable._parseDate(b[0], 'mmdd'); return dt1 - dt2; }, // shaker_sort (stable sort) is generally not needed as Array.prototype.sort // is stable in modern JavaScript engines (ES2019+). Kept for reference if needed. /* shaker_sort: function(list, comp_func) { // ... (original implementation using let/const) ... } */ }; // --- Initialization --- // Use DOMContentLoaded which is more reliable and fires earlier than window.onload if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => sorttable.init()); } else { // Handle cases where the script is loaded after DOMContentLoaded sorttable.init(); }