diff --git a/docs/_data/tables.json b/docs/_data/tables.json index 2aafbc9066..3b68ebb323 100755 --- a/docs/_data/tables.json +++ b/docs/_data/tables.json @@ -62,7 +62,7 @@ }, { "attribute": "data-action=\"click->s-table#sort\"", - "applies": "th", + "applies": "button", "description": "Causes a click on the header cell to sort by this column" }, { diff --git a/docs/product/components/tables.html b/docs/product/components/tables.html index db9bf03df1..9c27001db5 100644 --- a/docs/product/components/tables.html +++ b/docs/product/components/tables.html @@ -756,7 +756,7 @@ {% for item in tables.data-attributes %} {{ item.attribute }} - {{ item.applies }} + {{ item.applies }} {{ item.description }} {% endfor %} @@ -771,19 +771,25 @@ - - - @@ -805,23 +811,29 @@
- Season - @Svg.ArrowUpSm.With("js-sorting-indicator js-sorting-indicator-asc d-none") - @Svg.ArrowDownSm.With("js-sorting-indicator js-sorting-indicator-desc d-none") - @Svg.ArrowUpDownSm.With("js-sorting-indicator js-sorting-indicator-none") + + - Starts in month - … + + - Typical temperature in °C - … + +
- - - diff --git a/lib/css/components/tables.less b/lib/css/components/tables.less index 5500af65de..300941fbdf 100644 --- a/lib/css/components/tables.less +++ b/lib/css/components/tables.less @@ -280,11 +280,26 @@ color: var(--fc-light); cursor: pointer; - // If an anchor is used, it should appear like a normal header - a { + // If this column is sortable, then the padding will come from the button + &[data-s-table-target="column"] { + padding: 0; + } + + // If an anchor is used, it should appear like a normal header + a, + button { color: inherit; } + button { + appearance: none; + background-color: transparent; + border: 0; + padding: var(--su8); + text-align: left; + width: 100%; + } + // Selected state &.is-sorted { color: var(--black-900); diff --git a/lib/ts/controllers/s-table.ts b/lib/ts/controllers/s-table.ts index ff7aa975cb..175a176d5f 100644 --- a/lib/ts/controllers/s-table.ts +++ b/lib/ts/controllers/s-table.ts @@ -1,41 +1,34 @@ import * as Stacks from "../stacks"; -export class TableController extends Stacks.StacksController { - static targets = ["column"]; - readonly columnTarget!: Element; - readonly columnTargets!: Element[]; - - setCurrentSort(headElem: Element, direction: "asc" | "desc" | "none") { - if (["asc", "desc", "none"].indexOf(direction) < 0) { - throw "direction must be one of asc, desc, or none" - } - // eslint-disable-next-line @typescript-eslint/no-this-alias - const controller = this; - this.columnTargets.forEach(function (target) { - const isCurrrent = target === headElem; +/** + * The string values of these enumerations should correspond with `aria-sort` valid values. + * + * @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-sort#values + */ +enum SortOrder { + Ascending = 'ascending', + Descending = 'descending', + None = 'none', +} - target.classList.toggle("is-sorted", isCurrrent && direction !== "none"); +export class TableController extends Stacks.StacksController { + readonly columnTarget!: HTMLTableCellElement; + readonly columnTargets!: HTMLTableCellElement[]; - target.querySelectorAll(".js-sorting-indicator").forEach(function (icon) { - const visible = isCurrrent ? direction : "none"; - icon.classList.toggle("d-none", !icon.classList.contains("js-sorting-indicator-" + visible)); - }); + static targets = ["column"]; - if (!isCurrrent || direction === "none") { - controller.removeElementData(target, "sort-direction"); - } else { - controller.setElementData(target, "sort-direction", direction); - } - }); - }; + connect() { + this.columnTargets.forEach(this.ensureHeadersAreClickable); + } - sort(evt: Event) { + sort(evt: PointerEvent) { // eslint-disable-next-line @typescript-eslint/no-this-alias const controller = this; - const colHead = evt.currentTarget; - if (!(colHead instanceof HTMLTableCellElement)) { + const sortTriggerEl = evt.currentTarget; + if (!(sortTriggerEl instanceof HTMLButtonElement)) { throw "invalid event target"; } + const colHead = sortTriggerEl.parentElement as HTMLTableCellElement; const table = this.element as HTMLTableElement; const tbody = table.tBodies[0]; @@ -52,7 +45,7 @@ export class TableController extends Stacks.StacksController { // the default behavior when clicking a header is to sort by this column in ascending // direction, *unless* it is already sorted that way - const direction = this.getElementData(colHead, "sort-direction") === "asc" ? -1 : 1; + const direction = colHead.getAttribute('aria-sort') === SortOrder.Ascending ? -1 : 1; const rows = Array.from(table.tBodies[0].rows); @@ -82,7 +75,7 @@ export class TableController extends Stacks.StacksController { // unless the to-be-sorted-by value is explicitly provided on the element via this attribute, // the value we're using is the cell's text, trimmed of any whitespace const explicit = controller.getElementData(cell, "sort-val"); - const d = typeof explicit === "string" ? explicit : cell.textContent!.trim(); + const d = explicit != null ? explicit : cell.textContent!.trim(); if ((d !== "") && (`${parseInt(d, 10)}` !== d)) { anyNonInt = true; @@ -115,9 +108,10 @@ export class TableController extends Stacks.StacksController { }); // this is the actual reordering of the table rows - data.forEach(function (tup) { - const row = rows[tup[1]]; + data.forEach(([_, rowIndex]) => { + const row = rows[rowIndex]; row.parentElement!.removeChild(row); + if (firstBottomRow) { tbody.insertBefore(row, firstBottomRow); } else { @@ -127,9 +121,54 @@ export class TableController extends Stacks.StacksController { // update the UI and set the `data-sort-direction` attribute if appropriate, so that the next click // will cause sorting in descending direction - this.setCurrentSort(colHead, direction === 1 ? "asc" : "desc"); + this.updateSortedColumnStyles(colHead, direction === 1 ? SortOrder.Ascending : SortOrder.Descending); + } + + private updateSortedColumnStyles(targetColumnHeader: Element, direction: SortOrder): void { + // Loop through all sortable columns and remove their sorting direction + // (if any), and only leave/set a sorting on `targetColumnHeader`. + this.columnTargets.forEach((header: HTMLTableCellElement) => { + const isCurrent = header === targetColumnHeader; + const classSuffix = isCurrent + ? (direction === SortOrder.Ascending ? 'asc' : 'desc') + : SortOrder.None; + + header.classList.toggle('is-sorted', isCurrent && direction !== SortOrder.None); + header.querySelectorAll('.js-sorting-indicator').forEach((icon) => { + icon.classList.toggle('d-none', !icon.classList.contains('js-sorting-indicator-' + classSuffix)); + }); + + if (isCurrent) { + header.setAttribute('aria-sort', direction); + } else { + header.removeAttribute('aria-sort'); + } + }); } + /** + * Transform legacy header markup into the new markup. + * + * @param headerEl + */ + private ensureHeadersAreClickable(headerEl: HTMLTableCellElement) { + const headerAction = headerEl.getAttribute('data-action'); + + // Legacy markup that violates accessibility practices; change the DOM + if (headerAction !== null && headerAction.substring(0, 5) === 'click') { + if (process.env.NODE_ENV !== 'production') { + console.warn('s-table :: a `
- Season - {% icon "ArrowUpSm", "js-sorting-indicator js-sorting-indicator-asc d-none" %} - {% icon "ArrowDownSm", "js-sorting-indicator js-sorting-indicator-desc d-none" %} - {% icon "ArrowUpDownSm", "js-sorting-indicator js-sorting-indicator-none" %} + + - Starts in month - {% icon "ArrowUpSm", "js-sorting-indicator js-sorting-indicator-asc d-none" %} - {% icon "ArrowDownSm", "js-sorting-indicator js-sorting-indicator-desc d-none" %} - {% icon "ArrowUpDownSm", "js-sorting-indicator js-sorting-indicator-none" %} + + - Typical temperature in °C - {% icon "ArrowUpSm", "js-sorting-indicator js-sorting-indicator-asc d-none" %} - {% icon "ArrowDownSm", "js-sorting-indicator js-sorting-indicator-desc d-none" %} - {% icon "ArrowUpDownSm", "js-sorting-indicator js-sorting-indicator-none" %} + +
` should not have a `data-action="click->..."` attribute. https://stackoverflow.design/product/components/tables/#javascript-example'); + console.warn('target: ', headerEl); + } + + headerEl.removeAttribute('data-action'); + headerEl.innerHTML = ` + + `; + } + } } function buildIndex(section: HTMLTableSectionElement): HTMLTableCellElement[][] {