@surea11y/core 1.3.0 → 1.4.1

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 (166) hide show
  1. package/CHANGELOG.md +87 -2
  2. package/README.md +109 -35
  3. package/bin/surea11y-core.js +20 -0
  4. package/docs/API_STABILITY.md +26 -0
  5. package/docs/ARIA_DEPRECATION.md +95 -0
  6. package/docs/CI_INTEGRATIONS.md +7 -7
  7. package/docs/ENGINE_OPTIONS.md +1 -1
  8. package/docs/I18N.md +12 -9
  9. package/docs/INTEGRATION.md +1 -1
  10. package/docs/LIMITATIONS.md +1 -1
  11. package/docs/REPORT.md +1 -1
  12. package/docs/RULE_CATALOG.md +6 -6
  13. package/package.json +52 -16
  14. package/src/baseline.js +0 -0
  15. package/src/checks/automatic/area-alt-present.js +4 -6
  16. package/src/checks/automatic/aria-allowed-attr.js +663 -134
  17. package/src/checks/automatic/aria-allowed-role.js +2 -0
  18. package/src/checks/automatic/aria-braille-equivalent.js +2 -0
  19. package/src/checks/automatic/aria-conditional-attr.js +8 -7
  20. package/src/checks/automatic/aria-deprecated-role.js +107 -38
  21. package/src/checks/automatic/aria-hidden-body.js +6 -4
  22. package/src/checks/automatic/aria-hidden-focus.js +12 -13
  23. package/src/checks/automatic/aria-prohibited-attr.js +98 -105
  24. package/src/checks/automatic/aria-prohibited-children.js +56 -87
  25. package/src/checks/automatic/aria-required-attr.js +6 -7
  26. package/src/checks/automatic/aria-required-children.js +7 -10
  27. package/src/checks/automatic/aria-required-parent.js +20 -25
  28. package/src/checks/automatic/aria-role-name-present.js +2 -0
  29. package/src/checks/automatic/aria-roles-valid.js +33 -6
  30. package/src/checks/automatic/aria-valid-attr-value.js +18 -15
  31. package/src/checks/automatic/aria-valid-attr.js +2 -0
  32. package/src/checks/automatic/autocomplete-valid.js +39 -1
  33. package/src/checks/automatic/avoid-inline-spacing.js +181 -20
  34. package/src/checks/automatic/binary-control-name-present.js +13 -3
  35. package/src/checks/automatic/button-name-present.js +54 -21
  36. package/src/checks/automatic/canvas-text-alternative-present.js +17 -8
  37. package/src/checks/automatic/combobox-name-present.js +10 -1
  38. package/src/checks/automatic/contrast-computable.js +2 -0
  39. package/src/checks/automatic/contrast-enhanced.js +2 -0
  40. package/src/checks/automatic/contrast-minimum.js +2 -0
  41. package/src/checks/automatic/css-orientation-lock.js +21 -27
  42. package/src/checks/automatic/definition-list-children-valid.js +6 -6
  43. package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
  44. package/src/checks/automatic/dialog-name-present.js +18 -11
  45. package/src/checks/automatic/dlitem-parent-valid.js +2 -0
  46. package/src/checks/automatic/duplicate-id-aria.js +4 -3
  47. package/src/checks/automatic/embed-text-alternative-present.js +2 -0
  48. package/src/checks/automatic/form-control-programmatic-label-present.js +50 -4
  49. package/src/checks/automatic/form-control-single-label.js +110 -43
  50. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
  51. package/src/checks/automatic/iframe-focusable-content.js +246 -18
  52. package/src/checks/automatic/iframe-name-present.js +2 -0
  53. package/src/checks/automatic/iframe-title-unique.js +3 -1
  54. package/src/checks/automatic/img-alt-present.js +23 -18
  55. package/src/checks/automatic/input-image-alt-present.js +101 -52
  56. package/src/checks/automatic/label-in-name.js +97 -27
  57. package/src/checks/automatic/language-page-present.js +7 -1
  58. package/src/checks/automatic/link-in-text-block.js +2 -0
  59. package/src/checks/automatic/link-name-present.js +52 -17
  60. package/src/checks/automatic/list-children-valid.js +14 -24
  61. package/src/checks/automatic/listbox-name-present.js +10 -1
  62. package/src/checks/automatic/listitem-parent-valid.js +30 -7
  63. package/src/checks/automatic/menuitem-name-present.js +10 -1
  64. package/src/checks/automatic/meta-refresh-no-exceptions.js +33 -4
  65. package/src/checks/automatic/meta-refresh-timing-absent.js +32 -4
  66. package/src/checks/automatic/meta-viewport-zoom-enabled.js +39 -15
  67. package/src/checks/automatic/meter-name-present.js +12 -4
  68. package/src/checks/automatic/nested-interactive-controls-absent.js +177 -25
  69. package/src/checks/automatic/object-text-alternative-present.js +16 -7
  70. package/src/checks/automatic/option-name-present.js +10 -1
  71. package/src/checks/automatic/page-title-present.js +2 -0
  72. package/src/checks/automatic/progressbar-name-present.js +16 -11
  73. package/src/checks/automatic/role-img-alt-present.js +4 -4
  74. package/src/checks/automatic/searchbox-name-present.js +10 -1
  75. package/src/checks/automatic/server-side-image-map-absent.js +4 -3
  76. package/src/checks/automatic/slider-name-present.js +13 -2
  77. package/src/checks/automatic/spinbutton-name-present.js +10 -1
  78. package/src/checks/automatic/summary-name-present.js +10 -1
  79. package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
  80. package/src/checks/automatic/svg-text-alternative-present.js +17 -5
  81. package/src/checks/automatic/tab-name-present.js +10 -1
  82. package/src/checks/automatic/table-headers-attr-valid.js +3 -2
  83. package/src/checks/automatic/table-th-has-data-cells.js +69 -6
  84. package/src/checks/automatic/target-size-minimum.js +5 -0
  85. package/src/checks/automatic/td-has-header.js +24 -1
  86. package/src/checks/automatic/textbox-name-present.js +10 -1
  87. package/src/checks/automatic/tooltip-name-present.js +10 -1
  88. package/src/checks/automatic/treeitem-name-present.js +10 -1
  89. package/src/checks/automatic/valid-lang.js +18 -3
  90. package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
  91. package/src/checks/manual/accesskeys-manual.js +3 -1
  92. package/src/checks/manual/area-alt-decorative-manual.js +2 -0
  93. package/src/checks/manual/area-alt-quality-manual.js +2 -0
  94. package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
  95. package/src/checks/manual/aria-text-manual.js +6 -5
  96. package/src/checks/manual/bypass-blocks-present-manual.js +279 -0
  97. package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
  98. package/src/checks/manual/css-hidden-focus.js +184 -9
  99. package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
  100. package/src/checks/manual/empty-heading-manual.js +17 -17
  101. package/src/checks/manual/empty-table-header-manual.js +52 -25
  102. package/src/checks/manual/focus-order-semantics-manual.js +16 -4
  103. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
  104. package/src/checks/manual/heading-order-manual.js +28 -1
  105. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
  106. package/src/checks/manual/image-redundant-alt-manual.js +21 -1
  107. package/src/checks/manual/img-alt-decorative-manual.js +2 -0
  108. package/src/checks/manual/img-alt-quality-manual.js +2 -0
  109. package/src/checks/manual/input-image-alt-decorative-manual.js +26 -0
  110. package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
  111. package/src/checks/manual/label-title-only-manual.js +29 -22
  112. package/src/checks/manual/landmark-banner-is-top-level-manual.js +51 -55
  113. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +44 -31
  114. package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
  115. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
  116. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
  117. package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
  118. package/src/checks/manual/landmark-one-main-manual.js +12 -23
  119. package/src/checks/manual/landmark-unique-manual.js +37 -52
  120. package/src/checks/manual/link-name-quality-manual.js +2 -0
  121. package/src/checks/manual/media-transcript-present-manual.js +2 -0
  122. package/src/checks/manual/meta-viewport-large-manual.js +3 -1
  123. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
  124. package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
  125. package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
  126. package/src/checks/manual/p-as-heading-manual.js +2 -0
  127. package/src/checks/manual/page-has-heading-one-manual.js +12 -11
  128. package/src/checks/manual/page-title-patterns-manual.js +2 -0
  129. package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
  130. package/src/checks/manual/region-manual.js +27 -36
  131. package/src/checks/manual/scope-attr-valid-manual.js +3 -1
  132. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
  133. package/src/checks/manual/skip-link-manual.js +7 -6
  134. package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
  135. package/src/checks/manual/tabindex-manual.js +3 -1
  136. package/src/checks/manual/table-duplicate-name-manual.js +5 -4
  137. package/src/checks/manual/table-fake-caption-manual.js +24 -3
  138. package/src/checks/manual/video-caption-manual.js +2 -0
  139. package/src/checks/manual-review.js +2 -0
  140. package/src/core.js +11820 -3317
  141. package/src/index.js +2 -0
  142. package/src/report.js +51 -9
  143. package/src/sarif.js +20 -5
  144. package/surea11y.browser.js +4943 -1388
  145. package/bin/core.js +0 -473
  146. package/docs/CLI.md +0 -128
  147. package/src/catalogs/composites.wcag.js +0 -454
  148. package/src/checks/automatic/bypass-blocks-present.js +0 -215
  149. package/src/checks/rules-and-tags.full.csv +0 -19
  150. package/src/checks/rules-and-tags.full.json +0 -259
  151. package/src/core/aria-helpers.js +0 -1211
  152. package/src/core/contrast-helpers.js +0 -1302
  153. package/src/core/dom-helpers.js +0 -4493
  154. package/src/core/dom-runner.js +0 -787
  155. package/src/core/frame-messaging.js +0 -261
  156. package/src/core/frame-scan.js +0 -190
  157. package/src/core/rollup-composites.js +0 -127
  158. package/src/core/rule-meta.js +0 -176
  159. package/src/coverage/wcag-facets.js +0 -1079
  160. package/src/coverage/wcag-version-map.js +0 -84
  161. package/src/i18n/en.js +0 -1228
  162. package/src/i18n/fr.js +0 -1185
  163. package/src/policy/contracts.js +0 -18
  164. package/src/policy/resolvePolicy.js +0 -59
  165. package/src/policy/schemas/engine-options.schema.json +0 -103
  166. 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
  /**
@@ -62,10 +64,141 @@ const meta = {
62
64
  };
63
65
 
64
66
  function runInPage(ctx) {
65
- const { helpers, rule } = ctx;
67
+ const { helpers, rule, document } = ctx;
68
+
69
+ // Self-contained rendering check for the embedded document (a distinct
70
+ // realm — see this rule's own header comment on why the outer
71
+ // document's shared eligibility helpers can't be reused here).
72
+ // Deliberately checks only genuine non-rendering (display:none,
73
+ // visibility:hidden, the hidden attribute) via the ancestor chain, NOT
74
+ // aria-hidden: aria-hidden alone does not remove an element from a real
75
+ // browser's native tab order (the same anti-pattern this engine's own
76
+ // aria-hidden-focus rule exists to catch), so an aria-hidden-but-
77
+ // visually-rendered focusable element inside the frame is still
78
+ // genuinely reachable by keyboard and must stay flagged.
79
+ function isRenderedInDoc(doc, el) {
80
+ try {
81
+ const view = doc.defaultView;
82
+ if (!view || typeof view.getComputedStyle !== 'function') return true;
83
+ let node = el;
84
+ while (node && node.nodeType === 1) {
85
+ if (node.hasAttribute && node.hasAttribute('hidden')) return false;
86
+ const cs = view.getComputedStyle(node);
87
+ if (cs) {
88
+ if (cs.display === 'none') return false;
89
+ if (cs.visibility === 'hidden' || cs.visibility === 'collapse') return false;
90
+ }
91
+ node = node.parentElement;
92
+ }
93
+ return true;
94
+ } catch {
95
+ return true;
96
+ }
97
+ }
98
+
99
+ function getDeepActiveElement(docRef) {
100
+ let cur = docRef && docRef.activeElement ? docRef.activeElement : null;
101
+ let guard = 0;
102
+ while (cur && cur.shadowRoot && cur.shadowRoot.activeElement && guard++ < 20) {
103
+ cur = cur.shadowRoot.activeElement;
104
+ }
105
+ return cur;
106
+ }
107
+
108
+ function focusElementSafe(el) {
109
+ if (!el || typeof el.focus !== 'function') return false;
110
+ try {
111
+ el.focus({ preventScroll: true });
112
+ return true;
113
+ } catch {
114
+ try {
115
+ el.focus();
116
+ return true;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+ }
122
+
123
+ function runFocusObservationWindow(win, fn) {
124
+ if (!win || typeof fn !== 'function') return;
125
+ const originalSetTimeout =
126
+ typeof win.setTimeout === 'function' ? win.setTimeout.bind(win) : null;
127
+ const originalRequestAnimationFrame =
128
+ typeof win.requestAnimationFrame === 'function' ? win.requestAnimationFrame.bind(win) : null;
129
+ const originalQueueMicrotask =
130
+ typeof win.queueMicrotask === 'function' ? win.queueMicrotask.bind(win) : null;
131
+
132
+ const queuedMicrotasks = [];
133
+ const queuedRaf = [];
134
+ const queuedTimers = [];
135
+ let fakeTimerId = 1;
136
+
137
+ const patchedSetTimeout = function (cb, delay) {
138
+ const d = Number.isFinite(Number(delay)) ? Number(delay) : 0;
139
+ if (typeof cb === 'function' && d <= 200) {
140
+ const args = [];
141
+ for (let i = 2; i < arguments.length; i++) args.push(arguments[i]);
142
+ queuedTimers.push({ delay: d, cb: () => cb.apply(win, args) });
143
+ return fakeTimerId++;
144
+ }
145
+ if (originalSetTimeout) return originalSetTimeout.apply(win, arguments);
146
+ return fakeTimerId++;
147
+ };
148
+
149
+ const patchedRaf = function (cb) {
150
+ if (typeof cb === 'function') {
151
+ queuedRaf.push(cb);
152
+ return fakeTimerId++;
153
+ }
154
+ if (originalRequestAnimationFrame) return originalRequestAnimationFrame.apply(win, arguments);
155
+ return fakeTimerId++;
156
+ };
157
+
158
+ const patchedQueueMicrotask = function (cb) {
159
+ if (typeof cb === 'function') queuedMicrotasks.push(cb);
160
+ };
161
+
162
+ try {
163
+ if (originalSetTimeout) win.setTimeout = patchedSetTimeout;
164
+ if (originalRequestAnimationFrame) win.requestAnimationFrame = patchedRaf;
165
+ if (originalQueueMicrotask) win.queueMicrotask = patchedQueueMicrotask;
166
+ fn();
66
167
 
67
- function hasFocusableCandidate(doc) {
68
- if (!doc || !doc.querySelectorAll) return false;
168
+ let guard = 0;
169
+ while (
170
+ (queuedMicrotasks.length || queuedRaf.length || queuedTimers.length) &&
171
+ guard++ < 100
172
+ ) {
173
+ while (queuedMicrotasks.length) {
174
+ const mt = queuedMicrotasks.shift();
175
+ try {
176
+ mt();
177
+ } catch {}
178
+ }
179
+ while (queuedRaf.length) {
180
+ const rf = queuedRaf.shift();
181
+ try {
182
+ rf(16);
183
+ } catch {}
184
+ }
185
+ if (queuedTimers.length) {
186
+ queuedTimers.sort((a, b) => a.delay - b.delay);
187
+ const tt = queuedTimers.shift();
188
+ try {
189
+ tt.cb();
190
+ } catch {}
191
+ }
192
+ }
193
+ } finally {
194
+ if (originalSetTimeout) win.setTimeout = originalSetTimeout;
195
+ if (originalRequestAnimationFrame) win.requestAnimationFrame = originalRequestAnimationFrame;
196
+ if (originalQueueMicrotask) win.queueMicrotask = originalQueueMicrotask;
197
+ }
198
+ }
199
+
200
+ function getFocusableCandidates(doc) {
201
+ if (!doc || !doc.querySelectorAll) return [];
69
202
  let els;
70
203
  try {
71
204
  els = doc.querySelectorAll(
@@ -73,8 +206,9 @@ function runInPage(ctx) {
73
206
  'select:not([disabled]), textarea:not([disabled]), iframe, [contenteditable="true"], [tabindex]'
74
207
  );
75
208
  } catch {
76
- return false;
209
+ return [];
77
210
  }
211
+ const candidates = [];
78
212
  for (const el of els) {
79
213
  if (!el || !el.getAttribute) continue;
80
214
  const raw = el.getAttribute('tabindex');
@@ -82,9 +216,77 @@ function runInPage(ctx) {
82
216
  const n = Number(String(raw).trim());
83
217
  if (!Number.isNaN(n) && n < 0) continue; // explicitly removed from tab order
84
218
  }
85
- return true;
219
+ if (!isRenderedInDoc(doc, el)) continue; // display:none/visibility:hidden/[hidden]: never reachable at all
220
+ candidates.push(el);
86
221
  }
87
- return false;
222
+ return candidates;
223
+ }
224
+
225
+ function probeImmediateFocusRedirect(frameEl, embeddedDoc, candidate) {
226
+ if (!frameEl || !embeddedDoc || !candidate) return null;
227
+ const embeddedWindow = embeddedDoc.defaultView;
228
+ if (!embeddedWindow) return null;
229
+
230
+ let focusedByEvent = false;
231
+ const onFocusCapture = () => {
232
+ focusedByEvent = true;
233
+ };
234
+ try {
235
+ candidate.addEventListener('focus', onFocusCapture, true);
236
+ } catch {}
237
+
238
+ const innerFocusTrace = [];
239
+ const outerFocusTrace = [];
240
+ const onInnerFocusIn = (ev) => {
241
+ if (ev && ev.target) innerFocusTrace.push(ev.target);
242
+ };
243
+ const onOuterFocusIn = (ev) => {
244
+ if (ev && ev.target) outerFocusTrace.push(ev.target);
245
+ };
246
+ try {
247
+ embeddedDoc.addEventListener('focusin', onInnerFocusIn, true);
248
+ document.addEventListener('focusin', onOuterFocusIn, true);
249
+ } catch {}
250
+
251
+ const beforeInner = getDeepActiveElement(embeddedDoc);
252
+ const beforeOuter = getDeepActiveElement(document);
253
+ let focused = false;
254
+ runFocusObservationWindow(embeddedWindow, () => {
255
+ focused = focusElementSafe(candidate);
256
+ });
257
+ try {
258
+ embeddedDoc.removeEventListener('focusin', onInnerFocusIn, true);
259
+ document.removeEventListener('focusin', onOuterFocusIn, true);
260
+ } catch {}
261
+ try {
262
+ candidate.removeEventListener('focus', onFocusCapture, true);
263
+ } catch {}
264
+
265
+ if (!focused) return null;
266
+
267
+ const afterInner = getDeepActiveElement(embeddedDoc);
268
+ const afterOuter = getDeepActiveElement(document);
269
+ const sawRedirectedInnerTrace = innerFocusTrace.some((n) => n && n !== candidate);
270
+ const sawRedirectedOuterTrace = outerFocusTrace.some(
271
+ (n) => n && n !== frameEl && n !== candidate
272
+ );
273
+ const redirectedWithinFrame =
274
+ !!(afterInner && afterInner !== candidate) || sawRedirectedInnerTrace;
275
+ const redirectedOutOfFrame =
276
+ !!(afterOuter && afterOuter !== frameEl) || sawRedirectedOuterTrace;
277
+ const sawCandidateFocus = focusedByEvent || innerFocusTrace.some((n) => n === candidate);
278
+
279
+ if (beforeInner && beforeInner !== afterInner) focusElementSafe(beforeInner);
280
+ if (beforeOuter && beforeOuter !== afterOuter) focusElementSafe(beforeOuter);
281
+
282
+ if (!sawCandidateFocus) return null;
283
+ if (!redirectedWithinFrame && !redirectedOutOfFrame) return null;
284
+
285
+ return {
286
+ redirected: true,
287
+ redirectedWithinFrame,
288
+ redirectedOutOfFrame
289
+ };
88
290
  }
89
291
 
90
292
  function getNegativeTabIndex(el) {
@@ -98,7 +300,8 @@ function runInPage(ctx) {
98
300
  ? helpers.queryAllSmart('iframe, frame')
99
301
  : helpers.queryAll('iframe, frame');
100
302
 
101
- const occurrences = [];
303
+ const failOccurrences = [];
304
+ const cantTellOccurrences = [];
102
305
  let applicableCount = 0;
103
306
 
104
307
  for (const el of nodes) {
@@ -115,13 +318,37 @@ function runInPage(ctx) {
115
318
 
116
319
  applicableCount += 1;
117
320
 
118
- if (!hasFocusableCandidate(contentDoc)) continue;
321
+ const candidates = getFocusableCandidates(contentDoc);
322
+ if (!candidates.length) continue;
119
323
 
120
324
  const tag = el.tagName.toLowerCase();
121
325
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
122
326
  const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
327
+ const shouldProbe = candidates.length === 1;
328
+ const runtimeProbe = shouldProbe
329
+ ? probeImmediateFocusRedirect(el, contentDoc, candidates[0])
330
+ : null;
331
+
332
+ if (runtimeProbe && runtimeProbe.redirected) {
333
+ cantTellOccurrences.push({
334
+ selector: stableSelector,
335
+ html,
336
+ summary:
337
+ 'This frame has tabindex="-1" and a focusable candidate, but focus moves immediately to another target. Verify keyboard reachability in a real browser.',
338
+ hint: 'If this is an intentional focus handoff, ensure keyboard users cannot remain on hidden/intermediate frame content.',
339
+ i18n: null,
340
+ data: {
341
+ details: {
342
+ reasonCode: 'IFRAME_TABINDEX_NEGATIVE_CONTENT_RUNTIME_REDIRECT',
343
+ element: tag,
344
+ runtimeProbe
345
+ }
346
+ }
347
+ });
348
+ continue;
349
+ }
123
350
 
124
- occurrences.push({
351
+ failOccurrences.push({
125
352
  selector: stableSelector,
126
353
  html,
127
354
  summary:
@@ -141,15 +368,16 @@ function runInPage(ctx) {
141
368
  if (applicableCount === 0) {
142
369
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
143
370
  }
144
- if (occurrences.length) {
145
- return {
146
- ruleId: rule.ruleId,
147
- outcome: 'fail',
148
- severity: rule.defaultSeverity || 'moderate',
149
- occurrences
150
- };
151
- }
152
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
371
+ // helpers.resolveTieredOutcome is unconditionally provided by dom-helpers.js
372
+ // (see its own header comment) -- no fallback needed, matching the same
373
+ // cleanup already applied to aria-hidden-focus.js/aria-prohibited-attr.js/
374
+ // target-size-minimum.js.
375
+ const resolved = helpers.resolveTieredOutcome(
376
+ failOccurrences,
377
+ cantTellOccurrences,
378
+ rule.defaultSeverity || 'moderate'
379
+ );
380
+ return { ruleId: rule.ruleId, ...resolved };
153
381
  }
154
382
 
155
383
  module.exports = { id, meta, runInPage };
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -18,7 +20,7 @@
18
20
  * a frame can have a non-empty title while still failing uniqueness.
19
21
  * - Compares the title ATTRIBUTE specifically, not the full computed
20
22
  * accessible name (aria-label could legitimately differ in wording even
21
- * when title happens to collide) — matches a widely-used reference engine's frame-title-unique.
23
+ * when title happens to collide).
22
24
  * - Not rule-gated on isAccTreeEligible: duplicate titles are a static
23
25
  * markup property. Engine-level hidden-subtree filtering still applies
24
26
  * unless engineOptions.includeHiddenElements is true.
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -7,10 +9,10 @@
7
9
  * @standard WCAG 2.2
8
10
  * @sc 1.1.1
9
11
  * @applicability
10
- * Applies to <img> elements that are exposed to assistive technologies.
11
- * Images with role="presentation" or role="none" are excluded only when they are not focusable.
12
- * Elements otherwise hidden from the accessibility tree remain applicable
13
- * if they are focusable or referenced by IDREF relationships (per engine eligibility checks).
12
+ * Applies to <img> elements included in the accessibility tree.
13
+ * Images with role="presentation" or role="none" are excluded only when they are not focusable,
14
+ * since a focusable one reverts to the img role under presentational roles conflict resolution.
15
+ * Hidden images are excluded whether or not they are focusable.
14
16
  * @expectation
15
17
  * Each applicable <img> element has an alt attribute.
16
18
  * The alt attribute may be empty (alt="").
@@ -58,8 +60,15 @@ function runInPage(ctx) {
58
60
  const getEligibilityInfo =
59
61
  helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
60
62
 
61
- const isAccTreeEligible =
62
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
63
+ // ACT 23a2a8 exempts programmatically hidden images, and that glossary term
64
+ // has no focusability carve-out, so an aria-hidden image stays out of scope
65
+ // even when tabbable; aria-hidden-focus reports that markup instead.
66
+ const isEligibleHelper =
67
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
68
+ ? helpers.isIncludedInAccessibilityTree
69
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
70
+ ? helpers.isAccTreeEligible
71
+ : null;
63
72
  const getFocusableInfo =
64
73
  helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
65
74
  const getAriaNameInfo =
@@ -111,11 +120,11 @@ function runInPage(ctx) {
111
120
  const el = imgs[i];
112
121
  if (!el || !el.getAttribute) continue;
113
122
 
114
- // Eligibility: only imgs exposed to assistive tech (with focusable/IDREF exceptions handled by helper)
115
- if (isAccTreeEligible) {
123
+ // Eligibility: only imgs exposed to assistive tech.
124
+ if (isEligibleHelper) {
116
125
  let elig;
117
126
  try {
118
- elig = isAccTreeEligible(el, ctx);
127
+ elig = isEligibleHelper(el, ctx);
119
128
  } catch {
120
129
  elig = null;
121
130
  }
@@ -172,15 +181,11 @@ function runInPage(ctx) {
172
181
  if (ariaName && ariaName.present) continue;
173
182
  }
174
183
 
175
- // A non-empty title attribute is HTML-AAM's own next fallback naming
176
- // source for <img> once alt is entirely absent (not merely alt="",
177
- // which explicitly marks decorative and stays excluded from this
178
- // branch since hasAlt already short-circuited above) -- confirmed
179
- // against a widely-used reference engine's own image-alt rule, which lists a non-empty
180
- // title as one of its "any" satisfying conditions alongside has-alt/
181
- // aria-label/aria-labelledby. Found via a real page (AliExpress's
182
- // logo, 2026-07-23): <img src="..." title="...">, no alt attribute
183
- // at all -- a real false positive, not a missing text alternative.
184
+ // A non-empty title attribute is HTML-AAM's next fallback naming source
185
+ // for <img> once alt is entirely absent (not merely alt="", which
186
+ // explicitly marks decorative and stays excluded from this branch since
187
+ // hasAlt already short-circuited above). An `<img src="..." title="...">`
188
+ // with no alt attribute at all is not missing a text alternative.
184
189
  const title = trim(el.getAttribute('title'));
185
190
  if (title) continue;
186
191
 
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -7,11 +9,10 @@
7
9
  * @standard WCAG 2.2
8
10
  * @sc 1.1.1
9
11
  * @applicability
10
- * Applies to <input type="image"> elements that are exposed to assistive technologies.
11
- * Elements otherwise hidden from the accessibility tree remain applicable
12
- * if they are focusable or referenced by IDREF relationships (per engine eligibility checks).
12
+ * Applies to <input type="image"> elements included in the accessibility tree.
13
13
  * @expectation
14
- * Each applicable <input type="image"> element has an alt attribute.
14
+ * Each applicable <input type="image"> element has an alt attribute, and its
15
+ * accessible name is not the browser default for an image button.
15
16
  * The alt attribute may be empty (alt="").
16
17
  */
17
18
 
@@ -70,8 +71,14 @@ function runInPage(ctx) {
70
71
  const getEligibilityInfo =
71
72
  helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
72
73
 
73
- const isAccTreeEligible =
74
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
74
+ // ACT 59796f applies to image buttons included in the accessibility tree,
75
+ // which excludes focusable content inside aria-hidden.
76
+ const isEligibleHelper =
77
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
78
+ ? helpers.isIncludedInAccessibilityTree
79
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
80
+ ? helpers.isAccTreeEligible
81
+ : null;
75
82
 
76
83
  const getAriaNameInfo =
77
84
  helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
@@ -97,67 +104,109 @@ function runInPage(ctx) {
97
104
  for (const el of inputs) {
98
105
  if (!el || !el.getAttribute) continue;
99
106
 
100
- // Applicability: eligible in the acc tree (with helper exceptions).
101
- if (isAccTreeEligible) {
107
+ if (isEligibleHelper) {
102
108
  const elig = (() => {
103
109
  try {
104
- return isAccTreeEligible(el, ctx);
110
+ return isEligibleHelper(el, ctx);
105
111
  } catch {
106
- return { eligible: true, reasons: [] };
112
+ return true;
107
113
  }
108
114
  })();
109
- if (elig && elig.eligible === false) continue;
115
+ const eligible = typeof elig === 'boolean' ? elig : !(elig && elig.eligible === false);
116
+ if (!eligible) continue;
110
117
  }
111
118
 
112
119
  applicableCount += 1;
113
120
 
114
- const hasAlt = el.getAttribute('alt') !== null;
115
- if (hasAlt) continue;
116
-
117
- // aria-label / aria-labelledby is also a valid, standards-recognized
118
- // text-alternative mechanism for <input type="image"> (HTML-AAM
119
- // accessible name computation includes ARIA naming before falling
120
- // back to alt).
121
- if (getAriaNameInfo) {
122
- let ariaName;
123
- try {
124
- ariaName = getAriaNameInfo(el, ctx);
125
- } catch {
126
- ariaName = null;
121
+ // The browser's own fallback name for an image button carries no
122
+ // information, so an author-supplied name equal to it is treated as no
123
+ // name at all (ACT 59796f). Only the English defaults are recognised:
124
+ // "Submit Query" from HTML-AAM, "Submit" from Chrome.
125
+ const effectiveName = (() => {
126
+ let v = '';
127
+ if (getAriaNameInfo) {
128
+ try {
129
+ const aria = getAriaNameInfo(el, ctx);
130
+ if (aria && aria.present && aria.value) v = String(aria.value);
131
+ } catch {
132
+ v = '';
133
+ }
127
134
  }
128
- if (ariaName && ariaName.present) continue;
129
- }
130
-
131
- // A non-empty title attribute is HTML-AAM's own next fallback naming
132
- // source once alt is entirely absent -- also accepted by a widely-used
133
- // reference engine's equivalent input-image-alt rule (non-empty-title, same "any" list
134
- // as non-empty-alt/aria-label/aria-labelledby). See img-alt-present's
135
- // sibling fix (2026-07-23, AliExpress's title-only logo <img>) for
136
- // the real page this was found via -- same gap, same fix, different
137
- // element.
138
- const titleRaw = (() => {
139
- try {
140
- return el.getAttribute('title');
141
- } catch {
142
- return null;
135
+ if (!v) {
136
+ const alt = el.getAttribute('alt');
137
+ if (alt != null && String(alt).trim()) v = String(alt);
138
+ }
139
+ if (!v) {
140
+ const t = el.getAttribute('title');
141
+ if (t != null && String(t).trim()) v = String(t);
143
142
  }
143
+ return v.trim().toLowerCase();
144
144
  })();
145
- if (titleRaw !== null && String(titleRaw).trim()) continue;
145
+
146
+ if (effectiveName === 'submit query' || effectiveName === 'submit') {
147
+ const eligInfoDefault = getEligibilityInfo
148
+ ? getEligibilityInfo(el, ctx, { targetSet: 'acc' })
149
+ : null;
150
+ const defaultNameOccurrence = {
151
+ summary:
152
+ 'Accessible name is the browser default for an image button, which conveys nothing.',
153
+ hint: 'Replace it with text describing what the button does, for example "Search".',
154
+ i18n: {
155
+ summaryKey: 'inputImage_altPresent_summary_defaultName',
156
+ hintKey: 'inputImage_altPresent_hint_defaultName',
157
+ params: { element: 'input[type=image]' }
158
+ },
159
+ data: {
160
+ visibilityFilter: eligInfoDefault || { targetSet: 'acc', accEligible: null, reasons: [] },
161
+ details: { reasonCode: 'default_name' }
162
+ }
163
+ };
164
+ occurrences.push(
165
+ helpers && typeof helpers.reportOccurrence === 'function'
166
+ ? helpers.reportOccurrence(el, defaultNameOccurrence)
167
+ : { selector: '', html: '', ...defaultNameOccurrence }
168
+ );
169
+ continue;
170
+ }
171
+
172
+ // effectiveName already covers the naming sources HTML-AAM allows here,
173
+ // in order: aria-label/aria-labelledby, then alt, then title.
174
+ if (effectiveName) continue;
175
+
176
+ // An image button is a control, so an empty name fails whether alt is
177
+ // absent or present-but-empty. alt="" marks a decorative image, and an
178
+ // image button is never decorative.
179
+ const emptyAlt = el.getAttribute('alt') !== null;
146
180
 
147
181
  const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
148
182
 
149
- const baseOccurrence = {
150
- summary: 'Missing alt attribute on <input type="image">.',
151
- hint: 'Add an alt attribute (use alt="" only when a separate accessible name is provided).',
152
- i18n: {
153
- summaryKey: 'inputImage_altPresent_summary_fail',
154
- hintKey: 'inputImage_altPresent_hint_fail',
155
- params: { element: 'input[type=image]' }
156
- },
157
- data: {
158
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
159
- }
160
- };
183
+ const baseOccurrence = emptyAlt
184
+ ? {
185
+ summary: 'Empty alt="" on <input type="image"> leaves the control unnamed.',
186
+ hint: 'Describe the action in alt, or name the control with aria-label or aria-labelledby.',
187
+ i18n: {
188
+ summaryKey: 'inputImage_altPresent_summary_emptyAlt',
189
+ hintKey: 'inputImage_altPresent_hint_emptyAlt',
190
+ params: { element: 'input[type=image]' }
191
+ },
192
+ data: {
193
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
194
+ details: { reasonCode: 'empty_alt' }
195
+ }
196
+ }
197
+ : {
198
+ summary: 'Missing alt attribute on <input type="image">.',
199
+ hint: 'Add an alt attribute (use alt="" only when a separate accessible name is provided).',
200
+ i18n: {
201
+ summaryKey: 'inputImage_altPresent_summary_fail',
202
+ hintKey: 'inputImage_altPresent_hint_fail',
203
+ params: { element: 'input[type=image]' }
204
+ },
205
+ data: {
206
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
207
+ details: { reasonCode: 'missing_alt' }
208
+ }
209
+ };
161
210
 
162
211
  if (helpers && typeof helpers.reportOccurrence === 'function') {
163
212
  occurrences.push(helpers.reportOccurrence(el, baseOccurrence));