From c20db0f91499b0c3a87f1e4827b2178f63758a85 Mon Sep 17 00:00:00 2001 From: Luke Fretwell Date: Mon, 27 Jul 2026 12:53:36 -0700 Subject: [PATCH] Migrate standards table to shared tablesort library Replaces the bespoke sort/icon script in standards-table-sort.html with the shared tristen/tablesort library synced from components - one less copy of the same hand-rolled sort+icon logic that also lives in scangov's profile_table.html and (until now) my.scangov.com. Header icon markup simplified from the three-span sort-neutral/asc/desc pattern to the CSS-driven version, since @11ty/font-awesome rewrites these tags into SVG sprites at build time. --- _includes/js.html | 2 + _includes/standards-table-sort.html | 37 ---- _includes/table_header.html | 8 +- content/index.njk | 2 +- content/topic.njk | 2 +- public/css/scangov.css | 41 ++++ public/js/tablesort-init.js | 3 + public/js/tablesort-vendor.js | 320 ++++++++++++++++++++++++++++ 8 files changed, 372 insertions(+), 43 deletions(-) create mode 100644 public/js/tablesort-init.js create mode 100644 public/js/tablesort-vendor.js diff --git a/_includes/js.html b/_includes/js.html index 33ce0e38..6f498c01 100644 --- a/_includes/js.html +++ b/_includes/js.html @@ -1,4 +1,6 @@ + + diff --git a/_includes/table_header.html b/_includes/table_header.html index 9b44aa72..d58a172d 100644 --- a/_includes/table_header.html +++ b/_includes/table_header.html @@ -1,8 +1,8 @@ - Indicators - Standard - Why - Guidance + Indicators + Standard + Why + Guidance \ No newline at end of file diff --git a/content/index.njk b/content/index.njk index 4e3ee8f8..d6f36694 100644 --- a/content/index.njk +++ b/content/index.njk @@ -12,7 +12,7 @@ topics: {% include "filter.html" %}
- +
{% include "table_header.html" %} diff --git a/content/topic.njk b/content/topic.njk index e8d74735..adf7d636 100644 --- a/content/topic.njk +++ b/content/topic.njk @@ -25,7 +25,7 @@ eleventyComputed: {% set value = audits[topic] %}
-
Digital experience standards
+
{% include "table_header.html" %} diff --git a/public/css/scangov.css b/public/css/scangov.css index ef764120..bf810aba 100644 --- a/public/css/scangov.css +++ b/public/css/scangov.css @@ -2817,6 +2817,47 @@ th a:focus i, th a:focus svg { text-decoration: none; } +/* Sortable tables (tablesort library, public/js/tablesort-vendor.js): the icon is + driven entirely by the aria-sort attribute the library sets on the sorted
{{ audits[topic].displayName }} standards
- + a plain CSS rule, not a second piece of JS guessing at sort state. */ +table[data-sortable] th[role="columnheader"] { + cursor: pointer; +} + +th[aria-sort="ascending"] .fa-sort { + --fa: "\f0de" !important; +} + +th[aria-sort="descending"] .fa-sort { + --fa: "\f0dd" !important; +} + +/* Same aria-sort-driven icon convention, expressed as three sibling icons instead + of one - needed on the Eleventy static sites, where @11ty/font-awesome rewrites + every into an inline SVG sprite at build time. A sprite's + shape is fixed by which it references, so it can't be repainted via a + CSS content/custom-property swap the way a font glyph can; toggling which of + three fixed icons is visible works regardless of that rewrite. */ +th .sort-asc, +th .sort-desc { + display: none; +} + +th[aria-sort="ascending"] .sort-neutral, +th[aria-sort="ascending"] .sort-desc, +th[aria-sort="descending"] .sort-neutral, +th[aria-sort="descending"] .sort-asc { + display: none; +} + +th[aria-sort="ascending"] .sort-asc { + display: inline; +} + +th[aria-sort="descending"] .sort-desc { + display: inline; +} + /* Docs */ #post h2 { margin-top: 2.5rem; diff --git a/public/js/tablesort-init.js b/public/js/tablesort-init.js new file mode 100644 index 00000000..c2b9b660 --- /dev/null +++ b/public/js/tablesort-init.js @@ -0,0 +1,3 @@ +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('table[data-sortable]').forEach(t => new Tablesort(t)); +}); diff --git a/public/js/tablesort-vendor.js b/public/js/tablesort-vendor.js new file mode 100644 index 00000000..33529701 --- /dev/null +++ b/public/js/tablesort-vendor.js @@ -0,0 +1,320 @@ +/* tristen/tablesort v5.7.1 - https://github.com/tristen/tablesort (MIT) */ +/* Core + number plugin, vendored (not an npm dependency). */ + +;(function() { + function Tablesort(el, options) { + if (!(this instanceof Tablesort)) return new Tablesort(el, options); + + if (!el || el.tagName !== 'TABLE') { + throw new Error('Element must be a table'); + } + this.init(el, options || {}); + } + + var sortOptions = []; + + var createEvent = function(name) { + var evt; + + if (!window.CustomEvent || typeof window.CustomEvent !== 'function') { + evt = document.createEvent('CustomEvent'); + evt.initCustomEvent(name, false, false, undefined); + } else { + evt = new CustomEvent(name); + } + + return evt; + }; + + var getInnerText = function(el, options) { + var sortAttribute = options.sortAttribute || 'data-sort'; + if (el.hasAttribute(sortAttribute)) { + return el.getAttribute(sortAttribute); + } + return el.textContent || el.innerText || ''; + }; + + // Default sort method if no better sort method is found + var caseInsensitiveSort = function(a, b) { + a = a.trim().toLowerCase(); + b = b.trim().toLowerCase(); + + if (a === b) return 0; + if (a < b) return 1; + + return -1; + }; + + var getCellByKey = function(cells, key) { + return [].slice.call(cells).find(function(cell) { + return cell.getAttribute('data-sort-column-key') === key; + }); + }; + + // Stable sort function + // If two elements are equal under the original sort function, + // then there relative order is reversed + var stabilize = function(sort, antiStabilize) { + return function(a, b) { + var unstableResult = sort(a.td, b.td); + + if (unstableResult === 0) { + if (antiStabilize) return b.index - a.index; + return a.index - b.index; + } + + return unstableResult; + }; + }; + + Tablesort.extend = function(name, pattern, sort) { + if (typeof pattern !== 'function' || typeof sort !== 'function') { + throw new Error('Pattern and sort must be a function'); + } + + sortOptions.push({ + name: name, + pattern: pattern, + sort: sort + }); + }; + + Tablesort.prototype = { + + init: function(el, options) { + var that = this, + firstRow, + defaultSort, + i, + cell; + + that.table = el; + that.thead = false; + that.options = options; + + if (el.rows && el.rows.length > 0) { + if (el.tHead && el.tHead.rows.length > 0) { + for (i = 0; i < el.tHead.rows.length; i++) { + if (el.tHead.rows[i].getAttribute('data-sort-method') === 'thead') { + firstRow = el.tHead.rows[i]; + break; + } + } + if (!firstRow) { + firstRow = el.tHead.rows[el.tHead.rows.length - 1]; + } + that.thead = true; + } else { + firstRow = el.rows[0]; + } + } + + if (!firstRow) return; + + var onClick = function() { + if (that.current && that.current !== this) { + that.current.removeAttribute('aria-sort'); + } + + that.current = this; + that.sortTable(this); + }; + + // Assume first row is the header and attach a click handler to each. + for (i = 0; i < firstRow.cells.length; i++) { + cell = firstRow.cells[i]; + cell.setAttribute('role','columnheader'); + if (cell.getAttribute('data-sort-method') !== 'none') { + cell.tabIndex = 0; + cell.addEventListener('click', onClick, false); + + cell.addEventListener('keydown', function (event) { + if (event.key === "Enter") { + event.preventDefault(); + onClick.call(this); + } + }); + + if (cell.getAttribute('data-sort-default') !== null) { + defaultSort = cell; + } + } + } + + if (defaultSort) { + that.current = defaultSort; + that.sortTable(defaultSort); + } + }, + + sortTable: function(header, update) { + var that = this, + columnKey = header.getAttribute('data-sort-column-key'), + column = header.cellIndex, + sortFunction = caseInsensitiveSort, + item = '', + items = [], + i = that.thead ? 0 : 1, + sortMethod = header.getAttribute('data-sort-method'), + sortReverse = header.hasAttribute('data-sort-reverse'), + sortOrder = header.getAttribute('aria-sort'); + + that.table.dispatchEvent(createEvent('beforeSort')); + + // If updating an existing sort, direction should remain unchanged. + if (!update) { + if (sortOrder === 'ascending') { + sortOrder = 'descending'; + } else if (sortOrder === 'descending') { + sortOrder = 'ascending'; + } else { + sortOrder = !!that.options.descending != sortReverse ? 'descending' : 'ascending'; + } + + header.setAttribute('aria-sort', sortOrder); + } + + if (that.table.rows.length < 2) return; + + // If we force a sort method, it is not necessary to check rows + if (!sortMethod) { + var cell; + while (items.length < 3 && i < that.table.tBodies[0].rows.length) { + if(columnKey) { + cell = getCellByKey(that.table.tBodies[0].rows[i].cells, columnKey); + } else { + cell = that.table.tBodies[0].rows[i].cells[column]; + } + + // Treat missing cells as empty cells + item = cell ? getInnerText(cell,that.options) : ""; + + item = item.trim(); + + if (item.length > 0) { + items.push(item); + } + + i++; + } + + if (!items) return; + } + + for (i = 0; i < sortOptions.length; i++) { + item = sortOptions[i]; + + if (sortMethod) { + if (item.name === sortMethod) { + sortFunction = item.sort; + break; + } + } else if (items.every(item.pattern)) { + sortFunction = item.sort; + break; + } + } + + that.col = column; + + for (i = 0; i < that.table.tBodies.length; i++) { + var newRows = [], + noSorts = {}, + j, + totalRows = 0, + noSortsSoFar = 0; + + if (that.table.tBodies[i].rows.length < 2) continue; + + for (j = 0; j < that.table.tBodies[i].rows.length; j++) { + var cell; + + item = that.table.tBodies[i].rows[j]; + if (item.getAttribute('data-sort-method') === 'none') { + // keep no-sorts in separate list to be able to insert + // them back at their original position later + noSorts[totalRows] = item; + } else { + if (columnKey) { + cell = getCellByKey(item.cells, columnKey); + } else { + cell = item.cells[that.col]; + } + // Save the index for stable sorting + newRows.push({ + tr: item, + td: cell ? getInnerText(cell,that.options) : '', + index: totalRows + }); + } + totalRows++; + } + // Before we append should we reverse the new array or not? + // If we reverse, the sort needs to be `anti-stable` so that + // the double negatives cancel out + if (sortOrder === 'descending') { + newRows.sort(stabilize(sortFunction, true)); + } else { + newRows.sort(stabilize(sortFunction, false)); + newRows.reverse(); + } + + // append rows that already exist rather than creating new ones + for (j = 0; j < totalRows; j++) { + if (noSorts[j]) { + // We have a no-sort row for this position, insert it here. + item = noSorts[j]; + noSortsSoFar++; + } else { + item = newRows[j - noSortsSoFar].tr; + } + + // appendChild(x) moves x if already present somewhere else in the DOM + that.table.tBodies[i].appendChild(item); + } + } + + that.table.dispatchEvent(createEvent('afterSort')); + }, + + refresh: function() { + if (this.current !== undefined) { + this.sortTable(this.current, true); + } + } + }; + + if (typeof module !== 'undefined' && module.exports) { + module.exports = Tablesort; + } else { + window.Tablesort = Tablesort; + } +})(); + +/* tablesort.number.js - number sort plugin */ +(function(){ + var cleanNumber = function(i) { + return i.replace(/[^\-?0-9.]/g, ''); + }, + + compareNumber = function(a, b) { + a = parseFloat(a); + b = parseFloat(b); + + a = isNaN(a) ? 0 : a; + b = isNaN(b) ? 0 : b; + + return a - b; + }; + + Tablesort.extend('number', function(item) { + return item.match(/^[-+]?[£\x24Û¢´€]?\d+\s*([,\.]\d{0,2})/) || // Prefixed currency + item.match(/^[-+]?\d+\s*([,\.]\d{0,2})?[£\x24Û¢´€]/) || // Suffixed currency + item.match(/^[-+]?(\d)*-?([,\.]){0,1}-?(\d)+([E,e][\-+][\d]+)?%?$/); // Number + }, function(a, b) { + a = cleanNumber(a); + b = cleanNumber(b); + + return compareNumber(b, a); + }); +}());