From 1fe254437c433b4dd38d6f7403b39d36eb4b3c06 Mon Sep 17 00:00:00 2001 From: Wesley B <62723358+wesleyboar@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:54:40 -0500 Subject: [PATCH 01/13] feat(cms): add sortable project table module Vanilla sort for table.o-sortable-table (not tablesort: APG button headers). Init from assets_core_delayed on #cms-content. Co-authored-by: Cursor --- .../site_cms/js/modules/sortableTable.js | 140 ++++++++++++++++++ .../templates/assets_core_delayed.html | 3 + 2 files changed, 143 insertions(+) create mode 100644 taccsite_cms/static/site_cms/js/modules/sortableTable.js diff --git a/taccsite_cms/static/site_cms/js/modules/sortableTable.js b/taccsite_cms/static/site_cms/js/modules/sortableTable.js new file mode 100644 index 000000000..00ad68a33 --- /dev/null +++ b/taccsite_cms/static/site_cms/js/modules/sortableTable.js @@ -0,0 +1,140 @@ +/** + * Client-side sort for CMS tables marked with `table.o-sortable-table`. + * + * Not using tristen/tablesort: it sorts focusable (Enter/aria-sort) without a + * button in the header (W3C APG); no `th.is-not-sortable` or link-text sort keys. + * Matching our editor/CSS contract would duplicate this module. + * + * Editor markup: + * - Table: `o-fixed-header-table o-sortable-table` + * - Non-sortable column: `th.is-not-sortable` (e.g. Description) + */ + +const DEFAULT_TABLE_SELECTOR = 'table.o-sortable-table'; +const NOT_SORTABLE_SELECTOR = 'th.is-not-sortable'; + +/** + * @param {HTMLTableCellElement} cell + * @returns {string} + */ +function getSortValue(cell) { + const link = cell.querySelector('a'); + const text = link ? link.textContent : cell.textContent; + return (text ?? '').trim(); +} + +/** + * @param {HTMLTableElement} table + * @param {number} columnIndex + * @param {'ascending' | 'descending'} direction + */ +function sortTable(table, columnIndex, direction) { + const tbody = table.tBodies[0]; + if (!tbody) { + return; + } + + const rows = [ ...tbody.rows ]; + const multiplier = direction === 'ascending' ? 1 : -1; + + rows.sort((rowA, rowB) => { + const a = getSortValue(rowA.cells[columnIndex]); + const b = getSortValue(rowB.cells[columnIndex]); + return multiplier * a.localeCompare(b, undefined, { sensitivity: 'base' }); + }); + + rows.forEach((row) => tbody.appendChild(row)); +} + +/** + * @param {HTMLTableCellElement} headerCell + * @param {'ascending' | 'descending' | 'none'} ariaSort + */ +function setHeaderSortState(headerCell, ariaSort) { + headerCell.setAttribute('aria-sort', ariaSort); + const button = headerCell.querySelector('button'); + if (button) { + button.setAttribute( + 'aria-label', + ariaSort === 'none' + ? headerCell.dataset.sortLabel + : `${headerCell.dataset.sortLabel}, sorted ${ariaSort}` + ); + } +} + +/** + * @param {HTMLTableElement} table + * @param {string} notSortableSelector + */ +function initSortableTable(table, notSortableSelector) { + const headerRow = table.tHead?.rows[0]; + if (!headerRow) { + return; + } + + /** @type {HTMLTableCellElement[]} */ + const sortableHeaders = []; + + [ ...headerRow.cells ].forEach((cell, index) => { + if (!(cell instanceof HTMLTableCellElement)) { + return; + } + if (cell.matches(notSortableSelector)) { + cell.classList.add('is-not-sortable'); + return; + } + + const label = cell.textContent?.trim() ?? `Column ${index + 1}`; + cell.dataset.sortLabel = label; + cell.innerHTML = ''; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'o-sortable-table__sort'; + button.textContent = label; + cell.append(button); + + button.addEventListener('click', () => { + const current = cell.getAttribute('aria-sort'); + const next = + current === 'ascending' ? 'descending' : 'ascending'; + + sortableHeaders.forEach((other) => { + if (other !== cell) { + setHeaderSortState(other, 'none'); + } + }); + + setHeaderSortState(cell, next); + sortTable(table, index, next); + }); + + sortableHeaders.push(cell); + }); + + if (sortableHeaders.length) { + const first = sortableHeaders[0]; + const firstIndex = [ ...headerRow.cells ].indexOf(first); + setHeaderSortState(first, 'ascending'); + sortTable(table, firstIndex, 'ascending'); + } +} + +/** + * @param {object} [options] + * @param {ParentNode} [options.scopeElement=document] + * @param {string} [options.tableSelector=table.o-sortable-table] + * @param {string} [options.notSortableSelector=th.is-not-sortable] + */ +export default function sortableTable({ + scopeElement = document, + tableSelector = DEFAULT_TABLE_SELECTOR, + notSortableSelector = NOT_SORTABLE_SELECTOR, +} = {}) { + scopeElement.querySelectorAll(tableSelector).forEach((table) => { + if (table instanceof HTMLTableElement) { + initSortableTable(table, notSortableSelector); + } + }); +} diff --git a/taccsite_cms/templates/assets_core_delayed.html b/taccsite_cms/templates/assets_core_delayed.html index e349ebabc..75ba5ddbf 100644 --- a/taccsite_cms/templates/assets_core_delayed.html +++ b/taccsite_cms/templates/assets_core_delayed.html @@ -10,6 +10,7 @@ {# TACC/Core-CMS #} From b981afbff8f5e965a7a1e212a379d156a96d587a Mon Sep 17 00:00:00 2001 From: Wesley B <62723358+wesleyboar@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:21:51 -0500 Subject: [PATCH 02/13] fix(cms): guard sortable table when rows lack cells Return empty sort keys and warn in the console so CMS editors can fix markup. Co-authored-by: Cursor --- .../site_cms/js/modules/sortableTable.js | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/taccsite_cms/static/site_cms/js/modules/sortableTable.js b/taccsite_cms/static/site_cms/js/modules/sortableTable.js index 00ad68a33..2ac10adbd 100644 --- a/taccsite_cms/static/site_cms/js/modules/sortableTable.js +++ b/taccsite_cms/static/site_cms/js/modules/sortableTable.js @@ -14,15 +14,51 @@ const DEFAULT_TABLE_SELECTOR = 'table.o-sortable-table'; const NOT_SORTABLE_SELECTOR = 'th.is-not-sortable'; /** - * @param {HTMLTableCellElement} cell + * @param {HTMLTableCellElement | undefined} cell + * @param {{ table: HTMLTableElement, warnedMissingCell?: boolean }} [logContext] * @returns {string} */ -function getSortValue(cell) { +function getSortValue(cell, logContext) { + if (!cell) { + if (logContext && !logContext.warnedMissingCell) { + logContext.warnedMissingCell = true; + console.warn( + '[sortableTable] A row is missing a cell for the sorted column. Use the same number of columns on every row in the CMS table (watch colspan/rowspan).', + logContext.table + ); + } + return ''; + } + const link = cell.querySelector('a'); const text = link ? link.textContent : cell.textContent; return (text ?? '').trim(); } +/** + * @param {HTMLTableElement} table + * @param {HTMLTableRowElement} headerRow + */ +function warnIfIrregularRows(table, headerRow) { + const tbody = table.tBodies[0]; + if (!tbody) { + return; + } + + const expected = headerRow.cells.length; + + for (let i = 0; i < tbody.rows.length; i++) { + const row = tbody.rows[i]; + if (row.cells.length < expected) { + console.warn( + `[sortableTable] Row ${i + 1} has ${row.cells.length} cells but the header has ${expected}. Fix table markup in the CMS before publishing.`, + table + ); + return; + } + } +} + /** * @param {HTMLTableElement} table * @param {number} columnIndex @@ -36,10 +72,11 @@ function sortTable(table, columnIndex, direction) { const rows = [ ...tbody.rows ]; const multiplier = direction === 'ascending' ? 1 : -1; + const logContext = { table, warnedMissingCell: false }; rows.sort((rowA, rowB) => { - const a = getSortValue(rowA.cells[columnIndex]); - const b = getSortValue(rowB.cells[columnIndex]); + const a = getSortValue(rowA.cells[columnIndex], logContext); + const b = getSortValue(rowB.cells[columnIndex], logContext); return multiplier * a.localeCompare(b, undefined, { sensitivity: 'base' }); }); @@ -73,6 +110,8 @@ function initSortableTable(table, notSortableSelector) { return; } + warnIfIrregularRows(table, headerRow); + /** @type {HTMLTableCellElement[]} */ const sortableHeaders = []; From cda57a3d82942eefb8c0c2da46cf996d7fa60564 Mon Sep 17 00:00:00 2001 From: Wesley B <62723358+wesleyboar@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:37:36 -0500 Subject: [PATCH 03/13] refactor(cms): move sortable table styles to is-sortable module Pair sortableTable.css with JS; use table.is-sortable and c-button--as-link. Note Core-Components as future home. Co-authored-by: Cursor --- .../site_cms/css/modules/sortableTable.css | 32 +++++++++++++++++++ .../js/modules/README-sortableTable.md | 9 ++++++ .../site_cms/js/modules/sortableTable.js | 18 ++++++----- .../templates/assets_core_delayed.html | 1 + 4 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 taccsite_cms/static/site_cms/css/modules/sortableTable.css create mode 100644 taccsite_cms/static/site_cms/js/modules/README-sortableTable.md diff --git a/taccsite_cms/static/site_cms/css/modules/sortableTable.css b/taccsite_cms/static/site_cms/css/modules/sortableTable.css new file mode 100644 index 000000000..3317b2575 --- /dev/null +++ b/taccsite_cms/static/site_cms/css/modules/sortableTable.css @@ -0,0 +1,32 @@ +/** + * Coupled with sortableTable.js (table.is-sortable). + * Table look and feel come from Core-Styles base (elements/table). + * TODO: Consider a shared sortable-table component in TACC/Core-Components. + */ + +table.is-sortable thead th:not(.is-not-sortable) { + padding: 0; +} + +table.is-sortable .is-sortable__sort { + width: 100%; +} + +table.is-sortable .is-sortable__sort:focus-visible { + outline: 2px solid currentColor; + outline-offset: -2px; +} + +table.is-sortable th[aria-sort='ascending'] .is-sortable__sort::after, +table.is-sortable th[aria-sort='descending'] .is-sortable__sort::after { + margin-inline-start: 0.35em; + font-size: 0.85em; +} + +table.is-sortable th[aria-sort='ascending'] .is-sortable__sort::after { + content: '▲'; +} + +table.is-sortable th[aria-sort='descending'] .is-sortable__sort::after { + content: '▼'; +} diff --git a/taccsite_cms/static/site_cms/js/modules/README-sortableTable.md b/taccsite_cms/static/site_cms/js/modules/README-sortableTable.md new file mode 100644 index 000000000..37ac98dc1 --- /dev/null +++ b/taccsite_cms/static/site_cms/js/modules/README-sortableTable.md @@ -0,0 +1,9 @@ +# `sortableTable` module + +Client-side sort for CMS tables with class `is-sortable` on the ``. + +Load **`sortableTable.css`** and **`sortableTable.js`** together (see `assets_core_delayed.html` or the tup-ui **research-projects-assets** snippet). + +Editors: `o-fixed-header-table is-sortable`; non-sortable column: `th.is-not-sortable`. + +**TODO:** Consider elevating this behavior to [TACC/Core-Components](https://github.com/TACC/Core-Components) as a reusable table-sort component. diff --git a/taccsite_cms/static/site_cms/js/modules/sortableTable.js b/taccsite_cms/static/site_cms/js/modules/sortableTable.js index 2ac10adbd..6eefd3592 100644 --- a/taccsite_cms/static/site_cms/js/modules/sortableTable.js +++ b/taccsite_cms/static/site_cms/js/modules/sortableTable.js @@ -1,16 +1,18 @@ /** - * Client-side sort for CMS tables marked with `table.o-sortable-table`. + * Client-side sort for CMS tables with class `is-sortable` on the table. * - * Not using tristen/tablesort: it sorts focusable
(Enter/aria-sort) without a - * button in the header (W3C APG); no `th.is-not-sortable` or link-text sort keys. - * Matching our editor/CSS contract would duplicate this module. + * Load with `sortableTable.css` (same folder under `site_cms/css/modules/`). + * TODO: Consider TACC/Core-Components as a shared sortable-table component. + * + * Not using tristen/tablesort: focusable `` only; no button-in-header (APG); + * no `th.is-not-sortable` or link-text sort keys without duplicating this module. * * Editor markup: - * - Table: `o-fixed-header-table o-sortable-table` + * - Table: `o-fixed-header-table is-sortable` * - Non-sortable column: `th.is-not-sortable` (e.g. Description) */ -const DEFAULT_TABLE_SELECTOR = 'table.o-sortable-table'; +const DEFAULT_TABLE_SELECTOR = 'table.is-sortable'; const NOT_SORTABLE_SELECTOR = 'th.is-not-sortable'; /** @@ -130,7 +132,7 @@ function initSortableTable(table, notSortableSelector) { const button = document.createElement('button'); button.type = 'button'; - button.className = 'o-sortable-table__sort'; + button.className = 'c-button c-button--as-link is-sortable__sort'; button.textContent = label; cell.append(button); @@ -163,7 +165,7 @@ function initSortableTable(table, notSortableSelector) { /** * @param {object} [options] * @param {ParentNode} [options.scopeElement=document] - * @param {string} [options.tableSelector=table.o-sortable-table] + * @param {string} [options.tableSelector=table.is-sortable] * @param {string} [options.notSortableSelector=th.is-not-sortable] */ export default function sortableTable({ diff --git a/taccsite_cms/templates/assets_core_delayed.html b/taccsite_cms/templates/assets_core_delayed.html index 75ba5ddbf..f50073914 100644 --- a/taccsite_cms/templates/assets_core_delayed.html +++ b/taccsite_cms/templates/assets_core_delayed.html @@ -8,6 +8,7 @@ {# TACC/Core-CMS #} +