@surea11y/core 1.3.0 → 1.4.1

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 (166) hide show
  1. package/CHANGELOG.md +87 -2
  2. package/README.md +109 -35
  3. package/bin/surea11y-core.js +20 -0
  4. package/docs/API_STABILITY.md +26 -0
  5. package/docs/ARIA_DEPRECATION.md +95 -0
  6. package/docs/CI_INTEGRATIONS.md +7 -7
  7. package/docs/ENGINE_OPTIONS.md +1 -1
  8. package/docs/I18N.md +12 -9
  9. package/docs/INTEGRATION.md +1 -1
  10. package/docs/LIMITATIONS.md +1 -1
  11. package/docs/REPORT.md +1 -1
  12. package/docs/RULE_CATALOG.md +6 -6
  13. package/package.json +52 -16
  14. package/src/baseline.js +0 -0
  15. package/src/checks/automatic/area-alt-present.js +4 -6
  16. package/src/checks/automatic/aria-allowed-attr.js +663 -134
  17. package/src/checks/automatic/aria-allowed-role.js +2 -0
  18. package/src/checks/automatic/aria-braille-equivalent.js +2 -0
  19. package/src/checks/automatic/aria-conditional-attr.js +8 -7
  20. package/src/checks/automatic/aria-deprecated-role.js +107 -38
  21. package/src/checks/automatic/aria-hidden-body.js +6 -4
  22. package/src/checks/automatic/aria-hidden-focus.js +12 -13
  23. package/src/checks/automatic/aria-prohibited-attr.js +98 -105
  24. package/src/checks/automatic/aria-prohibited-children.js +56 -87
  25. package/src/checks/automatic/aria-required-attr.js +6 -7
  26. package/src/checks/automatic/aria-required-children.js +7 -10
  27. package/src/checks/automatic/aria-required-parent.js +20 -25
  28. package/src/checks/automatic/aria-role-name-present.js +2 -0
  29. package/src/checks/automatic/aria-roles-valid.js +33 -6
  30. package/src/checks/automatic/aria-valid-attr-value.js +18 -15
  31. package/src/checks/automatic/aria-valid-attr.js +2 -0
  32. package/src/checks/automatic/autocomplete-valid.js +39 -1
  33. package/src/checks/automatic/avoid-inline-spacing.js +181 -20
  34. package/src/checks/automatic/binary-control-name-present.js +13 -3
  35. package/src/checks/automatic/button-name-present.js +54 -21
  36. package/src/checks/automatic/canvas-text-alternative-present.js +17 -8
  37. package/src/checks/automatic/combobox-name-present.js +10 -1
  38. package/src/checks/automatic/contrast-computable.js +2 -0
  39. package/src/checks/automatic/contrast-enhanced.js +2 -0
  40. package/src/checks/automatic/contrast-minimum.js +2 -0
  41. package/src/checks/automatic/css-orientation-lock.js +21 -27
  42. package/src/checks/automatic/definition-list-children-valid.js +6 -6
  43. package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
  44. package/src/checks/automatic/dialog-name-present.js +18 -11
  45. package/src/checks/automatic/dlitem-parent-valid.js +2 -0
  46. package/src/checks/automatic/duplicate-id-aria.js +4 -3
  47. package/src/checks/automatic/embed-text-alternative-present.js +2 -0
  48. package/src/checks/automatic/form-control-programmatic-label-present.js +50 -4
  49. package/src/checks/automatic/form-control-single-label.js +110 -43
  50. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
  51. package/src/checks/automatic/iframe-focusable-content.js +246 -18
  52. package/src/checks/automatic/iframe-name-present.js +2 -0
  53. package/src/checks/automatic/iframe-title-unique.js +3 -1
  54. package/src/checks/automatic/img-alt-present.js +23 -18
  55. package/src/checks/automatic/input-image-alt-present.js +101 -52
  56. package/src/checks/automatic/label-in-name.js +97 -27
  57. package/src/checks/automatic/language-page-present.js +7 -1
  58. package/src/checks/automatic/link-in-text-block.js +2 -0
  59. package/src/checks/automatic/link-name-present.js +52 -17
  60. package/src/checks/automatic/list-children-valid.js +14 -24
  61. package/src/checks/automatic/listbox-name-present.js +10 -1
  62. package/src/checks/automatic/listitem-parent-valid.js +30 -7
  63. package/src/checks/automatic/menuitem-name-present.js +10 -1
  64. package/src/checks/automatic/meta-refresh-no-exceptions.js +33 -4
  65. package/src/checks/automatic/meta-refresh-timing-absent.js +32 -4
  66. package/src/checks/automatic/meta-viewport-zoom-enabled.js +39 -15
  67. package/src/checks/automatic/meter-name-present.js +12 -4
  68. package/src/checks/automatic/nested-interactive-controls-absent.js +177 -25
  69. package/src/checks/automatic/object-text-alternative-present.js +16 -7
  70. package/src/checks/automatic/option-name-present.js +10 -1
  71. package/src/checks/automatic/page-title-present.js +2 -0
  72. package/src/checks/automatic/progressbar-name-present.js +16 -11
  73. package/src/checks/automatic/role-img-alt-present.js +4 -4
  74. package/src/checks/automatic/searchbox-name-present.js +10 -1
  75. package/src/checks/automatic/server-side-image-map-absent.js +4 -3
  76. package/src/checks/automatic/slider-name-present.js +13 -2
  77. package/src/checks/automatic/spinbutton-name-present.js +10 -1
  78. package/src/checks/automatic/summary-name-present.js +10 -1
  79. package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
  80. package/src/checks/automatic/svg-text-alternative-present.js +17 -5
  81. package/src/checks/automatic/tab-name-present.js +10 -1
  82. package/src/checks/automatic/table-headers-attr-valid.js +3 -2
  83. package/src/checks/automatic/table-th-has-data-cells.js +69 -6
  84. package/src/checks/automatic/target-size-minimum.js +5 -0
  85. package/src/checks/automatic/td-has-header.js +24 -1
  86. package/src/checks/automatic/textbox-name-present.js +10 -1
  87. package/src/checks/automatic/tooltip-name-present.js +10 -1
  88. package/src/checks/automatic/treeitem-name-present.js +10 -1
  89. package/src/checks/automatic/valid-lang.js +18 -3
  90. package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
  91. package/src/checks/manual/accesskeys-manual.js +3 -1
  92. package/src/checks/manual/area-alt-decorative-manual.js +2 -0
  93. package/src/checks/manual/area-alt-quality-manual.js +2 -0
  94. package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
  95. package/src/checks/manual/aria-text-manual.js +6 -5
  96. package/src/checks/manual/bypass-blocks-present-manual.js +279 -0
  97. package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
  98. package/src/checks/manual/css-hidden-focus.js +184 -9
  99. package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
  100. package/src/checks/manual/empty-heading-manual.js +17 -17
  101. package/src/checks/manual/empty-table-header-manual.js +52 -25
  102. package/src/checks/manual/focus-order-semantics-manual.js +16 -4
  103. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
  104. package/src/checks/manual/heading-order-manual.js +28 -1
  105. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
  106. package/src/checks/manual/image-redundant-alt-manual.js +21 -1
  107. package/src/checks/manual/img-alt-decorative-manual.js +2 -0
  108. package/src/checks/manual/img-alt-quality-manual.js +2 -0
  109. package/src/checks/manual/input-image-alt-decorative-manual.js +26 -0
  110. package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
  111. package/src/checks/manual/label-title-only-manual.js +29 -22
  112. package/src/checks/manual/landmark-banner-is-top-level-manual.js +51 -55
  113. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +44 -31
  114. package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
  115. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
  116. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
  117. package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
  118. package/src/checks/manual/landmark-one-main-manual.js +12 -23
  119. package/src/checks/manual/landmark-unique-manual.js +37 -52
  120. package/src/checks/manual/link-name-quality-manual.js +2 -0
  121. package/src/checks/manual/media-transcript-present-manual.js +2 -0
  122. package/src/checks/manual/meta-viewport-large-manual.js +3 -1
  123. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
  124. package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
  125. package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
  126. package/src/checks/manual/p-as-heading-manual.js +2 -0
  127. package/src/checks/manual/page-has-heading-one-manual.js +12 -11
  128. package/src/checks/manual/page-title-patterns-manual.js +2 -0
  129. package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
  130. package/src/checks/manual/region-manual.js +27 -36
  131. package/src/checks/manual/scope-attr-valid-manual.js +3 -1
  132. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
  133. package/src/checks/manual/skip-link-manual.js +7 -6
  134. package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
  135. package/src/checks/manual/tabindex-manual.js +3 -1
  136. package/src/checks/manual/table-duplicate-name-manual.js +5 -4
  137. package/src/checks/manual/table-fake-caption-manual.js +24 -3
  138. package/src/checks/manual/video-caption-manual.js +2 -0
  139. package/src/checks/manual-review.js +2 -0
  140. package/src/core.js +11820 -3317
  141. package/src/index.js +2 -0
  142. package/src/report.js +51 -9
  143. package/src/sarif.js +20 -5
  144. package/surea11y.browser.js +4943 -1388
  145. package/bin/core.js +0 -473
  146. package/docs/CLI.md +0 -128
  147. package/src/catalogs/composites.wcag.js +0 -454
  148. package/src/checks/automatic/bypass-blocks-present.js +0 -215
  149. package/src/checks/rules-and-tags.full.csv +0 -19
  150. package/src/checks/rules-and-tags.full.json +0 -259
  151. package/src/core/aria-helpers.js +0 -1211
  152. package/src/core/contrast-helpers.js +0 -1302
  153. package/src/core/dom-helpers.js +0 -4493
  154. package/src/core/dom-runner.js +0 -787
  155. package/src/core/frame-messaging.js +0 -261
  156. package/src/core/frame-scan.js +0 -190
  157. package/src/core/rollup-composites.js +0 -127
  158. package/src/core/rule-meta.js +0 -176
  159. package/src/coverage/wcag-facets.js +0 -1079
  160. package/src/coverage/wcag-version-map.js +0 -84
  161. package/src/i18n/en.js +0 -1228
  162. package/src/i18n/fr.js +0 -1185
  163. package/src/policy/contracts.js +0 -18
  164. package/src/policy/resolvePolicy.js +0 -59
  165. package/src/policy/schemas/engine-options.schema.json +0 -103
  166. package/src/policy/schemas/policy-contract.schema.json +0 -40
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'dialog-name-present';
@@ -55,16 +57,14 @@ function runInPage(ctx) {
55
57
  // Resolve via the shared getTextFromIdRefs helper — computes each
56
58
  // referenced element's own ACCESSIBLE NAME (aria-label, then
57
59
  // aria-labelledby, then a value-like name, then content, then title),
58
- // not just its content text. Found via a real page (BBC News' cookie-
59
- // consent dialog, 2026-07-22): aria-labelledby pointed at an
60
- // <iframe title="SP Consent Message">, whose only name source is its
61
- // title attribute (an iframe's content is opaque/cross-origin per
62
- // HTML-AAM, so "name from content" is always empty). The previous
63
- // version here only ever computed name-from-content of the referenced
64
- // node (via getConservativeSubtreeText), silently missing the title
65
- // fallback and reporting no accessible name at all — the identical
66
- // pattern was hand-copied into 15 other *-name-present rules; all
67
- // fixed the same way.
60
+ // not just its content text. This matters when aria-labelledby points
61
+ // at an <iframe title="..."> (e.g. BBC News' cookie-consent dialog,
62
+ // pointing at an `<iframe title="SP Consent Message">`), whose only
63
+ // name source is its title attribute — an iframe's content is
64
+ // opaque/cross-origin per HTML-AAM, so "name from content" is always
65
+ // empty. Computing only name-from-content of the referenced node would
66
+ // silently miss the title fallback and report no accessible name at
67
+ // all, the same pattern every other *-name-present rule guards against.
68
68
  if (helpers.getTextFromIdRefs) {
69
69
  try {
70
70
  const r = helpers.getTextFromIdRefs(raw, ctx, { maxRefs: maxRefs || 8 });
@@ -74,9 +74,16 @@ function runInPage(ctx) {
74
74
  return '';
75
75
  }
76
76
 
77
+ // Naming rules apply only to elements included in the accessibility tree
78
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
79
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
77
80
  function isEligibleAcc(helpers, el, ctx) {
78
81
  const fn =
79
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
82
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
83
+ ? helpers.isIncludedInAccessibilityTree
84
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
85
+ ? helpers.isAccTreeEligible
86
+ : null;
80
87
  if (!fn) return true;
81
88
  try {
82
89
  const r = fn(el, ctx);
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -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
  */
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -71,8 +73,15 @@ const meta = {
71
73
  function runInPage(ctx) {
72
74
  const { helpers, rule } = ctx;
73
75
 
74
- const isAccTreeEligible =
75
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
76
+ // ACT e086e5 applies only to controls included in the accessibility tree, so
77
+ // a focusable control inside aria-hidden is out of scope; aria-hidden-focus
78
+ // reports that markup instead.
79
+ const isEligibleHelper =
80
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
81
+ ? helpers.isIncludedInAccessibilityTree
82
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
83
+ ? helpers.isAccTreeEligible
84
+ : null;
76
85
  const getEligibilityInfo =
77
86
  helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
78
87
 
@@ -83,6 +92,41 @@ function runInPage(ctx) {
83
92
 
84
93
  const trim = (v) => (v == null ? '' : String(v)).trim();
85
94
 
95
+ // Roles with their own *-name-present rule; a control carrying one
96
+ // explicitly is skipped here so it is reported once.
97
+ const ROLE_OWNED_ELSEWHERE = [
98
+ 'alertdialog',
99
+ 'button',
100
+ 'checkbox',
101
+ 'combobox',
102
+ 'dialog',
103
+ 'grid',
104
+ 'link',
105
+ 'listbox',
106
+ 'menu',
107
+ 'menubar',
108
+ 'menuitem',
109
+ 'menuitemcheckbox',
110
+ 'menuitemradio',
111
+ 'meter',
112
+ 'option',
113
+ 'progressbar',
114
+ 'radio',
115
+ 'radiogroup',
116
+ 'scrollbar',
117
+ 'searchbox',
118
+ 'slider',
119
+ 'spinbutton',
120
+ 'switch',
121
+ 'tab',
122
+ 'tablist',
123
+ 'textbox',
124
+ 'toolbar',
125
+ 'tooltip',
126
+ 'tree',
127
+ 'treeitem'
128
+ ];
129
+
86
130
  const metrics = {
87
131
  applicableCount: 0,
88
132
  passCount: 0,
@@ -106,9 +150,9 @@ function runInPage(ctx) {
106
150
  }
107
151
 
108
152
  function isEligibleAcc(el) {
109
- if (!isAccTreeEligible) return true;
153
+ if (!isEligibleHelper) return true;
110
154
  try {
111
- const r = isAccTreeEligible(el, ctx);
155
+ const r = isEligibleHelper(el, ctx);
112
156
  if (typeof r === 'boolean') return r;
113
157
  return !!(r && r.eligible);
114
158
  } catch {
@@ -191,6 +235,8 @@ function runInPage(ctx) {
191
235
  role = '';
192
236
  }
193
237
 
238
+ if (role && ROLE_OWNED_ELSEWHERE.indexOf(role) !== -1) continue;
239
+
194
240
  if (role === 'presentation' || role === 'none') {
195
241
  const fi = getFocusableInfo
196
242
  ? (() => {
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -10,23 +12,29 @@
10
12
  * Applies to labelable form controls (input, excluding
11
13
  * hidden/submit/reset/button/image; select; textarea).
12
14
  * @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.
15
+ * At most one <label> that can contribute to the control's accessible name
16
+ * is associated with it — by wrapping it, or by a <label for="..."> on its
17
+ * id (a label that both wraps and self-references via for counts once).
18
+ * Graded by whether the surplus labels actually compete for the name:
19
+ * - PASS when an override (aria-labelledby / aria-label) supersedes every
20
+ * native <label>: the labels then contribute nothing to the name, so
21
+ * they cannot be ambiguous. A visible-label-vs-name mismatch is SC 2.5.3
22
+ * Label in Name's concern, not this rule's.
23
+ * - FAIL when two or more non-empty labels compete and there is no
24
+ * override: screen readers announce a non-deterministic subset.
25
+ * - CANTTELL when one non-empty label is joined by empty label
26
+ * association(s) and there is no override: the name usually resolves to
27
+ * the real label, but handling of the empty association is not
28
+ * guaranteed across user agents.
29
+ * All-empty associations with no override are a missing-name case (the
30
+ * sibling rule below), not an ambiguity, so this rule stays silent.
19
31
  * @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
- * - 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.
32
+ * - Distinct, atomic decision from form-control-programmatic-label-present
33
+ * (that rule checks a label exists at all; this one checks at most one
34
+ * matters). Whether a label contributes a name is decided by the shared
35
+ * helpers.labelContributesAccessibleName, so the two rules agree.
36
+ * - Accessibility-tree-ineligible labels (display:none, aria-hidden, etc.)
37
+ * are excluded before counting: they can't contribute to the name.
30
38
  */
31
39
 
32
40
  const id = 'form-control-single-label';
@@ -63,6 +71,12 @@ function runInPage(ctx) {
63
71
 
64
72
  const isAccTreeEligible =
65
73
  helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
74
+ const getAriaNameInfo =
75
+ helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
76
+ const labelContributesName =
77
+ helpers && typeof helpers.labelContributesAccessibleName === 'function'
78
+ ? helpers.labelContributesAccessibleName
79
+ : null;
66
80
 
67
81
  const selector =
68
82
  'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
@@ -83,7 +97,8 @@ function runInPage(ctx) {
83
97
  labelsByFor.get(forValue).push(lab);
84
98
  }
85
99
 
86
- const occurrences = [];
100
+ const failOccurrences = [];
101
+ const cantTellOccurrences = [];
87
102
  let applicableCount = 0;
88
103
 
89
104
  for (const el of nodes) {
@@ -112,42 +127,94 @@ function runInPage(ctx) {
112
127
 
113
128
  if (eligibleLabels.size <= 1) continue;
114
129
 
130
+ // An override (aria-labelledby / aria-label) supersedes every native
131
+ // <label>, so the labels contribute nothing to the accessible name and
132
+ // cannot compete.
133
+ const override = getAriaNameInfo ? getAriaNameInfo(el, ctx) : null;
134
+ if (override && override.present && override.value) continue;
135
+
136
+ // Without an override the labels feed the name; only labels with their
137
+ // own text compete for it.
138
+ const contributing = labelContributesName
139
+ ? [...eligibleLabels].filter((lab) => labelContributesName(lab))
140
+ : [...eligibleLabels];
141
+
115
142
  const tag = el.tagName.toLowerCase();
116
143
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
117
144
  const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
118
145
 
119
- occurrences.push({
120
- selector: stableSelector,
121
- html,
122
- summary: 'This form control is associated with more than one <label>.',
123
- hint: 'Keep only one <label> per form control (either wrapping it or referencing it via for/id).',
124
- i18n: {
125
- summaryKey: 'formControlSingleLabel_summary_fail',
126
- hintKey: 'formControlSingleLabel_hint_fail',
127
- params: { element: tag, labelCount: String(eligibleLabels.size) }
128
- },
129
- data: {
130
- details: {
131
- reasonCode: 'FORM_FIELD_MULTIPLE_LABELS',
132
- element: tag,
133
- labelCount: eligibleLabels.size
146
+ if (contributing.length >= 2) {
147
+ failOccurrences.push({
148
+ selector: stableSelector,
149
+ html,
150
+ summary: 'This form control is associated with more than one <label>.',
151
+ hint: 'Keep only one <label> per form control (either wrapping it or referencing it via for/id).',
152
+ occurrenceOutcome: 'fail',
153
+ i18n: {
154
+ summaryKey: 'formControlSingleLabel_summary_fail',
155
+ hintKey: 'formControlSingleLabel_hint_fail',
156
+ params: { element: tag, labelCount: String(contributing.length) }
157
+ },
158
+ data: {
159
+ details: {
160
+ reasonCode: 'FORM_FIELD_MULTIPLE_LABELS',
161
+ element: tag,
162
+ labelCount: contributing.length
163
+ }
164
+ }
165
+ });
166
+ } else if (contributing.length === 1) {
167
+ cantTellOccurrences.push({
168
+ selector: stableSelector,
169
+ html,
170
+ summary:
171
+ 'This form control has one labelling <label> plus an extra empty <label> association.',
172
+ hint: 'Remove the redundant empty <label> so exactly one <label> is associated with the control.',
173
+ occurrenceOutcome: 'cantTell',
174
+ i18n: {
175
+ summaryKey: 'formControlSingleLabel_summary_cantTell',
176
+ hintKey: 'formControlSingleLabel_hint_cantTell',
177
+ params: { element: tag, labelCount: String(eligibleLabels.size) }
178
+ },
179
+ data: {
180
+ details: {
181
+ reasonCode: 'FORM_FIELD_EXTRA_EMPTY_LABEL',
182
+ element: tag,
183
+ labelCount: eligibleLabels.size,
184
+ contributingLabelCount: contributing.length
185
+ }
134
186
  }
135
- }
136
- });
187
+ });
188
+ }
189
+ // contributing.length === 0: no label carries text, so nothing competes
190
+ // for the name. A control left unnamed is form-control-programmatic-
191
+ // label-present's concern.
137
192
  }
138
193
 
139
194
  if (applicableCount === 0) {
140
195
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
141
196
  }
142
- if (occurrences.length) {
143
- return {
144
- ruleId: rule.ruleId,
145
- outcome: 'fail',
146
- severity: rule.defaultSeverity || 'moderate',
147
- occurrences
148
- };
149
- }
150
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
197
+
198
+ const resolved = helpers.resolveTieredOutcome
199
+ ? helpers.resolveTieredOutcome(
200
+ failOccurrences,
201
+ cantTellOccurrences,
202
+ rule.defaultSeverity || 'moderate'
203
+ )
204
+ : failOccurrences.length
205
+ ? {
206
+ outcome: 'fail',
207
+ severity: rule.defaultSeverity || 'moderate',
208
+ occurrences: failOccurrences
209
+ }
210
+ : cantTellOccurrences.length
211
+ ? {
212
+ outcome: 'cantTell',
213
+ severity: rule.defaultSeverity || 'moderate',
214
+ occurrences: cantTellOccurrences
215
+ }
216
+ : { outcome: 'pass', severity: 'minor', occurrences: [] };
217
+ return { ruleId: rule.ruleId, ...resolved };
151
218
  }
152
219
 
153
220
  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
  /**