Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve a11y support for table sorting #1144

Draft
wants to merge 6 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/_data/tables.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
{
Expand Down
66 changes: 39 additions & 27 deletions docs/product/components/tables.html
Original file line number Diff line number Diff line change
Expand Up @@ -756,7 +756,7 @@
{% for item in tables.data-attributes %}
<tr>
<th scope="row"><code class="stacks-code">{{ item.attribute }}</code></th>
<td><code class="stacks-code">{{ item.applies }}</td>
<td><code class="stacks-code">{{ item.applies }}</code></td>
<td class="p8">{{ item.description }}</td>
</tr>
{% endfor %}
Expand All @@ -771,19 +771,25 @@
<table class="s-table s-table__sortable" data-controller="s-table">
<thead>
<tr>
<th data-s-table-target="column" data-action="click->s-table#sort">
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")
<th data-s-table-target="column">
<button data-action="click->s-table#sort">
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")
</button>
</th>
<th data-s-table-target="column" data-action="click->s-table#sort">
Starts in month
<th data-s-table-target="column">
<button data-action="click->s-table#sort">
Starts in month
</button>
</th>
<th data-s-table-target="column" data-action="click->s-table#sort">
Typical temperature in °C
<th data-s-table-target="column">
<button data-action="click->s-table#sort">
Typical temperature in °C
</button>
</th>
</tr>
</thead>
Expand All @@ -805,23 +811,29 @@
<table class="s-table s-table__sortable" data-controller="s-table">
<thead>
<tr>
<th data-s-table-target="column" data-action="click->s-table#sort">
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" %}
<th data-s-table-target="column">
<button data-action="click->s-table#sort">
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" %}
</button>
</th>
<th data-s-table-target="column" data-action="click->s-table#sort">
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" %}
<th data-s-table-target="column">
<button data-action="click->s-table#sort">
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" %}
</button>
</th>
<th data-s-table-target="column" data-action="click->s-table#sort">
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" %}
<th data-s-table-target="column">
<button data-action="click->s-table#sort">
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" %}
</button>
</th>
</tr>
</thead>
Expand Down
19 changes: 17 additions & 2 deletions lib/css/components/tables.less
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
105 changes: 72 additions & 33 deletions lib/ts/controllers/s-table.ts
Original file line number Diff line number Diff line change
@@ -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];

Expand All @@ -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);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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 `<th>` 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 = `
<button data-action="${headerAction}">
${headerEl.innerHTML}
</button>
`;
}
}
}

function buildIndex(section: HTMLTableSectionElement): HTMLTableCellElement[][] {
Expand Down