@surea11y/core 1.4.0 → 1.5.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 (115) hide show
  1. package/CHANGELOG.md +88 -7
  2. package/README.md +19 -3
  3. package/bin/surea11y-core.js +0 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/ARIA_DEPRECATION.md +95 -0
  6. package/docs/ENGINE_OPTIONS.md +14 -10
  7. package/docs/I18N.md +176 -20
  8. package/docs/INTEGRATION.md +28 -6
  9. package/docs/LIMITATIONS.md +1 -1
  10. package/docs/OUTPUT_SCHEMA.md +13 -3
  11. package/docs/REPORT.md +2 -0
  12. package/docs/RULE_AUTHORING.md +53 -0
  13. package/docs/RULE_CATALOG.md +6 -6
  14. package/docs/TROUBLESHOOTING.md +2 -2
  15. package/package.json +8 -1
  16. package/src/checks/automatic/aria-allowed-attr.js +661 -99
  17. package/src/checks/automatic/aria-allowed-role.js +14 -16
  18. package/src/checks/automatic/aria-braille-equivalent.js +17 -19
  19. package/src/checks/automatic/aria-conditional-attr.js +17 -19
  20. package/src/checks/automatic/aria-deprecated-role.js +122 -41
  21. package/src/checks/automatic/aria-hidden-body.js +2 -9
  22. package/src/checks/automatic/aria-hidden-focus.js +99 -18
  23. package/src/checks/automatic/aria-prohibited-attr.js +54 -55
  24. package/src/checks/automatic/aria-prohibited-children.js +26 -26
  25. package/src/checks/automatic/aria-required-attr.js +14 -17
  26. package/src/checks/automatic/aria-required-children.js +17 -20
  27. package/src/checks/automatic/aria-required-parent.js +17 -20
  28. package/src/checks/automatic/aria-roles-valid.js +66 -27
  29. package/src/checks/automatic/aria-valid-attr-value.js +18 -21
  30. package/src/checks/automatic/aria-valid-attr.js +14 -17
  31. package/src/checks/automatic/autocomplete-valid.js +52 -18
  32. package/src/checks/automatic/avoid-inline-spacing.js +192 -34
  33. package/src/checks/automatic/binary-control-name-present.js +30 -24
  34. package/src/checks/automatic/button-name-present.js +73 -45
  35. package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
  36. package/src/checks/automatic/combobox-name-present.js +23 -18
  37. package/src/checks/automatic/css-orientation-lock.js +22 -22
  38. package/src/checks/automatic/definition-list-children-valid.js +18 -21
  39. package/src/checks/automatic/deprecated-elements-not-used.js +14 -16
  40. package/src/checks/automatic/dialog-name-present.js +24 -19
  41. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  42. package/src/checks/automatic/duplicate-id-aria.js +45 -37
  43. package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
  44. package/src/checks/automatic/form-control-single-label.js +109 -43
  45. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  46. package/src/checks/automatic/iframe-focusable-content.js +29 -31
  47. package/src/checks/automatic/iframe-name-present.js +15 -17
  48. package/src/checks/automatic/iframe-title-unique.js +18 -23
  49. package/src/checks/automatic/img-alt-present.js +16 -9
  50. package/src/checks/automatic/input-image-alt-present.js +99 -48
  51. package/src/checks/automatic/label-in-name.js +102 -33
  52. package/src/checks/automatic/language-page-present.js +5 -1
  53. package/src/checks/automatic/link-in-text-block.js +19 -21
  54. package/src/checks/automatic/link-name-present.js +75 -47
  55. package/src/checks/automatic/list-children-valid.js +15 -17
  56. package/src/checks/automatic/listbox-name-present.js +23 -18
  57. package/src/checks/automatic/listitem-parent-valid.js +14 -17
  58. package/src/checks/automatic/menuitem-name-present.js +24 -19
  59. package/src/checks/automatic/meta-refresh-no-exceptions.js +44 -18
  60. package/src/checks/automatic/meta-refresh-timing-absent.js +44 -21
  61. package/src/checks/automatic/meta-viewport-zoom-enabled.js +51 -32
  62. package/src/checks/automatic/meter-name-present.js +24 -19
  63. package/src/checks/automatic/nested-interactive-controls-absent.js +179 -51
  64. package/src/checks/automatic/object-text-alternative-present.js +14 -7
  65. package/src/checks/automatic/option-name-present.js +24 -19
  66. package/src/checks/automatic/progressbar-name-present.js +27 -22
  67. package/src/checks/automatic/searchbox-name-present.js +27 -18
  68. package/src/checks/automatic/server-side-image-map-absent.js +15 -18
  69. package/src/checks/automatic/slider-name-present.js +26 -19
  70. package/src/checks/automatic/spinbutton-name-present.js +27 -18
  71. package/src/checks/automatic/summary-name-present.js +24 -19
  72. package/src/checks/automatic/tab-name-present.js +24 -19
  73. package/src/checks/automatic/table-headers-attr-valid.js +15 -17
  74. package/src/checks/automatic/table-th-has-data-cells.js +82 -25
  75. package/src/checks/automatic/target-size-minimum.js +104 -81
  76. package/src/checks/automatic/td-has-header.js +15 -20
  77. package/src/checks/automatic/textbox-name-present.js +23 -18
  78. package/src/checks/automatic/tooltip-name-present.js +24 -19
  79. package/src/checks/automatic/treeitem-name-present.js +24 -19
  80. package/src/checks/automatic/valid-lang.js +31 -20
  81. package/src/checks/manual/accesskeys-manual.js +18 -19
  82. package/src/checks/manual/aria-checked-state-mismatch-manual.js +19 -22
  83. package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +98 -44
  84. package/src/checks/manual/empty-heading-manual.js +15 -17
  85. package/src/checks/manual/empty-table-header-manual.js +27 -30
  86. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -9
  87. package/src/checks/manual/heading-order-manual.js +17 -22
  88. package/src/checks/manual/image-redundant-alt-manual.js +14 -17
  89. package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
  90. package/src/checks/manual/label-title-only-manual.js +15 -17
  91. package/src/checks/manual/landmark-banner-is-top-level-manual.js +25 -39
  92. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +21 -27
  93. package/src/checks/manual/landmark-main-is-top-level-manual.js +14 -17
  94. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +2 -7
  95. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +2 -7
  96. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -7
  97. package/src/checks/manual/landmark-one-main-manual.js +2 -9
  98. package/src/checks/manual/landmark-unique-manual.js +22 -27
  99. package/src/checks/manual/link-name-quality-manual.js +15 -17
  100. package/src/checks/manual/meta-viewport-large-manual.js +14 -17
  101. package/src/checks/manual/mouse-only-event-handlers-manual.js +17 -19
  102. package/src/checks/manual/page-has-heading-one-manual.js +2 -9
  103. package/src/checks/manual/presentation-role-conflict-manual.js +19 -21
  104. package/src/checks/manual/region-manual.js +13 -6
  105. package/src/checks/manual/scope-attr-valid-manual.js +14 -17
  106. package/src/checks/manual/skip-link-manual.js +39 -47
  107. package/src/checks/manual/tabindex-manual.js +14 -17
  108. package/src/checks/manual/table-duplicate-name-manual.js +14 -17
  109. package/src/core.js +8818 -4330
  110. package/src/report.js +14 -0
  111. package/src/sarif.js +2 -2
  112. package/surea11y.browser.js +4023 -3911
  113. package/surea11y.i18n.de.js +22 -0
  114. package/surea11y.i18n.es.js +22 -0
  115. package/surea11y.i18n.fr.js +22 -0
@@ -109,9 +109,16 @@ function runInPage(ctx) {
109
109
  return '';
110
110
  }
111
111
 
112
+ // Naming rules apply only to elements included in the accessibility tree
113
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
114
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
112
115
  function isEligibleAcc(helpers, el, ctx) {
113
116
  const fn =
114
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
117
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
118
+ ? helpers.isIncludedInAccessibilityTree
119
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
120
+ ? helpers.isAccTreeEligible
121
+ : null;
115
122
  if (!fn) return true;
116
123
  try {
117
124
  const r = fn(el, ctx);
@@ -220,24 +227,26 @@ function runInPage(ctx) {
220
227
  }
221
228
  })()
222
229
  : null;
223
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
224
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
225
230
 
226
- occurrences.push({
227
- selector: stableSelector,
228
- html,
229
- summary: 'This element has no accessible name.',
230
- hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this searchbox's accessible name.",
231
- i18n: {
232
- summaryKey: 'searchboxNamePresent_summary_fail',
233
- hintKey: 'searchboxNamePresent_hint_fail',
234
- params: { controlType: 'searchbox' }
235
- },
236
- data: {
237
- details: { reasonCode: 'name_missing', controlType: 'searchbox', methodTried: res.method },
238
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
239
- }
240
- });
231
+ occurrences.push(
232
+ helpers.reportOccurrence(el, {
233
+ summary: 'This element has no accessible name.',
234
+ hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this searchbox's accessible name.",
235
+ i18n: {
236
+ summaryKey: 'searchboxNamePresent_summary_fail',
237
+ hintKey: 'searchboxNamePresent_hint_fail',
238
+ params: { controlType: 'searchbox' }
239
+ },
240
+ data: {
241
+ details: {
242
+ reasonCode: 'name_missing',
243
+ controlType: 'searchbox',
244
+ methodTried: res.method
245
+ },
246
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
247
+ }
248
+ })
249
+ );
241
250
  }
242
251
 
243
252
  if (applicableCount === 0) {
@@ -66,24 +66,21 @@ function runInPage(ctx) {
66
66
 
67
67
  applicableCount += 1;
68
68
 
69
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
70
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
71
-
72
- occurrences.push({
73
- selector: stableSelector,
74
- html,
75
- summary:
76
- 'This image uses a server-side image map, which has no keyboard-operable equivalent.',
77
- hint: 'Replace the server-side image map (ismap) with a client-side image map (<map>/<area>) or separate accessible links/buttons.',
78
- i18n: {
79
- summaryKey: 'serverSideImageMapAbsent_summary_fail',
80
- hintKey: 'serverSideImageMapAbsent_hint_fail',
81
- params: {}
82
- },
83
- data: {
84
- details: { reasonCode: 'SERVER_SIDE_IMAGE_MAP' }
85
- }
86
- });
69
+ occurrences.push(
70
+ helpers.reportOccurrence(el, {
71
+ summary:
72
+ 'This image uses a server-side image map, which has no keyboard-operable equivalent.',
73
+ hint: 'Replace the server-side image map (ismap) with a client-side image map (<map>/<area>) or separate accessible links/buttons.',
74
+ i18n: {
75
+ summaryKey: 'serverSideImageMapAbsent_summary_fail',
76
+ hintKey: 'serverSideImageMapAbsent_hint_fail',
77
+ params: {}
78
+ },
79
+ data: {
80
+ details: { reasonCode: 'SERVER_SIDE_IMAGE_MAP' }
81
+ }
82
+ })
83
+ );
87
84
  }
88
85
 
89
86
  if (applicableCount === 0) {
@@ -125,9 +125,16 @@ function runInPage(ctx) {
125
125
  return '';
126
126
  }
127
127
 
128
+ // Naming rules apply only to elements included in the accessibility tree
129
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
130
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
128
131
  function isEligibleAcc(helpers, el, ctx) {
129
132
  const fn =
130
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
133
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
134
+ ? helpers.isIncludedInAccessibilityTree
135
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
136
+ ? helpers.isAccTreeEligible
137
+ : null;
131
138
  if (!fn) return true;
132
139
  try {
133
140
  const r = fn(el, ctx);
@@ -141,7 +148,9 @@ function runInPage(ctx) {
141
148
  const occurrences = [];
142
149
  let applicableCount = 0;
143
150
 
144
- const selector = 'input[type="range"], [role="slider"]';
151
+ // Native input[type=range] belongs to
152
+ // form-control-programmatic-label-present.
153
+ const selector = '[role="slider"]';
145
154
  const nodes = helpers.queryAllSmart
146
155
  ? helpers.queryAllSmart(selector)
147
156
  : helpers.queryAll(selector);
@@ -233,24 +242,22 @@ function runInPage(ctx) {
233
242
  }
234
243
  })()
235
244
  : null;
236
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
237
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
238
245
 
239
- occurrences.push({
240
- selector: stableSelector,
241
- html,
242
- summary: 'This slider has no accessible name.',
243
- hint: 'Provide a label, aria-label, or aria-labelledby so assistive technologies can identify the slider.',
244
- i18n: {
245
- summaryKey: 'sliderNamePresent_summary_fail',
246
- hintKey: 'sliderNamePresent_hint_fail',
247
- params: { kind }
248
- },
249
- data: {
250
- details: { reasonCode: 'name_missing', controlType: kind, methodTried: res.method },
251
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
252
- }
253
- });
246
+ occurrences.push(
247
+ helpers.reportOccurrence(el, {
248
+ summary: 'This slider has no accessible name.',
249
+ hint: 'Provide a label, aria-label, or aria-labelledby so assistive technologies can identify the slider.',
250
+ i18n: {
251
+ summaryKey: 'sliderNamePresent_summary_fail',
252
+ hintKey: 'sliderNamePresent_hint_fail',
253
+ params: { kind }
254
+ },
255
+ data: {
256
+ details: { reasonCode: 'name_missing', controlType: kind, methodTried: res.method },
257
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
258
+ }
259
+ })
260
+ );
254
261
  }
255
262
 
256
263
  if (applicableCount === 0) {
@@ -109,9 +109,16 @@ function runInPage(ctx) {
109
109
  return '';
110
110
  }
111
111
 
112
+ // Naming rules apply only to elements included in the accessibility tree
113
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
114
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
112
115
  function isEligibleAcc(helpers, el, ctx) {
113
116
  const fn =
114
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
117
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
118
+ ? helpers.isIncludedInAccessibilityTree
119
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
120
+ ? helpers.isAccTreeEligible
121
+ : null;
115
122
  if (!fn) return true;
116
123
  try {
117
124
  const r = fn(el, ctx);
@@ -220,24 +227,26 @@ function runInPage(ctx) {
220
227
  }
221
228
  })()
222
229
  : null;
223
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
224
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
225
230
 
226
- occurrences.push({
227
- selector: stableSelector,
228
- html,
229
- summary: 'This element has no accessible name.',
230
- hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this spinbutton's accessible name.",
231
- i18n: {
232
- summaryKey: 'spinbuttonNamePresent_summary_fail',
233
- hintKey: 'spinbuttonNamePresent_hint_fail',
234
- params: { controlType: 'spinbutton' }
235
- },
236
- data: {
237
- details: { reasonCode: 'name_missing', controlType: 'spinbutton', methodTried: res.method },
238
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
239
- }
240
- });
231
+ occurrences.push(
232
+ helpers.reportOccurrence(el, {
233
+ summary: 'This element has no accessible name.',
234
+ hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this spinbutton's accessible name.",
235
+ i18n: {
236
+ summaryKey: 'spinbuttonNamePresent_summary_fail',
237
+ hintKey: 'spinbuttonNamePresent_hint_fail',
238
+ params: { controlType: 'spinbutton' }
239
+ },
240
+ data: {
241
+ details: {
242
+ reasonCode: 'name_missing',
243
+ controlType: 'spinbutton',
244
+ methodTried: res.method
245
+ },
246
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
247
+ }
248
+ })
249
+ );
241
250
  }
242
251
 
243
252
  if (applicableCount === 0) {
@@ -83,9 +83,16 @@ function runInPage(ctx) {
83
83
  return '';
84
84
  }
85
85
 
86
+ // Naming rules apply only to elements included in the accessibility tree
87
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
88
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
86
89
  function isEligibleAcc(helpers, el, ctx) {
87
90
  const fn =
88
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
91
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
92
+ ? helpers.isIncludedInAccessibilityTree
93
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
94
+ ? helpers.isAccTreeEligible
95
+ : null;
89
96
  if (!fn) return true;
90
97
  try {
91
98
  const r = fn(el, ctx);
@@ -137,24 +144,22 @@ function runInPage(ctx) {
137
144
  }
138
145
  })()
139
146
  : null;
140
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
141
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
142
-
143
- occurrences.push({
144
- selector: stableSelector,
145
- html,
146
- summary: 'This summary has no accessible name.',
147
- hint: 'Provide summary text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
148
- i18n: {
149
- summaryKey: 'summaryNamePresent_summary_fail',
150
- hintKey: 'summaryNamePresent_hint_fail',
151
- params: {}
152
- },
153
- data: {
154
- details: { reasonCode: 'name_missing', controlType: 'summary', methodTried: res.method },
155
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
156
- }
157
- });
147
+
148
+ occurrences.push(
149
+ helpers.reportOccurrence(el, {
150
+ summary: 'This summary has no accessible name.',
151
+ hint: 'Provide summary text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
152
+ i18n: {
153
+ summaryKey: 'summaryNamePresent_summary_fail',
154
+ hintKey: 'summaryNamePresent_hint_fail',
155
+ params: {}
156
+ },
157
+ data: {
158
+ details: { reasonCode: 'name_missing', controlType: 'summary', methodTried: res.method },
159
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
160
+ }
161
+ })
162
+ );
158
163
  }
159
164
 
160
165
  if (applicableCount === 0) {
@@ -83,9 +83,16 @@ function runInPage(ctx) {
83
83
  return '';
84
84
  }
85
85
 
86
+ // Naming rules apply only to elements included in the accessibility tree
87
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
88
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
86
89
  function isEligibleAcc(helpers, el, ctx) {
87
90
  const fn =
88
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
91
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
92
+ ? helpers.isIncludedInAccessibilityTree
93
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
94
+ ? helpers.isAccTreeEligible
95
+ : null;
89
96
  if (!fn) return true;
90
97
  try {
91
98
  const r = fn(el, ctx);
@@ -141,24 +148,22 @@ function runInPage(ctx) {
141
148
  }
142
149
  })()
143
150
  : null;
144
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
145
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
146
-
147
- occurrences.push({
148
- selector: stableSelector,
149
- html,
150
- summary: 'This tab has no accessible name.',
151
- hint: 'Provide tab text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
152
- i18n: {
153
- summaryKey: 'tabNamePresent_summary_fail',
154
- hintKey: 'tabNamePresent_hint_fail',
155
- params: {}
156
- },
157
- data: {
158
- details: { reasonCode: 'name_missing', controlType: 'tab', methodTried: res.method },
159
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
160
- }
161
- });
151
+
152
+ occurrences.push(
153
+ helpers.reportOccurrence(el, {
154
+ summary: 'This tab has no accessible name.',
155
+ hint: 'Provide tab text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
156
+ i18n: {
157
+ summaryKey: 'tabNamePresent_summary_fail',
158
+ hintKey: 'tabNamePresent_hint_fail',
159
+ params: {}
160
+ },
161
+ data: {
162
+ details: { reasonCode: 'name_missing', controlType: 'tab', methodTried: res.method },
163
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
164
+ }
165
+ })
166
+ );
162
167
  }
163
168
 
164
169
  if (applicableCount === 0) {
@@ -104,23 +104,21 @@ function runInPage(ctx) {
104
104
  const dedupedInvalidIds = [...new Set(invalid.map((i) => i.id))];
105
105
 
106
106
  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
- });
107
+
108
+ occurrences.push(
109
+ helpers.reportOccurrence(el, {
110
+ summary: 'This cell’s headers attribute references one or more invalid header cells.',
111
+ hint: 'Update the headers attribute so every id refers to a <th> element within the same table.',
112
+ i18n: {
113
+ summaryKey: 'tableHeadersAttrValid_summary_fail',
114
+ hintKey: 'tableHeadersAttrValid_hint_fail',
115
+ params: { element: tag, invalidIds: dedupedInvalidIds.join(', ') }
116
+ },
117
+ data: {
118
+ details: { reasonCode: 'TABLE_HEADERS_ATTR_INVALID', element: tag, invalid }
119
+ }
120
+ })
121
+ );
124
122
  }
125
123
 
126
124
  if (applicableCount === 0) {
@@ -9,7 +9,10 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.3.1
11
11
  * @applicability
12
- * Applies to <table> elements that contain at least one <th>.
12
+ * Applies to <table> elements that keep their table semantics and are included
13
+ * in the accessibility tree, and that contain at least one <th> which is
14
+ * visible, included in the accessibility tree, and not overridden by an
15
+ * explicit role other than rowheader/columnheader.
13
16
  * @expectation
14
17
  * The table also contains at least one <td> somewhere in it.
15
18
  * @implementation-notes
@@ -30,9 +33,10 @@
30
33
  * if some particular <th> in it doesn't actually describe any cell
31
34
  * (false negative, not a false positive — acceptable under this
32
35
  * engine's philosophy).
33
- * - Not rule-gated on isAccTreeEligible: this remains a static-markup
34
- * property, while engine-level hidden-subtree filtering still applies
35
- * unless engineOptions.includeHiddenElements is true.
36
+ * - Applicability IS gated, because the zero-<td> case above turns any
37
+ * over-broad scope straight into a false fail: a layout table marked
38
+ * role="presentation" with a stray <th> and no <td> was being failed,
39
+ * as were tables whose only header was hidden or carried role="cell".
36
40
  */
37
41
 
38
42
  const id = 'table-th-has-data-cells';
@@ -72,16 +76,73 @@ function runInPage(ctx) {
72
76
  const occurrences = [];
73
77
  let applicableCount = 0;
74
78
 
79
+ function explicitRole(el) {
80
+ try {
81
+ return String((el && el.getAttribute && el.getAttribute('role')) || '')
82
+ .trim()
83
+ .toLowerCase()
84
+ .split(/\s+/)[0];
85
+ } catch {
86
+ return '';
87
+ }
88
+ }
89
+
90
+ function isIncludedInTree(el) {
91
+ const fn =
92
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
93
+ ? helpers.isIncludedInAccessibilityTree
94
+ : null;
95
+ if (!fn) return true;
96
+ try {
97
+ const r = fn(el, ctx);
98
+ return typeof r === 'boolean' ? r : !!(r && r.eligible);
99
+ } catch {
100
+ return true;
101
+ }
102
+ }
103
+
104
+ // role="presentation"/"none" removes the table role, and with it every
105
+ // header cell the role would have implied. A table is not focusable, so
106
+ // presentational role conflict resolution does not restore it.
107
+ function hasTableSemantics(table) {
108
+ const role = explicitRole(table);
109
+ if (role === 'presentation' || role === 'none') return false;
110
+ if (role && role !== 'table' && role !== 'grid' && role !== 'treegrid') return false;
111
+ return isIncludedInTree(table);
112
+ }
113
+
114
+ // A <th> only carries a rowheader/columnheader role while nothing overrides
115
+ // it, and a hidden header describes nothing to a screen reader either way.
116
+ function isHeaderCellInScope(th) {
117
+ if (!th) return false;
118
+ const role = explicitRole(th);
119
+ if (role && role !== 'rowheader' && role !== 'columnheader') return false;
120
+ if (helpers.isDomVisibleEligible) {
121
+ try {
122
+ if (!helpers.isDomVisibleEligible(th, ctx, { targetSet: 'dom' }).eligible) return false;
123
+ } catch {
124
+ // fall through to the tree check
125
+ }
126
+ }
127
+ return isIncludedInTree(th);
128
+ }
129
+
75
130
  for (const table of tables) {
76
131
  if (!table || !table.querySelectorAll) continue;
77
132
 
133
+ // A table stripped of its semantics has no header cells to describe
134
+ // anything, so nothing in it is in scope.
135
+ if (!hasTableSemantics(table)) continue;
136
+
78
137
  let ths;
79
138
  try {
80
139
  ths = table.querySelectorAll('th');
81
140
  } catch {
82
141
  ths = [];
83
142
  }
84
- if (!ths.length) continue;
143
+
144
+ const headers = Array.from(ths).filter(isHeaderCellInScope);
145
+ if (!headers.length) continue;
85
146
 
86
147
  applicableCount += 1;
87
148
 
@@ -93,27 +154,23 @@ function runInPage(ctx) {
93
154
  }
94
155
  if (hasDataCell) continue;
95
156
 
96
- for (const th of ths) {
157
+ for (const th of headers) {
97
158
  if (!th) continue;
98
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(th) : 'html';
99
- const html = helpers.getOuterHtmlSnippet
100
- ? helpers.getOuterHtmlSnippet(th)
101
- : th.outerHTML || '';
102
-
103
- occurrences.push({
104
- selector: stableSelector,
105
- html,
106
- summary: 'This table has header cells but no data cells for them to describe.',
107
- hint: 'Add data cells (<td>) to the table, or remove the header cells if the table has no data.',
108
- i18n: {
109
- summaryKey: 'tableThHasDataCells_summary_fail',
110
- hintKey: 'tableThHasDataCells_hint_fail',
111
- params: {}
112
- },
113
- data: {
114
- details: { reasonCode: 'TABLE_TH_NO_DATA_CELLS' }
115
- }
116
- });
159
+
160
+ occurrences.push(
161
+ helpers.reportOccurrence(th, {
162
+ summary: 'This table has header cells but no data cells for them to describe.',
163
+ hint: 'Add data cells (<td>) to the table, or remove the header cells if the table has no data.',
164
+ i18n: {
165
+ summaryKey: 'tableThHasDataCells_summary_fail',
166
+ hintKey: 'tableThHasDataCells_hint_fail',
167
+ params: {}
168
+ },
169
+ data: {
170
+ details: { reasonCode: 'TABLE_TH_NO_DATA_CELLS' }
171
+ }
172
+ })
173
+ );
117
174
  }
118
175
  }
119
176