Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions generate-act-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,6 @@ const rulesToIgnore = [
// --- Not implemented - various other rules ---
"2ee8b8", // Visible label is part of accessible name - not implemented
"73f2c2", // Autocomplete attribute has valid value - not implemented in ACT test format
"a25f45", // Headers attribute specified on a cell refers to cells in the same table element - not implemented
"d0f69e", // Table header cell has assigned cells - not implemented
"b4f0c3", // Meta viewport allows for zoom - not implemented in ACT test format
"4b1c6c", // Iframe elements with identical accessible names have equivalent purpose - not implemented
"akn7bn", // Iframe with interactive elements is not excluded from tab-order - not implemented
Expand Down
4 changes: 2 additions & 2 deletions rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@
"ACT Rules": "[7d6734](https://act-rules.github.io/rules/7d6734)"
},
{
"implemented": "",
"implemented": "",
"id": "td-headers-attr",
"url": "https://dequeuniversity.com/rules/axe/4.11/td-headers-attr?application=RuleDescription",
"Description": "Ensure that each cell in a table that uses the headers attribute refers only to other cells in that table",
Expand All @@ -533,7 +533,7 @@
"ACT Rules": "[a25f45](https://act-rules.github.io/rules/a25f45)"
},
{
"implemented": "",
"implemented": "",
"id": "th-has-data-cells",
"url": "https://dequeuniversity.com/rules/axe/4.11/th-has-data-cells?application=RuleDescription",
"Description": "Ensure that <th> elements and elements with role=columnheader/rowheader have data cells they describe",
Expand Down
24 changes: 16 additions & 8 deletions src/rules/aria-allowed-attr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,25 +99,33 @@ const implicitRoles: Record<string, string | undefined> = {
function getInputImplicitRole(element: Element): string | undefined {
const type = (element.getAttribute("type") || "text").toLowerCase();
switch (type) {
case "checkbox":
case "checkbox": {
return "checkbox";
case "radio":
}
case "radio": {
return "radio";
case "range":
}
case "range": {
return "slider";
case "number":
}
case "number": {
return "spinbutton";
case "search":
}
case "search": {
return "searchbox";
}
case "button":
case "image":
case "reset":
case "submit":
case "submit": {
return "button";
case "hidden":
}
case "hidden": {
return undefined;
default:
}
default: {
return "textbox";
}
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/rules/td-headers-attr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ function validateHeadersAttribute(cell: HTMLTableCellElement): boolean {
return false;
}

// A cell must not reference itself
if (referencedElement === cell) {
return false;
}

// Check if it's a table cell (td or th)
if (
referencedElement.tagName !== "TD" &&
Expand Down
182 changes: 161 additions & 21 deletions src/rules/th-has-data-cells.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,54 @@ const text =
"Ensure that <th> elements and elements with role=columnheader/rowheader have data cells they describe";
const url = `https://dequeuniversity.com/rules/axe/4.11/${id}`;

/**
* Build a column-index map for a table that accounts for colspan/rowspan.
* Returns a Map from each cell element to its starting column index.
*/
function buildColumnMap(
table: HTMLTableElement,
): Map<HTMLTableCellElement, number> {
const map = new Map<HTMLTableCellElement, number>();
// Track which grid positions are occupied (for rowspan)
const occupied: boolean[][] = [];

for (let r = 0; r < table.rows.length; r++) {
if (!occupied[r]) occupied[r] = [];
const row = table.rows[r];
let col = 0;
for (let c = 0; c < row.cells.length; c++) {
// Find next unoccupied column
while (occupied[r][col]) col++;
const cell = row.cells[c];
map.set(cell, col);
const colspan = cell.colSpan || 1;
const rowspan = cell.rowSpan || 1;
// Mark occupied positions
for (let dr = 0; dr < rowspan; dr++) {
if (!occupied[r + dr]) occupied[r + dr] = [];
for (let dc = 0; dc < colspan; dc++) {
occupied[r + dr][col + dc] = true;
}
}
col += colspan;
}
}
return map;
}

/**
* Check if a cell in a given row occupies a particular column index,
* accounting for colspan.
*/
function cellOccupiesColumn(
cell: HTMLTableCellElement,
colStart: number,
targetCol: number,
): boolean {
const colspan = cell.colSpan || 1;
return targetCol >= colStart && targetCol < colStart + colspan;
}

/**
* Check if a header cell has associated data cells
*/
Expand All @@ -18,8 +66,9 @@ function hasDataCells(
const headerId = header.getAttribute("id");
if (headerId) {
// Search all td cells in the table
const allCells = querySelectorAll("td", table);
const allCells = querySelectorAll("td, th", table);
for (const cell of allCells) {
if (cell === header) continue;
const headersAttr = cell.getAttribute("headers");
if (headersAttr) {
const headerIds = headersAttr.trim().split(/\s+/);
Expand All @@ -41,32 +90,64 @@ function hasDataCells(
const row = header.parentElement as HTMLTableRowElement;
if (!row) return false;

// Get the column index of this header
const cellIndex = [...row.cells].indexOf(header);
const colMap = buildColumnMap(table);
const headerCol = colMap.get(header) ?? 0;

if (
scope === "col" ||
scope === "colgroup" ||
(!scope && row.rowIndex === 0)
) {
// Column header - check if there are data cells in the same column below
// Column header - check if there are cells in the same column below
// that don't override assignment with an explicit headers attribute
for (let i = 0; i < table.rows.length; i++) {
if (i === row.rowIndex) continue; // Skip the header row itself
const cell = table.rows[i].cells[cellIndex];
if (cell && cell.tagName === "TD") {
return true;
if (i === row.rowIndex) continue;
for (let c = 0; c < table.rows[i].cells.length; c++) {
const cell = table.rows[i].cells[c];
const cellCol = colMap.get(cell) ?? 0;
if (cellOccupiesColumn(cell, cellCol, headerCol)) {
// If the cell has an explicit headers attribute, it must reference
// this header's id for the assignment to count
if (cell.hasAttribute("headers")) {
if (
headerId &&
cell
.getAttribute("headers")!
.trim()
.split(/\s+/)
.includes(headerId)
) {
return true;
}
// Explicit headers don't include this header - not assigned
continue;
}
// Skip th elements that are themselves column headers
// (they have scope="col"/"colgroup" and are not data cells)
const cellScope = cell.getAttribute("scope");
if (
cell.tagName === "TH" &&
(cellScope === "col" || cellScope === "colgroup")
) {
continue;
}
// Cell is implicitly assigned to this column header
// Count both td and th without column scope (a th can be
// an assigned cell of another th, e.g. row headers in a column)
return true;
}
}
}
} else if (
scope === "row" ||
scope === "rowgroup" ||
(!scope && cellIndex === 0)
(!scope && headerCol === 0)
) {
// Row header - check if there are data cells in the same row
for (let i = 0; i < row.cells.length; i++) {
if (i === cellIndex) continue; // Skip the header itself
const cell = row.cells[i];
if (cell && cell.tagName === "TD") {
if (cell === header) continue;
if (cell.tagName === "TD") {
return true;
}
}
Expand All @@ -75,29 +156,88 @@ function hasDataCells(
// Check column
for (let i = 0; i < table.rows.length; i++) {
if (i === row.rowIndex) continue;
const cell = table.rows[i].cells[cellIndex];
if (cell && cell.tagName === "TD") {
return true;
for (let c = 0; c < table.rows[i].cells.length; c++) {
const cell = table.rows[i].cells[c];
const cellCol = colMap.get(cell) ?? 0;
if (cellOccupiesColumn(cell, cellCol, headerCol)) {
if (cell.hasAttribute("headers")) {
if (
headerId &&
cell
.getAttribute("headers")!
.trim()
.split(/\s+/)
.includes(headerId)
) {
return true;
}
continue;
}
// Skip th elements that are column headers themselves
const cellScope = cell.getAttribute("scope");
if (
cell.tagName === "TH" &&
(cellScope === "col" || cellScope === "colgroup")
) {
continue;
}
return true;
}
}
}
// Check row
for (let i = 0; i < row.cells.length; i++) {
if (i === cellIndex) continue;
const cell = row.cells[i];
if (cell && cell.tagName === "TD") {
if (cell === header) continue;
if (cell.tagName === "TD") {
return true;
}
}
}
}

// For elements with role=columnheader or rowheader, check for cells with role=cell
// For elements with role=columnheader or rowheader, check positionally
const role = header.getAttribute("role");
if (role === "columnheader" || role === "rowheader") {
// Find all cells in the same table/grid
const cells = querySelectorAll('[role="cell"], [role="gridcell"]', table);
if (cells.length > 0) {
return true;
const rows = querySelectorAll('[role="row"]', table);
// Find which row the header is in and its position
const headerRow = header.closest('[role="row"]');
if (headerRow) {
const headerChildren = [...headerRow.children];
const headerIndex = headerChildren.indexOf(header as HTMLElement);

if (role === "columnheader") {
// Check if any other row has a cell/gridcell at this position
for (const row of rows) {
if (row === headerRow) continue;
const children = [...row.children];
if (headerIndex < children.length) {
const cell = children[headerIndex];
const cellRole = cell.getAttribute("role");
if (
cellRole === "cell" ||
cellRole === "gridcell" ||
cell.tagName === "TD"
) {
return true;
}
}
}
} else {
// rowheader - check if there are cells in the same row
const children = [...headerRow.children];
for (const [i, child] of children.entries()) {
if (i === headerIndex) continue;
const cellRole = child.getAttribute("role");
if (
cellRole === "cell" ||
cellRole === "gridcell" ||
child.tagName === "TD"
) {
return true;
}
}
}
}
}

Expand Down
38 changes: 38 additions & 0 deletions tests/act/tests/a25f45/1400d13aa5a86dbacf71db631f5de1abfc982094.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect } from "@open-wc/testing";
import { scan } from "../../../../src/scanner";

const parser = new DOMParser();

describe("[a25f45]Headers attribute specified on a cell refers to cells in the same table element", function () {
it("Passed Example 2 (https://www.w3.org/WAI/content-assets/wcag-act-rules/testcases/a25f45/1400d13aa5a86dbacf71db631f5de1abfc982094.html)", async () => {
const document = parser.parseFromString(`<!DOCTYPE html>
<html lang="en">
<head>
<title>Passed Example 2</title>
</head>
<body>
<table>
<thead>
<tr>
<th id="header1">Projects</th>
<th id="header2">Exams</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="2" headers="header1 header2">15%</td>
</tr>
</tbody>
</table>
</body>
</html>`, 'text/html');

const results = (await scan(document.body)).map(({ text, url }) => {
return { text, url };
});

const expectedUrls = ["https://dequeuniversity.com/rules/axe/4.11/td-headers-attr"];
const relevant = results.filter(r => expectedUrls.includes(r.url));
expect(relevant).to.be.empty;
});
});
Loading