@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 = 'progressbar-name-present';
@@ -13,7 +15,13 @@ const meta = {
13
15
  tags: ['wcag2a', 'wcag111', 'nontext', 'atomic', 'automatic', 'name'],
14
16
  wcagSc: ['1.1.1'],
15
17
  normativeMappings: [
16
- { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
18
+ {
19
+ standard: 'WCAG',
20
+ version: '2.2',
21
+ requirement: '1.1.1',
22
+ title: 'Non-text Content',
23
+ conformanceLevel: 'A'
24
+ }
17
25
  ],
18
26
  defaultSeverity: 'serious',
19
27
  category: 'perceivable',
@@ -24,36 +32,22 @@ const meta = {
24
32
 
25
33
  function runInPage(ctx) {
26
34
  const { document, helpers, rule } = ctx;
27
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
28
- ? helpers.getEligibilityInfo
29
- : null;
30
-
35
+ const getEligibilityInfo =
36
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
31
37
 
32
38
  function normalizeWs(s) {
33
- return String(s || '').replace(/\s+/g, ' ').trim();
39
+ return String(s || '')
40
+ .replace(/\s+/g, ' ')
41
+ .trim();
34
42
  }
35
43
 
36
44
  function getAttr(el, name) {
37
45
  try {
38
46
  if (!el || !el.getAttribute) return '';
39
47
  return normalizeWs(el.getAttribute(name));
40
- } catch { return ''; }
41
- }
42
-
43
- function getConservativeSubtreeText(document, container) {
44
- // "Name from content" — recurses into descendants and uses each one's
45
- // own accessible name (img alt, aria-label/aria-labelledby, title) when
46
- // it has one, not just literal text nodes. See getContentNameInfo's
47
- // header comment in src/core/dom-helpers.js for the full rationale
48
- // (this replaced a text-node-only TreeWalker that missed the common
49
- // "<a><img alt='...'></a>" logo-link / "<button><img alt='...'></button>"
50
- // icon-button pattern).
51
- if (helpers.getContentNameInfo) {
52
- const info = helpers.getContentNameInfo(container, ctx);
53
- return info && info.present ? info.value : '';
48
+ } catch {
49
+ return '';
54
50
  }
55
- const t = (container && container.textContent) ? String(container.textContent) : '';
56
- return t.replace(/\s+/g, ' ').trim();
57
51
  }
58
52
 
59
53
  function resolveAriaLabelledbyText(document, el, maxRefs) {
@@ -74,7 +68,8 @@ function runInPage(ctx) {
74
68
  }
75
69
 
76
70
  function isEligibleAcc(helpers, el, ctx) {
77
- const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
71
+ const fn =
72
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
78
73
  if (!fn) return true;
79
74
  try {
80
75
  const r = fn(el, ctx);
@@ -85,13 +80,13 @@ function runInPage(ctx) {
85
80
  }
86
81
  }
87
82
 
88
-
89
83
  const occurrences = [];
90
84
  let applicableCount = 0;
91
85
 
92
86
  const selector = '[role="progressbar"]';
93
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
94
-
87
+ const nodes = helpers.queryAllSmart
88
+ ? helpers.queryAllSmart(selector)
89
+ : helpers.queryAll(selector);
95
90
 
96
91
  function evaluate(el) {
97
92
  const ariaLabel = getAttr(el, 'aria-label');
@@ -103,16 +98,12 @@ function runInPage(ctx) {
103
98
  const title = getAttr(el, 'title');
104
99
  if (title) return { ok: true, method: 'title' };
105
100
 
106
- // role="progressbar" is name-from-author-only per WAI-ARIA (verified
107
- // against a widely-used reference engine's own aria-progressbar-name check: any:
108
- // ['aria-label', 'aria-labelledby', title] — no content-based naming
109
- // method at all). It must NOT fall back to subtree content: found on a
110
- // real site, Instacart's <ul role="progressbar"> loading skeleton has
111
- // no name of its own, but nested descendants carry their own
112
- // aria-label="Loading Box" (for a completely different purpose) —
113
- // falling back to subtree content picked that up and wrongly treated
114
- // it as the progressbar's own name, when that reference engine correctly
115
- // still flags it as unnamed.
101
+ // role="progressbar" is name-from-author-only per WAI-ARIA: aria-label,
102
+ // aria-labelledby, or title — no content-based naming method at all. It
103
+ // must NOT fall back to subtree content: e.g. a <ul role="progressbar">
104
+ // loading skeleton with no name of its own, whose nested descendants
105
+ // carry their own aria-label for a different purpose — falling back to
106
+ // subtree content would wrongly treat that as the progressbar's name.
116
107
  return { ok: false, method: 'none' };
117
108
  }
118
109
 
@@ -129,36 +120,50 @@ function runInPage(ctx) {
129
120
  if (res.ok) continue;
130
121
 
131
122
  const eligInfo = getEligibilityInfo
132
- ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
133
- : null;
123
+ ? (() => {
124
+ try {
125
+ return getEligibilityInfo(el, ctx, { targetSet: 'acc' });
126
+ } catch {
127
+ return null;
128
+ }
129
+ })()
130
+ : null;
134
131
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
135
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
132
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
136
133
 
137
134
  occurrences.push({
138
135
  selector: stableSelector,
139
136
  html,
140
137
  summary: 'This progress bar has no accessible name.',
141
- hint: 'Provide progress bar text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
138
+ hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this progress bar's accessible name.",
142
139
  i18n: {
143
140
  summaryKey: 'progressbarNamePresent_summary_fail',
144
141
  hintKey: 'progressbarNamePresent_hint_fail',
145
142
  params: {}
146
143
  },
147
144
  data: {
148
- details: { reasonCode: 'name_missing', controlType: 'progressbar', methodTried: res.method },
145
+ details: {
146
+ reasonCode: 'name_missing',
147
+ controlType: 'progressbar',
148
+ methodTried: res.method
149
+ },
149
150
  visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
150
151
  }
151
152
  });
152
153
  }
153
154
 
154
-
155
155
  if (applicableCount === 0) {
156
156
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
157
157
  }
158
158
  if (occurrences.length) {
159
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
159
+ return {
160
+ ruleId: rule.ruleId,
161
+ outcome: 'fail',
162
+ severity: rule.defaultSeverity || 'minor',
163
+ occurrences
164
+ };
160
165
  }
161
166
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
162
167
  }
163
168
 
164
- module.exports = { id, meta, runInPage };
169
+ 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
  /**
@@ -14,193 +16,243 @@
14
16
  * Each applicable element with role="img" has an accessible text alternative:
15
17
  * - aria-label with a non-empty value; OR
16
18
  * - aria-labelledby referencing at least one existing element that contributes non-empty text; OR
17
- * - a non-empty title attribute (last-resort accessible-name source per HTML-AAM,
18
- * also accepted by a widely-used reference engine's equivalent role-img-alt rule).
19
+ * - a non-empty title attribute (last-resort accessible-name source per HTML-AAM).
19
20
  */
20
21
 
21
22
  const id = 'role-img-text-alternative-present';
22
23
 
23
24
  const meta = {
24
- title: '[role="img"] must have an accessible text alternative',
25
- description:
26
- 'Checks that elements with role="img" provide an accessible text alternative via aria-label, aria-labelledby, or a title attribute.',
27
- i18n: {
28
- titleKey: 'roleImg_textAlternativePresent_title',
29
- descriptionKey: 'roleImg_textAlternativePresent_description'
30
- },
31
- helpUrl: null,
32
- tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'aria', 'atomic', 'automatic'],
33
- wcagSc: ['1.1.1'],
34
- normativeMappings: [
35
- { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
36
- ],
37
- defaultSeverity: 'serious',
38
- category: 'perceivable',
39
- type: 'automatic',
40
- defaultConfidence: 'high',
41
- coverage: {
42
- facetsBySc: {
43
- '1.1.1': ['role-img-text-alternative-present']
44
- }
25
+ title: '[role="img"] must have an accessible text alternative',
26
+ description:
27
+ 'Checks that elements with role="img" provide an accessible text alternative via aria-label, aria-labelledby, or a title attribute.',
28
+ i18n: {
29
+ titleKey: 'roleImg_textAlternativePresent_title',
30
+ descriptionKey: 'roleImg_textAlternativePresent_description'
31
+ },
32
+ helpUrl: null,
33
+ tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'aria', 'atomic', 'automatic'],
34
+ wcagSc: ['1.1.1'],
35
+ normativeMappings: [
36
+ {
37
+ standard: 'WCAG',
38
+ version: '2.2',
39
+ requirement: '1.1.1',
40
+ title: 'Non-text Content',
41
+ conformanceLevel: 'A'
42
+ }
43
+ ],
44
+ defaultSeverity: 'serious',
45
+ category: 'perceivable',
46
+ type: 'automatic',
47
+ defaultConfidence: 'high',
48
+ coverage: {
49
+ facetsBySc: {
50
+ '1.1.1': ['role-img-text-alternative-present']
45
51
  }
52
+ }
46
53
  };
47
54
 
48
55
  function runInPage(ctx) {
49
- const { root, helpers, rule } = ctx;
50
- const safeRoot = root || document;
51
-
52
- const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
53
- const queryAll = helpers && typeof helpers.queryAll === 'function'
54
- ? helpers.queryAll
55
- : (sel) => {
56
- try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
57
- catch { return []; }
56
+ const { root, helpers, rule } = ctx;
57
+ const safeRoot = root || document;
58
+
59
+ const queryAllSmart =
60
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
61
+ const queryAll =
62
+ helpers && typeof helpers.queryAll === 'function'
63
+ ? helpers.queryAll
64
+ : (sel) => {
65
+ try {
66
+ return safeRoot && safeRoot.querySelectorAll
67
+ ? Array.from(safeRoot.querySelectorAll(sel))
68
+ : [];
69
+ } catch {
70
+ return [];
71
+ }
58
72
  };
59
73
 
60
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
61
- ? helpers.getEligibilityInfo
62
- : null;
74
+ const getEligibilityInfo =
75
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
63
76
 
64
- const trim = (v) => {
65
- try { return String(v == null ? '' : v).replace(/\s+/g, ' ').trim(); }
66
- catch { return ''; }
67
- };
68
-
69
- const imgElements = (() => {
70
- // do not consider element "img" because it has its own rule
71
- const sel = '[role="img" i]:not(img)';
72
- try { return Array.from((queryAllSmart ? queryAllSmart(sel) : queryAll(sel)) || []); }
73
- catch { return queryAll(sel); }
74
- })();
75
-
76
- if (!imgElements.length) {
77
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
77
+ const trim = (v) => {
78
+ try {
79
+ return String(v == null ? '' : v)
80
+ .replace(/\s+/g, ' ')
81
+ .trim();
82
+ } catch {
83
+ return '';
84
+ }
85
+ };
86
+
87
+ const imgElements = (() => {
88
+ // do not consider element "img" because it has its own rule
89
+ const sel = '[role="img" i]:not(img)';
90
+ try {
91
+ return Array.from((queryAllSmart ? queryAllSmart(sel) : queryAll(sel)) || []);
92
+ } catch {
93
+ return queryAll(sel);
78
94
  }
95
+ })();
79
96
 
80
- const occurrences = [];
81
- let applicableCount = 0;
97
+ if (!imgElements.length) {
98
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
99
+ }
82
100
 
83
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
84
- ? helpers.isAccTreeEligible
85
- : null;
101
+ const occurrences = [];
102
+ let applicableCount = 0;
86
103
 
87
- const getAccessibleNameInfo = helpers && typeof helpers.getAccessibleNameInfo === 'function'
88
- ? helpers.getAccessibleNameInfo
89
- : null;
104
+ const isAccTreeEligible =
105
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
90
106
 
91
- for (const el of imgElements) {
92
- if (!el || !el.getAttribute) continue;
107
+ const getAccessibleNameInfo =
108
+ helpers && typeof helpers.getAccessibleNameInfo === 'function'
109
+ ? helpers.getAccessibleNameInfo
110
+ : null;
93
111
 
94
- // Applicability: eligible in the acc tree (with helper exceptions).
95
- if (isAccTreeEligible) {
96
- const elig = (() => {
97
- try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
98
- })();
99
- if (elig && elig.eligible === false) continue;
100
- }
112
+ for (const el of imgElements) {
113
+ if (!el || !el.getAttribute) continue;
101
114
 
102
- applicableCount += 1;
103
-
104
- // Expectation: aria-label OR aria-labelledby. We use helper name-info when available,
105
- // but we also validate the source to keep this rule scoped/deterministic.
106
-
107
- const ariaLabelRaw = (() => { try { return el.getAttribute('aria-label'); } catch { return null; } })();
108
- const ariaLabel = trim(ariaLabelRaw);
109
-
110
- const ariaLabelledbyRaw = (() => { try { return el.getAttribute('aria-labelledby'); } catch { return null; } })();
111
- const ariaLabelledby = trim(ariaLabelledbyRaw);
112
-
113
- const hasAriaLabelAttr = ariaLabelRaw !== null;
114
- const hasAriaLabelledbyAttr = ariaLabelledbyRaw !== null;
115
-
116
- const hasValidAriaLabel = hasAriaLabelAttr && ariaLabel.length > 0;
117
- const hasValidAriaLabelledbyAttr = hasAriaLabelledbyAttr && ariaLabelledby.length > 0;
118
-
119
- // Last-resort naming mechanism per HTML-AAM (also accepted by
120
- // a widely-used reference engine's role-img-alt): a non-empty title attribute.
121
- const titleRaw = (() => { try { return el.getAttribute('title'); } catch { return null; } })();
122
- const hasValidTitle = titleRaw !== null && trim(titleRaw).length > 0;
123
-
124
- let nameInfo = null;
125
-
126
- // Fast outcomes first (no helper needed)
127
- let reasonCode = '';
128
- let hasName = false;
129
-
130
- if (!hasAriaLabelAttr && !hasAriaLabelledbyAttr) {
131
- if (hasValidTitle) {
132
- hasName = true;
133
- } else {
134
- reasonCode = 'missingTextAlternative';
135
- }
136
- } else if (hasAriaLabelAttr && !hasValidAriaLabel) {
137
- if (hasValidTitle) {
138
- hasName = true;
139
- } else {
140
- reasonCode = 'emptyAriaLabel';
141
- }
142
- } else if (hasAriaLabelledbyAttr && !hasValidAriaLabelledbyAttr) {
143
- if (hasValidTitle) {
144
- hasName = true;
145
- } else {
146
- reasonCode = 'emptyAriaLabelledby';
147
- }
148
- } else {
149
- // Mechanism present + non-empty. Optionally validate resolution via helper.
150
- if (getAccessibleNameInfo) {
151
- nameInfo = (() => { try { return getAccessibleNameInfo(el, ctx); } catch { return null; } })();
152
- const helperSaysHasName = !!(nameInfo && nameInfo.present && trim(nameInfo.value));
153
- if (helperSaysHasName) {
154
- hasName = true;
155
- } else {
156
- reasonCode = 'nameNotResolved';
157
- }
158
- } else {
159
- // Without helper, accept non-empty aria-label/labelledby as sufficient.
160
- hasName = true;
161
- }
115
+ // Applicability: eligible in the acc tree (with helper exceptions).
116
+ if (isAccTreeEligible) {
117
+ const elig = (() => {
118
+ try {
119
+ return isAccTreeEligible(el, ctx);
120
+ } catch {
121
+ return { eligible: true, reasons: [] };
162
122
  }
123
+ })();
124
+ if (elig && elig.eligible === false) continue;
125
+ }
163
126
 
164
- if (hasName) continue;
165
-
166
-
167
- const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
168
-
169
- const baseOccurrence = {
170
- summary: 'Missing text alternative on element with role="img".',
171
- hint: 'Provide aria-label or aria-labelledby (referencing non-empty text) to give this image a text alternative.',
172
- i18n: {
173
- summaryKey: 'roleImg_textAlternativePresent_summary_fail',
174
- hintKey: 'roleImg_textAlternativePresent_hint_fail',
175
- params: { role: 'img' }
176
- },
177
- data: {
178
- details: {
179
- reasonCode,
180
- ariaLabel: ariaLabelRaw === null ? null : ariaLabel,
181
- ariaLabelledby: ariaLabelledbyRaw === null ? null : ariaLabelledby,
182
- accessibleNameInfo: nameInfo || null
183
- },
184
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
185
- }
186
- };
127
+ applicableCount += 1;
128
+
129
+ // Expectation: aria-label OR aria-labelledby. We use helper name-info when available,
130
+ // but we also validate the source to keep this rule scoped/deterministic.
131
+
132
+ const ariaLabelRaw = (() => {
133
+ try {
134
+ return el.getAttribute('aria-label');
135
+ } catch {
136
+ return null;
137
+ }
138
+ })();
139
+ const ariaLabel = trim(ariaLabelRaw);
140
+
141
+ const ariaLabelledbyRaw = (() => {
142
+ try {
143
+ return el.getAttribute('aria-labelledby');
144
+ } catch {
145
+ return null;
146
+ }
147
+ })();
148
+ const ariaLabelledby = trim(ariaLabelledbyRaw);
149
+
150
+ const hasAriaLabelAttr = ariaLabelRaw !== null;
151
+ const hasAriaLabelledbyAttr = ariaLabelledbyRaw !== null;
187
152
 
188
- if (helpers && typeof helpers.reportOccurrence === 'function') {
189
- occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
153
+ const hasValidAriaLabel = hasAriaLabelAttr && ariaLabel.length > 0;
154
+ const hasValidAriaLabelledbyAttr = hasAriaLabelledbyAttr && ariaLabelledby.length > 0;
155
+
156
+ // Last-resort naming mechanism per HTML-AAM: a non-empty title attribute.
157
+ const titleRaw = (() => {
158
+ try {
159
+ return el.getAttribute('title');
160
+ } catch {
161
+ return null;
162
+ }
163
+ })();
164
+ const hasValidTitle = titleRaw !== null && trim(titleRaw).length > 0;
165
+
166
+ let nameInfo = null;
167
+
168
+ // Fast outcomes first (no helper needed)
169
+ let reasonCode = '';
170
+ let hasName = false;
171
+
172
+ if (!hasAriaLabelAttr && !hasAriaLabelledbyAttr) {
173
+ if (hasValidTitle) {
174
+ hasName = true;
175
+ } else {
176
+ reasonCode = 'missingTextAlternative';
177
+ }
178
+ } else if (hasAriaLabelAttr && !hasValidAriaLabel) {
179
+ if (hasValidTitle) {
180
+ hasName = true;
181
+ } else {
182
+ reasonCode = 'emptyAriaLabel';
183
+ }
184
+ } else if (hasAriaLabelledbyAttr && !hasValidAriaLabelledbyAttr) {
185
+ if (hasValidTitle) {
186
+ hasName = true;
187
+ } else {
188
+ reasonCode = 'emptyAriaLabelledby';
189
+ }
190
+ } else {
191
+ // Mechanism present + non-empty. Optionally validate resolution via helper.
192
+ if (getAccessibleNameInfo) {
193
+ nameInfo = (() => {
194
+ try {
195
+ return getAccessibleNameInfo(el, ctx);
196
+ } catch {
197
+ return null;
198
+ }
199
+ })();
200
+ const helperSaysHasName = !!(nameInfo && nameInfo.present && trim(nameInfo.value));
201
+ if (helperSaysHasName) {
202
+ hasName = true;
190
203
  } else {
191
- occurrences.push({ selector: '', html: '', ...baseOccurrence });
204
+ reasonCode = 'nameNotResolved';
192
205
  }
206
+ } else {
207
+ // Without helper, accept non-empty aria-label/labelledby as sufficient.
208
+ hasName = true;
209
+ }
193
210
  }
194
211
 
195
- if (applicableCount === 0) {
196
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
197
- }
212
+ if (hasName) continue;
213
+
214
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
215
+
216
+ const baseOccurrence = {
217
+ summary: 'Missing text alternative on element with role="img".',
218
+ hint: 'Provide aria-label or aria-labelledby (referencing non-empty text) to give this image a text alternative.',
219
+ i18n: {
220
+ summaryKey: 'roleImg_textAlternativePresent_summary_fail',
221
+ hintKey: 'roleImg_textAlternativePresent_hint_fail',
222
+ params: { role: 'img' }
223
+ },
224
+ data: {
225
+ details: {
226
+ reasonCode,
227
+ ariaLabel: ariaLabelRaw === null ? null : ariaLabel,
228
+ ariaLabelledby: ariaLabelledbyRaw === null ? null : ariaLabelledby,
229
+ accessibleNameInfo: nameInfo || null
230
+ },
231
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
232
+ }
233
+ };
198
234
 
199
- if (!occurrences.length) {
200
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
235
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
236
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
237
+ } else {
238
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
201
239
  }
202
-
203
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
240
+ }
241
+
242
+ if (applicableCount === 0) {
243
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
244
+ }
245
+
246
+ if (!occurrences.length) {
247
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
248
+ }
249
+
250
+ return {
251
+ ruleId: rule.ruleId,
252
+ outcome: 'fail',
253
+ severity: rule.defaultSeverity || 'minor',
254
+ occurrences
255
+ };
204
256
  }
205
257
 
206
258
  module.exports = { id, meta, runInPage };