@surea11y/core 1.0.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 (164) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +145 -0
  4. package/bin/core.js +244 -0
  5. package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
  6. package/docs/CLI.md +49 -0
  7. package/docs/ENGINE_OPTIONS.md +155 -0
  8. package/docs/I18N.md +47 -0
  9. package/docs/INTEGRATION.md +156 -0
  10. package/docs/LIMITATIONS.md +31 -0
  11. package/docs/OUTPUT_SCHEMA.md +237 -0
  12. package/docs/POLICY.md +71 -0
  13. package/docs/RULE_AUTHORING.md +375 -0
  14. package/docs/RULE_CATALOG.md +180 -0
  15. package/docs/RULE_TAXONOMY.md +145 -0
  16. package/docs/TROUBLESHOOTING.md +48 -0
  17. package/docs/WCAG_CONFORMANCE.md +49 -0
  18. package/package.json +60 -0
  19. package/src/catalogs/composites.wcag.js +490 -0
  20. package/src/checks/automatic/area-alt-present.js +225 -0
  21. package/src/checks/automatic/aria-allowed-attr.js +206 -0
  22. package/src/checks/automatic/aria-allowed-role.js +102 -0
  23. package/src/checks/automatic/aria-braille-equivalent.js +139 -0
  24. package/src/checks/automatic/aria-conditional-attr.js +110 -0
  25. package/src/checks/automatic/aria-deprecated-role.js +106 -0
  26. package/src/checks/automatic/aria-hidden-body.js +87 -0
  27. package/src/checks/automatic/aria-hidden-focus.js +480 -0
  28. package/src/checks/automatic/aria-prohibited-attr.js +156 -0
  29. package/src/checks/automatic/aria-prohibited-children.js +265 -0
  30. package/src/checks/automatic/aria-required-attr.js +154 -0
  31. package/src/checks/automatic/aria-required-children.js +274 -0
  32. package/src/checks/automatic/aria-required-parent.js +222 -0
  33. package/src/checks/automatic/aria-role-name-present.js +201 -0
  34. package/src/checks/automatic/aria-roles-valid.js +110 -0
  35. package/src/checks/automatic/aria-valid-attr-value.js +123 -0
  36. package/src/checks/automatic/aria-valid-attr.js +109 -0
  37. package/src/checks/automatic/autocomplete-valid.js +134 -0
  38. package/src/checks/automatic/avoid-inline-spacing.js +107 -0
  39. package/src/checks/automatic/binary-control-name-present.js +294 -0
  40. package/src/checks/automatic/button-name-present.js +146 -0
  41. package/src/checks/automatic/bypass-blocks-present.js +162 -0
  42. package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
  43. package/src/checks/automatic/combobox-name-present.js +267 -0
  44. package/src/checks/automatic/contrast-computable.js +378 -0
  45. package/src/checks/automatic/contrast-enhanced.js +517 -0
  46. package/src/checks/automatic/contrast-minimum.js +512 -0
  47. package/src/checks/automatic/css-orientation-lock.js +206 -0
  48. package/src/checks/automatic/definition-list-children-valid.js +148 -0
  49. package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
  50. package/src/checks/automatic/dialog-name-present.js +209 -0
  51. package/src/checks/automatic/dlitem-parent-valid.js +100 -0
  52. package/src/checks/automatic/duplicate-id-aria.js +126 -0
  53. package/src/checks/automatic/embed-text-alternative-present.js +190 -0
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
  55. package/src/checks/automatic/form-control-single-label.js +117 -0
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
  57. package/src/checks/automatic/iframe-focusable-content.js +141 -0
  58. package/src/checks/automatic/iframe-name-present.js +102 -0
  59. package/src/checks/automatic/iframe-title-unique.js +107 -0
  60. package/src/checks/automatic/img-alt-present.js +223 -0
  61. package/src/checks/automatic/input-image-alt-present.js +155 -0
  62. package/src/checks/automatic/label-in-name.js +326 -0
  63. package/src/checks/automatic/language-page-present.js +159 -0
  64. package/src/checks/automatic/link-in-text-block.js +218 -0
  65. package/src/checks/automatic/link-name-present.js +114 -0
  66. package/src/checks/automatic/list-children-valid.js +152 -0
  67. package/src/checks/automatic/listbox-name-present.js +236 -0
  68. package/src/checks/automatic/listitem-parent-valid.js +118 -0
  69. package/src/checks/automatic/menuitem-name-present.js +201 -0
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
  73. package/src/checks/automatic/meter-name-present.js +160 -0
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
  75. package/src/checks/automatic/object-text-alternative-present.js +193 -0
  76. package/src/checks/automatic/option-name-present.js +157 -0
  77. package/src/checks/automatic/page-title-present.js +86 -0
  78. package/src/checks/automatic/progressbar-name-present.js +165 -0
  79. package/src/checks/automatic/role-img-alt-present.js +206 -0
  80. package/src/checks/automatic/searchbox-name-present.js +236 -0
  81. package/src/checks/automatic/server-side-image-map-absent.js +88 -0
  82. package/src/checks/automatic/slider-name-present.js +276 -0
  83. package/src/checks/automatic/spinbutton-name-present.js +236 -0
  84. package/src/checks/automatic/summary-name-present.js +153 -0
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
  86. package/src/checks/automatic/svg-text-alternative-present.js +298 -0
  87. package/src/checks/automatic/tab-name-present.js +200 -0
  88. package/src/checks/automatic/table-headers-attr-valid.js +122 -0
  89. package/src/checks/automatic/table-th-has-data-cells.js +117 -0
  90. package/src/checks/automatic/target-size-minimum.js +605 -0
  91. package/src/checks/automatic/td-has-header.js +151 -0
  92. package/src/checks/automatic/textbox-name-present.js +236 -0
  93. package/src/checks/automatic/tooltip-name-present.js +158 -0
  94. package/src/checks/automatic/treeitem-name-present.js +157 -0
  95. package/src/checks/automatic/valid-lang.js +100 -0
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
  97. package/src/checks/manual/accesskeys-manual.js +93 -0
  98. package/src/checks/manual/area-alt-decorative-manual.js +247 -0
  99. package/src/checks/manual/area-alt-quality-manual.js +204 -0
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
  101. package/src/checks/manual/aria-text-manual.js +109 -0
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
  103. package/src/checks/manual/css-hidden-focus.js +259 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
  105. package/src/checks/manual/empty-heading-manual.js +182 -0
  106. package/src/checks/manual/empty-table-header-manual.js +163 -0
  107. package/src/checks/manual/focus-order-semantics-manual.js +117 -0
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
  109. package/src/checks/manual/heading-order-manual.js +130 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
  111. package/src/checks/manual/image-redundant-alt-manual.js +118 -0
  112. package/src/checks/manual/img-alt-decorative-manual.js +148 -0
  113. package/src/checks/manual/img-alt-quality-manual.js +182 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
  116. package/src/checks/manual/label-title-only-manual.js +115 -0
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
  123. package/src/checks/manual/landmark-one-main-manual.js +151 -0
  124. package/src/checks/manual/landmark-unique-manual.js +252 -0
  125. package/src/checks/manual/link-name-quality-manual.js +143 -0
  126. package/src/checks/manual/media-transcript-present-manual.js +373 -0
  127. package/src/checks/manual/meta-viewport-large-manual.js +119 -0
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
  129. package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
  131. package/src/checks/manual/p-as-heading-manual.js +163 -0
  132. package/src/checks/manual/page-has-heading-one-manual.js +110 -0
  133. package/src/checks/manual/page-title-patterns-manual.js +262 -0
  134. package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
  135. package/src/checks/manual/region-manual.js +183 -0
  136. package/src/checks/manual/scope-attr-valid-manual.js +93 -0
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
  138. package/src/checks/manual/skip-link-manual.js +150 -0
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
  140. package/src/checks/manual/tabindex-manual.js +94 -0
  141. package/src/checks/manual/table-duplicate-name-manual.js +99 -0
  142. package/src/checks/manual/table-fake-caption-manual.js +122 -0
  143. package/src/checks/manual/video-caption-manual.js +118 -0
  144. package/src/checks/manual-review.js +95 -0
  145. package/src/checks/rules-and-tags.full.csv +19 -0
  146. package/src/checks/rules-and-tags.full.json +259 -0
  147. package/src/core/aria-helpers.js +906 -0
  148. package/src/core/contrast-helpers.js +1147 -0
  149. package/src/core/dom-helpers.js +4085 -0
  150. package/src/core/dom-runner.js +627 -0
  151. package/src/core/frame-messaging.js +210 -0
  152. package/src/core/frame-scan.js +178 -0
  153. package/src/core/rollup-composites.js +135 -0
  154. package/src/core/rule-meta.js +140 -0
  155. package/src/core.js +79055 -0
  156. package/src/coverage/wcag-facets.js +1079 -0
  157. package/src/coverage/wcag-version-map.js +84 -0
  158. package/src/i18n/en.js +919 -0
  159. package/src/i18n/fr.js +527 -0
  160. package/src/index.js +4 -0
  161. package/src/policy/contracts.js +18 -0
  162. package/src/policy/resolvePolicy.js +55 -0
  163. package/src/policy/schemas/engine-options.schema.json +103 -0
  164. package/src/policy/schemas/policy-contract.schema.json +40 -0
@@ -0,0 +1,409 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Optimized, regression-safe rewrite of:
5
+ * form-control-programmatic-label-present
6
+ *
7
+ * Goals:
8
+ * - Preserve semantics, schema, determinism, and no-throw behavior.
9
+ * - Reduce per-element DOM queries and avoid unnecessary helper calls.
10
+ *
11
+ * Key safety points:
12
+ * - Control discovery goes through helpers.queryAllSmart/queryAll (multi-root
13
+ * and shadow-DOM aware), not a direct root/safeRoot DOM query -- ctx.root
14
+ * is an array (multi-region contextSelector support), not a single element.
15
+ * - label[for] association uses a single precomputed Set of `for` values (document-wide, like original).
16
+ * - getFocusableInfo is only invoked when role is presentational and thus relevant.
17
+ * - aria-labelledby / aria-label helper resolution is only invoked when the attribute is present.
18
+ *
19
+ * WCAG mapping: matches technique H44 ("Using label elements to associate
20
+ * text labels with form controls"), which WCAG's own Techniques document
21
+ * lists as sufficient for 1.3.1, 3.3.2, AND 4.1.2 simultaneously — a label
22
+ * that programmatically associates with a control conveys the
23
+ * relationship (1.3.1), provides the instruction (3.3.2), and exposes the
24
+ * accessible name (4.1.2) all at once. This rule was originally only wired
25
+ * to 4.1.2, even though its own `tags` already listed wcag131/wcag332 and
26
+ * wcag-facets.js already had matching facet ids under those SCs.
27
+ */
28
+
29
+ const id = 'form-control-programmatic-label-present';
30
+
31
+ const meta = {
32
+ title: 'Form controls must have a programmatic label',
33
+ description:
34
+ 'Checks that form controls have a programmatic label via <label>, aria-label, aria-labelledby, title, or placeholder.',
35
+ i18n: {
36
+ titleKey: 'formControl_programmaticLabelPresent_title',
37
+ descriptionKey: 'formControl_programmaticLabelPresent_description'
38
+ },
39
+ helpUrl: null,
40
+ tags: ['wcag2a', 'wcag131', 'wcag332', 'wcag412', 'forms', 'labels', 'atomic', 'automatic'],
41
+ wcagSc: ['1.3.1', '3.3.2', '4.1.2'],
42
+ normativeMappings: [
43
+ { standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' },
44
+ { standard: 'WCAG', version: '2.2', requirement: '3.3.2', title: 'Labels or Instructions', conformanceLevel: 'A' },
45
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
46
+ ],
47
+ defaultSeverity: 'serious',
48
+ category: 'robust',
49
+ type: 'automatic',
50
+ defaultConfidence: 'medium',
51
+ coverage: {
52
+ facetsBySc: {
53
+ '1.3.1': ['form-control-programmatic-label-present'],
54
+ '3.3.2': ['form-control-labels-or-instructions-present'],
55
+ '4.1.2': ['form-control-name-present']
56
+ }
57
+ }
58
+ };
59
+
60
+ function runInPage(ctx) {
61
+ const { document, helpers, rule } = ctx;
62
+
63
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
64
+ const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
65
+
66
+ const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
67
+ const getAriaLabelInfo = helpers && typeof helpers.getAriaLabelInfo === 'function' ? helpers.getAriaLabelInfo : null;
68
+ const getAriaLabelledByInfo =
69
+ helpers && typeof helpers.getAriaLabelledByInfo === 'function' ? helpers.getAriaLabelledByInfo : null;
70
+
71
+ const getAttributeInfo = helpers && typeof helpers.getAttributeInfo === 'function' ? helpers.getAttributeInfo : null;
72
+ const getLabelMethod = helpers && typeof helpers.getLabelMethod === 'function' ? helpers.getLabelMethod : null;
73
+ const getContentNameInfo = helpers && typeof helpers.getContentNameInfo === 'function' ? helpers.getContentNameInfo : null;
74
+ const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
75
+
76
+ // A <label> counts as an association if it contributes a name either via
77
+ // its own aria-label/aria-labelledby (checked first, same ARIA-over-
78
+ // content precedence any element's accessible name gives — verified
79
+ // against a real page: <label aria-label="Toggle Navigation"><svg
80
+ // aria-hidden="true">...</svg></label> names its control "Toggle
81
+ // Navigation" even though the label's only child content is aria-hidden)
82
+ // or, failing that, non-empty ACCESSIBLE content (getContentNameInfo
83
+ // already excludes aria-hidden/display:none/inert descendants — a label
84
+ // whose only text is aria-hidden gives the control no real accessible
85
+ // name even though the structural association exists). See
86
+ // dom-helpers.js's labelContributesAccessibleName for the primary
87
+ // (shared-helper) path; this is the fallback-only mirror.
88
+ function labelHasAccessibleContent(labelEl) {
89
+ if (getAriaNameInfo) {
90
+ try {
91
+ const aria = getAriaNameInfo(labelEl, ctx);
92
+ if (aria && aria.present && trim(aria.value)) return true;
93
+ } catch {}
94
+ }
95
+ if (!getContentNameInfo) return true; // conservative: don't newly fail without the helper
96
+ try {
97
+ const info = getContentNameInfo(labelEl, ctx);
98
+ return !!(info && info.present && trim(info.value));
99
+ } catch {
100
+ return true;
101
+ }
102
+ }
103
+
104
+ const trim = (v) => (v == null ? '' : String(v)).trim();
105
+
106
+ const metrics = {
107
+ applicableCount: 0,
108
+ passCount: 0,
109
+ failCount: 0,
110
+ byMethod: { label: 0, 'aria-labelledby': 0, 'aria-label': 0, title: 0, placeholder: 0, none: 0 },
111
+ weakPassCount: 0
112
+ };
113
+
114
+ function getLabelStrength(method) {
115
+ if (method === 'label' || method === 'aria-labelledby') return 'strong';
116
+ if (method === 'aria-label') return 'medium';
117
+ if (method === 'title' || method === 'placeholder') return 'weak';
118
+ return 'none';
119
+ }
120
+
121
+ function getNonEmptyAttrViaHelper(el, name) {
122
+ if (!getAttributeInfo) return '';
123
+ try {
124
+ const info = getAttributeInfo(el, name);
125
+ return info && info.present ? trim(info.value) : '';
126
+ } catch {
127
+ return '';
128
+ }
129
+ }
130
+
131
+ // Build a document-wide Map of label[for] value -> label element once
132
+ // (O(#labels) instead of O(#controls) selector queries). Keeps the
133
+ // element reference (not just a presence flag) so hasLabelAssociation can
134
+ // check the label's actual accessible content.
135
+ const labelForMap = new Map();
136
+ try {
137
+ if (document && typeof document.getElementsByTagName === 'function') {
138
+ const labels = document.getElementsByTagName('label');
139
+ for (let i = 0; i < labels.length; i++) {
140
+ const lab = labels[i];
141
+ if (!lab || !lab.getAttribute) continue;
142
+ const f = trim(lab.getAttribute('for'));
143
+ if (f && !labelForMap.has(f)) labelForMap.set(f, lab);
144
+ }
145
+ }
146
+ } catch {
147
+ // no-throw
148
+ }
149
+
150
+ function isEligibleAcc(el) {
151
+ if (!isAccTreeEligible) return true;
152
+ try {
153
+ const r = isAccTreeEligible(el, ctx);
154
+ if (typeof r === 'boolean') return r;
155
+ return !!(r && r.eligible);
156
+ } catch {
157
+ return true;
158
+ }
159
+ }
160
+
161
+ function hasLabelAssociation(el) {
162
+ // 1) Native labels API — resolves both wrapping <label> and
163
+ // <label for="id"> as real elements in one call.
164
+ try {
165
+ if (el && 'labels' in el && el.labels && el.labels.length) {
166
+ return Array.prototype.some.call(el.labels, labelHasAccessibleContent);
167
+ }
168
+ } catch {}
169
+
170
+ // 2) Wrapped by <label>
171
+ try {
172
+ if (el && el.closest) {
173
+ const wrap = el.closest('label');
174
+ if (wrap) return labelHasAccessibleContent(wrap);
175
+ }
176
+ } catch {}
177
+
178
+ // 3) <label for="id">
179
+ try {
180
+ const idAttr = el && el.getAttribute ? trim(el.getAttribute('id')) : '';
181
+ if (!idAttr) return false;
182
+ const lab = labelForMap.get(idAttr);
183
+ return !!lab && labelHasAccessibleContent(lab);
184
+ } catch {
185
+ return false;
186
+ }
187
+ }
188
+
189
+ function computeLabelMethodFallback(el) {
190
+ // Deterministic priority order
191
+ if (hasLabelAssociation(el)) return { method: 'label', value: '' };
192
+
193
+ // Only resolve aria-labelledby if attribute exists
194
+ let raw = '';
195
+ try {
196
+ raw = el && el.getAttribute ? trim(el.getAttribute('aria-labelledby')) : '';
197
+ } catch {
198
+ raw = '';
199
+ }
200
+ if (raw && getAriaLabelledByInfo) {
201
+ try {
202
+ const info = getAriaLabelledByInfo(el, ctx, { maxRefs: 8 });
203
+ const v = info && info.present ? trim(info.value) : '';
204
+ if (v) return { method: 'aria-labelledby', value: v };
205
+ } catch {}
206
+ }
207
+
208
+ // Only resolve aria-label if attribute exists
209
+ raw = '';
210
+ try {
211
+ raw = el && el.getAttribute ? trim(el.getAttribute('aria-label')) : '';
212
+ } catch {
213
+ raw = '';
214
+ }
215
+ if (raw && getAriaLabelInfo) {
216
+ try {
217
+ const info = getAriaLabelInfo(el, ctx);
218
+ const v = info && info.present ? trim(info.value) : '';
219
+ if (v) return { method: 'aria-label', value: v };
220
+ } catch {}
221
+ }
222
+
223
+ // title / placeholder
224
+ const titleV = getNonEmptyAttrViaHelper(el, 'title') || (() => {
225
+ try { return trim(el.getAttribute('title')); } catch { return ''; }
226
+ })();
227
+ if (titleV) return { method: 'title', value: titleV };
228
+
229
+ // `placeholder` is only a valid name/hint source for text-entry input
230
+ // types and <textarea> — not checkbox/radio/range/color/date/... or
231
+ // <select>, which browsers/AT never read a placeholder from.
232
+ const isPlaceholderCapable = (() => {
233
+ try {
234
+ const tag = (el.tagName || '').toLowerCase();
235
+ if (tag === 'textarea') return true;
236
+ if (tag !== 'input') return false;
237
+ const type = trim(el.getAttribute('type') || 'text').toLowerCase() || 'text';
238
+ return ['text', 'search', 'tel', 'url', 'email', 'password', 'number'].includes(type);
239
+ } catch {
240
+ return false;
241
+ }
242
+ })();
243
+
244
+ const phV = isPlaceholderCapable && (getNonEmptyAttrViaHelper(el, 'placeholder') || (() => {
245
+ try { return trim(el.getAttribute('placeholder')); } catch { return ''; }
246
+ })());
247
+ if (phV) return { method: 'placeholder', value: phV };
248
+
249
+ return { method: 'none', value: '' };
250
+ }
251
+
252
+ function getLabelMethodSafe(el) {
253
+ if (getLabelMethod) {
254
+ try {
255
+ const r = getLabelMethod(el, ctx);
256
+ const m = r && typeof r.method === 'string' ? r.method : 'none';
257
+ const v = r && r.value != null ? trim(r.value) : '';
258
+ if (!Object.prototype.hasOwnProperty.call(metrics.byMethod, m)) return { method: 'none', value: '' };
259
+ return { method: m, value: v };
260
+ } catch {
261
+ // fall through
262
+ }
263
+ }
264
+ return computeLabelMethodFallback(el);
265
+ }
266
+
267
+ // Collect candidate nodes via the shared queryAllSmart/queryAll helpers
268
+ // (multi-root/shadow-DOM-aware, cached) rather than raw safeRoot DOM
269
+ // access -- safeRoot is ctx.root, which is an array with multi-region
270
+ // contextSelector support and never had a .querySelectorAll/
271
+ // .getElementsByTagName of its own to call directly.
272
+ const nodes = [];
273
+ try {
274
+ const sel = 'input,select,textarea';
275
+ const candidates = (helpers && typeof helpers.queryAllSmart === 'function')
276
+ ? helpers.queryAllSmart(sel)
277
+ : (helpers && typeof helpers.queryAll === 'function' ? helpers.queryAll(sel) : []);
278
+
279
+ for (const el of (candidates || [])) {
280
+ if (!el || !el.getAttribute) continue;
281
+ const tag = (el.tagName || '').toLowerCase();
282
+ if (tag === 'input') {
283
+ const t = trim(el.getAttribute('type')).toLowerCase();
284
+ // exclude hidden|submit|reset|button|image
285
+ if (t === 'hidden' || t === 'submit' || t === 'reset' || t === 'button' || t === 'image') continue;
286
+ }
287
+ nodes.push(el);
288
+ }
289
+ } catch {
290
+ // no-throw
291
+ }
292
+
293
+ if (!nodes.length) {
294
+ return {
295
+ ruleId: rule.ruleId,
296
+ outcome: 'notApplicable',
297
+ severity: 'minor',
298
+ occurrences: [],
299
+ data: { details: { metrics } }
300
+ };
301
+ }
302
+
303
+ const occurrences = [];
304
+
305
+ for (let i = 0; i < nodes.length; i++) {
306
+ const el = nodes[i];
307
+ if (!el || !el.getAttribute) continue;
308
+
309
+ if (!isEligibleAcc(el)) continue;
310
+
311
+ // role="presentation"/"none" exclusion only when NOT focusable
312
+ let role = '';
313
+ try {
314
+ role = trim(el.getAttribute('role')).toLowerCase();
315
+ } catch {
316
+ role = '';
317
+ }
318
+
319
+ if (role === 'presentation' || role === 'none') {
320
+ const fi = getFocusableInfo
321
+ ? (() => {
322
+ try {
323
+ return getFocusableInfo(el, ctx);
324
+ } catch {
325
+ return null;
326
+ }
327
+ })()
328
+ : null;
329
+ const tabbable = !!(fi && fi.tabbable);
330
+ if (!tabbable) continue;
331
+ }
332
+
333
+ metrics.applicableCount += 1;
334
+
335
+ const label = getLabelMethodSafe(el);
336
+ if (Object.prototype.hasOwnProperty.call(metrics.byMethod, label.method)) {
337
+ metrics.byMethod[label.method] += 1;
338
+ } else {
339
+ metrics.byMethod.none += 1;
340
+ }
341
+
342
+ const strength = getLabelStrength(label.method);
343
+ const ok = label.method !== 'none';
344
+
345
+ if (ok) {
346
+ metrics.passCount += 1;
347
+ if (strength === 'weak') metrics.weakPassCount += 1;
348
+ continue;
349
+ }
350
+
351
+ metrics.failCount += 1;
352
+
353
+ const vf = getEligibilityInfo ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })() : null;
354
+
355
+ const baseOccurrence = {
356
+ summary: 'Form control is missing a programmatic label.',
357
+ hint: 'Provide a <label> association, aria-label, aria-labelledby, title, or placeholder.',
358
+ i18n: {
359
+ summaryKey: 'formControl_programmaticLabelPresent_summary_fail',
360
+ hintKey: 'formControl_programmaticLabelPresent_hint_fail',
361
+ params: { element: (el.tagName || '').toLowerCase() }
362
+ },
363
+ data: {
364
+ visibilityFilter: vf
365
+ ? { targetSet: vf.targetSet, accEligible: vf.accEligible, reasons: vf.reasons }
366
+ : { targetSet: 'acc', accEligible: null, reasons: [] },
367
+ details: {
368
+ reasonCode: 'missing_programmatic_label',
369
+ labelMethod: 'none',
370
+ labelStrength: 'none'
371
+ }
372
+ }
373
+ };
374
+
375
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
376
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
377
+ } else {
378
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
379
+ }
380
+ }
381
+
382
+ if (metrics.applicableCount === 0) {
383
+ return {
384
+ ruleId: rule.ruleId,
385
+ outcome: 'notApplicable',
386
+ severity: 'minor',
387
+ occurrences: [],
388
+ data: { details: { metrics } }
389
+ };
390
+ }
391
+ if (occurrences.length) {
392
+ return {
393
+ ruleId: rule.ruleId,
394
+ outcome: 'fail',
395
+ severity: rule.defaultSeverity || 'minor',
396
+ occurrences,
397
+ data: { details: { metrics } }
398
+ };
399
+ }
400
+ return {
401
+ ruleId: rule.ruleId,
402
+ outcome: 'pass',
403
+ severity: 'minor',
404
+ occurrences: [],
405
+ data: { details: { metrics } }
406
+ };
407
+ }
408
+
409
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check form-control-single-label
5
+ * @atomic true
6
+ * @summary A form control must not be associated with more than one <label>
7
+ * @standard WCAG 2.2
8
+ * @sc 3.3.2
9
+ * @applicability
10
+ * Applies to labelable form controls (input, excluding
11
+ * hidden/submit/reset/button/image; select; textarea).
12
+ * @expectation
13
+ * At most one <label> is associated with the control — either by
14
+ * wrapping it, or by a <label for="..."> pointing to its id
15
+ * (deduplicated: a label that both wraps the control and
16
+ * self-references it via for counts once). Multiple associated labels
17
+ * are ambiguous: many screen readers only announce one of them, and it
18
+ * is not deterministic which.
19
+ * @implementation-notes
20
+ * - Matches a widely-used reference engine's form-field-multiple-labels. Distinct, atomic
21
+ * decision from form-control-programmatic-label-present (that
22
+ * rule checks a label exists at all; this one checks there is at most
23
+ * one).
24
+ */
25
+
26
+ const id = 'form-control-single-label';
27
+
28
+ const meta = {
29
+ title: 'Form controls must not have multiple labels',
30
+ description: 'Checks that a form control is associated with at most one <label> (by wrapping or by label[for]).',
31
+ i18n: {
32
+ titleKey: 'formControlSingleLabel_title',
33
+ descriptionKey: 'formControlSingleLabel_description'
34
+ },
35
+ helpUrl: null,
36
+ tags: ['wcag2a', 'wcag332', 'forms', 'atomic', 'automatic'],
37
+ wcagSc: ['3.3.2'],
38
+ normativeMappings: [
39
+ { standard: 'WCAG', version: '2.2', requirement: '3.3.2', title: 'Labels or Instructions', conformanceLevel: 'A' }
40
+ ],
41
+ defaultSeverity: 'moderate',
42
+ category: 'understandable',
43
+ type: 'automatic',
44
+ defaultConfidence: 'high',
45
+ coverage: { facetsBySc: { '3.3.2': ['form-control-single-label'] } }
46
+ };
47
+
48
+ function runInPage(ctx) {
49
+ const { document, root, helpers, rule } = ctx;
50
+ const safeRoot = root || document;
51
+
52
+ const selector = 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
53
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
54
+
55
+ // Build a for-value -> label[] map once (avoids per-control dynamic
56
+ // attribute-selector construction / CSS.escape, which is not guaranteed
57
+ // to exist as a global in every runtime this engine executes in).
58
+ const labelsByFor = new Map();
59
+ const allLabels = document.getElementsByTagName ? document.getElementsByTagName('label') : [];
60
+ for (const lab of allLabels) {
61
+ if (!lab || !lab.getAttribute) continue;
62
+ const forValue = String(lab.getAttribute('for') || '').trim();
63
+ if (!forValue) continue;
64
+ if (!labelsByFor.has(forValue)) labelsByFor.set(forValue, []);
65
+ labelsByFor.get(forValue).push(lab);
66
+ }
67
+
68
+ const occurrences = [];
69
+ let applicableCount = 0;
70
+
71
+ for (const el of nodes) {
72
+ if (!el || !el.getAttribute) continue;
73
+
74
+ applicableCount += 1;
75
+
76
+ const labels = new Set();
77
+
78
+ const wrappingLabel = el.closest ? el.closest('label') : null;
79
+ if (wrappingLabel) labels.add(wrappingLabel);
80
+
81
+ const controlId = String(el.getAttribute('id') || '').trim();
82
+ if (controlId && labelsByFor.has(controlId)) {
83
+ for (const lab of labelsByFor.get(controlId)) labels.add(lab);
84
+ }
85
+
86
+ if (labels.size <= 1) continue;
87
+
88
+ const tag = el.tagName.toLowerCase();
89
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
90
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
91
+
92
+ occurrences.push({
93
+ selector: stableSelector,
94
+ html,
95
+ summary: 'This form control is associated with more than one <label>.',
96
+ hint: 'Keep only one <label> per form control (either wrapping it or referencing it via for/id).',
97
+ i18n: {
98
+ summaryKey: 'formControlSingleLabel_summary_fail',
99
+ hintKey: 'formControlSingleLabel_hint_fail',
100
+ params: { element: tag, labelCount: String(labels.size) }
101
+ },
102
+ data: {
103
+ details: { reasonCode: 'FORM_FIELD_MULTIPLE_LABELS', element: tag, labelCount: labels.size }
104
+ }
105
+ });
106
+ }
107
+
108
+ if (applicableCount === 0) {
109
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
110
+ }
111
+ if (occurrences.length) {
112
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
113
+ }
114
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
115
+ }
116
+
117
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check html-xml-lang-mismatch
5
+ * @atomic true
6
+ * @summary The lang and xml:lang attributes on <html> must not disagree
7
+ * @standard WCAG 2.2
8
+ * @sc 3.1.1
9
+ * @applicability
10
+ * Applies when the <html> element has both a non-empty lang attribute
11
+ * and a non-empty xml:lang attribute.
12
+ * @expectation
13
+ * The primary language subtag (the part before the first "-") of lang
14
+ * and xml:lang match, case-insensitively. When both attributes are
15
+ * present but declare different languages, assistive technology and
16
+ * user agents may resolve the page's language inconsistently.
17
+ * @implementation-notes
18
+ * - Distinct, atomic decision from html-lang-attr-present (that
19
+ * rule checks presence/syntax of lang alone; this rule checks
20
+ * agreement between lang and xml:lang when both exist).
21
+ * - Compares primary subtags only (not full tag equality), since e.g.
22
+ * lang="en-US" and xml:lang="en-GB" both correctly declare English and
23
+ * should not be flagged as disagreeing on language.
24
+ */
25
+
26
+ const id = 'html-xml-lang-mismatch';
27
+
28
+ const meta = {
29
+ title: 'lang and xml:lang must not disagree',
30
+ description: 'Checks that the <html> element\'s lang and xml:lang attributes declare the same primary language, when both are present.',
31
+ i18n: {
32
+ titleKey: 'htmlXmlLangMismatch_title',
33
+ descriptionKey: 'htmlXmlLangMismatch_description'
34
+ },
35
+ helpUrl: null,
36
+ tags: ['wcag2a', 'wcag311', 'structure', 'language', 'atomic', 'automatic'],
37
+ wcagSc: ['3.1.1'],
38
+ normativeMappings: [
39
+ { standard: 'WCAG', version: '2.2', requirement: '3.1.1', title: 'Language of Page', conformanceLevel: 'A' }
40
+ ],
41
+ defaultSeverity: 'serious',
42
+ category: 'understandable',
43
+ type: 'automatic',
44
+ defaultConfidence: 'high',
45
+ coverage: { facetsBySc: { '3.1.1': ['html-xml-lang-mismatch'] } }
46
+ };
47
+
48
+ function runInPage(ctx) {
49
+ const { document, helpers, rule } = ctx;
50
+
51
+ const html = document && document.documentElement;
52
+ const tag = (html && html.tagName) ? String(html.tagName).toLowerCase() : '';
53
+ if (!html || tag !== 'html') {
54
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
55
+ }
56
+
57
+ const lang = String(html.getAttribute('lang') || '').trim();
58
+ const xmlLang = String(html.getAttribute('xml:lang') || '').trim();
59
+
60
+ if (!lang || !xmlLang) {
61
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
62
+ }
63
+
64
+ const primary = (s) => s.split('-')[0].toLowerCase();
65
+
66
+ if (primary(lang) === primary(xmlLang)) {
67
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
68
+ }
69
+
70
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(html) : 'html';
71
+ const htmlSnippet = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(html) : (html.outerHTML || '').slice(0, 200);
72
+
73
+ const occurrences = [{
74
+ selector: stableSelector,
75
+ html: htmlSnippet,
76
+ summary: `The lang ("${lang}") and xml:lang ("${xmlLang}") attributes declare different languages.`,
77
+ hint: 'Make lang and xml:lang declare the same primary language, or remove the deprecated xml:lang attribute.',
78
+ i18n: {
79
+ summaryKey: 'htmlXmlLangMismatch_summary_fail',
80
+ hintKey: 'htmlXmlLangMismatch_hint_fail',
81
+ params: { lang, xmlLang }
82
+ },
83
+ data: {
84
+ details: { reasonCode: 'HTML_XML_LANG_MISMATCH', lang, xmlLang }
85
+ }
86
+ }];
87
+
88
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
89
+ }
90
+
91
+ module.exports = { id, meta, runInPage };