diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md index 88e5e5f57d..d6ff97a26a 100644 --- a/doc/rule-descriptions.md +++ b/doc/rule-descriptions.md @@ -71,7 +71,7 @@ | [server-side-image-map](https://dequeuniversity.com/rules/axe/4.10/server-side-image-map?application=RuleDescription) | Ensure that server-side image maps are not used | Minor | cat.text-alternatives, wcag2a, wcag211, section508, section508.22.f, TTv5, TT4.a, EN-301-549, EN-9.2.1.1 | needs review | | | [summary-name](https://dequeuniversity.com/rules/axe/4.10/summary-name?application=RuleDescription) | Ensure summary elements have discernible text | Serious | cat.name-role-value, wcag2a, wcag412, section508, section508.22.a, TTv5, TT6.a, EN-301-549, EN-9.4.1.2 | failure, needs review | | | [svg-img-alt](https://dequeuniversity.com/rules/axe/4.10/svg-img-alt?application=RuleDescription) | Ensure <svg> elements with an img, graphics-document or graphics-symbol role have an accessible text | Serious | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a, TTv5, TT7.a, EN-301-549, EN-9.1.1.1, ACT | failure, needs review | [7d6734](https://act-rules.github.io/rules/7d6734) | -| [td-headers-attr](https://dequeuniversity.com/rules/axe/4.10/td-headers-attr?application=RuleDescription) | Ensure that each cell in a table that uses the headers attribute refers only to other cells in that table | Serious | cat.tables, wcag2a, wcag131, section508, section508.22.g, TTv5, TT14.b, EN-301-549, EN-9.1.3.1 | failure, needs review | [a25f45](https://act-rules.github.io/rules/a25f45) | +| [td-headers-attr](https://dequeuniversity.com/rules/axe/4.10/td-headers-attr?application=RuleDescription) | Ensure that each cell in a table that uses the headers attribute refers only to other <th> elements in that table | Serious | cat.tables, wcag2a, wcag131, section508, section508.22.g, TTv5, TT14.b, EN-301-549, EN-9.1.3.1 | failure, needs review | [a25f45](https://act-rules.github.io/rules/a25f45) | | [th-has-data-cells](https://dequeuniversity.com/rules/axe/4.10/th-has-data-cells?application=RuleDescription) | Ensure that <th> elements and elements with role=columnheader/rowheader have data cells they describe | Serious | cat.tables, wcag2a, wcag131, section508, section508.22.g, TTv5, TT14.b, EN-301-549, EN-9.1.3.1 | failure, needs review | [d0f69e](https://act-rules.github.io/rules/d0f69e) | | [valid-lang](https://dequeuniversity.com/rules/axe/4.10/valid-lang?application=RuleDescription) | Ensure lang attributes have valid values | Serious | cat.language, wcag2aa, wcag312, TTv5, TT11.b, EN-301-549, EN-9.3.1.2, ACT | failure | [de46e4](https://act-rules.github.io/rules/de46e4) | | [video-caption](https://dequeuniversity.com/rules/axe/4.10/video-caption?application=RuleDescription) | Ensure <video> elements have captions | Critical | cat.text-alternatives, wcag2a, wcag122, section508, section508.22.a, TTv5, TT17.a, EN-301-549, EN-9.1.2.2 | needs review | [eac66b](https://act-rules.github.io/rules/eac66b) | diff --git a/lib/checks/tables/td-headers-attr-evaluate.js b/lib/checks/tables/td-headers-attr-evaluate.js index 4275eb47cf..14f48052f1 100644 --- a/lib/checks/tables/td-headers-attr-evaluate.js +++ b/lib/checks/tables/td-headers-attr-evaluate.js @@ -1,63 +1,79 @@ import { tokenList } from '../../core/utils'; import { isVisibleToScreenReaders } from '../../commons/dom'; +import { getRole } from '../../commons/aria'; + +// Order determines the priority of reporting +// Only if 0 of higher issues exists will the next be reported +const messageKeys = [ + 'cell-header-not-in-table', + 'cell-header-not-th', + 'header-refs-self', + 'empty-hdrs' // incomplete +]; +const [notInTable, notTh, selfRef, emptyHdrs] = messageKeys; export default function tdHeadersAttrEvaluate(node) { const cells = []; - const reviewCells = []; - const badCells = []; - + const cellRoleById = {}; for (let rowIndex = 0; rowIndex < node.rows.length; rowIndex++) { const row = node.rows[rowIndex]; for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex++) { - cells.push(row.cells[cellIndex]); + const cell = row.cells[cellIndex]; + cells.push(cell); + + // Save header id to set if it's th or td with roles columnheader/rowheader + const cellId = cell.getAttribute('id'); + if (cellId) { + cellRoleById[cellId] = getRole(cell); + } } } - const ids = cells - .filter(cell => cell.getAttribute('id')) - .map(cell => cell.getAttribute('id')); - + const badCells = { + [selfRef]: new Set(), + [notInTable]: new Set(), + [notTh]: new Set(), + [emptyHdrs]: new Set() + }; cells.forEach(cell => { - let isSelf = false; - let notOfTable = false; - if (!cell.hasAttribute('headers') || !isVisibleToScreenReaders(cell)) { return; } - const headersAttr = cell.getAttribute('headers').trim(); if (!headersAttr) { - return reviewCells.push(cell); + badCells[emptyHdrs].add(cell); + return; } + const cellId = cell.getAttribute('id'); // Get a list all the values of the headers attribute const headers = tokenList(headersAttr); - - if (headers.length !== 0) { - // Check if the cell's id is in this list - if (cell.getAttribute('id')) { - isSelf = headers.indexOf(cell.getAttribute('id').trim()) !== -1; + headers.forEach(headerId => { + if (cellId && headerId === cellId) { + // Header references its own cell + badCells[selfRef].add(cell); + } else if (!cellRoleById[headerId]) { + // Header references a cell that is not in the table + badCells[notInTable].add(cell); + } else if ( + !['columnheader', 'rowheader'].includes(cellRoleById[headerId]) + ) { + // Header references a cell that is not a row or column header + badCells[notTh].add(cell); } + }); + }); - // Check if the headers are of cells inside the table - notOfTable = headers.some(header => !ids.includes(header)); - - if (isSelf || notOfTable) { - badCells.push(cell); + for (const messageKey of messageKeys) { + if (badCells[messageKey].size > 0) { + this.relatedNodes([...badCells[messageKey]]); + if (messageKey === emptyHdrs) { + return undefined; } + this.data({ messageKey }); + return false; } - }); - - if (badCells.length > 0) { - this.relatedNodes(badCells); - return false; - } - - if (reviewCells.length) { - this.relatedNodes(reviewCells); - return undefined; } - return true; } diff --git a/lib/checks/tables/td-headers-attr.json b/lib/checks/tables/td-headers-attr.json index 8013130f44..75d42206b4 100644 --- a/lib/checks/tables/td-headers-attr.json +++ b/lib/checks/tables/td-headers-attr.json @@ -4,9 +4,13 @@ "metadata": { "impact": "serious", "messages": { - "pass": "The headers attribute is exclusively used to refer to other cells in the table", + "pass": "The headers attribute is exclusively used to refer to other header cells in the table", "incomplete": "The headers attribute is empty", - "fail": "The headers attribute is not exclusively used to refer to other cells in the table" + "fail": { + "cell-header-not-in-table": "The headers attribute is not exclusively used to refer to other header cells in the table", + "cell-header-not-th": "The headers attribute must refer to header cells, not data cells", + "header-refs-self": "The element with headers attribute refers to itself" + } } } } diff --git a/lib/rules/td-headers-attr.json b/lib/rules/td-headers-attr.json index b729c6acd0..22d7dd2488 100644 --- a/lib/rules/td-headers-attr.json +++ b/lib/rules/td-headers-attr.json @@ -16,8 +16,8 @@ ], "actIds": ["a25f45"], "metadata": { - "description": "Ensure that each cell in a table that uses the headers attribute refers only to other cells in that table", - "help": "Table cells that use the headers attribute must only refer to cells in the same table" + "description": "Ensure that each cell in a table that uses the headers attribute refers only to other elements in that table", + "help": "Table cell headers attributes must refer to other elements in the same table" }, "all": ["td-headers-attr"], "any": [], diff --git a/locales/_template.json b/locales/_template.json index b250b8cdaf..a607fb058a 100644 --- a/locales/_template.json +++ b/locales/_template.json @@ -402,8 +402,8 @@ "help": "Non-empty elements in larger must have an associated table header" }, "td-headers-attr": { - "description": "Ensure that each cell in a table that uses the headers attribute refers only to other cells in that table", - "help": "Table cells that use the headers attribute must only refer to cells in the same table" + "description": "Ensure that each cell in a table that uses the headers attribute refers only to other
elements in that table", + "help": "Table cell headers attributes must refer to other elements in the same table" }, "th-has-data-cells": { "description": "Ensure that elements and elements with role=columnheader/rowheader have data cells they describe", @@ -1096,9 +1096,13 @@ "fail": "Some non-empty data cells do not have table headers" }, "td-headers-attr": { - "pass": "The headers attribute is exclusively used to refer to other cells in the table", + "pass": "The headers attribute is exclusively used to refer to other header cells in the table", "incomplete": "The headers attribute is empty", - "fail": "The headers attribute is not exclusively used to refer to other cells in the table" + "fail": { + "cell-header-not-in-table": "The headers attribute is not exclusively used to refer to other header cells in the table", + "cell-header-not-th": "The headers attribute must refer to header cells, not data cells", + "header-refs-self": "The element with headers attribute refers to itself" + } }, "th-has-data-cells": { "pass": "All table header cells refer to data cells", diff --git a/locales/ru.json b/locales/ru.json index b161d7d40d..29c80cee50 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -402,8 +402,8 @@ "help": "Непустые элементы в больших таблицах должны иметь связанные заголовки таблицы" }, "td-headers-attr": { - "description": "Убедитесь, что каждая ячейка в таблице, использующая атрибут headers, ссылается только на другие ячейки в этой таблице", - "help": "Ячейки таблицы, использующие атрибут headers, должны ссылаться только на ячейки в той же таблице" + "description": "Убедитесь, что каждая ячейка в таблице, использующая атрибут headers, ссылается только на другие элементы в этой таблице", + "help": "Атрибуты headers ячеек таблицы должны ссылаться на другие элементы в той же таблице" }, "th-has-data-cells": { "description": "Убедитесь, что элементы и элементы с ролью columnheader/rowheader имеют ячейки данных, которые они описывают", @@ -1098,7 +1098,11 @@ "td-headers-attr": { "pass": "Атрибут headers используется исключительно для ссылки на другие ячейки таблицы", "incomplete": "Атрибут headers пуст", - "fail": "Атрибут headers не используется исключительно для ссылки на другие ячейки таблицы" + "fail": { + "cell-header-not-in-table": "Атрибут headers не используется исключительно для ссылки на другие заголовочные ячейки в таблице", + "cell-header-not-th": "Атрибут headers должен ссылаться на заголовочные ячейки, а не на ячейки с данными", + "header-refs-self": "Элемент с атрибутом headers ссылается на самого себя" + } }, "th-has-data-cells": { "pass": "Все ячейки заголовков таблицы ссылаются на ячейки данных", diff --git a/test/checks/tables/td-headers-attr.js b/test/checks/tables/td-headers-attr.js index 4fe08bc0b6..c12cf33546 100644 --- a/test/checks/tables/td-headers-attr.js +++ b/test/checks/tables/td-headers-attr.js @@ -91,6 +91,9 @@ describe('td-headers-attr', function () { ); node = fixture.querySelector('table'); assert.isFalse(check.call(checkContext, node)); + assert.deepEqual(checkContext._data, { + messageKey: 'cell-header-not-in-table' + }); fixtureSetup( '' + @@ -102,6 +105,59 @@ describe('td-headers-attr', function () { assert.isFalse(check.call(checkContext, node)); }); + it('returns false if table cell referenced as header', function () { + fixtureSetup(` +
+ + +
hello
goodbye
+ `); + + var node = fixture.querySelector('table'); + assert.isFalse(check.call(checkContext, node)); + assert.deepEqual(checkContext._data, { messageKey: 'cell-header-not-th' }); + }); + + it('returns true if table cell referenced as header with role rowheader or columnheader', function () { + var node; + + fixtureSetup(` + + + +
hello
goodbye
+ `); + + node = fixture.querySelector('table'); + assert.isTrue(check.call(checkContext, node)); + + fixtureSetup(` + + + +
hello
goodbye
+ `); + + node = fixture.querySelector('table'); + assert.isTrue(check.call(checkContext, node)); + }); + + it('relatedNodes contains each cell only once', function () { + fixtureSetup(` + + + + +
hello
hello
goodbye
' + `); + + var node = fixture.querySelector('table'); + check.call(checkContext, node); + assert.deepEqual(checkContext._relatedNodes, [ + fixture.querySelector('#bye') + ]); + }); + it('returns false if the header refers to the same cell', function () { fixtureSetup( '' + @@ -112,6 +168,7 @@ describe('td-headers-attr', function () { var node = fixture.querySelector('table'); assert.isFalse(check.call(checkContext, node)); + assert.deepEqual(checkContext._data, { messageKey: 'header-refs-self' }); }); it('returns true if td[headers] is hidden', function () { diff --git a/test/integration/rules/td-headers-attr/td-headers-attr.html b/test/integration/rules/td-headers-attr/td-headers-attr.html index 64fc6cb33c..df4543b7f5 100644 --- a/test/integration/rules/td-headers-attr/td-headers-attr.html +++ b/test/integration/rules/td-headers-attr/td-headers-attr.html @@ -19,6 +19,11 @@
+ + + +
HelloWorld
+ @@ -32,6 +37,21 @@
Hello WorldWorld
+ + + +
HelloWorld
+ + + + +
HelloWorld
+ + + + +
HelloWorld
+
World
diff --git a/test/integration/rules/td-headers-attr/td-headers-attr.json b/test/integration/rules/td-headers-attr/td-headers-attr.json index e687869b6b..093cc82ddc 100644 --- a/test/integration/rules/td-headers-attr/td-headers-attr.json +++ b/test/integration/rules/td-headers-attr/td-headers-attr.json @@ -1,6 +1,13 @@ { "description": "td-headers-attr test", "rule": "td-headers-attr", - "violations": [["#fail1"], ["#fail2"], ["#fail3"]], - "passes": [["#pass1"], ["#pass2"], ["#pass3"], ["#pass4"]] + "violations": [ + ["#fail1"], + ["#fail2"], + ["#fail3"], + ["#fail4"], + ["#fail5"], + ["#fail6"] + ], + "passes": [["#pass1"], ["#pass2"], ["#pass3"], ["#pass4"], ["#pass5"]] }