@surea11y/core 1.2.0 → 1.3.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 (157) hide show
  1. package/CHANGELOG.md +37 -7
  2. package/LICENSE +373 -21
  3. package/README.md +67 -1
  4. package/bin/core.js +144 -19
  5. package/docs/API_STABILITY.md +1 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/CLI.md +54 -1
  9. package/docs/ENGINE_OPTIONS.md +2 -0
  10. package/docs/INTEGRATION.md +18 -0
  11. package/docs/OUTPUT_SCHEMA.md +1 -1
  12. package/docs/RULE_CATALOG.md +1 -1
  13. package/docs/SARIF.md +59 -0
  14. package/package.json +15 -4
  15. package/src/baseline.js +0 -0
  16. package/src/catalogs/composites.wcag.js +414 -450
  17. package/src/checks/automatic/area-alt-present.js +59 -25
  18. package/src/checks/automatic/aria-allowed-attr.js +193 -33
  19. package/src/checks/automatic/aria-allowed-role.js +21 -7
  20. package/src/checks/automatic/aria-braille-equivalent.js +32 -10
  21. package/src/checks/automatic/aria-conditional-attr.js +24 -7
  22. package/src/checks/automatic/aria-deprecated-role.js +22 -8
  23. package/src/checks/automatic/aria-hidden-body.js +42 -19
  24. package/src/checks/automatic/aria-hidden-focus.js +408 -53
  25. package/src/checks/automatic/aria-prohibited-attr.js +296 -22
  26. package/src/checks/automatic/aria-prohibited-children.js +55 -16
  27. package/src/checks/automatic/aria-required-attr.js +23 -8
  28. package/src/checks/automatic/aria-required-children.js +37 -14
  29. package/src/checks/automatic/aria-required-parent.js +48 -14
  30. package/src/checks/automatic/aria-role-name-present.js +47 -21
  31. package/src/checks/automatic/aria-roles-valid.js +22 -12
  32. package/src/checks/automatic/aria-valid-attr-value.js +28 -7
  33. package/src/checks/automatic/aria-valid-attr.js +17 -5
  34. package/src/checks/automatic/autocomplete-valid.js +74 -16
  35. package/src/checks/automatic/avoid-inline-spacing.js +20 -6
  36. package/src/checks/automatic/binary-control-name-present.js +60 -50
  37. package/src/checks/automatic/button-name-present.js +48 -18
  38. package/src/checks/automatic/bypass-blocks-present.js +42 -25
  39. package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
  40. package/src/checks/automatic/combobox-name-present.js +38 -45
  41. package/src/checks/automatic/contrast-computable.js +361 -341
  42. package/src/checks/automatic/contrast-enhanced.js +487 -466
  43. package/src/checks/automatic/contrast-minimum.js +486 -465
  44. package/src/checks/automatic/css-orientation-lock.js +30 -8
  45. package/src/checks/automatic/definition-list-children-valid.js +40 -19
  46. package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
  47. package/src/checks/automatic/dialog-name-present.js +37 -75
  48. package/src/checks/automatic/dlitem-parent-valid.js +23 -8
  49. package/src/checks/automatic/duplicate-id-aria.js +24 -6
  50. package/src/checks/automatic/embed-text-alternative-present.js +86 -35
  51. package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
  52. package/src/checks/automatic/form-control-single-label.js +47 -10
  53. package/src/checks/automatic/html-xml-lang-mismatch.js +34 -18
  54. package/src/checks/automatic/iframe-focusable-content.js +26 -11
  55. package/src/checks/automatic/iframe-name-present.js +31 -9
  56. package/src/checks/automatic/iframe-title-unique.js +29 -8
  57. package/src/checks/automatic/img-alt-present.js +47 -43
  58. package/src/checks/automatic/input-image-alt-present.js +143 -112
  59. package/src/checks/automatic/label-in-name.js +50 -22
  60. package/src/checks/automatic/language-page-present.js +109 -109
  61. package/src/checks/automatic/link-in-text-block.js +59 -19
  62. package/src/checks/automatic/link-name-present.js +45 -14
  63. package/src/checks/automatic/list-children-valid.js +26 -9
  64. package/src/checks/automatic/listbox-name-present.js +39 -19
  65. package/src/checks/automatic/listitem-parent-valid.js +18 -6
  66. package/src/checks/automatic/menuitem-name-present.js +39 -61
  67. package/src/checks/automatic/meta-refresh-no-exceptions.js +28 -7
  68. package/src/checks/automatic/meta-refresh-timing-absent.js +20 -6
  69. package/src/checks/automatic/meta-viewport-zoom-enabled.js +24 -7
  70. package/src/checks/automatic/meter-name-present.js +36 -33
  71. package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
  72. package/src/checks/automatic/object-text-alternative-present.js +91 -39
  73. package/src/checks/automatic/option-name-present.js +38 -21
  74. package/src/checks/automatic/page-title-present.js +17 -6
  75. package/src/checks/automatic/progressbar-name-present.js +41 -34
  76. package/src/checks/automatic/role-img-alt-present.js +209 -157
  77. package/src/checks/automatic/searchbox-name-present.js +39 -19
  78. package/src/checks/automatic/server-side-image-map-absent.js +23 -8
  79. package/src/checks/automatic/slider-name-present.js +40 -47
  80. package/src/checks/automatic/spinbutton-name-present.js +39 -19
  81. package/src/checks/automatic/summary-name-present.js +37 -17
  82. package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
  83. package/src/checks/automatic/svg-text-alternative-present.js +246 -226
  84. package/src/checks/automatic/tab-name-present.js +37 -60
  85. package/src/checks/automatic/table-headers-attr-valid.js +24 -8
  86. package/src/checks/automatic/table-th-has-data-cells.js +22 -8
  87. package/src/checks/automatic/target-size-minimum.js +118 -48
  88. package/src/checks/automatic/td-has-header.js +29 -11
  89. package/src/checks/automatic/textbox-name-present.js +39 -19
  90. package/src/checks/automatic/tooltip-name-present.js +37 -18
  91. package/src/checks/automatic/treeitem-name-present.js +38 -21
  92. package/src/checks/automatic/valid-lang.js +20 -6
  93. package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
  94. package/src/checks/manual/accesskeys-manual.js +14 -5
  95. package/src/checks/manual/area-alt-decorative-manual.js +192 -193
  96. package/src/checks/manual/area-alt-quality-manual.js +182 -141
  97. package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
  98. package/src/checks/manual/aria-text-manual.js +14 -6
  99. package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
  100. package/src/checks/manual/css-hidden-focus.js +196 -165
  101. package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
  102. package/src/checks/manual/empty-heading-manual.js +24 -7
  103. package/src/checks/manual/empty-table-header-manual.js +17 -6
  104. package/src/checks/manual/focus-order-semantics-manual.js +45 -10
  105. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
  106. package/src/checks/manual/heading-order-manual.js +22 -7
  107. package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
  108. package/src/checks/manual/image-redundant-alt-manual.js +17 -7
  109. package/src/checks/manual/img-alt-decorative-manual.js +131 -96
  110. package/src/checks/manual/img-alt-quality-manual.js +176 -127
  111. package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
  112. package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
  113. package/src/checks/manual/label-title-only-manual.js +14 -5
  114. package/src/checks/manual/landmark-banner-is-top-level-manual.js +66 -16
  115. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +53 -16
  116. package/src/checks/manual/landmark-main-is-top-level-manual.js +35 -10
  117. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +29 -10
  118. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +29 -10
  119. package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
  120. package/src/checks/manual/landmark-one-main-manual.js +26 -20
  121. package/src/checks/manual/landmark-unique-manual.js +41 -15
  122. package/src/checks/manual/link-name-quality-manual.js +43 -12
  123. package/src/checks/manual/media-transcript-present-manual.js +35 -22
  124. package/src/checks/manual/meta-viewport-large-manual.js +16 -5
  125. package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
  126. package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
  127. package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
  128. package/src/checks/manual/p-as-heading-manual.js +22 -7
  129. package/src/checks/manual/page-has-heading-one-manual.js +31 -22
  130. package/src/checks/manual/page-title-patterns-manual.js +78 -50
  131. package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
  132. package/src/checks/manual/region-manual.js +241 -48
  133. package/src/checks/manual/scope-attr-valid-manual.js +10 -3
  134. package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
  135. package/src/checks/manual/skip-link-manual.js +35 -12
  136. package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
  137. package/src/checks/manual/tabindex-manual.js +10 -3
  138. package/src/checks/manual/table-duplicate-name-manual.js +17 -7
  139. package/src/checks/manual/table-fake-caption-manual.js +24 -7
  140. package/src/checks/manual/video-caption-manual.js +15 -4
  141. package/src/checks/manual-review.js +56 -12
  142. package/src/core/aria-helpers.js +1127 -886
  143. package/src/core/contrast-helpers.js +1217 -1062
  144. package/src/core/dom-helpers.js +4175 -3917
  145. package/src/core/dom-runner.js +720 -604
  146. package/src/core/frame-messaging.js +189 -138
  147. package/src/core/frame-scan.js +94 -82
  148. package/src/core/rollup-composites.js +94 -102
  149. package/src/core/rule-meta.js +58 -41
  150. package/src/core.js +36552 -29111
  151. package/src/i18n/en.js +1194 -889
  152. package/src/i18n/fr.js +1136 -795
  153. package/src/policy/contracts.js +13 -13
  154. package/src/policy/resolvePolicy.js +48 -44
  155. package/src/report.js +63 -43
  156. package/src/sarif.js +175 -0
  157. package/surea11y.browser.js +36042 -0
@@ -32,7 +32,13 @@ const meta = {
32
32
  tags: ['wcag2a', 'wcag111', 'nontext', 'embed', 'atomic', 'automatic'],
33
33
  wcagSc: ['1.1.1'],
34
34
  normativeMappings: [
35
- { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
35
+ {
36
+ standard: 'WCAG',
37
+ version: '2.2',
38
+ requirement: '1.1.1',
39
+ title: 'Non-text Content',
40
+ conformanceLevel: 'A'
41
+ }
36
42
  ],
37
43
  defaultSeverity: 'serious',
38
44
  category: 'perceivable',
@@ -45,33 +51,39 @@ function runInPage(ctx) {
45
51
  const { document, root, helpers, rule } = ctx;
46
52
  const safeRoot = root || document;
47
53
 
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;
54
+ const queryAllSmart =
55
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
56
+ const queryAll =
57
+ helpers && typeof helpers.queryAll === 'function'
58
+ ? helpers.queryAll
59
+ : (sel) => {
60
+ try {
61
+ return safeRoot && safeRoot.querySelectorAll
62
+ ? Array.from(safeRoot.querySelectorAll(sel))
63
+ : [];
64
+ } catch {
65
+ return [];
66
+ }
67
+ };
68
+
69
+ const getEligibilityInfo =
70
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
71
+
72
+ const isAccTreeEligible =
73
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
74
+
75
+ const getFocusableInfo =
76
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
77
+
78
+ const getAriaNameInfo =
79
+ helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
71
80
 
72
81
  const embeds = (() => {
73
- try { return Array.from((queryAllSmart ? queryAllSmart('embed') : queryAll('embed')) || []); }
74
- catch { return queryAll('embed'); }
82
+ try {
83
+ return Array.from((queryAllSmart ? queryAllSmart('embed') : queryAll('embed')) || []);
84
+ } catch {
85
+ return queryAll('embed');
86
+ }
75
87
  })();
76
88
 
77
89
  if (!embeds.length) {
@@ -89,11 +101,22 @@ function runInPage(ctx) {
89
101
  let aria = null;
90
102
 
91
103
  if (getAriaNameInfo) {
92
- aria = (() => { try { return getAriaNameInfo(el, ctx); } catch { return null; } })();
104
+ aria = (() => {
105
+ try {
106
+ return getAriaNameInfo(el, ctx);
107
+ } catch {
108
+ return null;
109
+ }
110
+ })();
93
111
  }
94
112
 
95
113
  if (aria && aria.present && trim(aria.value)) {
96
- return { present: true, value: trim(aria.value), mechanism: aria.mechanism || 'aria', flags: (aria.flags || []).slice(0) };
114
+ return {
115
+ present: true,
116
+ value: trim(aria.value),
117
+ mechanism: aria.mechanism || 'aria',
118
+ flags: (aria.flags || []).slice(0)
119
+ };
97
120
  }
98
121
 
99
122
  const title = trim(el.getAttribute && el.getAttribute('title'));
@@ -120,20 +143,43 @@ function runInPage(ctx) {
120
143
  if (!el || !el.getAttribute) continue;
121
144
 
122
145
  if (isAccTreeEligible) {
123
- const elig = (() => { try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; } })();
146
+ const elig = (() => {
147
+ try {
148
+ return isAccTreeEligible(el, ctx);
149
+ } catch {
150
+ return { eligible: true, reasons: [] };
151
+ }
152
+ })();
124
153
  if (elig && elig.eligible === false) continue;
125
154
  }
126
155
 
127
156
  // role presentation/none exclusion only when not focusable
128
- const role = (() => { try { return String(el.getAttribute('role') || '').trim().toLowerCase(); } catch { return ''; } })();
157
+ const role = (() => {
158
+ try {
159
+ return String(el.getAttribute('role') || '')
160
+ .trim()
161
+ .toLowerCase();
162
+ } catch {
163
+ return '';
164
+ }
165
+ })();
129
166
  if (role === 'presentation' || role === 'none') {
130
- let focusable = false;
167
+ let focusable;
131
168
  if (getFocusableInfo) {
132
- const fi = (() => { try { return getFocusableInfo(el, ctx); } catch { return null; } })();
169
+ const fi = (() => {
170
+ try {
171
+ return getFocusableInfo(el, ctx);
172
+ } catch {
173
+ return null;
174
+ }
175
+ })();
133
176
  focusable = !!(fi && fi.focusable);
134
177
  } else {
135
178
  const tabindex = el.getAttribute('tabindex');
136
- focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
179
+ focusable =
180
+ tabindex != null &&
181
+ String(tabindex).trim() !== '' &&
182
+ !Number.isNaN(Number(String(tabindex).trim()));
137
183
  }
138
184
  if (!focusable) continue;
139
185
  }
@@ -150,7 +196,7 @@ function runInPage(ctx) {
150
196
 
151
197
  const baseOccurrence = {
152
198
  summary: 'Missing text alternative for <embed>.',
153
- hint: 'Add an accessible name to <embed> (aria-label/aria-labelledby).',
199
+ hint: 'Add an accessible name to <embed> (aria-label/aria-labelledby preferred, or a title attribute as a best-effort fallback).',
154
200
  i18n: {
155
201
  summaryKey: 'embed_textAltPresent_summary_fail',
156
202
  hintKey: 'embed_textAltPresent_hint_fail',
@@ -184,7 +230,12 @@ function runInPage(ctx) {
184
230
  };
185
231
  }
186
232
 
187
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
233
+ return {
234
+ ruleId: rule.ruleId,
235
+ outcome: 'fail',
236
+ severity: rule.defaultSeverity || 'minor',
237
+ occurrences
238
+ };
188
239
  }
189
240
 
190
241
  module.exports = { id, meta, runInPage };
@@ -1,20 +1,13 @@
1
1
  'use strict';
2
2
 
3
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.
4
+ * Control discovery goes through helpers.queryAllSmart/queryAll (multi-root
5
+ * and shadow-DOM aware), not a direct root/safeRoot DOM query -- ctx.root
6
+ * is an array (multi-region contextSelector support), not a single element.
7
+ * Label-method resolution (label/aria-labelledby/aria-label/title/
8
+ * placeholder priority) delegates entirely to the shared dom-helpers
9
+ * bundle's getLabelMethod, which dom-runner.js always provides to every
10
+ * rule execution -- see getLabelMethodSafe below.
18
11
  *
19
12
  * WCAG mapping: matches technique H44 ("Using label elements to associate
20
13
  * text labels with form controls"), which WCAG's own Techniques document
@@ -40,9 +33,27 @@ const meta = {
40
33
  tags: ['wcag2a', 'wcag131', 'wcag332', 'wcag412', 'forms', 'labels', 'atomic', 'automatic'],
41
34
  wcagSc: ['1.3.1', '3.3.2', '4.1.2'],
42
35
  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' }
36
+ {
37
+ standard: 'WCAG',
38
+ version: '2.2',
39
+ requirement: '1.3.1',
40
+ title: 'Info and Relationships',
41
+ conformanceLevel: 'A'
42
+ },
43
+ {
44
+ standard: 'WCAG',
45
+ version: '2.2',
46
+ requirement: '3.3.2',
47
+ title: 'Labels or Instructions',
48
+ conformanceLevel: 'A'
49
+ },
50
+ {
51
+ standard: 'WCAG',
52
+ version: '2.2',
53
+ requirement: '4.1.2',
54
+ title: 'Name, Role, Value',
55
+ conformanceLevel: 'A'
56
+ }
46
57
  ],
47
58
  defaultSeverity: 'serious',
48
59
  category: 'robust',
@@ -58,48 +69,17 @@ const meta = {
58
69
  };
59
70
 
60
71
  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;
72
+ const { helpers, rule } = ctx;
65
73
 
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;
74
+ const isAccTreeEligible =
75
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
76
+ const getEligibilityInfo =
77
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
70
78
 
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
- }
79
+ const getFocusableInfo =
80
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
81
+ const getLabelMethod =
82
+ helpers && typeof helpers.getLabelMethod === 'function' ? helpers.getLabelMethod : null;
103
83
 
104
84
  const trim = (v) => (v == null ? '' : String(v)).trim();
105
85
 
@@ -107,7 +87,14 @@ function runInPage(ctx) {
107
87
  applicableCount: 0,
108
88
  passCount: 0,
109
89
  failCount: 0,
110
- byMethod: { label: 0, 'aria-labelledby': 0, 'aria-label': 0, title: 0, placeholder: 0, none: 0 },
90
+ byMethod: {
91
+ label: 0,
92
+ 'aria-labelledby': 0,
93
+ 'aria-label': 0,
94
+ title: 0,
95
+ placeholder: 0,
96
+ none: 0
97
+ },
111
98
  weakPassCount: 0
112
99
  };
113
100
 
@@ -118,35 +105,6 @@ function runInPage(ctx) {
118
105
  return 'none';
119
106
  }
120
107
 
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
108
  function isEligibleAcc(el) {
151
109
  if (!isAccTreeEligible) return true;
152
110
  try {
@@ -158,110 +116,23 @@ function runInPage(ctx) {
158
116
  }
159
117
  }
160
118
 
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 = '';
119
+ // getLabelMethod is provided by the shared dom-helpers bundle that
120
+ // dom-runner.js always constructs for every rule execution (built-in or
121
+ // custom) — see createDomHelpers's own getLabelMethod, which implements
122
+ // this exact <label>/aria-labelledby/aria-label/title/placeholder
123
+ // priority order. No local reimplementation is needed as a fallback.
124
+ function getLabelMethodSafe(el) {
125
+ if (!getLabelMethod) return { method: 'none', value: '' };
210
126
  try {
211
- raw = el && el.getAttribute ? trim(el.getAttribute('aria-label')) : '';
127
+ const r = getLabelMethod(el, ctx);
128
+ const m = r && typeof r.method === 'string' ? r.method : 'none';
129
+ const v = r && r.value != null ? trim(r.value) : '';
130
+ if (!Object.prototype.hasOwnProperty.call(metrics.byMethod, m))
131
+ return { method: 'none', value: '' };
132
+ return { method: m, value: v };
212
133
  } 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 {}
134
+ return { method: 'none', value: '' };
221
135
  }
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
136
  }
266
137
 
267
138
  // Collect candidate nodes via the shared queryAllSmart/queryAll helpers
@@ -272,17 +143,21 @@ function runInPage(ctx) {
272
143
  const nodes = [];
273
144
  try {
274
145
  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 || [])) {
146
+ const candidates =
147
+ helpers && typeof helpers.queryAllSmart === 'function'
148
+ ? helpers.queryAllSmart(sel)
149
+ : helpers && typeof helpers.queryAll === 'function'
150
+ ? helpers.queryAll(sel)
151
+ : [];
152
+
153
+ for (const el of candidates || []) {
280
154
  if (!el || !el.getAttribute) continue;
281
155
  const tag = (el.tagName || '').toLowerCase();
282
156
  if (tag === 'input') {
283
157
  const t = trim(el.getAttribute('type')).toLowerCase();
284
158
  // exclude hidden|submit|reset|button|image
285
- if (t === 'hidden' || t === 'submit' || t === 'reset' || t === 'button' || t === 'image') continue;
159
+ if (t === 'hidden' || t === 'submit' || t === 'reset' || t === 'button' || t === 'image')
160
+ continue;
286
161
  }
287
162
  nodes.push(el);
288
163
  }
@@ -309,7 +184,7 @@ function runInPage(ctx) {
309
184
  if (!isEligibleAcc(el)) continue;
310
185
 
311
186
  // role="presentation"/"none" exclusion only when NOT focusable
312
- let role = '';
187
+ let role;
313
188
  try {
314
189
  role = trim(el.getAttribute('role')).toLowerCase();
315
190
  } catch {
@@ -350,7 +225,15 @@ function runInPage(ctx) {
350
225
 
351
226
  metrics.failCount += 1;
352
227
 
353
- const vf = getEligibilityInfo ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })() : null;
228
+ const vf = getEligibilityInfo
229
+ ? (() => {
230
+ try {
231
+ return getEligibilityInfo(el, ctx, { targetSet: 'acc' });
232
+ } catch {
233
+ return null;
234
+ }
235
+ })()
236
+ : null;
354
237
 
355
238
  const baseOccurrence = {
356
239
  summary: 'Form control is missing a programmatic label.',
@@ -21,13 +21,20 @@
21
21
  * decision from form-control-programmatic-label-present (that
22
22
  * rule checks a label exists at all; this one checks there is at most
23
23
  * one).
24
+ * - A label that isn't accessibility-tree-eligible (display:none,
25
+ * aria-hidden, etc.) is excluded before counting: it can't contribute to
26
+ * the control's accessible name, so it can't create the ambiguity this
27
+ * rule exists to catch. Matches the reference engine's own
28
+ * multiple-label check, which filters hidden labels the same way before
29
+ * deciding whether there's more than one.
24
30
  */
25
31
 
26
32
  const id = 'form-control-single-label';
27
33
 
28
34
  const meta = {
29
35
  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]).',
36
+ description:
37
+ 'Checks that a form control is associated with at most one <label> (by wrapping or by label[for]).',
31
38
  i18n: {
32
39
  titleKey: 'formControlSingleLabel_title',
33
40
  descriptionKey: 'formControlSingleLabel_description'
@@ -36,7 +43,13 @@ const meta = {
36
43
  tags: ['wcag2a', 'wcag332', 'forms', 'atomic', 'automatic'],
37
44
  wcagSc: ['3.3.2'],
38
45
  normativeMappings: [
39
- { standard: 'WCAG', version: '2.2', requirement: '3.3.2', title: 'Labels or Instructions', conformanceLevel: 'A' }
46
+ {
47
+ standard: 'WCAG',
48
+ version: '2.2',
49
+ requirement: '3.3.2',
50
+ title: 'Labels or Instructions',
51
+ conformanceLevel: 'A'
52
+ }
40
53
  ],
41
54
  defaultSeverity: 'moderate',
42
55
  category: 'understandable',
@@ -48,8 +61,14 @@ const meta = {
48
61
  function runInPage(ctx) {
49
62
  const { document, helpers, rule } = ctx;
50
63
 
51
- const selector = 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
52
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
64
+ const isAccTreeEligible =
65
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
66
+
67
+ const selector =
68
+ 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
69
+ const nodes = helpers.queryAllSmart
70
+ ? helpers.queryAllSmart(selector)
71
+ : helpers.queryAll(selector);
53
72
 
54
73
  // Build a for-value -> label[] map once (avoids per-control dynamic
55
74
  // attribute-selector construction / CSS.escape, which is not guaranteed
@@ -82,11 +101,20 @@ function runInPage(ctx) {
82
101
  for (const lab of labelsByFor.get(controlId)) labels.add(lab);
83
102
  }
84
103
 
85
- if (labels.size <= 1) continue;
104
+ const eligibleLabels = isAccTreeEligible
105
+ ? new Set(
106
+ [...labels].filter((lab) => {
107
+ const elig = isAccTreeEligible(lab);
108
+ return !elig || elig.eligible !== false;
109
+ })
110
+ )
111
+ : labels;
112
+
113
+ if (eligibleLabels.size <= 1) continue;
86
114
 
87
115
  const tag = el.tagName.toLowerCase();
88
116
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
89
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
117
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
90
118
 
91
119
  occurrences.push({
92
120
  selector: stableSelector,
@@ -96,10 +124,14 @@ function runInPage(ctx) {
96
124
  i18n: {
97
125
  summaryKey: 'formControlSingleLabel_summary_fail',
98
126
  hintKey: 'formControlSingleLabel_hint_fail',
99
- params: { element: tag, labelCount: String(labels.size) }
127
+ params: { element: tag, labelCount: String(eligibleLabels.size) }
100
128
  },
101
129
  data: {
102
- details: { reasonCode: 'FORM_FIELD_MULTIPLE_LABELS', element: tag, labelCount: labels.size }
130
+ details: {
131
+ reasonCode: 'FORM_FIELD_MULTIPLE_LABELS',
132
+ element: tag,
133
+ labelCount: eligibleLabels.size
134
+ }
103
135
  }
104
136
  });
105
137
  }
@@ -108,9 +140,14 @@ function runInPage(ctx) {
108
140
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
109
141
  }
110
142
  if (occurrences.length) {
111
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
143
+ return {
144
+ ruleId: rule.ruleId,
145
+ outcome: 'fail',
146
+ severity: rule.defaultSeverity || 'moderate',
147
+ occurrences
148
+ };
112
149
  }
113
150
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
114
151
  }
115
152
 
116
- module.exports = { id, meta, runInPage };
153
+ module.exports = { id, meta, runInPage };