@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
  /**
@@ -18,9 +20,8 @@
18
20
  * which element the reference resolves to (typically the first, silently
19
21
  * dropping the others).
20
22
  * @implementation-notes
21
- * - Scoped deliberately to ids referenced by ARIA (matching a widely-used
22
- * reference engine's duplicate-id-aria, not the broader/deprecated page-wide duplicate-id
23
- * check — see ROADMAP.md's "Skip" list).
23
+ * - Scoped deliberately to ids referenced by ARIA, not the broader/
24
+ * deprecated page-wide duplicate-id check (see ROADMAP.md's "Skip" list).
24
25
  * - Document-wide by design: id uniqueness and ARIA id references are a
25
26
  * whole-document property, not scoped to a sub-root.
26
27
  */
@@ -29,7 +30,8 @@ const id = 'duplicate-id-aria';
29
30
 
30
31
  const meta = {
31
32
  title: 'IDs referenced by ARIA must be unique',
32
- description: 'Checks that any id value referenced by an ARIA ID-reference attribute (aria-labelledby, aria-describedby, aria-owns, aria-controls, aria-activedescendant, aria-flowto, aria-errormessage, aria-details) is unique in the document.',
33
+ description:
34
+ 'Checks that any id value referenced by an ARIA ID-reference attribute (aria-labelledby, aria-describedby, aria-owns, aria-controls, aria-activedescendant, aria-flowto, aria-errormessage, aria-details) is unique in the document.',
33
35
  i18n: {
34
36
  titleKey: 'duplicateIdAria_title',
35
37
  descriptionKey: 'duplicateIdAria_description'
@@ -38,7 +40,13 @@ const meta = {
38
40
  tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
39
41
  wcagSc: ['4.1.2'],
40
42
  normativeMappings: [
41
- { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
43
+ {
44
+ standard: 'WCAG',
45
+ version: '2.2',
46
+ requirement: '4.1.2',
47
+ title: 'Name, Role, Value',
48
+ conformanceLevel: 'A'
49
+ }
42
50
  ],
43
51
  defaultSeverity: 'serious',
44
52
  category: 'robust',
@@ -95,7 +103,9 @@ function runInPage(ctx) {
95
103
 
96
104
  for (const el of els) {
97
105
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
98
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
106
+ const html = helpers.getOuterHtmlSnippet
107
+ ? helpers.getOuterHtmlSnippet(el)
108
+ : el.outerHTML || '';
99
109
 
100
110
  occurrences.push({
101
111
  selector: stableSelector,
@@ -108,7 +118,11 @@ function runInPage(ctx) {
108
118
  params: { id: refId, duplicateCount: String(els.length) }
109
119
  },
110
120
  data: {
111
- details: { reasonCode: 'DUPLICATE_ID_ARIA_REFERENCED', id: refId, duplicateCount: els.length }
121
+ details: {
122
+ reasonCode: 'DUPLICATE_ID_ARIA_REFERENCED',
123
+ id: refId,
124
+ duplicateCount: els.length
125
+ }
112
126
  }
113
127
  });
114
128
  }
@@ -118,9 +132,14 @@ function runInPage(ctx) {
118
132
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
119
133
  }
120
134
  if (occurrences.length) {
121
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
135
+ return {
136
+ ruleId: rule.ruleId,
137
+ outcome: 'fail',
138
+ severity: rule.defaultSeverity || 'serious',
139
+ occurrences
140
+ };
122
141
  }
123
142
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
124
143
  }
125
144
 
126
- module.exports = { id, meta, runInPage };
145
+ 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
  /**
@@ -32,7 +34,13 @@ const meta = {
32
34
  tags: ['wcag2a', 'wcag111', 'nontext', 'embed', 'atomic', 'automatic'],
33
35
  wcagSc: ['1.1.1'],
34
36
  normativeMappings: [
35
- { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
37
+ {
38
+ standard: 'WCAG',
39
+ version: '2.2',
40
+ requirement: '1.1.1',
41
+ title: 'Non-text Content',
42
+ conformanceLevel: 'A'
43
+ }
36
44
  ],
37
45
  defaultSeverity: 'serious',
38
46
  category: 'perceivable',
@@ -45,33 +53,39 @@ function runInPage(ctx) {
45
53
  const { document, root, helpers, rule } = ctx;
46
54
  const safeRoot = root || document;
47
55
 
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 []; }
54
- };
55
-
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
- const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function'
69
- ? helpers.getAriaNameInfo
70
- : null;
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
+ }
69
+ };
70
+
71
+ const getEligibilityInfo =
72
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
73
+
74
+ const isAccTreeEligible =
75
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
76
+
77
+ const getFocusableInfo =
78
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
79
+
80
+ const getAriaNameInfo =
81
+ helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
71
82
 
72
83
  const embeds = (() => {
73
- try { return Array.from((queryAllSmart ? queryAllSmart('embed') : queryAll('embed')) || []); }
74
- catch { return queryAll('embed'); }
84
+ try {
85
+ return Array.from((queryAllSmart ? queryAllSmart('embed') : queryAll('embed')) || []);
86
+ } catch {
87
+ return queryAll('embed');
88
+ }
75
89
  })();
76
90
 
77
91
  if (!embeds.length) {
@@ -89,11 +103,22 @@ function runInPage(ctx) {
89
103
  let aria = null;
90
104
 
91
105
  if (getAriaNameInfo) {
92
- aria = (() => { try { return getAriaNameInfo(el, ctx); } catch { return null; } })();
106
+ aria = (() => {
107
+ try {
108
+ return getAriaNameInfo(el, ctx);
109
+ } catch {
110
+ return null;
111
+ }
112
+ })();
93
113
  }
94
114
 
95
115
  if (aria && aria.present && trim(aria.value)) {
96
- return { present: true, value: trim(aria.value), mechanism: aria.mechanism || 'aria', flags: (aria.flags || []).slice(0) };
116
+ return {
117
+ present: true,
118
+ value: trim(aria.value),
119
+ mechanism: aria.mechanism || 'aria',
120
+ flags: (aria.flags || []).slice(0)
121
+ };
97
122
  }
98
123
 
99
124
  const title = trim(el.getAttribute && el.getAttribute('title'));
@@ -120,20 +145,43 @@ function runInPage(ctx) {
120
145
  if (!el || !el.getAttribute) continue;
121
146
 
122
147
  if (isAccTreeEligible) {
123
- const elig = (() => { try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; } })();
148
+ const elig = (() => {
149
+ try {
150
+ return isAccTreeEligible(el, ctx);
151
+ } catch {
152
+ return { eligible: true, reasons: [] };
153
+ }
154
+ })();
124
155
  if (elig && elig.eligible === false) continue;
125
156
  }
126
157
 
127
158
  // role presentation/none exclusion only when not focusable
128
- const role = (() => { try { return String(el.getAttribute('role') || '').trim().toLowerCase(); } catch { return ''; } })();
159
+ const role = (() => {
160
+ try {
161
+ return String(el.getAttribute('role') || '')
162
+ .trim()
163
+ .toLowerCase();
164
+ } catch {
165
+ return '';
166
+ }
167
+ })();
129
168
  if (role === 'presentation' || role === 'none') {
130
- let focusable = false;
169
+ let focusable;
131
170
  if (getFocusableInfo) {
132
- const fi = (() => { try { return getFocusableInfo(el, ctx); } catch { return null; } })();
171
+ const fi = (() => {
172
+ try {
173
+ return getFocusableInfo(el, ctx);
174
+ } catch {
175
+ return null;
176
+ }
177
+ })();
133
178
  focusable = !!(fi && fi.focusable);
134
179
  } else {
135
180
  const tabindex = el.getAttribute('tabindex');
136
- focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
181
+ focusable =
182
+ tabindex != null &&
183
+ String(tabindex).trim() !== '' &&
184
+ !Number.isNaN(Number(String(tabindex).trim()));
137
185
  }
138
186
  if (!focusable) continue;
139
187
  }
@@ -150,7 +198,7 @@ function runInPage(ctx) {
150
198
 
151
199
  const baseOccurrence = {
152
200
  summary: 'Missing text alternative for <embed>.',
153
- hint: 'Add an accessible name to <embed> (aria-label/aria-labelledby).',
201
+ hint: 'Add an accessible name to <embed> (aria-label/aria-labelledby preferred, or a title attribute as a best-effort fallback).',
154
202
  i18n: {
155
203
  summaryKey: 'embed_textAltPresent_summary_fail',
156
204
  hintKey: 'embed_textAltPresent_hint_fail',
@@ -184,7 +232,12 @@ function runInPage(ctx) {
184
232
  };
185
233
  }
186
234
 
187
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
235
+ return {
236
+ ruleId: rule.ruleId,
237
+ outcome: 'fail',
238
+ severity: rule.defaultSeverity || 'minor',
239
+ occurrences
240
+ };
188
241
  }
189
242
 
190
243
  module.exports = { id, meta, runInPage };
@@ -1,20 +1,15 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
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.
6
+ * Control discovery goes through helpers.queryAllSmart/queryAll (multi-root
7
+ * and shadow-DOM aware), not a direct root/safeRoot DOM query -- ctx.root
8
+ * is an array (multi-region contextSelector support), not a single element.
9
+ * Label-method resolution (label/aria-labelledby/aria-label/title/
10
+ * placeholder priority) delegates entirely to the shared dom-helpers
11
+ * bundle's getLabelMethod, which dom-runner.js always provides to every
12
+ * rule execution -- see getLabelMethodSafe below.
18
13
  *
19
14
  * WCAG mapping: matches technique H44 ("Using label elements to associate
20
15
  * text labels with form controls"), which WCAG's own Techniques document
@@ -40,9 +35,27 @@ const meta = {
40
35
  tags: ['wcag2a', 'wcag131', 'wcag332', 'wcag412', 'forms', 'labels', 'atomic', 'automatic'],
41
36
  wcagSc: ['1.3.1', '3.3.2', '4.1.2'],
42
37
  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' }
38
+ {
39
+ standard: 'WCAG',
40
+ version: '2.2',
41
+ requirement: '1.3.1',
42
+ title: 'Info and Relationships',
43
+ conformanceLevel: 'A'
44
+ },
45
+ {
46
+ standard: 'WCAG',
47
+ version: '2.2',
48
+ requirement: '3.3.2',
49
+ title: 'Labels or Instructions',
50
+ conformanceLevel: 'A'
51
+ },
52
+ {
53
+ standard: 'WCAG',
54
+ version: '2.2',
55
+ requirement: '4.1.2',
56
+ title: 'Name, Role, Value',
57
+ conformanceLevel: 'A'
58
+ }
46
59
  ],
47
60
  defaultSeverity: 'serious',
48
61
  category: 'robust',
@@ -58,48 +71,17 @@ const meta = {
58
71
  };
59
72
 
60
73
  function runInPage(ctx) {
61
- const { document, helpers, rule } = ctx;
74
+ const { helpers, rule } = ctx;
62
75
 
63
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
64
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
76
+ const isAccTreeEligible =
77
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
78
+ const getEligibilityInfo =
79
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
65
80
 
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
- }
81
+ const getFocusableInfo =
82
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
83
+ const getLabelMethod =
84
+ helpers && typeof helpers.getLabelMethod === 'function' ? helpers.getLabelMethod : null;
103
85
 
104
86
  const trim = (v) => (v == null ? '' : String(v)).trim();
105
87
 
@@ -107,7 +89,14 @@ function runInPage(ctx) {
107
89
  applicableCount: 0,
108
90
  passCount: 0,
109
91
  failCount: 0,
110
- byMethod: { label: 0, 'aria-labelledby': 0, 'aria-label': 0, title: 0, placeholder: 0, none: 0 },
92
+ byMethod: {
93
+ label: 0,
94
+ 'aria-labelledby': 0,
95
+ 'aria-label': 0,
96
+ title: 0,
97
+ placeholder: 0,
98
+ none: 0
99
+ },
111
100
  weakPassCount: 0
112
101
  };
113
102
 
@@ -118,35 +107,6 @@ function runInPage(ctx) {
118
107
  return 'none';
119
108
  }
120
109
 
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
110
  function isEligibleAcc(el) {
151
111
  if (!isAccTreeEligible) return true;
152
112
  try {
@@ -158,110 +118,23 @@ function runInPage(ctx) {
158
118
  }
159
119
  }
160
120
 
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 = '';
121
+ // getLabelMethod is provided by the shared dom-helpers bundle that
122
+ // dom-runner.js always constructs for every rule execution (built-in or
123
+ // custom) — see createDomHelpers's own getLabelMethod, which implements
124
+ // this exact <label>/aria-labelledby/aria-label/title/placeholder
125
+ // priority order. No local reimplementation is needed as a fallback.
126
+ function getLabelMethodSafe(el) {
127
+ if (!getLabelMethod) return { method: 'none', value: '' };
210
128
  try {
211
- raw = el && el.getAttribute ? trim(el.getAttribute('aria-label')) : '';
129
+ const r = getLabelMethod(el, ctx);
130
+ const m = r && typeof r.method === 'string' ? r.method : 'none';
131
+ const v = r && r.value != null ? trim(r.value) : '';
132
+ if (!Object.prototype.hasOwnProperty.call(metrics.byMethod, m))
133
+ return { method: 'none', value: '' };
134
+ return { method: m, value: v };
212
135
  } 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 {}
136
+ return { method: 'none', value: '' };
221
137
  }
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
138
  }
266
139
 
267
140
  // Collect candidate nodes via the shared queryAllSmart/queryAll helpers
@@ -272,17 +145,21 @@ function runInPage(ctx) {
272
145
  const nodes = [];
273
146
  try {
274
147
  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 || [])) {
148
+ const candidates =
149
+ helpers && typeof helpers.queryAllSmart === 'function'
150
+ ? helpers.queryAllSmart(sel)
151
+ : helpers && typeof helpers.queryAll === 'function'
152
+ ? helpers.queryAll(sel)
153
+ : [];
154
+
155
+ for (const el of candidates || []) {
280
156
  if (!el || !el.getAttribute) continue;
281
157
  const tag = (el.tagName || '').toLowerCase();
282
158
  if (tag === 'input') {
283
159
  const t = trim(el.getAttribute('type')).toLowerCase();
284
160
  // exclude hidden|submit|reset|button|image
285
- if (t === 'hidden' || t === 'submit' || t === 'reset' || t === 'button' || t === 'image') continue;
161
+ if (t === 'hidden' || t === 'submit' || t === 'reset' || t === 'button' || t === 'image')
162
+ continue;
286
163
  }
287
164
  nodes.push(el);
288
165
  }
@@ -309,7 +186,7 @@ function runInPage(ctx) {
309
186
  if (!isEligibleAcc(el)) continue;
310
187
 
311
188
  // role="presentation"/"none" exclusion only when NOT focusable
312
- let role = '';
189
+ let role;
313
190
  try {
314
191
  role = trim(el.getAttribute('role')).toLowerCase();
315
192
  } catch {
@@ -350,7 +227,15 @@ function runInPage(ctx) {
350
227
 
351
228
  metrics.failCount += 1;
352
229
 
353
- const vf = getEligibilityInfo ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })() : null;
230
+ const vf = getEligibilityInfo
231
+ ? (() => {
232
+ try {
233
+ return getEligibilityInfo(el, ctx, { targetSet: 'acc' });
234
+ } catch {
235
+ return null;
236
+ }
237
+ })()
238
+ : null;
354
239
 
355
240
  const baseOccurrence = {
356
241
  summary: 'Form control is missing a programmatic label.',