@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
  /**
@@ -11,20 +13,26 @@
11
13
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
12
14
  */
13
15
 
14
- const id = "img-alt-quality";
16
+ const id = 'img-alt-quality';
15
17
 
16
18
  const meta = {
17
- title: "<img> alt text must be appropriate (manual review)",
18
- description: "Flags <img> elements with non-empty alt text for human review of appropriateness.",
19
+ title: '<img> alt text must be appropriate (manual review)',
20
+ description: 'Flags <img> elements with non-empty alt text for human review of appropriateness.',
19
21
  i18n: {
20
- titleKey: "img_altQuality_title",
21
- descriptionKey: "img_altQuality_description"
22
+ titleKey: 'img_altQuality_title',
23
+ descriptionKey: 'img_altQuality_description'
22
24
  },
23
25
  helpUrl: null,
24
- tags: ["wcag2a", "wcag111", "nontext", "images", "manual", "atomic"],
26
+ tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'manual', 'atomic'],
25
27
  wcagSc: ['1.1.1'],
26
28
  normativeMappings: [
27
- { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
29
+ {
30
+ standard: 'WCAG',
31
+ version: '2.2',
32
+ requirement: '1.1.1',
33
+ title: 'Non-text Content',
34
+ conformanceLevel: 'A'
35
+ }
28
36
  ],
29
37
  defaultSeverity: 'minor',
30
38
  category: 'perceivable',
@@ -38,145 +46,188 @@ const meta = {
38
46
  };
39
47
 
40
48
  function runInPage(ctx) {
41
- const { document, root, helpers, rule } = ctx;
42
- const safeRoot = root || document;
43
-
44
- // Cap occurrences to keep manual “quality” checks fast on large pages.
45
- // Deterministic: we keep DOM order, just stop collecting after N.
46
- const MAX_OCCURRENCES = 50;
47
-
48
- const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
49
- const queryAll = helpers && typeof helpers.queryAll === 'function'
50
- ? helpers.queryAll
51
- : (sel) => {
52
- try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
53
- catch { return []; }
49
+ const { document, root, helpers, rule } = ctx;
50
+ const safeRoot = root || document;
51
+
52
+ // Cap occurrences to keep manual “quality” checks fast on large pages.
53
+ // Deterministic: we keep DOM order, just stop collecting after N.
54
+ const MAX_OCCURRENCES = 50;
55
+
56
+ const queryAllSmart =
57
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
58
+ const queryAll =
59
+ helpers && typeof helpers.queryAll === 'function'
60
+ ? helpers.queryAll
61
+ : (sel) => {
62
+ try {
63
+ return safeRoot && safeRoot.querySelectorAll
64
+ ? Array.from(safeRoot.querySelectorAll(sel))
65
+ : [];
66
+ } catch {
67
+ return [];
68
+ }
54
69
  };
55
70
 
56
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
57
- ? helpers.getEligibilityInfo
58
- : null;
59
-
60
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
61
- ? helpers.isAccTreeEligible
62
- : null;
63
-
64
- const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
65
- ? helpers.getFocusableInfo
66
- : null;
67
-
68
- function isRolePresentationExcluded(el) {
69
- const role = (() => {
70
- try { return String(el.getAttribute('role') || '').trim().toLowerCase(); }
71
- catch { return ''; }
72
- })();
73
- if (role !== 'presentation' && role !== 'none') return false;
74
-
75
- // Exclude only when NOT focusable (mirrors img-alt-present policy)
76
- let focusable = false;
77
- if (getFocusableInfo) {
78
- const fi = (() => { try { return getFocusableInfo(el, ctx); } catch { return null; } })();
79
- focusable = !!(fi && fi.focusable);
80
- } else {
81
- const tabindex = el.getAttribute('tabindex');
82
- focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
83
- }
84
- return !focusable;
85
- }
71
+ const getEligibilityInfo =
72
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
86
73
 
87
- const selector = 'img[alt]:not([alt=""])';
88
- const els = (() => {
89
- try { return Array.from((queryAllSmart ? queryAllSmart(selector) : queryAll(selector)) || []); }
90
- catch { return queryAll(selector); }
91
- })();
74
+ const isAccTreeEligible =
75
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
92
76
 
93
- if (!els.length) {
94
- return {
95
- ruleId: rule.ruleId,
96
- outcome: 'notApplicable',
97
- severity: 'minor',
98
- occurrences: [],
99
- data: { details: { applicableCount: 0, reportedCount: 0, maxOccurrences: MAX_OCCURRENCES, truncated: false } }
100
- };
101
- }
77
+ const getFocusableInfo =
78
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
102
79
 
103
- const occurrences = [];
104
- let applicableCount = 0; // total applicable elements
105
- let collectedCount = 0; // how many occurrences we actually reported
80
+ function isRolePresentationExcluded(el) {
81
+ const role = (() => {
82
+ try {
83
+ return String(el.getAttribute('role') || '')
84
+ .trim()
85
+ .toLowerCase();
86
+ } catch {
87
+ return '';
88
+ }
89
+ })();
90
+ if (role !== 'presentation' && role !== 'none') return false;
91
+
92
+ // Exclude only when NOT focusable (mirrors img-alt-present policy)
93
+ let focusable;
94
+ if (getFocusableInfo) {
95
+ const fi = (() => {
96
+ try {
97
+ return getFocusableInfo(el, ctx);
98
+ } catch {
99
+ return null;
100
+ }
101
+ })();
102
+ focusable = !!(fi && fi.focusable);
103
+ } else {
104
+ const tabindex = el.getAttribute('tabindex');
105
+ focusable =
106
+ tabindex != null &&
107
+ String(tabindex).trim() !== '' &&
108
+ !Number.isNaN(Number(String(tabindex).trim()));
109
+ }
110
+ return !focusable;
111
+ }
106
112
 
107
- for (const el of els) {
108
- if (!el || !el.getAttribute) continue;
113
+ const selector = 'img[alt]:not([alt=""])';
114
+ const els = (() => {
115
+ try {
116
+ return Array.from((queryAllSmart ? queryAllSmart(selector) : queryAll(selector)) || []);
117
+ } catch {
118
+ return queryAll(selector);
119
+ }
120
+ })();
109
121
 
110
- if (isAccTreeEligible) {
111
- const elig = (() => {
112
- try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
113
- })();
114
- if (elig && elig.eligible === false) continue;
122
+ if (!els.length) {
123
+ return {
124
+ ruleId: rule.ruleId,
125
+ outcome: 'notApplicable',
126
+ severity: 'minor',
127
+ occurrences: [],
128
+ data: {
129
+ details: {
130
+ applicableCount: 0,
131
+ reportedCount: 0,
132
+ maxOccurrences: MAX_OCCURRENCES,
133
+ truncated: false
115
134
  }
135
+ }
136
+ };
137
+ }
116
138
 
117
- if (isRolePresentationExcluded(el)) continue;
118
-
119
- // Rule-specific applicability: non-empty alt
120
- const alt = (() => {
121
- try { return String(el.getAttribute('alt') || '').trim(); } catch { return ''; }
122
- })();
123
- if (!alt) continue;
124
-
125
- applicableCount += 1;
126
-
127
- // IMPORTANT: stop doing expensive occurrence building after we hit the cap
128
- if (collectedCount >= MAX_OCCURRENCES) continue;
129
-
130
- const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
131
- const baseOccurrence = {
132
- summary: 'Review alt text on <img> for accuracy and appropriateness.',
133
- hint: 'Ensure the alt text conveys the image’s purpose/information in context (not redundant, not filename-like).',
134
- i18n: {
135
- summaryKey: 'img_altQuality_summary_cantTell',
136
- hintKey: 'img_altQuality_hint_cantTell',
137
- params: { element: 'img' }
138
- },
139
- data: {
140
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
141
- details: null
142
- }
143
- };
139
+ const occurrences = [];
140
+ let applicableCount = 0; // total applicable elements
141
+ let collectedCount = 0; // how many occurrences we actually reported
144
142
 
145
- if (helpers && typeof helpers.reportOccurrence === 'function') {
146
- occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
147
- } else {
148
- occurrences.push({ selector: '', html: '', ...baseOccurrence });
149
- }
143
+ for (const el of els) {
144
+ if (!el || !el.getAttribute) continue;
150
145
 
151
- collectedCount += 1;
146
+ if (isAccTreeEligible) {
147
+ const elig = (() => {
148
+ try {
149
+ return isAccTreeEligible(el, ctx);
150
+ } catch {
151
+ return { eligible: true, reasons: [] };
152
+ }
153
+ })();
154
+ if (elig && elig.eligible === false) continue;
152
155
  }
153
156
 
154
- if (applicableCount === 0) {
155
- return {
156
- ruleId: rule.ruleId,
157
- outcome: 'notApplicable',
158
- severity: 'minor',
159
- occurrences: [],
160
- data: { details: { applicableCount: 0, reportedCount: 0, maxOccurrences: MAX_OCCURRENCES, truncated: false } }
161
- };
157
+ if (isRolePresentationExcluded(el)) continue;
158
+
159
+ // Rule-specific applicability: non-empty alt
160
+ const alt = (() => {
161
+ try {
162
+ return String(el.getAttribute('alt') || '').trim();
163
+ } catch {
164
+ return '';
165
+ }
166
+ })();
167
+ if (!alt) continue;
168
+
169
+ applicableCount += 1;
170
+
171
+ // IMPORTANT: stop doing expensive occurrence building after we hit the cap
172
+ if (collectedCount >= MAX_OCCURRENCES) continue;
173
+
174
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
175
+ const baseOccurrence = {
176
+ summary: 'Review alt text on <img> for accuracy and appropriateness.',
177
+ hint: 'Ensure the alt text conveys the image’s purpose/information in context (not redundant, not filename-like).',
178
+ i18n: {
179
+ summaryKey: 'img_altQuality_summary_cantTell',
180
+ hintKey: 'img_altQuality_hint_cantTell',
181
+ params: { element: 'img' }
182
+ },
183
+ data: {
184
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
185
+ details: null
186
+ }
187
+ };
188
+
189
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
190
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
191
+ } else {
192
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
162
193
  }
163
194
 
164
- const truncated = applicableCount > collectedCount;
195
+ collectedCount += 1;
196
+ }
165
197
 
198
+ if (applicableCount === 0) {
166
199
  return {
167
- ruleId: rule.ruleId,
168
- outcome: 'cantTell',
169
- severity: 'minor',
170
- occurrences,
171
- data: {
172
- details: {
173
- applicableCount,
174
- reportedCount: collectedCount,
175
- maxOccurrences: MAX_OCCURRENCES,
176
- truncated
177
- }
200
+ ruleId: rule.ruleId,
201
+ outcome: 'notApplicable',
202
+ severity: 'minor',
203
+ occurrences: [],
204
+ data: {
205
+ details: {
206
+ applicableCount: 0,
207
+ reportedCount: 0,
208
+ maxOccurrences: MAX_OCCURRENCES,
209
+ truncated: false
178
210
  }
211
+ }
179
212
  };
213
+ }
214
+
215
+ const truncated = applicableCount > collectedCount;
216
+
217
+ return {
218
+ ruleId: rule.ruleId,
219
+ outcome: 'cantTell',
220
+ severity: 'minor',
221
+ occurrences,
222
+ data: {
223
+ details: {
224
+ applicableCount,
225
+ reportedCount: collectedCount,
226
+ maxOccurrences: MAX_OCCURRENCES,
227
+ truncated
228
+ }
229
+ }
230
+ };
180
231
  }
181
232
 
182
233
  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
  /**
@@ -11,20 +13,27 @@
11
13
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
12
14
  */
13
15
 
14
- const id = "input-image-alt-decorative";
16
+ const id = 'input-image-alt-decorative';
15
17
 
16
18
  const meta = {
17
- title: "<input type=\"image\"> with alt=\"\" must be appropriate (manual review)",
18
- description: "Flags <input type=\"image\"> elements with empty alt for human review (usually not appropriate for functional controls).",
19
+ title: '<input type="image"> with alt="" must be appropriate (manual review)',
20
+ description:
21
+ 'Flags <input type="image"> elements with empty alt for human review (usually not appropriate for functional controls).',
19
22
  i18n: {
20
- titleKey: "inputImage_altDecorative_title",
21
- descriptionKey: "inputImage_altDecorative_description"
23
+ titleKey: 'inputImage_altDecorative_title',
24
+ descriptionKey: 'inputImage_altDecorative_description'
22
25
  },
23
26
  helpUrl: null,
24
- tags: ["wcag2a", "wcag111", "forms", "images", "manual", "atomic"],
27
+ tags: ['wcag2a', 'wcag111', 'forms', 'images', 'manual', 'atomic'],
25
28
  wcagSc: ['1.1.1'],
26
29
  normativeMappings: [
27
- { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
30
+ {
31
+ standard: 'WCAG',
32
+ version: '2.2',
33
+ requirement: '1.1.1',
34
+ title: 'Non-text Content',
35
+ conformanceLevel: 'A'
36
+ }
28
37
  ],
29
38
  defaultSeverity: 'minor',
30
39
  category: 'perceivable',
@@ -38,107 +47,133 @@ const meta = {
38
47
  };
39
48
 
40
49
  function runInPage(ctx) {
41
-
42
- const { document, root, helpers, rule } = ctx;
43
- const safeRoot = root || document;
44
-
45
- const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
46
- const queryAll = helpers && typeof helpers.queryAll === 'function'
47
- ? helpers.queryAll
48
- : (sel) => {
49
- try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
50
- catch { return []; }
50
+ const { document, root, helpers, rule } = ctx;
51
+ const safeRoot = root || document;
52
+
53
+ const queryAllSmart =
54
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
55
+ const queryAll =
56
+ helpers && typeof helpers.queryAll === 'function'
57
+ ? helpers.queryAll
58
+ : (sel) => {
59
+ try {
60
+ return safeRoot && safeRoot.querySelectorAll
61
+ ? Array.from(safeRoot.querySelectorAll(sel))
62
+ : [];
63
+ } catch {
64
+ return [];
65
+ }
51
66
  };
52
67
 
53
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
54
- ? helpers.getEligibilityInfo
55
- : null;
56
-
57
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
58
- ? helpers.isAccTreeEligible
59
- : null;
60
-
61
- const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
62
- ? helpers.getFocusableInfo
63
- : null;
64
-
65
- function isRolePresentationExcluded(el) {
66
- const role = (() => {
67
- try { return String(el.getAttribute('role') || '').trim().toLowerCase(); }
68
- catch { return ''; }
69
- })();
70
- if (role !== 'presentation' && role !== 'none') return false;
71
-
72
- // Exclude only when NOT focusable (mirrors img-alt-present policy)
73
- let focusable = false;
74
- if (getFocusableInfo) {
75
- const fi = (() => { try { return getFocusableInfo(el, ctx); } catch { return null; } })();
76
- focusable = !!(fi && fi.focusable);
77
- } else {
78
- const tabindex = el.getAttribute('tabindex');
79
- focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
80
- }
81
- return !focusable;
82
- }
83
-
84
-
85
- const els = (() => {
86
- try { return Array.from((queryAllSmart ? queryAllSmart('input[type="image"]') : queryAll('input[type="image"]')) || []); }
87
- catch { return queryAll('input[type="image"]'); }
88
- })();
89
-
90
- if (!els.length) {
91
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
92
- }
68
+ const getEligibilityInfo =
69
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
93
70
 
94
- const occurrences = [];
95
- let applicableCount = 0;
71
+ const isAccTreeEligible =
72
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
96
73
 
97
- for (const el of els) {
98
- if (!el || !el.getAttribute) continue;
74
+ const getFocusableInfo =
75
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
99
76
 
100
- if (isAccTreeEligible) {
101
- const elig = (() => {
102
- try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
103
- })();
104
- if (elig && elig.eligible === false) continue;
77
+ function isRolePresentationExcluded(el) {
78
+ const role = (() => {
79
+ try {
80
+ return String(el.getAttribute('role') || '')
81
+ .trim()
82
+ .toLowerCase();
83
+ } catch {
84
+ return '';
85
+ }
86
+ })();
87
+ if (role !== 'presentation' && role !== 'none') return false;
88
+
89
+ // Exclude only when NOT focusable (mirrors img-alt-present policy)
90
+ let focusable;
91
+ if (getFocusableInfo) {
92
+ const fi = (() => {
93
+ try {
94
+ return getFocusableInfo(el, ctx);
95
+ } catch {
96
+ return null;
105
97
  }
98
+ })();
99
+ focusable = !!(fi && fi.focusable);
100
+ } else {
101
+ const tabindex = el.getAttribute('tabindex');
102
+ focusable =
103
+ tabindex != null &&
104
+ String(tabindex).trim() !== '' &&
105
+ !Number.isNaN(Number(String(tabindex).trim()));
106
+ }
107
+ return !focusable;
108
+ }
106
109
 
107
- if (isRolePresentationExcluded(el)) continue;
108
-
109
- // Rule-specific applicability (only elements that already have a text alternative mechanism)
110
- if (!((el.getAttribute('alt') != null && String(el.getAttribute('alt')).trim() === ''))) continue;
110
+ const els = (() => {
111
+ try {
112
+ return Array.from(
113
+ (queryAllSmart ? queryAllSmart('input[type="image"]') : queryAll('input[type="image"]')) ||
114
+ []
115
+ );
116
+ } catch {
117
+ return queryAll('input[type="image"]');
118
+ }
119
+ })();
111
120
 
112
- applicableCount += 1;
121
+ if (!els.length) {
122
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
123
+ }
113
124
 
114
- const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
125
+ const occurrences = [];
126
+ let applicableCount = 0;
115
127
 
116
- const baseOccurrence = {
117
- summary: 'Review <input type="image"> with alt="".',
118
- hint: 'This control is typically functional. Confirm it has an equivalent accessible name elsewhere, or provide meaningful alt text.',
119
- i18n: {
120
- summaryKey: 'inputImage_altDecorative_summary_cantTell',
121
- hintKey: 'inputImage_altDecorative_hint_cantTell',
122
- params: { element: 'input[type=image]' }
123
- },
124
- data: {
125
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
126
- details: null
127
- }
128
- };
128
+ for (const el of els) {
129
+ if (!el || !el.getAttribute) continue;
129
130
 
130
- if (helpers && typeof helpers.reportOccurrence === 'function') {
131
- occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
132
- } else {
133
- occurrences.push({ selector: '', html: '', ...baseOccurrence });
131
+ if (isAccTreeEligible) {
132
+ const elig = (() => {
133
+ try {
134
+ return isAccTreeEligible(el, ctx);
135
+ } catch {
136
+ return { eligible: true, reasons: [] };
134
137
  }
138
+ })();
139
+ if (elig && elig.eligible === false) continue;
135
140
  }
136
141
 
137
- if (applicableCount === 0) {
138
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
142
+ if (isRolePresentationExcluded(el)) continue;
143
+
144
+ // Rule-specific applicability (only elements that already have a text alternative mechanism)
145
+ if (!(el.getAttribute('alt') != null && String(el.getAttribute('alt')).trim() === '')) continue;
146
+
147
+ applicableCount += 1;
148
+
149
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
150
+
151
+ const baseOccurrence = {
152
+ summary: 'Review <input type="image"> with alt="".',
153
+ hint: 'This control is typically functional. Confirm it has an equivalent accessible name elsewhere, or provide meaningful alt text.',
154
+ i18n: {
155
+ summaryKey: 'inputImage_altDecorative_summary_cantTell',
156
+ hintKey: 'inputImage_altDecorative_hint_cantTell',
157
+ params: { element: 'input[type=image]' }
158
+ },
159
+ data: {
160
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
161
+ details: null
162
+ }
163
+ };
164
+
165
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
166
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
167
+ } else {
168
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
139
169
  }
170
+ }
171
+
172
+ if (applicableCount === 0) {
173
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
174
+ }
140
175
 
141
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences };
176
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences };
142
177
  }
143
178
 
144
179
  module.exports = { id, meta, runInPage };