@surea11y/core 1.4.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/CHANGELOG.md +212 -128
  2. package/README.md +46 -9
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +4 -4
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +30 -14
  8. package/docs/I18N.md +176 -20
  9. package/docs/INTEGRATION.md +29 -7
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/OUTPUT_SCHEMA.md +13 -3
  12. package/docs/REPORT.md +3 -1
  13. package/docs/RULE_AUTHORING.md +104 -23
  14. package/docs/RULE_CATALOG.md +1878 -169
  15. package/docs/RULE_TAXONOMY.md +2 -2
  16. package/docs/TROUBLESHOOTING.md +4 -4
  17. package/docs/WCAG_CONFORMANCE.md +25 -9
  18. package/package.json +8 -7
  19. package/src/baseline.js +3 -3
  20. package/src/checks/automatic/area-alt-present.js +2 -2
  21. package/src/checks/automatic/aria-allowed-attr.js +95 -40
  22. package/src/checks/automatic/aria-allowed-role.js +16 -18
  23. package/src/checks/automatic/aria-braille-equivalent.js +19 -21
  24. package/src/checks/automatic/aria-conditional-attr.js +22 -24
  25. package/src/checks/automatic/aria-deprecated-role.js +63 -50
  26. package/src/checks/automatic/aria-hidden-body.js +4 -11
  27. package/src/checks/automatic/aria-hidden-focus.js +104 -23
  28. package/src/checks/automatic/aria-prohibited-attr.js +71 -72
  29. package/src/checks/automatic/aria-prohibited-children.js +154 -61
  30. package/src/checks/automatic/aria-required-attr.js +74 -29
  31. package/src/checks/automatic/aria-required-children.js +38 -34
  32. package/src/checks/automatic/aria-required-parent.js +78 -29
  33. package/src/checks/automatic/aria-role-name-present.js +36 -22
  34. package/src/checks/automatic/aria-roles-valid.js +37 -23
  35. package/src/checks/automatic/aria-valid-attr-value.js +33 -33
  36. package/src/checks/automatic/aria-valid-attr.js +15 -18
  37. package/src/checks/automatic/autocomplete-valid.js +17 -19
  38. package/src/checks/automatic/avoid-inline-spacing.js +14 -16
  39. package/src/checks/automatic/binary-control-name-present.js +46 -26
  40. package/src/checks/automatic/button-name-present.js +115 -34
  41. package/src/checks/automatic/combobox-name-present.js +40 -22
  42. package/src/checks/automatic/contrast-computable.js +32 -0
  43. package/src/checks/automatic/contrast-enhanced.js +21 -1
  44. package/src/checks/automatic/contrast-minimum.js +21 -1
  45. package/src/checks/automatic/css-orientation-lock.js +118 -41
  46. package/src/checks/automatic/definition-list-children-valid.js +25 -29
  47. package/src/checks/automatic/deprecated-elements-not-used.js +15 -17
  48. package/src/checks/automatic/dialog-name-present.js +36 -20
  49. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  50. package/src/checks/automatic/duplicate-id-aria.js +50 -40
  51. package/src/checks/automatic/duplicate-id.js +198 -0
  52. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  53. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  54. package/src/checks/automatic/form-control-single-label.js +39 -41
  55. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  56. package/src/checks/automatic/iframe-focusable-content.js +92 -38
  57. package/src/checks/automatic/iframe-name-present.js +53 -21
  58. package/src/checks/automatic/iframe-title-unique.js +19 -24
  59. package/src/checks/automatic/img-alt-present.js +12 -4
  60. package/src/checks/automatic/label-in-name.js +198 -49
  61. package/src/checks/automatic/link-in-text-block.js +29 -31
  62. package/src/checks/automatic/link-name-present.js +47 -31
  63. package/src/checks/automatic/list-children-valid.js +21 -23
  64. package/src/checks/automatic/listbox-name-present.js +42 -24
  65. package/src/checks/automatic/listitem-parent-valid.js +18 -21
  66. package/src/checks/automatic/menuitem-name-present.js +36 -20
  67. package/src/checks/automatic/meta-refresh-no-exceptions.js +39 -31
  68. package/src/checks/automatic/meta-refresh-timing-absent.js +29 -25
  69. package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
  70. package/src/checks/automatic/meter-name-present.js +38 -21
  71. package/src/checks/automatic/nested-interactive-controls-absent.js +22 -24
  72. package/src/checks/automatic/option-name-present.js +39 -22
  73. package/src/checks/automatic/page-title-present.js +21 -3
  74. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  75. package/src/checks/automatic/progressbar-name-present.js +41 -24
  76. package/src/checks/automatic/role-img-alt-present.js +64 -16
  77. package/src/checks/automatic/searchbox-name-present.js +46 -24
  78. package/src/checks/automatic/server-side-image-map-absent.js +16 -19
  79. package/src/checks/automatic/slider-name-present.js +42 -23
  80. package/src/checks/automatic/spinbutton-name-present.js +46 -24
  81. package/src/checks/automatic/summary-name-present.js +34 -20
  82. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  83. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  84. package/src/checks/automatic/tab-name-present.js +37 -20
  85. package/src/checks/automatic/table-headers-attr-valid.js +57 -24
  86. package/src/checks/automatic/table-th-has-data-cells.js +76 -24
  87. package/src/checks/automatic/target-size-minimum.js +172 -131
  88. package/src/checks/automatic/td-has-header.js +20 -25
  89. package/src/checks/automatic/textbox-name-present.js +42 -24
  90. package/src/checks/automatic/tooltip-name-present.js +37 -20
  91. package/src/checks/automatic/treeitem-name-present.js +39 -22
  92. package/src/checks/automatic/valid-lang.js +107 -24
  93. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  94. package/src/checks/manual/accesskeys-manual.js +21 -22
  95. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  96. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  97. package/src/checks/manual/aria-checked-state-mismatch-manual.js +23 -26
  98. package/src/checks/manual/aria-text-manual.js +4 -4
  99. package/src/checks/manual/bypass-blocks-present-manual.js +48 -38
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  101. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/empty-heading-manual.js +73 -28
  104. package/src/checks/manual/empty-table-header-manual.js +33 -36
  105. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  106. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  107. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +4 -11
  108. package/src/checks/manual/heading-order-manual.js +20 -25
  109. package/src/checks/manual/heading-quality-manual.js +338 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  111. package/src/checks/manual/image-redundant-alt-manual.js +18 -21
  112. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  113. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  116. package/src/checks/manual/label-title-only-manual.js +19 -21
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +21 -24
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +23 -26
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +20 -23
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +8 -13
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +8 -13
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +4 -9
  123. package/src/checks/manual/landmark-one-main-manual.js +8 -15
  124. package/src/checks/manual/landmark-unique-manual.js +31 -36
  125. package/src/checks/manual/link-name-quality-manual.js +162 -35
  126. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  127. package/src/checks/manual/meta-viewport-large-manual.js +16 -19
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +26 -28
  129. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  131. package/src/checks/manual/p-as-heading-manual.js +4 -4
  132. package/src/checks/manual/page-has-heading-one-manual.js +8 -15
  133. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  134. package/src/checks/manual/presentation-role-conflict-manual.js +74 -46
  135. package/src/checks/manual/region-manual.js +32 -25
  136. package/src/checks/manual/scope-attr-valid-manual.js +16 -19
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  138. package/src/checks/manual/skip-link-manual.js +44 -52
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  140. package/src/checks/manual/tabindex-manual.js +16 -19
  141. package/src/checks/manual/table-duplicate-name-manual.js +16 -19
  142. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  143. package/src/checks/manual/video-caption-manual.js +3 -3
  144. package/src/checks/manual-review.js +17 -1
  145. package/src/core.js +13086 -5169
  146. package/src/report.js +16 -2
  147. package/surea11y.browser.js +5665 -4219
  148. package/surea11y.i18n.de.js +22 -0
  149. package/surea11y.i18n.es.js +22 -0
  150. package/surea11y.i18n.fr.js +22 -0
  151. package/bin/surea11y-core.js +0 -20
@@ -2,6 +2,22 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check summary-name-present
7
+ * @atomic true
8
+ * @summary <summary> elements must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to <summary> elements included in the accessibility tree,
13
+ * wherever they appear, a <summary> outside a <details> is still matched.
14
+ * @expectation
15
+ * The element has a non-empty accessible name from aria-label, from an
16
+ * aria-labelledby that resolves to non-empty text, from title, or from its
17
+ * own subtree text, where a descendant's own name (an <img alt>,
18
+ * aria-label or title) counts as that descendant's contribution.
19
+ */
20
+
5
21
  const id = 'summary-name-present';
6
22
 
7
23
  const meta = {
@@ -51,7 +67,7 @@ function runInPage(ctx) {
51
67
  }
52
68
 
53
69
  function getConservativeSubtreeText(document, container) {
54
- // "Name from content" — recurses into descendants and uses each one's
70
+ // "Name from content", recurses into descendants and uses each one's
55
71
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
56
72
  // it has one, not just literal text nodes. See getContentNameInfo's
57
73
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -70,7 +86,7 @@ function runInPage(ctx) {
70
86
  const raw = getAttr(el, 'aria-labelledby');
71
87
  if (!raw) return '';
72
88
  // Delegates to the shared getTextFromIdRefs helper instead of computing
73
- // name-from-content of the referenced element — see dialog-name-
89
+ // name-from-content of the referenced element, see dialog-name-
74
90
  // present.js's identical fix for the full rationale (an <iframe>
75
91
  // aria-labelledby target's only name source is its title attribute,
76
92
  // which name-from-content alone can never see).
@@ -144,24 +160,22 @@ function runInPage(ctx) {
144
160
  }
145
161
  })()
146
162
  : null;
147
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
148
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
149
-
150
- occurrences.push({
151
- selector: stableSelector,
152
- html,
153
- summary: 'This summary has no accessible name.',
154
- hint: 'Provide summary text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
155
- i18n: {
156
- summaryKey: 'summaryNamePresent_summary_fail',
157
- hintKey: 'summaryNamePresent_hint_fail',
158
- params: {}
159
- },
160
- data: {
161
- details: { reasonCode: 'name_missing', controlType: 'summary', methodTried: res.method },
162
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
163
- }
164
- });
163
+
164
+ occurrences.push(
165
+ helpers.reportOccurrence(el, {
166
+ summary: 'This summary has no accessible name.',
167
+ hint: 'Provide summary text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
168
+ i18n: {
169
+ summaryKey: 'summaryNamePresent_summary_fail',
170
+ hintKey: 'summaryNamePresent_hint_fail',
171
+ params: {}
172
+ },
173
+ data: {
174
+ details: { reasonCode: 'name_missing', controlType: 'summary', methodTried: res.method },
175
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
176
+ }
177
+ })
178
+ );
165
179
  }
166
180
 
167
181
  if (applicableCount === 0) {
@@ -109,7 +109,7 @@ function runInPage(ctx) {
109
109
  }
110
110
 
111
111
  // <desc> counts when it is the first child, or the second child
112
- // immediately following a <title> — the standard <title>+<desc> pairing.
112
+ // immediately following a <title>, the standard <title>+<desc> pairing.
113
113
  // A <desc> appearing later than that is not reliably read by AT.
114
114
  function descText(el) {
115
115
  try {
@@ -13,12 +13,15 @@
13
13
  * "Intended to be conveyed" is approximated deterministically by at least one of:
14
14
  * - role="img", role="graphics-symbol", or role="graphics-document"
15
15
  * on the SVG root element itself (the WAI-ARIA Graphics Module
16
- * roles, alongside img). Deliberately does NOT extend to arbitrary
17
- * role="graphics-symbol" descendants nested inside an <svg> — this
18
- * check's scope is the <svg> root only; a broader feature, not
19
- * attempted here.
16
+ * roles, alongside img). Does NOT extend to arbitrary
17
+ * role="img"/"graphics-symbol"/"graphics-document" descendants
18
+ * nested inside an <svg>. This check's scope is the <svg> root
19
+ * only; role-img-text-alternative-present covers those same three
20
+ * roles on any other element, including nested SVG shapes (ACT
21
+ * 7d6734's own failed example: a bare `<svg>` root with a nested
22
+ * `<circle role="graphics-symbol">`).
20
23
  * - aria-label / aria-labelledby present
21
- * - <title> or <desc> present (desc alone is an applicability signal only — see @expectation)
24
+ * - <title> or <desc> present (desc alone is an applicability signal only, see @expectation)
22
25
  * - focusable/tabbable (e.g., tabindex, native focusability)
23
26
  *
24
27
  * Images with role="presentation" or role="none" are excluded only when they are not focusable.
@@ -28,7 +31,7 @@
28
31
  * Each applicable <svg> element provides a text alternative via:
29
32
  * - non-empty <title> text, OR
30
33
  * - an ARIA name (aria-label / aria-labelledby).
31
- * A <desc> element alone does NOT satisfy this — per the SVG Accessibility
34
+ * A <desc> element alone does NOT satisfy this, per the SVG Accessibility
32
35
  * API Mappings spec §7.1, <desc> only ever contributes to the accessible
33
36
  * DESCRIPTION, never the accessible NAME. An <svg> with only a
34
37
  * <desc> and no <title>/ARIA name is still "applicable" (desc signals
@@ -40,7 +43,7 @@ const id = 'svg-text-alternative-present';
40
43
  const meta = {
41
44
  title: '<svg> must provide a text alternative',
42
45
  description:
43
- 'Checks that inline <svg> elements provide a text alternative via a <title> element or an ARIA name (a <desc> element alone does not count — it only contributes to the accessible description, not the name).',
46
+ 'Checks that inline <svg> elements provide a text alternative via a <title> element or an ARIA name (a <desc> element alone does not count).',
44
47
  i18n: {
45
48
  titleKey: 'svg_textAltPresent_title',
46
49
  descriptionKey: 'svg_textAltPresent_description'
@@ -124,7 +127,7 @@ function runInPage(ctx) {
124
127
  }
125
128
 
126
129
  // <desc> counts when it is the first child, or the second child
127
- // immediately following a <title> — the standard <title>+<desc> pairing
130
+ // immediately following a <title>, the standard <title>+<desc> pairing
128
131
  // (e.g. <svg><title>...</title><desc>...</desc>...</svg>). A <desc>
129
132
  // appearing later than that is not reliably read by AT.
130
133
  function nonEmptyDescText(svg) {
@@ -277,7 +280,7 @@ function runInPage(ctx) {
277
280
  }
278
281
 
279
282
  // Per SVG-AAM §7.1: <desc> contributes only to the accessible
280
- // DESCRIPTION, never the accessible NAME — so descText does not
283
+ // DESCRIPTION, never the accessible NAME, so descText does not
281
284
  // count here even though it does count toward applicability above.
282
285
  const ok = !!titleText || hasAriaName;
283
286
  if (ok) continue;
@@ -293,7 +296,7 @@ function runInPage(ctx) {
293
296
 
294
297
  const baseOccurrence = {
295
298
  summary: 'Missing text alternative for <svg>.',
296
- hint: 'Provide a <title> element with text, or an ARIA name (aria-label/aria-labelledby) — a <desc> element alone does not provide an accessible name.',
299
+ hint: 'Provide a <title> element with text, or an ARIA name (aria-label/aria-labelledby); a <desc> element alone does not provide an accessible name.',
297
300
  i18n: {
298
301
  summaryKey: 'svg_textAltPresent_summary_fail',
299
302
  hintKey: 'svg_textAltPresent_hint_fail',
@@ -2,6 +2,25 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check tab-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="tab" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="tab" (the attribute must name that
13
+ * role alone, not a fallback list) that are included in the accessibility
14
+ * tree. An element with the matching implicit role but no role attribute
15
+ * is out of scope.
16
+ * @expectation
17
+ * The element has a non-empty accessible name from aria-label, from an
18
+ * aria-labelledby that resolves to non-empty text, from title, or,
19
+ * role="tab" being name-from-content, from its own subtree text, where a
20
+ * descendant's own name (an <img alt>, aria-label or title) counts as that
21
+ * descendant's contribution rather than only its text nodes.
22
+ */
23
+
5
24
  const id = 'tab-name-present';
6
25
 
7
26
  const meta = {
@@ -51,7 +70,7 @@ function runInPage(ctx) {
51
70
  }
52
71
 
53
72
  function getConservativeSubtreeText(document, container) {
54
- // "Name from content" — recurses into descendants and uses each one's
73
+ // "Name from content", recurses into descendants and uses each one's
55
74
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
56
75
  // it has one, not just literal text nodes. See getContentNameInfo's
57
76
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -70,7 +89,7 @@ function runInPage(ctx) {
70
89
  const raw = getAttr(el, 'aria-labelledby');
71
90
  if (!raw) return '';
72
91
  // Delegates to the shared getTextFromIdRefs helper instead of computing
73
- // name-from-content of the referenced element — see dialog-name-
92
+ // name-from-content of the referenced element, see dialog-name-
74
93
  // present.js's identical fix for the full rationale (an <iframe>
75
94
  // aria-labelledby target's only name source is its title attribute,
76
95
  // which name-from-content alone can never see).
@@ -148,24 +167,22 @@ function runInPage(ctx) {
148
167
  }
149
168
  })()
150
169
  : null;
151
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
152
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
153
-
154
- occurrences.push({
155
- selector: stableSelector,
156
- html,
157
- summary: 'This tab has no accessible name.',
158
- hint: 'Provide tab text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
159
- i18n: {
160
- summaryKey: 'tabNamePresent_summary_fail',
161
- hintKey: 'tabNamePresent_hint_fail',
162
- params: {}
163
- },
164
- data: {
165
- details: { reasonCode: 'name_missing', controlType: 'tab', methodTried: res.method },
166
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
167
- }
168
- });
170
+
171
+ occurrences.push(
172
+ helpers.reportOccurrence(el, {
173
+ summary: 'This tab has no accessible name.',
174
+ hint: 'Provide tab text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
175
+ i18n: {
176
+ summaryKey: 'tabNamePresent_summary_fail',
177
+ hintKey: 'tabNamePresent_hint_fail',
178
+ params: {}
179
+ },
180
+ data: {
181
+ details: { reasonCode: 'name_missing', controlType: 'tab', methodTried: res.method },
182
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
183
+ }
184
+ })
185
+ );
169
186
  }
170
187
 
171
188
  if (applicableCount === 0) {
@@ -9,11 +9,18 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.3.1
11
11
  * @applicability
12
- * Applies to <td>/<th> elements that carry a non-empty headers attribute.
12
+ * Applies to <td>/<th> elements that carry a non-empty headers attribute,
13
+ * within a <table> whose semantic role is still table/grid/treegrid --
14
+ * an explicit role of anything else (role="presentation"/"none", but
15
+ * also role="heading" or any other real role) replaces the native table
16
+ * semantics, leaving no table for headers to describe. Matches ACT
17
+ * a25f45's applicability.
13
18
  * @expectation
14
19
  * Every id token in the headers attribute resolves to an element that:
15
- * (a) exists, (b) is a <th> element, (c) is inside the same <table> as
16
- * the referencing cell, and (d) is not the cell itself.
20
+ * (a) exists, (b) is a cell (<td> or <th>) of the same <table> as the
21
+ * referencing cell, and (c) is not the cell itself. A <td> serving as a
22
+ * header via role="columnheader"/"rowheader" is a valid target, same as
23
+ * a plain <th> -- ACT a25f45 does not require the native tag.
17
24
  * @implementation-notes
18
25
  * - One occurrence per offending cell (not per bad token), listing every
19
26
  * invalid reference.
@@ -27,7 +34,7 @@ const id = 'table-headers-attr-valid';
27
34
  const meta = {
28
35
  title: 'Table cell "headers" attribute must reference valid header cells',
29
36
  description:
30
- 'Checks that each id in a <td>/<th> headers attribute resolves to a <th> element within the same table (not missing, not a non-th element, not itself).',
37
+ 'Checks that each id in a <td>/<th> headers attribute resolves to a cell (<td> or <th>) within the same table (not missing, not a non-cell element, not itself).',
31
38
  i18n: {
32
39
  titleKey: 'tableHeadersAttrValid_title',
33
40
  descriptionKey: 'tableHeadersAttrValid_description'
@@ -54,6 +61,23 @@ const meta = {
54
61
  function runInPage(ctx) {
55
62
  const { document, helpers, rule } = ctx;
56
63
 
64
+ const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
65
+
66
+ // The role attribute holds a fallback list; the first token naming a real
67
+ // role wins, and unknown tokens are skipped over.
68
+ function getExplicitRole(el) {
69
+ const raw = el && el.getAttribute ? el.getAttribute('role') : null;
70
+ if (!raw) return '';
71
+ const tokens = String(raw).trim().toLowerCase().split(/\s+/);
72
+ for (const token of tokens) {
73
+ if (!token) continue;
74
+ if (token === 'presentation' || token === 'none') return token;
75
+ const known = ariaHelpers ? ariaHelpers.isValidConcreteRole(token) : true;
76
+ if (known) return token;
77
+ }
78
+ return '';
79
+ }
80
+
57
81
  const nodes = helpers.queryAllSmart
58
82
  ? helpers.queryAllSmart('td[headers], th[headers]')
59
83
  : helpers.queryAll('td[headers], th[headers]');
@@ -69,9 +93,19 @@ function runInPage(ctx) {
69
93
  const ids = raw.split(/\s+/).filter(Boolean);
70
94
  if (!ids.length) continue;
71
95
 
96
+ const table = el.closest ? el.closest('table') : null;
97
+
98
+ // An explicit role on the <table> replaces its native table role. Only
99
+ // the three roles that still describe a table keep the cell's headers
100
+ // attribute meaningful; anything else (presentation/none, heading, ...)
101
+ // takes the whole table out of scope. Unknown tokens name no role, so
102
+ // the native one stands.
103
+ const tableRole = table ? getExplicitRole(table) : '';
104
+ if (tableRole && tableRole !== 'table' && tableRole !== 'grid' && tableRole !== 'treegrid')
105
+ continue;
106
+
72
107
  applicableCount += 1;
73
108
 
74
- const table = el.closest ? el.closest('table') : null;
75
109
  const invalid = [];
76
110
 
77
111
  for (const headerId of ids) {
@@ -90,8 +124,9 @@ function runInPage(ctx) {
90
124
  invalid.push({ id: headerId, reason: 'self-reference' });
91
125
  continue;
92
126
  }
93
- if (!ref.tagName || ref.tagName.toLowerCase() !== 'th') {
94
- invalid.push({ id: headerId, reason: 'not-a-th' });
127
+ const refTag = ref.tagName ? ref.tagName.toLowerCase() : '';
128
+ if (refTag !== 'th' && refTag !== 'td') {
129
+ invalid.push({ id: headerId, reason: 'not-a-cell' });
95
130
  continue;
96
131
  }
97
132
  if (table && (!ref.closest || ref.closest('table') !== table)) {
@@ -104,23 +139,21 @@ function runInPage(ctx) {
104
139
  const dedupedInvalidIds = [...new Set(invalid.map((i) => i.id))];
105
140
 
106
141
  const tag = el.tagName.toLowerCase();
107
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
108
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
109
-
110
- occurrences.push({
111
- selector: stableSelector,
112
- html,
113
- summary: 'This cell’s headers attribute references one or more invalid header cells.',
114
- hint: 'Update the headers attribute so every id refers to a <th> element within the same table.',
115
- i18n: {
116
- summaryKey: 'tableHeadersAttrValid_summary_fail',
117
- hintKey: 'tableHeadersAttrValid_hint_fail',
118
- params: { element: tag, invalidIds: dedupedInvalidIds.join(', ') }
119
- },
120
- data: {
121
- details: { reasonCode: 'TABLE_HEADERS_ATTR_INVALID', element: tag, invalid }
122
- }
123
- });
142
+
143
+ occurrences.push(
144
+ helpers.reportOccurrence(el, {
145
+ summary: 'This cell’s headers attribute references one or more invalid header cells.',
146
+ hint: 'Update the headers attribute so every id refers to a cell (<td> or <th>) within the same table.',
147
+ i18n: {
148
+ summaryKey: 'tableHeadersAttrValid_summary_fail',
149
+ hintKey: 'tableHeadersAttrValid_hint_fail',
150
+ params: { element: tag, invalidIds: dedupedInvalidIds.join(', ') }
151
+ },
152
+ data: {
153
+ details: { reasonCode: 'TABLE_HEADERS_ATTR_INVALID', element: tag, invalid }
154
+ }
155
+ })
156
+ );
124
157
  }
125
158
 
126
159
  if (applicableCount === 0) {
@@ -12,26 +12,29 @@
12
12
  * Applies to <table> elements that keep their table semantics and are included
13
13
  * in the accessibility tree, and that contain at least one <th> which is
14
14
  * visible, included in the accessibility tree, and not overridden by an
15
- * explicit role other than rowheader/columnheader.
15
+ * explicit role other than rowheader/columnheader. Also applies to the
16
+ * ARIA-only equivalent: an element with role="grid"/"treegrid" (no
17
+ * native <table> involved) that contains at least one in-scope
18
+ * columnheader/rowheader-role element.
16
19
  * @expectation
17
20
  * The table also contains at least one <td> somewhere in it.
18
21
  * @implementation-notes
19
- * - DELIBERATELY SCOPED (see aria-helpers.js file header for the same
22
+ * - SCOPED (see aria-helpers.js file header for the same
20
23
  * conservative-scope rationale used throughout this engine): this rule
21
24
  * does NOT implement the full HTML5 header-association algorithm
22
25
  * (resolving which specific data cells a given <th scope="row"|"col">
23
26
  * describes, accounting for colspan/rowspan and default-scope
24
27
  * inference). That algorithm is one of the more error-prone parts of
25
28
  * the HTML spec to reimplement correctly, and a wrong positional match
26
- * would risk a false `fail` — unacceptable for this engine's fail-
29
+ * would risk a false `fail`, unacceptable for this engine's fail-
27
30
  * integrity bar.
28
31
  * - Instead, this rule only catches the single unambiguous case: a table
29
32
  * that has <th> elements but ZERO <td> elements anywhere. In that case
30
- * every <th> in the table trivially describes no data cell — no
33
+ * every <th> in the table trivially describes no data cell, no
31
34
  * positional analysis is needed to know that. A table that has at
32
35
  * least one <td> somewhere is not evaluated further by this rule, even
33
36
  * if some particular <th> in it doesn't actually describe any cell
34
- * (false negative, not a false positive — acceptable under this
37
+ * (false negative, not a false positive, acceptable under this
35
38
  * engine's philosophy).
36
39
  * - Applicability IS gated, because the zero-<td> case above turns any
37
40
  * over-broad scope straight into a false fail: a layout table marked
@@ -156,25 +159,74 @@ function runInPage(ctx) {
156
159
 
157
160
  for (const th of headers) {
158
161
  if (!th) continue;
159
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(th) : 'html';
160
- const html = helpers.getOuterHtmlSnippet
161
- ? helpers.getOuterHtmlSnippet(th)
162
- : th.outerHTML || '';
163
-
164
- occurrences.push({
165
- selector: stableSelector,
166
- html,
167
- summary: 'This table has header cells but no data cells for them to describe.',
168
- hint: 'Add data cells (<td>) to the table, or remove the header cells if the table has no data.',
169
- i18n: {
170
- summaryKey: 'tableThHasDataCells_summary_fail',
171
- hintKey: 'tableThHasDataCells_hint_fail',
172
- params: {}
173
- },
174
- data: {
175
- details: { reasonCode: 'TABLE_TH_NO_DATA_CELLS' }
176
- }
177
- });
162
+
163
+ occurrences.push(
164
+ helpers.reportOccurrence(th, {
165
+ summary: 'This table has header cells but no data cells for them to describe.',
166
+ hint: 'Add data cells (<td>) to the table, or remove the header cells if the table has no data.',
167
+ i18n: {
168
+ summaryKey: 'tableThHasDataCells_summary_fail',
169
+ hintKey: 'tableThHasDataCells_hint_fail',
170
+ params: {}
171
+ },
172
+ data: {
173
+ details: { reasonCode: 'TABLE_TH_NO_DATA_CELLS' }
174
+ }
175
+ })
176
+ );
177
+ }
178
+ }
179
+
180
+ // ARIA-only equivalent: a role="grid"/"treegrid" container with no
181
+ // backing <table> at all. Same trivial "zero data cells anywhere" check,
182
+ // just keyed off ARIA roles instead of native tags -- a genuine
183
+ // columnheader/rowheader with zero gridcell/cell-role elements anywhere
184
+ // in the container is exactly as unambiguous as a <th>-only <table>.
185
+ const grids = helpers.queryAllSmart
186
+ ? helpers.queryAllSmart('[role="grid"], [role="treegrid"]')
187
+ : helpers.queryAll('[role="grid"], [role="treegrid"]');
188
+
189
+ for (const grid of grids) {
190
+ if (!grid || !grid.querySelectorAll) continue;
191
+ if (grid.tagName && grid.tagName.toLowerCase() === 'table') continue; // already handled above
192
+ if (!isIncludedInTree(grid)) continue;
193
+
194
+ let headerNodes;
195
+ try {
196
+ headerNodes = grid.querySelectorAll('[role="columnheader"], [role="rowheader"]');
197
+ } catch {
198
+ headerNodes = [];
199
+ }
200
+ const headers = Array.from(headerNodes).filter(isHeaderCellInScope);
201
+ if (!headers.length) continue;
202
+
203
+ applicableCount += 1;
204
+
205
+ let hasDataCell;
206
+ try {
207
+ hasDataCell = grid.querySelectorAll('[role="gridcell"], [role="cell"]').length > 0;
208
+ } catch {
209
+ hasDataCell = false;
210
+ }
211
+ if (hasDataCell) continue;
212
+
213
+ for (const th of headers) {
214
+ if (!th) continue;
215
+
216
+ occurrences.push(
217
+ helpers.reportOccurrence(th, {
218
+ summary: 'This table has header cells but no data cells for them to describe.',
219
+ hint: 'Add data cells (<td>) to the table, or remove the header cells if the table has no data.',
220
+ i18n: {
221
+ summaryKey: 'tableThHasDataCells_summary_fail',
222
+ hintKey: 'tableThHasDataCells_hint_fail',
223
+ params: {}
224
+ },
225
+ data: {
226
+ details: { reasonCode: 'TABLE_TH_NO_DATA_CELLS' }
227
+ }
228
+ })
229
+ );
178
230
  }
179
231
  }
180
232