@surea11y/core 1.2.0 → 1.4.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 (168) hide show
  1. package/CHANGELOG.md +81 -7
  2. package/LICENSE +373 -21
  3. package/README.md +175 -35
  4. package/bin/surea11y-core.js +20 -0
  5. package/docs/API_STABILITY.md +27 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/ENGINE_OPTIONS.md +2 -0
  9. package/docs/I18N.md +12 -9
  10. package/docs/INTEGRATION.md +19 -1
  11. package/docs/LIMITATIONS.md +1 -1
  12. package/docs/OUTPUT_SCHEMA.md +1 -1
  13. package/docs/REPORT.md +1 -1
  14. package/docs/RULE_CATALOG.md +1 -1
  15. package/docs/SARIF.md +59 -0
  16. package/package.json +63 -18
  17. package/src/baseline.js +0 -0
  18. package/src/checks/automatic/area-alt-present.js +63 -31
  19. package/src/checks/automatic/aria-allowed-attr.js +204 -80
  20. package/src/checks/automatic/aria-allowed-role.js +23 -7
  21. package/src/checks/automatic/aria-braille-equivalent.js +34 -10
  22. package/src/checks/automatic/aria-conditional-attr.js +32 -14
  23. package/src/checks/automatic/aria-deprecated-role.js +26 -11
  24. package/src/checks/automatic/aria-hidden-body.js +48 -23
  25. package/src/checks/automatic/aria-hidden-focus.js +420 -66
  26. package/src/checks/automatic/aria-prohibited-attr.js +327 -60
  27. package/src/checks/automatic/aria-prohibited-children.js +111 -103
  28. package/src/checks/automatic/aria-required-attr.js +29 -15
  29. package/src/checks/automatic/aria-required-children.js +44 -24
  30. package/src/checks/automatic/aria-required-parent.js +64 -35
  31. package/src/checks/automatic/aria-role-name-present.js +49 -21
  32. package/src/checks/automatic/aria-roles-valid.js +24 -12
  33. package/src/checks/automatic/aria-valid-attr-value.js +46 -22
  34. package/src/checks/automatic/aria-valid-attr.js +19 -5
  35. package/src/checks/automatic/autocomplete-valid.js +76 -16
  36. package/src/checks/automatic/avoid-inline-spacing.js +23 -8
  37. package/src/checks/automatic/binary-control-name-present.js +62 -50
  38. package/src/checks/automatic/button-name-present.js +54 -24
  39. package/src/checks/automatic/bypass-blocks-present.js +51 -32
  40. package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
  41. package/src/checks/automatic/combobox-name-present.js +40 -45
  42. package/src/checks/automatic/contrast-computable.js +363 -341
  43. package/src/checks/automatic/contrast-enhanced.js +489 -466
  44. package/src/checks/automatic/contrast-minimum.js +488 -465
  45. package/src/checks/automatic/css-orientation-lock.js +51 -35
  46. package/src/checks/automatic/definition-list-children-valid.js +46 -25
  47. package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
  48. package/src/checks/automatic/dialog-name-present.js +47 -85
  49. package/src/checks/automatic/dlitem-parent-valid.js +25 -8
  50. package/src/checks/automatic/duplicate-id-aria.js +28 -9
  51. package/src/checks/automatic/embed-text-alternative-present.js +88 -35
  52. package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
  53. package/src/checks/automatic/form-control-single-label.js +50 -14
  54. package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
  55. package/src/checks/automatic/iframe-focusable-content.js +265 -22
  56. package/src/checks/automatic/iframe-name-present.js +33 -9
  57. package/src/checks/automatic/iframe-title-unique.js +32 -9
  58. package/src/checks/automatic/img-alt-present.js +54 -52
  59. package/src/checks/automatic/input-image-alt-present.js +141 -112
  60. package/src/checks/automatic/label-in-name.js +65 -41
  61. package/src/checks/automatic/language-page-present.js +111 -109
  62. package/src/checks/automatic/link-in-text-block.js +61 -19
  63. package/src/checks/automatic/link-name-present.js +47 -14
  64. package/src/checks/automatic/list-children-valid.js +40 -33
  65. package/src/checks/automatic/listbox-name-present.js +41 -19
  66. package/src/checks/automatic/listitem-parent-valid.js +48 -13
  67. package/src/checks/automatic/menuitem-name-present.js +41 -61
  68. package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
  69. package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
  70. package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
  71. package/src/checks/automatic/meter-name-present.js +40 -36
  72. package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
  73. package/src/checks/automatic/object-text-alternative-present.js +93 -39
  74. package/src/checks/automatic/option-name-present.js +40 -21
  75. package/src/checks/automatic/page-title-present.js +19 -6
  76. package/src/checks/automatic/progressbar-name-present.js +49 -44
  77. package/src/checks/automatic/role-img-alt-present.js +211 -159
  78. package/src/checks/automatic/searchbox-name-present.js +41 -19
  79. package/src/checks/automatic/server-side-image-map-absent.js +27 -11
  80. package/src/checks/automatic/slider-name-present.js +42 -47
  81. package/src/checks/automatic/spinbutton-name-present.js +41 -19
  82. package/src/checks/automatic/summary-name-present.js +39 -17
  83. package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
  84. package/src/checks/automatic/svg-text-alternative-present.js +262 -230
  85. package/src/checks/automatic/tab-name-present.js +39 -60
  86. package/src/checks/automatic/table-headers-attr-valid.js +27 -10
  87. package/src/checks/automatic/table-th-has-data-cells.js +24 -8
  88. package/src/checks/automatic/target-size-minimum.js +123 -48
  89. package/src/checks/automatic/td-has-header.js +53 -12
  90. package/src/checks/automatic/textbox-name-present.js +41 -19
  91. package/src/checks/automatic/tooltip-name-present.js +39 -18
  92. package/src/checks/automatic/treeitem-name-present.js +40 -21
  93. package/src/checks/automatic/valid-lang.js +22 -6
  94. package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
  95. package/src/checks/manual/accesskeys-manual.js +17 -6
  96. package/src/checks/manual/area-alt-decorative-manual.js +194 -193
  97. package/src/checks/manual/area-alt-quality-manual.js +184 -141
  98. package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
  99. package/src/checks/manual/aria-text-manual.js +20 -11
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
  101. package/src/checks/manual/css-hidden-focus.js +375 -169
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
  103. package/src/checks/manual/empty-heading-manual.js +41 -24
  104. package/src/checks/manual/empty-table-header-manual.js +69 -31
  105. package/src/checks/manual/focus-order-semantics-manual.js +60 -13
  106. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
  107. package/src/checks/manual/heading-order-manual.js +50 -8
  108. package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
  109. package/src/checks/manual/image-redundant-alt-manual.js +38 -8
  110. package/src/checks/manual/img-alt-decorative-manual.js +133 -96
  111. package/src/checks/manual/img-alt-quality-manual.js +178 -127
  112. package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
  113. package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
  114. package/src/checks/manual/label-title-only-manual.js +44 -28
  115. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
  116. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
  117. package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
  118. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
  119. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
  120. package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
  121. package/src/checks/manual/landmark-one-main-manual.js +38 -43
  122. package/src/checks/manual/landmark-unique-manual.js +78 -67
  123. package/src/checks/manual/link-name-quality-manual.js +45 -12
  124. package/src/checks/manual/media-transcript-present-manual.js +37 -22
  125. package/src/checks/manual/meta-viewport-large-manual.js +19 -6
  126. package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
  127. package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
  128. package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
  129. package/src/checks/manual/p-as-heading-manual.js +24 -7
  130. package/src/checks/manual/page-has-heading-one-manual.js +42 -32
  131. package/src/checks/manual/page-title-patterns-manual.js +80 -50
  132. package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
  133. package/src/checks/manual/region-manual.js +244 -60
  134. package/src/checks/manual/scope-attr-valid-manual.js +13 -4
  135. package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
  136. package/src/checks/manual/skip-link-manual.js +42 -18
  137. package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
  138. package/src/checks/manual/tabindex-manual.js +13 -4
  139. package/src/checks/manual/table-duplicate-name-manual.js +22 -11
  140. package/src/checks/manual/table-fake-caption-manual.js +48 -10
  141. package/src/checks/manual/video-caption-manual.js +17 -4
  142. package/src/checks/manual-review.js +58 -12
  143. package/src/core.js +41705 -29650
  144. package/src/index.js +2 -0
  145. package/src/report.js +109 -47
  146. package/src/sarif.js +190 -0
  147. package/surea11y.browser.js +37774 -0
  148. package/bin/core.js +0 -348
  149. package/docs/CLI.md +0 -75
  150. package/src/catalogs/composites.wcag.js +0 -490
  151. package/src/checks/rules-and-tags.full.csv +0 -19
  152. package/src/checks/rules-and-tags.full.json +0 -259
  153. package/src/core/aria-helpers.js +0 -970
  154. package/src/core/contrast-helpers.js +0 -1147
  155. package/src/core/dom-helpers.js +0 -4235
  156. package/src/core/dom-runner.js +0 -671
  157. package/src/core/frame-messaging.js +0 -210
  158. package/src/core/frame-scan.js +0 -178
  159. package/src/core/rollup-composites.js +0 -135
  160. package/src/core/rule-meta.js +0 -159
  161. package/src/coverage/wcag-facets.js +0 -1079
  162. package/src/coverage/wcag-version-map.js +0 -84
  163. package/src/i18n/en.js +0 -923
  164. package/src/i18n/fr.js +0 -844
  165. package/src/policy/contracts.js +0 -18
  166. package/src/policy/resolvePolicy.js +0 -55
  167. package/src/policy/schemas/engine-options.schema.json +0 -103
  168. package/src/policy/schemas/policy-contract.schema.json +0 -40
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'page-title-patterns';
@@ -15,7 +17,13 @@ const meta = {
15
17
  tags: ['wcag2a', 'wcag242', 'titles', 'atomic', 'navigation', 'manual'],
16
18
  wcagSc: ['2.4.2'],
17
19
  normativeMappings: [
18
- { standard: 'WCAG', version: '2.2', requirement: '2.4.2', title: 'Page Titled', conformanceLevel: 'A' }
20
+ {
21
+ standard: 'WCAG',
22
+ version: '2.2',
23
+ requirement: '2.4.2',
24
+ title: 'Page Titled',
25
+ conformanceLevel: 'A'
26
+ }
19
27
  ],
20
28
  defaultSeverity: 'minor',
21
29
  category: 'operable',
@@ -34,11 +42,13 @@ function applicability(ctx) {
34
42
 
35
43
  function runInPage(ctx) {
36
44
  const { document, helpers, rule } = ctx;
37
- const probes = ctx && ctx.inputs && ctx.inputs.probes && typeof ctx.inputs.probes === 'object'
45
+ const probes =
46
+ ctx && ctx.inputs && ctx.inputs.probes && typeof ctx.inputs.probes === 'object'
38
47
  ? ctx.inputs.probes
39
48
  : null;
40
49
 
41
- const pageTitlesProbe = probes && probes['crawl.pageTitles'] && typeof probes['crawl.pageTitles'] === 'object'
50
+ const pageTitlesProbe =
51
+ probes && probes['crawl.pageTitles'] && typeof probes['crawl.pageTitles'] === 'object'
42
52
  ? probes['crawl.pageTitles']
43
53
  : null;
44
54
 
@@ -53,16 +63,16 @@ function runInPage(ctx) {
53
63
  // Cross-page pattern analysis (preferred) if crawl.pageTitles probe is provided
54
64
  // =========================
55
65
  if (pageTitlesProbe && Array.isArray(pageTitlesProbe.pages)) {
56
- const pages = pageTitlesProbe.pages.filter(p => p && typeof p === 'object');
66
+ const pages = pageTitlesProbe.pages.filter((p) => p && typeof p === 'object');
57
67
 
58
68
  // Require enough data to avoid noisy conclusions
59
69
  const MIN_PAGES = 10;
60
70
  const analyzable = pages
61
- .map(p => ({
62
- url: p.url ? String(p.url) : null,
63
- title: typeof p.title === 'string' ? p.title.replace(/\s+/g, ' ').trim() : ''
64
- }))
65
- .filter(p => p.url && p.title);
71
+ .map((p) => ({
72
+ url: p.url ? String(p.url) : null,
73
+ title: typeof p.title === 'string' ? p.title.replace(/\s+/g, ' ').trim() : ''
74
+ }))
75
+ .filter((p) => p.url && p.title);
66
76
 
67
77
  if (analyzable.length >= MIN_PAGES) {
68
78
  // Build normalized title groups (case-insensitive)
@@ -74,11 +84,11 @@ function runInPage(ctx) {
74
84
  }
75
85
 
76
86
  // Duplicate titles across distinct URLs is a strong "review" signal (not a guaranteed failure)
77
- const dupGroups = Array.from(groups.values()).filter(g => g.urls.length >= 2);
87
+ const dupGroups = Array.from(groups.values()).filter((g) => g.urls.length >= 2);
78
88
 
79
89
  // Boilerplate-ish: detect a long common suffix/prefix across most titles.
80
90
  // Keep conservative: only flag if the common part is long and shared by many.
81
- const titles = analyzable.map(p => p.title);
91
+ const titles = analyzable.map((p) => p.title);
82
92
  function commonPrefix(a, b) {
83
93
  const n = Math.min(a.length, b.length);
84
94
  let i = 0;
@@ -103,35 +113,41 @@ function runInPage(ctx) {
103
113
  const suffixLen = sharedSuffix.trim().length;
104
114
 
105
115
  const hasStrongTemplateSignal =
106
- (prefixLen >= 12 || suffixLen >= 12) &&
107
- (prefixLen >= 12 ? sharedPrefix.trim().length : 0) + (suffixLen >= 12 ? sharedSuffix.trim().length : 0) >= 12;
116
+ (prefixLen >= 12 || suffixLen >= 12) &&
117
+ (prefixLen >= 12 ? sharedPrefix.trim().length : 0) +
118
+ (suffixLen >= 12 ? sharedSuffix.trim().length : 0) >=
119
+ 12;
108
120
 
109
121
  // If any cross-page signal exists, emit cantTell occurrence(s)
110
122
  if (dupGroups.length || hasStrongTemplateSignal) {
111
123
  const reasonCode = dupGroups.length
112
- ? 'duplicateTitlesAcrossPages'
113
- : 'templatedTitlesAcrossPages';
124
+ ? 'duplicateTitlesAcrossPages'
125
+ : 'templatedTitlesAcrossPages';
114
126
 
115
127
  // Deterministic example title for i18n params (lexicographic, case-insensitive)
116
128
  const exampleTitle = dupGroups.length
117
- ? dupGroups
118
- .map(g => String(g.title || '').replace(/\s+/g, ' ').trim())
119
- .filter(Boolean)
120
- .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))[0] || ''
121
- : '';
129
+ ? dupGroups
130
+ .map((g) =>
131
+ String(g.title || '')
132
+ .replace(/\s+/g, ' ')
133
+ .trim()
134
+ )
135
+ .filter(Boolean)
136
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))[0] || ''
137
+ : '';
122
138
 
123
139
  const summaryKey = dupGroups.length
124
- ? 'pageTitlePatterns_summary_cantTell_duplicateAcrossPages'
125
- : 'pageTitlePatterns_summary_cantTell_templatedAcrossPages';
140
+ ? 'pageTitlePatterns_summary_cantTell_duplicateAcrossPages'
141
+ : 'pageTitlePatterns_summary_cantTell_templatedAcrossPages';
126
142
 
127
143
  const i18nParams = dupGroups.length
128
- ? {
144
+ ? {
129
145
  reasonCode,
130
146
  pagesAnalyzed: analyzable.length,
131
147
  duplicateGroups: dupGroups.length,
132
148
  exampleTitle
133
149
  }
134
- : {
150
+ : {
135
151
  reasonCode,
136
152
  pagesAnalyzed: analyzable.length
137
153
  };
@@ -139,7 +155,8 @@ function runInPage(ctx) {
139
155
  const occBase = {
140
156
  selector: 'head > title',
141
157
  html: '',
142
- summary: 'The set of page titles may not be descriptive enough to distinguish pages by topic or purpose.',
158
+ summary:
159
+ 'The set of page titles may not be descriptive enough to distinguish pages by topic or purpose.',
143
160
  hint: 'Ensure each page title is sufficiently descriptive and helps users distinguish pages (avoid identical or overly templated titles across many pages).',
144
161
  i18n: {
145
162
  summaryKey,
@@ -152,12 +169,14 @@ function runInPage(ctx) {
152
169
  metrics: {
153
170
  pagesAnalyzed: analyzable.length,
154
171
  duplicateGroups: dupGroups.length,
155
- largestDuplicateGroupSize: dupGroups.length ? Math.max(...dupGroups.map(g => g.urls.length)) : 0,
172
+ largestDuplicateGroupSize: dupGroups.length
173
+ ? Math.max(...dupGroups.map((g) => g.urls.length))
174
+ : 0,
156
175
  sharedPrefix: prefixLen >= 12 ? sharedPrefix.trim() : '',
157
176
  sharedSuffix: suffixLen >= 12 ? sharedSuffix.trim() : ''
158
177
  },
159
178
  refs: {
160
- exampleDuplicateTitles: dupGroups.slice(0, 3).map(g => ({
179
+ exampleDuplicateTitles: dupGroups.slice(0, 3).map((g) => ({
161
180
  title: g.title,
162
181
  urls: g.urls.slice(0, 5)
163
182
  }))
@@ -173,12 +192,20 @@ function runInPage(ctx) {
173
192
  // No node available: keep deterministic fallback snippet (no helper calls).
174
193
  occurrences.push({
175
194
  ...occBase,
176
- html: titleEl && titleEl.outerHTML ? String(titleEl.outerHTML).slice(0, 2000) : '<title>(unknown)</title>'
195
+ html:
196
+ titleEl && titleEl.outerHTML
197
+ ? String(titleEl.outerHTML).slice(0, 2000)
198
+ : '<title>(unknown)</title>'
177
199
  });
178
200
  }
179
201
 
180
202
  // Cross-page analysis is authoritative when present; do not also run single-page heuristics.
181
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
203
+ return {
204
+ ruleId: rule.ruleId,
205
+ outcome: 'cantTell',
206
+ severity: rule.defaultSeverity || 'minor',
207
+ occurrences
208
+ };
182
209
  }
183
210
 
184
211
  // If we had enough pages and found no review signal, there is nothing
@@ -194,14 +221,7 @@ function runInPage(ctx) {
194
221
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
195
222
  }
196
223
 
197
- const GENERIC_TITLES = new Set([
198
- 'home',
199
- 'homepage',
200
- 'welcome',
201
- 'untitled',
202
- 'page',
203
- 'document'
204
- ]);
224
+ const GENERIC_TITLES = new Set(['home', 'homepage', 'welcome', 'untitled', 'page', 'document']);
205
225
 
206
226
  // Conservative signals:
207
227
  // - very short title (likely non-descriptive)
@@ -210,27 +230,29 @@ function runInPage(ctx) {
210
230
  const isGeneric = GENERIC_TITLES.has(titleLc);
211
231
 
212
232
  // Template-like: "Brand | Home" or "Home - Brand" where the page-specific part is a generic token.
213
- const templateLike = /\b(home|homepage|welcome)\b\s*(\||-|—|:)\s*.+/i.test(titleText) ||
214
- /.+\s*(\||-|—|:)\s*\b(home|homepage|welcome)\b/i.test(titleText);
233
+ const templateLike =
234
+ /\b(home|homepage|welcome)\b\s*(\||-|—|:)\s*.+/i.test(titleText) ||
235
+ /.+\s*(\||-|—|:)\s*\b(home|homepage|welcome)\b/i.test(titleText);
215
236
 
216
237
  if (isGeneric || isVeryShort || templateLike) {
217
238
  const reasonCode = isGeneric
218
239
  ? 'genericTitle'
219
- : (isVeryShort ? 'veryShortTitle' : 'templateLikeTitle');
240
+ : isVeryShort
241
+ ? 'veryShortTitle'
242
+ : 'templateLikeTitle';
220
243
 
221
244
  const summaryKey =
222
- reasonCode === 'genericTitle'
223
- ? 'pageTitlePatterns_summary_cantTell_generic'
224
- : (reasonCode === 'veryShortTitle'
225
- ? 'pageTitlePatterns_summary_cantTell_veryShort'
226
- : 'pageTitlePatterns_summary_cantTell_templateLike');
245
+ reasonCode === 'genericTitle'
246
+ ? 'pageTitlePatterns_summary_cantTell_generic'
247
+ : reasonCode === 'veryShortTitle'
248
+ ? 'pageTitlePatterns_summary_cantTell_veryShort'
249
+ : 'pageTitlePatterns_summary_cantTell_templateLike';
227
250
  const occBase = {
228
251
  selector: 'head > title',
229
252
  html: '',
230
253
  summary:
231
- 'The page title may not be descriptive enough to identify the page topic or purpose.',
232
- hint:
233
- 'Use a more specific title that identifies the page topic or purpose (for example, include the section name or task).',
254
+ 'The page title may not be descriptive enough to identify the page topic or purpose.',
255
+ hint: 'Use a more specific title that identifies the page topic or purpose (for example, include the section name or task).',
234
256
  i18n: {
235
257
  summaryKey,
236
258
  hintKey: 'pageTitlePatterns_hint_cantTell',
@@ -250,7 +272,10 @@ function runInPage(ctx) {
250
272
  } else {
251
273
  occurrences.push({
252
274
  ...occBase,
253
- html: titleEl && titleEl.outerHTML ? String(titleEl.outerHTML).slice(0, 2000) : '<title>(unknown)</title>'
275
+ html:
276
+ titleEl && titleEl.outerHTML
277
+ ? String(titleEl.outerHTML).slice(0, 2000)
278
+ : '<title>(unknown)</title>'
254
279
  });
255
280
  }
256
281
  }
@@ -261,7 +286,12 @@ function runInPage(ctx) {
261
286
 
262
287
  if (occurrences.length) {
263
288
  // Patterns are review signals: cantTell rather than fail.
264
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
289
+ return {
290
+ ruleId: rule.ruleId,
291
+ outcome: 'cantTell',
292
+ severity: rule.defaultSeverity || 'minor',
293
+ occurrences
294
+ };
265
295
  }
266
296
 
267
297
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
@@ -1,16 +1,17 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check presentation-role-conflict
5
7
  * @atomic true
6
8
  * @summary role="presentation"/"none" must not be combined with a global ARIA naming attribute or focusability
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Applies to elements with an explicit role="presentation" or
10
12
  * role="none", OR an <img alt=""> (empty alt gives an <img> an implicit
11
13
  * presentation role per HTML-AAM, even with no explicit role attribute
12
- * at all — verified against a widely-used reference engine's own selector
13
- * for this exact check, `img[alt=''], [role="none"], [role="presentation"]`).
14
+ * at all — `img[alt=''], [role="none"], [role="presentation"]`).
14
15
  * @expectation
15
16
  * The element does not also carry a WAI-ARIA *global* state/property
16
17
  * (aria-label, aria-hidden, aria-describedby, aria-live, aria-current,
@@ -25,28 +26,27 @@
25
26
  * - Not WCAG-normative — authored as an advisory, cantTell-capped
26
27
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
27
28
  * header comment for the shared rationale/precedent.
28
- * - The conflicting-attribute set matches a widely-used reference engine's
29
- * own `none: ['is-element-focusable', 'has-global-aria-attribute']` condition
30
- * exactly — `has-global-aria-attribute` checks against the full list of
31
- * ARIA attributes marked `global: true` in that engine's own standards data
32
- * (confirmed by reading `standards.ariaAttrs` directly at runtime, not
33
- * guessed), which is much broader than the 3-attribute naming-only list
34
- * this rule originally checked (added 2026-07-20, then widened
35
- * 2026-07-20 after a real page — Slack's homepage has
36
- * `<img alt="" aria-hidden="">`, both gaps at once: the img[alt=''] case
37
- * wasn't in the applicability selector at all, and aria-hidden wasn't in
38
- * the conflicting-attribute set even if it had been).
39
- * - Deliberately NOT replicating that reference engine's
40
- * `hasImplicitChromiumRoleMatches` applicability gate, which (per direct
41
- * probing of the reference engine's runtime) makes its own check
42
- * inapplicable to role="presentation" on elements with no
43
- * native implicit role to suppress in the first place (e.g.
44
- * `<div role="presentation" aria-hidden="true">` — a <div> has no native
45
- * role, so there's nothing for the presentational role to "conflict"
46
- * with per that engine's own scope decision). Replicating that would require a
47
- * full native-implicit-role table and isn't evidenced by any real false
48
- * positive in this corpus yet — surea11y stays broader/more cautious
49
- * here rather than narrower, which is the safer direction to diverge in.
29
+ * - `aria-hidden="true"` (the exact valid truthy value) on the
30
+ * presentational element itself is deliberately EXCLUDED as a trigger,
31
+ * even though it's a global ARIA attribute: it removes the element from
32
+ * the accessibility tree unconditionally, so the "role restoration"
33
+ * this rule warns about never actually reaches assistive tech, making a
34
+ * flag misleading. Any OTHER conflicting attribute present alongside
35
+ * `aria-hidden="true"` is equally inert for the same reason and is not
36
+ * flagged either. An `aria-hidden=""` (empty/invalid value, does NOT
37
+ * hide) still triggers normally. Focusability is unaffected by this
38
+ * exemption (see the code comment at the check site).
39
+ * - The conflicting-attribute set is the full list of ARIA attributes
40
+ * marked `global: true`, not a narrower naming-only list.
41
+ * - Deliberately NOT applying an implicit-role applicability gate that
42
+ * would make the check inapplicable to role="presentation" on elements
43
+ * with no native implicit role to suppress (e.g. `<div
44
+ * role="presentation" aria-hidden="true">` — a <div> has no native role,
45
+ * so there's nothing for the presentational role to "conflict" with).
46
+ * surea11y stays broader/more cautious here rather than narrower, which
47
+ * is the safer direction to diverge in. The native-implicit-role table
48
+ * needed to add that gate, if this scope decision is ever revisited, is
49
+ * in ROADMAP.md §7 item 9.
50
50
  * - Focusability is computed via helpers.getFocusableInfo (native +
51
51
  * tabindex), same helper aria-hidden-focus already relies on — a
52
52
  * `:disabled` or otherwise non-focusable element is not flagged.
@@ -56,7 +56,8 @@ const id = 'presentation-role-conflict';
56
56
 
57
57
  const meta = {
58
58
  title: 'Presentational role must not conflict with a global ARIA attribute or focusability',
59
- description: 'Checks that role="presentation"/"none" (including an <img alt=""> implicit presentation role) is not combined with a global ARIA attribute (aria-label, aria-hidden, aria-describedby, ...) or focusability (tabindex/native).',
59
+ description:
60
+ 'Checks that role="presentation"/"none" (including an <img alt=""> implicit presentation role) is not combined with a global ARIA attribute (aria-label, aria-hidden, aria-describedby, ...) or focusability (tabindex/native).',
60
61
  i18n: {
61
62
  titleKey: 'presentationRoleConflict_title',
62
63
  descriptionKey: 'presentationRoleConflict_description'
@@ -73,25 +74,44 @@ const meta = {
73
74
  };
74
75
 
75
76
  function runInPage(ctx) {
76
- const { document, helpers, rule } = ctx;
77
+ const { helpers, rule } = ctx;
77
78
 
78
79
  // The full set of ARIA attributes marked `global: true` per the WAI-ARIA
79
- // spec (confirmed against a widely-used reference engine's own
80
- // `standards.ariaAttrs` data at runtime, 2026-07-20) — any of these present on a presentational
81
- // element restores its implicit role, not just the naming ones.
80
+ // spec — any of these present on a presentational element restores its
81
+ // implicit role, not just the naming ones.
82
82
  const CONFLICTING_ATTRS = [
83
- 'aria-atomic', 'aria-braillelabel', 'aria-brailleroledescription', 'aria-busy',
84
- 'aria-controls', 'aria-current', 'aria-describedby', 'aria-description', 'aria-details',
85
- 'aria-disabled', 'aria-dropeffect', 'aria-errormessage', 'aria-flowto', 'aria-grabbed',
86
- 'aria-haspopup', 'aria-hidden', 'aria-invalid', 'aria-keyshortcuts', 'aria-label',
87
- 'aria-labelledby', 'aria-live', 'aria-owns', 'aria-relevant', 'aria-roledescription'
83
+ 'aria-atomic',
84
+ 'aria-braillelabel',
85
+ 'aria-brailleroledescription',
86
+ 'aria-busy',
87
+ 'aria-controls',
88
+ 'aria-current',
89
+ 'aria-describedby',
90
+ 'aria-description',
91
+ 'aria-details',
92
+ 'aria-disabled',
93
+ 'aria-dropeffect',
94
+ 'aria-errormessage',
95
+ 'aria-flowto',
96
+ 'aria-grabbed',
97
+ 'aria-haspopup',
98
+ 'aria-hidden',
99
+ 'aria-invalid',
100
+ 'aria-keyshortcuts',
101
+ 'aria-label',
102
+ 'aria-labelledby',
103
+ 'aria-live',
104
+ 'aria-owns',
105
+ 'aria-relevant',
106
+ 'aria-roledescription'
88
107
  ];
89
108
 
90
- const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
109
+ const getFocusableInfo =
110
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
91
111
 
92
112
  const nodes = helpers.queryAllSmart
93
- ? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]')
94
- : helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]');
113
+ ? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]')
114
+ : helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]');
95
115
 
96
116
  const occurrences = [];
97
117
  let applicableCount = 0;
@@ -103,11 +123,32 @@ function runInPage(ctx) {
103
123
 
104
124
  // Presence, not value truthiness: the WAI-ARIA role-conflict-resolution
105
125
  // rule triggers on a global ARIA attribute being SPECIFIED at all, even
106
- // with an empty value — found on a real site, Slack's homepage has
107
- // <img alt="" aria-hidden="">, where aria-hidden="" (empty string) is
108
- // still a specified attribute. A truthy-value check would have missed
109
- // this even after aria-hidden was added to CONFLICTING_ATTRS above.
110
- const present = CONFLICTING_ATTRS.filter((attr) => (el.hasAttribute ? el.hasAttribute(attr) : el.getAttribute(attr) != null));
126
+ // with an empty value — e.g. <img alt="" aria-hidden="">, where
127
+ // aria-hidden="" (empty string) is still a specified attribute. A
128
+ // truthy-value check would miss this.
129
+ let present = CONFLICTING_ATTRS.filter((attr) =>
130
+ el.hasAttribute ? el.hasAttribute(attr) : el.getAttribute(attr) != null
131
+ );
132
+
133
+ // aria-hidden="true" (the exact, valid truthy value — not the
134
+ // empty-string case above, which never actually hides anything) is a
135
+ // special case: it removes the element and its subtree from the
136
+ // accessibility tree unconditionally, independent of role. That makes
137
+ // the "role restoration" this rule warns about ("...which restores its
138
+ // implicit role and cancels the presentational intent") factually
139
+ // inert — no AT will ever expose the restored role OR any of the other
140
+ // conflicting attributes (aria-label, aria-describedby, ...) present
141
+ // alongside it, since the whole element stays out of the tree
142
+ // regardless. This pattern is extremely common (e.g. <svg
143
+ // role="presentation" aria-hidden="true"> decorative icons — a
144
+ // defensive belt-and-suspenders double-hide, not an authoring mistake).
145
+ // Focusability is NOT covered by this exemption — a keyboard user can
146
+ // still tab onto an aria-hidden="true" focusable element (the
147
+ // aria-hidden-focus anti-pattern), a real, independent hazard
148
+ // aria-hidden does nothing to prevent.
149
+ if (el.getAttribute('aria-hidden') === 'true') {
150
+ present = [];
151
+ }
111
152
 
112
153
  let isFocusable = false;
113
154
  if (getFocusableInfo) {
@@ -126,9 +167,12 @@ function runInPage(ctx) {
126
167
 
127
168
  // No explicit role attribute means this matched via the img[alt=""]
128
169
  // implicit-presentation case.
129
- const role = String(el.getAttribute('role') || '').trim().toLowerCase() || 'presentation';
170
+ const role =
171
+ String(el.getAttribute('role') || '')
172
+ .trim()
173
+ .toLowerCase() || 'presentation';
130
174
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
131
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
175
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
132
176
 
133
177
  occurrences.push({
134
178
  selector: stableSelector,
@@ -141,7 +185,12 @@ function runInPage(ctx) {
141
185
  params: { role, attrs: parts.join(', ') }
142
186
  },
143
187
  data: {
144
- details: { reasonCode: 'PRESENTATION_ROLE_CONFLICT', role, conflictingAttrs: present, focusable: isFocusable }
188
+ details: {
189
+ reasonCode: 'PRESENTATION_ROLE_CONFLICT',
190
+ role,
191
+ conflictingAttrs: present,
192
+ focusable: isFocusable
193
+ }
145
194
  }
146
195
  });
147
196
  }
@@ -150,7 +199,12 @@ function runInPage(ctx) {
150
199
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
151
200
  }
152
201
  if (occurrences.length) {
153
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
202
+ return {
203
+ ruleId: rule.ruleId,
204
+ outcome: 'cantTell',
205
+ severity: rule.defaultSeverity || 'minor',
206
+ occurrences
207
+ };
154
208
  }
155
209
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
156
210
  }