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..890b93f90 --- /dev/null +++ b/taccsite_cms/static/site_cms/css/modules/sortableTable.css @@ -0,0 +1,44 @@ +/** + * Styles for CMS tables with class `is-sortable` + * IMPORTANT: Coupled with sortableTable.js + */ + +table.is-sortable { + /* Buttons */ + & .is-sortable__sort { + width: 100%; /* so entire cell is clickable */ + + /* To improve legibility during visible focus */ + &:focus-visible { + outline-offset: 0.25em; + } + + /* So known button styles have no ill effect */ + &.btn-link, + &.c-button--as-link { + line-height: inherit; + height: 1lh; + justify-content: start; + vertical-align: baseline; + } + &.c-button--as-link { + height: 1lh; + } + &.btn-link { + padding: 0; + } + } + + /* Sort indicators */ + & th[aria-sort="ascending"] .is-sortable__sort::after { content: '▲'; } + & th[aria-sort="descending"] .is-sortable__sort::after { content: '▼'; } + & th[aria-sort="ascending"] .is-sortable__sort, + & th[aria-sort="descending"] .is-sortable__sort { + &::after { + font-size: smaller; + } + &:not(.c-button--as-link)::after { + margin-inline-start: 0.35em; + } + } +} 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..e1a5d01af --- /dev/null +++ b/taccsite_cms/static/site_cms/js/modules/sortableTable.js @@ -0,0 +1,188 @@ +/** + * Client-side sort for CMS tables with class `is-sortable` + * IMPORTANT: Coupled with `sortableTable.css` + * + * TODO: Consider adding to TACC/Core-Components + * NOTE: Not using tristen/tablesort cuz it forgoes button in header cell (a11y) + * SEE: https://github.com/tristen/tablesort + * SEE: https://www.w3.org/WAI/ARIA/apg/patterns/table/examples/sortable-table/ + * + * Editor markup: + * - Table: `o-fixed-header-table is-sortable` + * - Non-sortable column: `th.is-not-sortable` (e.g. Description) + */ + +const SORT_TABLE_CLASS = 'is-sortable'; +const NOT_SORTABLE_CLASS = 'is-not-sortable'; +const SORT_BUTTON_CLASS = 'is-sortable__sort'; + +const DEFAULT_TABLE_SELECTOR = 'table.' + SORT_TABLE_CLASS; +const NOT_SORTABLE_SELECTOR = 'th.' + NOT_SORTABLE_CLASS; + +/** + * @param {HTMLTableCellElement | undefined} cell + * @param {{ table: HTMLTableElement, warnedMissingCell?: boolean }} [logContext] + * @returns {string} + */ +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 + * @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; + const logContext = { table, warnedMissingCell: false }; + + rows.sort((rowA, rowB) => { + const a = getSortValue(rowA.cells[columnIndex], logContext); + const b = getSortValue(rowB.cells[columnIndex], logContext); + 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 + * @param {string} buttonClass + */ +function initSortableTable(table, notSortableSelector, buttonClass) { + const headerRow = table.tHead?.rows[0]; + if (!headerRow) { + return; + } + + warnIfIrregularRows(table, headerRow); + + /** @type {HTMLTableCellElement[]} */ + const sortableHeaders = []; + + [ ...headerRow.cells ].forEach((cell, index) => { + if (!(cell instanceof HTMLTableCellElement)) { + return; + } + if (cell.matches(notSortableSelector)) { + cell.classList.add(NOT_SORTABLE_CLASS); + 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 = buttonClass + ' ' + SORT_BUTTON_CLASS; + 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.is-sortable] + * @param {string} [options.notSortableSelector=th.is-not-sortable] + * @param {string} [options.buttonClass=''] // e.g. 'c-button c-button--as-link' + */ +export default function sortableTable({ + scopeElement = document, + tableSelector = DEFAULT_TABLE_SELECTOR, + notSortableSelector = NOT_SORTABLE_SELECTOR, + buttonClass = '', +} = {}) { + scopeElement.querySelectorAll(tableSelector).forEach((table) => { + if (table instanceof HTMLTableElement) { + initSortableTable(table, notSortableSelector, buttonClass); + } + }); +} diff --git a/taccsite_cms/templates/assets_core_delayed.html b/taccsite_cms/templates/assets_core_delayed.html index e349ebabc..f50073914 100644 --- a/taccsite_cms/templates/assets_core_delayed.html +++ b/taccsite_cms/templates/assets_core_delayed.html @@ -8,8 +8,10 @@ {# TACC/Core-CMS #} +