@surea11y/core 1.2.0 → 1.3.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 (157) hide show
  1. package/CHANGELOG.md +37 -7
  2. package/LICENSE +373 -21
  3. package/README.md +67 -1
  4. package/bin/core.js +144 -19
  5. package/docs/API_STABILITY.md +1 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/CLI.md +54 -1
  9. package/docs/ENGINE_OPTIONS.md +2 -0
  10. package/docs/INTEGRATION.md +18 -0
  11. package/docs/OUTPUT_SCHEMA.md +1 -1
  12. package/docs/RULE_CATALOG.md +1 -1
  13. package/docs/SARIF.md +59 -0
  14. package/package.json +15 -4
  15. package/src/baseline.js +0 -0
  16. package/src/catalogs/composites.wcag.js +414 -450
  17. package/src/checks/automatic/area-alt-present.js +59 -25
  18. package/src/checks/automatic/aria-allowed-attr.js +193 -33
  19. package/src/checks/automatic/aria-allowed-role.js +21 -7
  20. package/src/checks/automatic/aria-braille-equivalent.js +32 -10
  21. package/src/checks/automatic/aria-conditional-attr.js +24 -7
  22. package/src/checks/automatic/aria-deprecated-role.js +22 -8
  23. package/src/checks/automatic/aria-hidden-body.js +42 -19
  24. package/src/checks/automatic/aria-hidden-focus.js +408 -53
  25. package/src/checks/automatic/aria-prohibited-attr.js +296 -22
  26. package/src/checks/automatic/aria-prohibited-children.js +55 -16
  27. package/src/checks/automatic/aria-required-attr.js +23 -8
  28. package/src/checks/automatic/aria-required-children.js +37 -14
  29. package/src/checks/automatic/aria-required-parent.js +48 -14
  30. package/src/checks/automatic/aria-role-name-present.js +47 -21
  31. package/src/checks/automatic/aria-roles-valid.js +22 -12
  32. package/src/checks/automatic/aria-valid-attr-value.js +28 -7
  33. package/src/checks/automatic/aria-valid-attr.js +17 -5
  34. package/src/checks/automatic/autocomplete-valid.js +74 -16
  35. package/src/checks/automatic/avoid-inline-spacing.js +20 -6
  36. package/src/checks/automatic/binary-control-name-present.js +60 -50
  37. package/src/checks/automatic/button-name-present.js +48 -18
  38. package/src/checks/automatic/bypass-blocks-present.js +42 -25
  39. package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
  40. package/src/checks/automatic/combobox-name-present.js +38 -45
  41. package/src/checks/automatic/contrast-computable.js +361 -341
  42. package/src/checks/automatic/contrast-enhanced.js +487 -466
  43. package/src/checks/automatic/contrast-minimum.js +486 -465
  44. package/src/checks/automatic/css-orientation-lock.js +30 -8
  45. package/src/checks/automatic/definition-list-children-valid.js +40 -19
  46. package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
  47. package/src/checks/automatic/dialog-name-present.js +37 -75
  48. package/src/checks/automatic/dlitem-parent-valid.js +23 -8
  49. package/src/checks/automatic/duplicate-id-aria.js +24 -6
  50. package/src/checks/automatic/embed-text-alternative-present.js +86 -35
  51. package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
  52. package/src/checks/automatic/form-control-single-label.js +47 -10
  53. package/src/checks/automatic/html-xml-lang-mismatch.js +34 -18
  54. package/src/checks/automatic/iframe-focusable-content.js +26 -11
  55. package/src/checks/automatic/iframe-name-present.js +31 -9
  56. package/src/checks/automatic/iframe-title-unique.js +29 -8
  57. package/src/checks/automatic/img-alt-present.js +47 -43
  58. package/src/checks/automatic/input-image-alt-present.js +143 -112
  59. package/src/checks/automatic/label-in-name.js +50 -22
  60. package/src/checks/automatic/language-page-present.js +109 -109
  61. package/src/checks/automatic/link-in-text-block.js +59 -19
  62. package/src/checks/automatic/link-name-present.js +45 -14
  63. package/src/checks/automatic/list-children-valid.js +26 -9
  64. package/src/checks/automatic/listbox-name-present.js +39 -19
  65. package/src/checks/automatic/listitem-parent-valid.js +18 -6
  66. package/src/checks/automatic/menuitem-name-present.js +39 -61
  67. package/src/checks/automatic/meta-refresh-no-exceptions.js +28 -7
  68. package/src/checks/automatic/meta-refresh-timing-absent.js +20 -6
  69. package/src/checks/automatic/meta-viewport-zoom-enabled.js +24 -7
  70. package/src/checks/automatic/meter-name-present.js +36 -33
  71. package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
  72. package/src/checks/automatic/object-text-alternative-present.js +91 -39
  73. package/src/checks/automatic/option-name-present.js +38 -21
  74. package/src/checks/automatic/page-title-present.js +17 -6
  75. package/src/checks/automatic/progressbar-name-present.js +41 -34
  76. package/src/checks/automatic/role-img-alt-present.js +209 -157
  77. package/src/checks/automatic/searchbox-name-present.js +39 -19
  78. package/src/checks/automatic/server-side-image-map-absent.js +23 -8
  79. package/src/checks/automatic/slider-name-present.js +40 -47
  80. package/src/checks/automatic/spinbutton-name-present.js +39 -19
  81. package/src/checks/automatic/summary-name-present.js +37 -17
  82. package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
  83. package/src/checks/automatic/svg-text-alternative-present.js +246 -226
  84. package/src/checks/automatic/tab-name-present.js +37 -60
  85. package/src/checks/automatic/table-headers-attr-valid.js +24 -8
  86. package/src/checks/automatic/table-th-has-data-cells.js +22 -8
  87. package/src/checks/automatic/target-size-minimum.js +118 -48
  88. package/src/checks/automatic/td-has-header.js +29 -11
  89. package/src/checks/automatic/textbox-name-present.js +39 -19
  90. package/src/checks/automatic/tooltip-name-present.js +37 -18
  91. package/src/checks/automatic/treeitem-name-present.js +38 -21
  92. package/src/checks/automatic/valid-lang.js +20 -6
  93. package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
  94. package/src/checks/manual/accesskeys-manual.js +14 -5
  95. package/src/checks/manual/area-alt-decorative-manual.js +192 -193
  96. package/src/checks/manual/area-alt-quality-manual.js +182 -141
  97. package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
  98. package/src/checks/manual/aria-text-manual.js +14 -6
  99. package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
  100. package/src/checks/manual/css-hidden-focus.js +196 -165
  101. package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
  102. package/src/checks/manual/empty-heading-manual.js +24 -7
  103. package/src/checks/manual/empty-table-header-manual.js +17 -6
  104. package/src/checks/manual/focus-order-semantics-manual.js +45 -10
  105. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
  106. package/src/checks/manual/heading-order-manual.js +22 -7
  107. package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
  108. package/src/checks/manual/image-redundant-alt-manual.js +17 -7
  109. package/src/checks/manual/img-alt-decorative-manual.js +131 -96
  110. package/src/checks/manual/img-alt-quality-manual.js +176 -127
  111. package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
  112. package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
  113. package/src/checks/manual/label-title-only-manual.js +14 -5
  114. package/src/checks/manual/landmark-banner-is-top-level-manual.js +66 -16
  115. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +53 -16
  116. package/src/checks/manual/landmark-main-is-top-level-manual.js +35 -10
  117. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +29 -10
  118. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +29 -10
  119. package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
  120. package/src/checks/manual/landmark-one-main-manual.js +26 -20
  121. package/src/checks/manual/landmark-unique-manual.js +41 -15
  122. package/src/checks/manual/link-name-quality-manual.js +43 -12
  123. package/src/checks/manual/media-transcript-present-manual.js +35 -22
  124. package/src/checks/manual/meta-viewport-large-manual.js +16 -5
  125. package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
  126. package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
  127. package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
  128. package/src/checks/manual/p-as-heading-manual.js +22 -7
  129. package/src/checks/manual/page-has-heading-one-manual.js +31 -22
  130. package/src/checks/manual/page-title-patterns-manual.js +78 -50
  131. package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
  132. package/src/checks/manual/region-manual.js +241 -48
  133. package/src/checks/manual/scope-attr-valid-manual.js +10 -3
  134. package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
  135. package/src/checks/manual/skip-link-manual.js +35 -12
  136. package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
  137. package/src/checks/manual/tabindex-manual.js +10 -3
  138. package/src/checks/manual/table-duplicate-name-manual.js +17 -7
  139. package/src/checks/manual/table-fake-caption-manual.js +24 -7
  140. package/src/checks/manual/video-caption-manual.js +15 -4
  141. package/src/checks/manual-review.js +56 -12
  142. package/src/core/aria-helpers.js +1127 -886
  143. package/src/core/contrast-helpers.js +1217 -1062
  144. package/src/core/dom-helpers.js +4175 -3917
  145. package/src/core/dom-runner.js +720 -604
  146. package/src/core/frame-messaging.js +189 -138
  147. package/src/core/frame-scan.js +94 -82
  148. package/src/core/rollup-composites.js +94 -102
  149. package/src/core/rule-meta.js +58 -41
  150. package/src/core.js +36552 -29111
  151. package/src/i18n/en.js +1194 -889
  152. package/src/i18n/fr.js +1136 -795
  153. package/src/policy/contracts.js +13 -13
  154. package/src/policy/resolvePolicy.js +48 -44
  155. package/src/report.js +63 -43
  156. package/src/sarif.js +175 -0
  157. package/surea11y.browser.js +36042 -0
@@ -15,7 +15,13 @@ const meta = {
15
15
  tags: ['wcag2a', 'wcag242', 'titles', 'atomic', 'navigation', 'manual'],
16
16
  wcagSc: ['2.4.2'],
17
17
  normativeMappings: [
18
- { standard: 'WCAG', version: '2.2', requirement: '2.4.2', title: 'Page Titled', conformanceLevel: 'A' }
18
+ {
19
+ standard: 'WCAG',
20
+ version: '2.2',
21
+ requirement: '2.4.2',
22
+ title: 'Page Titled',
23
+ conformanceLevel: 'A'
24
+ }
19
25
  ],
20
26
  defaultSeverity: 'minor',
21
27
  category: 'operable',
@@ -34,11 +40,13 @@ function applicability(ctx) {
34
40
 
35
41
  function runInPage(ctx) {
36
42
  const { document, helpers, rule } = ctx;
37
- const probes = ctx && ctx.inputs && ctx.inputs.probes && typeof ctx.inputs.probes === 'object'
43
+ const probes =
44
+ ctx && ctx.inputs && ctx.inputs.probes && typeof ctx.inputs.probes === 'object'
38
45
  ? ctx.inputs.probes
39
46
  : null;
40
47
 
41
- const pageTitlesProbe = probes && probes['crawl.pageTitles'] && typeof probes['crawl.pageTitles'] === 'object'
48
+ const pageTitlesProbe =
49
+ probes && probes['crawl.pageTitles'] && typeof probes['crawl.pageTitles'] === 'object'
42
50
  ? probes['crawl.pageTitles']
43
51
  : null;
44
52
 
@@ -53,16 +61,16 @@ function runInPage(ctx) {
53
61
  // Cross-page pattern analysis (preferred) if crawl.pageTitles probe is provided
54
62
  // =========================
55
63
  if (pageTitlesProbe && Array.isArray(pageTitlesProbe.pages)) {
56
- const pages = pageTitlesProbe.pages.filter(p => p && typeof p === 'object');
64
+ const pages = pageTitlesProbe.pages.filter((p) => p && typeof p === 'object');
57
65
 
58
66
  // Require enough data to avoid noisy conclusions
59
67
  const MIN_PAGES = 10;
60
68
  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);
69
+ .map((p) => ({
70
+ url: p.url ? String(p.url) : null,
71
+ title: typeof p.title === 'string' ? p.title.replace(/\s+/g, ' ').trim() : ''
72
+ }))
73
+ .filter((p) => p.url && p.title);
66
74
 
67
75
  if (analyzable.length >= MIN_PAGES) {
68
76
  // Build normalized title groups (case-insensitive)
@@ -74,11 +82,11 @@ function runInPage(ctx) {
74
82
  }
75
83
 
76
84
  // 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);
85
+ const dupGroups = Array.from(groups.values()).filter((g) => g.urls.length >= 2);
78
86
 
79
87
  // Boilerplate-ish: detect a long common suffix/prefix across most titles.
80
88
  // Keep conservative: only flag if the common part is long and shared by many.
81
- const titles = analyzable.map(p => p.title);
89
+ const titles = analyzable.map((p) => p.title);
82
90
  function commonPrefix(a, b) {
83
91
  const n = Math.min(a.length, b.length);
84
92
  let i = 0;
@@ -103,35 +111,41 @@ function runInPage(ctx) {
103
111
  const suffixLen = sharedSuffix.trim().length;
104
112
 
105
113
  const hasStrongTemplateSignal =
106
- (prefixLen >= 12 || suffixLen >= 12) &&
107
- (prefixLen >= 12 ? sharedPrefix.trim().length : 0) + (suffixLen >= 12 ? sharedSuffix.trim().length : 0) >= 12;
114
+ (prefixLen >= 12 || suffixLen >= 12) &&
115
+ (prefixLen >= 12 ? sharedPrefix.trim().length : 0) +
116
+ (suffixLen >= 12 ? sharedSuffix.trim().length : 0) >=
117
+ 12;
108
118
 
109
119
  // If any cross-page signal exists, emit cantTell occurrence(s)
110
120
  if (dupGroups.length || hasStrongTemplateSignal) {
111
121
  const reasonCode = dupGroups.length
112
- ? 'duplicateTitlesAcrossPages'
113
- : 'templatedTitlesAcrossPages';
122
+ ? 'duplicateTitlesAcrossPages'
123
+ : 'templatedTitlesAcrossPages';
114
124
 
115
125
  // Deterministic example title for i18n params (lexicographic, case-insensitive)
116
126
  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
- : '';
127
+ ? dupGroups
128
+ .map((g) =>
129
+ String(g.title || '')
130
+ .replace(/\s+/g, ' ')
131
+ .trim()
132
+ )
133
+ .filter(Boolean)
134
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))[0] || ''
135
+ : '';
122
136
 
123
137
  const summaryKey = dupGroups.length
124
- ? 'pageTitlePatterns_summary_cantTell_duplicateAcrossPages'
125
- : 'pageTitlePatterns_summary_cantTell_templatedAcrossPages';
138
+ ? 'pageTitlePatterns_summary_cantTell_duplicateAcrossPages'
139
+ : 'pageTitlePatterns_summary_cantTell_templatedAcrossPages';
126
140
 
127
141
  const i18nParams = dupGroups.length
128
- ? {
142
+ ? {
129
143
  reasonCode,
130
144
  pagesAnalyzed: analyzable.length,
131
145
  duplicateGroups: dupGroups.length,
132
146
  exampleTitle
133
147
  }
134
- : {
148
+ : {
135
149
  reasonCode,
136
150
  pagesAnalyzed: analyzable.length
137
151
  };
@@ -139,7 +153,8 @@ function runInPage(ctx) {
139
153
  const occBase = {
140
154
  selector: 'head > title',
141
155
  html: '',
142
- summary: 'The set of page titles may not be descriptive enough to distinguish pages by topic or purpose.',
156
+ summary:
157
+ 'The set of page titles may not be descriptive enough to distinguish pages by topic or purpose.',
143
158
  hint: 'Ensure each page title is sufficiently descriptive and helps users distinguish pages (avoid identical or overly templated titles across many pages).',
144
159
  i18n: {
145
160
  summaryKey,
@@ -152,12 +167,14 @@ function runInPage(ctx) {
152
167
  metrics: {
153
168
  pagesAnalyzed: analyzable.length,
154
169
  duplicateGroups: dupGroups.length,
155
- largestDuplicateGroupSize: dupGroups.length ? Math.max(...dupGroups.map(g => g.urls.length)) : 0,
170
+ largestDuplicateGroupSize: dupGroups.length
171
+ ? Math.max(...dupGroups.map((g) => g.urls.length))
172
+ : 0,
156
173
  sharedPrefix: prefixLen >= 12 ? sharedPrefix.trim() : '',
157
174
  sharedSuffix: suffixLen >= 12 ? sharedSuffix.trim() : ''
158
175
  },
159
176
  refs: {
160
- exampleDuplicateTitles: dupGroups.slice(0, 3).map(g => ({
177
+ exampleDuplicateTitles: dupGroups.slice(0, 3).map((g) => ({
161
178
  title: g.title,
162
179
  urls: g.urls.slice(0, 5)
163
180
  }))
@@ -173,12 +190,20 @@ function runInPage(ctx) {
173
190
  // No node available: keep deterministic fallback snippet (no helper calls).
174
191
  occurrences.push({
175
192
  ...occBase,
176
- html: titleEl && titleEl.outerHTML ? String(titleEl.outerHTML).slice(0, 2000) : '<title>(unknown)</title>'
193
+ html:
194
+ titleEl && titleEl.outerHTML
195
+ ? String(titleEl.outerHTML).slice(0, 2000)
196
+ : '<title>(unknown)</title>'
177
197
  });
178
198
  }
179
199
 
180
200
  // 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 };
201
+ return {
202
+ ruleId: rule.ruleId,
203
+ outcome: 'cantTell',
204
+ severity: rule.defaultSeverity || 'minor',
205
+ occurrences
206
+ };
182
207
  }
183
208
 
184
209
  // If we had enough pages and found no review signal, there is nothing
@@ -194,14 +219,7 @@ function runInPage(ctx) {
194
219
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
195
220
  }
196
221
 
197
- const GENERIC_TITLES = new Set([
198
- 'home',
199
- 'homepage',
200
- 'welcome',
201
- 'untitled',
202
- 'page',
203
- 'document'
204
- ]);
222
+ const GENERIC_TITLES = new Set(['home', 'homepage', 'welcome', 'untitled', 'page', 'document']);
205
223
 
206
224
  // Conservative signals:
207
225
  // - very short title (likely non-descriptive)
@@ -210,27 +228,29 @@ function runInPage(ctx) {
210
228
  const isGeneric = GENERIC_TITLES.has(titleLc);
211
229
 
212
230
  // 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);
231
+ const templateLike =
232
+ /\b(home|homepage|welcome)\b\s*(\||-|—|:)\s*.+/i.test(titleText) ||
233
+ /.+\s*(\||-|—|:)\s*\b(home|homepage|welcome)\b/i.test(titleText);
215
234
 
216
235
  if (isGeneric || isVeryShort || templateLike) {
217
236
  const reasonCode = isGeneric
218
237
  ? 'genericTitle'
219
- : (isVeryShort ? 'veryShortTitle' : 'templateLikeTitle');
238
+ : isVeryShort
239
+ ? 'veryShortTitle'
240
+ : 'templateLikeTitle';
220
241
 
221
242
  const summaryKey =
222
- reasonCode === 'genericTitle'
223
- ? 'pageTitlePatterns_summary_cantTell_generic'
224
- : (reasonCode === 'veryShortTitle'
225
- ? 'pageTitlePatterns_summary_cantTell_veryShort'
226
- : 'pageTitlePatterns_summary_cantTell_templateLike');
243
+ reasonCode === 'genericTitle'
244
+ ? 'pageTitlePatterns_summary_cantTell_generic'
245
+ : reasonCode === 'veryShortTitle'
246
+ ? 'pageTitlePatterns_summary_cantTell_veryShort'
247
+ : 'pageTitlePatterns_summary_cantTell_templateLike';
227
248
  const occBase = {
228
249
  selector: 'head > title',
229
250
  html: '',
230
251
  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).',
252
+ 'The page title may not be descriptive enough to identify the page topic or purpose.',
253
+ hint: 'Use a more specific title that identifies the page topic or purpose (for example, include the section name or task).',
234
254
  i18n: {
235
255
  summaryKey,
236
256
  hintKey: 'pageTitlePatterns_hint_cantTell',
@@ -250,7 +270,10 @@ function runInPage(ctx) {
250
270
  } else {
251
271
  occurrences.push({
252
272
  ...occBase,
253
- html: titleEl && titleEl.outerHTML ? String(titleEl.outerHTML).slice(0, 2000) : '<title>(unknown)</title>'
273
+ html:
274
+ titleEl && titleEl.outerHTML
275
+ ? String(titleEl.outerHTML).slice(0, 2000)
276
+ : '<title>(unknown)</title>'
254
277
  });
255
278
  }
256
279
  }
@@ -261,7 +284,12 @@ function runInPage(ctx) {
261
284
 
262
285
  if (occurrences.length) {
263
286
  // Patterns are review signals: cantTell rather than fail.
264
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
287
+ return {
288
+ ruleId: rule.ruleId,
289
+ outcome: 'cantTell',
290
+ severity: rule.defaultSeverity || 'minor',
291
+ occurrences
292
+ };
265
293
  }
266
294
 
267
295
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
@@ -43,10 +43,14 @@
43
43
  * native implicit role to suppress in the first place (e.g.
44
44
  * `<div role="presentation" aria-hidden="true">` — a <div> has no native
45
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.
46
+ * with per that engine's own scope decision). surea11y stays
47
+ * broader/more cautious here rather than narrower, which is the safer
48
+ * direction to diverge in, and no real false positive from staying
49
+ * broad has surfaced in any corpus round to date.
50
+ * - The native-implicit-role table needed to replicate that gate (if this
51
+ * scope decision is ever revisited) has since been produced — see
52
+ * ROADMAP.md §7 item 9 (2026-07-31) for the full table and the open
53
+ * maintainer decision; not implemented here pending that call.
50
54
  * - Focusability is computed via helpers.getFocusableInfo (native +
51
55
  * tabindex), same helper aria-hidden-focus already relies on — a
52
56
  * `:disabled` or otherwise non-focusable element is not flagged.
@@ -56,7 +60,8 @@ const id = 'presentation-role-conflict';
56
60
 
57
61
  const meta = {
58
62
  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).',
63
+ description:
64
+ '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
65
  i18n: {
61
66
  titleKey: 'presentationRoleConflict_title',
62
67
  descriptionKey: 'presentationRoleConflict_description'
@@ -73,25 +78,45 @@ const meta = {
73
78
  };
74
79
 
75
80
  function runInPage(ctx) {
76
- const { document, helpers, rule } = ctx;
81
+ const { helpers, rule } = ctx;
77
82
 
78
83
  // The full set of ARIA attributes marked `global: true` per the WAI-ARIA
79
84
  // spec (confirmed against a widely-used reference engine's own
80
85
  // `standards.ariaAttrs` data at runtime, 2026-07-20) — any of these present on a presentational
81
86
  // element restores its implicit role, not just the naming ones.
82
87
  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'
88
+ 'aria-atomic',
89
+ 'aria-braillelabel',
90
+ 'aria-brailleroledescription',
91
+ 'aria-busy',
92
+ 'aria-controls',
93
+ 'aria-current',
94
+ 'aria-describedby',
95
+ 'aria-description',
96
+ 'aria-details',
97
+ 'aria-disabled',
98
+ 'aria-dropeffect',
99
+ 'aria-errormessage',
100
+ 'aria-flowto',
101
+ 'aria-grabbed',
102
+ 'aria-haspopup',
103
+ 'aria-hidden',
104
+ 'aria-invalid',
105
+ 'aria-keyshortcuts',
106
+ 'aria-label',
107
+ 'aria-labelledby',
108
+ 'aria-live',
109
+ 'aria-owns',
110
+ 'aria-relevant',
111
+ 'aria-roledescription'
88
112
  ];
89
113
 
90
- const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
114
+ const getFocusableInfo =
115
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
91
116
 
92
117
  const nodes = helpers.queryAllSmart
93
- ? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]')
94
- : helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]');
118
+ ? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]')
119
+ : helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]');
95
120
 
96
121
  const occurrences = [];
97
122
  let applicableCount = 0;
@@ -107,7 +132,9 @@ function runInPage(ctx) {
107
132
  // <img alt="" aria-hidden="">, where aria-hidden="" (empty string) is
108
133
  // still a specified attribute. A truthy-value check would have missed
109
134
  // 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));
135
+ const present = CONFLICTING_ATTRS.filter((attr) =>
136
+ el.hasAttribute ? el.hasAttribute(attr) : el.getAttribute(attr) != null
137
+ );
111
138
 
112
139
  let isFocusable = false;
113
140
  if (getFocusableInfo) {
@@ -126,9 +153,12 @@ function runInPage(ctx) {
126
153
 
127
154
  // No explicit role attribute means this matched via the img[alt=""]
128
155
  // implicit-presentation case.
129
- const role = String(el.getAttribute('role') || '').trim().toLowerCase() || 'presentation';
156
+ const role =
157
+ String(el.getAttribute('role') || '')
158
+ .trim()
159
+ .toLowerCase() || 'presentation';
130
160
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
131
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
161
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
132
162
 
133
163
  occurrences.push({
134
164
  selector: stableSelector,
@@ -141,7 +171,12 @@ function runInPage(ctx) {
141
171
  params: { role, attrs: parts.join(', ') }
142
172
  },
143
173
  data: {
144
- details: { reasonCode: 'PRESENTATION_ROLE_CONFLICT', role, conflictingAttrs: present, focusable: isFocusable }
174
+ details: {
175
+ reasonCode: 'PRESENTATION_ROLE_CONFLICT',
176
+ role,
177
+ conflictingAttrs: present,
178
+ focusable: isFocusable
179
+ }
145
180
  }
146
181
  });
147
182
  }
@@ -150,7 +185,12 @@ function runInPage(ctx) {
150
185
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
151
186
  }
152
187
  if (occurrences.length) {
153
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
188
+ return {
189
+ ruleId: rule.ruleId,
190
+ outcome: 'cantTell',
191
+ severity: rule.defaultSeverity || 'minor',
192
+ occurrences
193
+ };
154
194
  }
155
195
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
156
196
  }