@surea11y/core 1.4.0 → 1.5.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 (115) hide show
  1. package/CHANGELOG.md +88 -7
  2. package/README.md +19 -3
  3. package/bin/surea11y-core.js +0 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/ARIA_DEPRECATION.md +95 -0
  6. package/docs/ENGINE_OPTIONS.md +14 -10
  7. package/docs/I18N.md +176 -20
  8. package/docs/INTEGRATION.md +28 -6
  9. package/docs/LIMITATIONS.md +1 -1
  10. package/docs/OUTPUT_SCHEMA.md +13 -3
  11. package/docs/REPORT.md +2 -0
  12. package/docs/RULE_AUTHORING.md +53 -0
  13. package/docs/RULE_CATALOG.md +6 -6
  14. package/docs/TROUBLESHOOTING.md +2 -2
  15. package/package.json +8 -1
  16. package/src/checks/automatic/aria-allowed-attr.js +661 -99
  17. package/src/checks/automatic/aria-allowed-role.js +14 -16
  18. package/src/checks/automatic/aria-braille-equivalent.js +17 -19
  19. package/src/checks/automatic/aria-conditional-attr.js +17 -19
  20. package/src/checks/automatic/aria-deprecated-role.js +122 -41
  21. package/src/checks/automatic/aria-hidden-body.js +2 -9
  22. package/src/checks/automatic/aria-hidden-focus.js +99 -18
  23. package/src/checks/automatic/aria-prohibited-attr.js +54 -55
  24. package/src/checks/automatic/aria-prohibited-children.js +26 -26
  25. package/src/checks/automatic/aria-required-attr.js +14 -17
  26. package/src/checks/automatic/aria-required-children.js +17 -20
  27. package/src/checks/automatic/aria-required-parent.js +17 -20
  28. package/src/checks/automatic/aria-roles-valid.js +66 -27
  29. package/src/checks/automatic/aria-valid-attr-value.js +18 -21
  30. package/src/checks/automatic/aria-valid-attr.js +14 -17
  31. package/src/checks/automatic/autocomplete-valid.js +52 -18
  32. package/src/checks/automatic/avoid-inline-spacing.js +192 -34
  33. package/src/checks/automatic/binary-control-name-present.js +30 -24
  34. package/src/checks/automatic/button-name-present.js +73 -45
  35. package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
  36. package/src/checks/automatic/combobox-name-present.js +23 -18
  37. package/src/checks/automatic/css-orientation-lock.js +22 -22
  38. package/src/checks/automatic/definition-list-children-valid.js +18 -21
  39. package/src/checks/automatic/deprecated-elements-not-used.js +14 -16
  40. package/src/checks/automatic/dialog-name-present.js +24 -19
  41. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  42. package/src/checks/automatic/duplicate-id-aria.js +45 -37
  43. package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
  44. package/src/checks/automatic/form-control-single-label.js +109 -43
  45. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  46. package/src/checks/automatic/iframe-focusable-content.js +29 -31
  47. package/src/checks/automatic/iframe-name-present.js +15 -17
  48. package/src/checks/automatic/iframe-title-unique.js +18 -23
  49. package/src/checks/automatic/img-alt-present.js +16 -9
  50. package/src/checks/automatic/input-image-alt-present.js +99 -48
  51. package/src/checks/automatic/label-in-name.js +102 -33
  52. package/src/checks/automatic/language-page-present.js +5 -1
  53. package/src/checks/automatic/link-in-text-block.js +19 -21
  54. package/src/checks/automatic/link-name-present.js +75 -47
  55. package/src/checks/automatic/list-children-valid.js +15 -17
  56. package/src/checks/automatic/listbox-name-present.js +23 -18
  57. package/src/checks/automatic/listitem-parent-valid.js +14 -17
  58. package/src/checks/automatic/menuitem-name-present.js +24 -19
  59. package/src/checks/automatic/meta-refresh-no-exceptions.js +44 -18
  60. package/src/checks/automatic/meta-refresh-timing-absent.js +44 -21
  61. package/src/checks/automatic/meta-viewport-zoom-enabled.js +51 -32
  62. package/src/checks/automatic/meter-name-present.js +24 -19
  63. package/src/checks/automatic/nested-interactive-controls-absent.js +179 -51
  64. package/src/checks/automatic/object-text-alternative-present.js +14 -7
  65. package/src/checks/automatic/option-name-present.js +24 -19
  66. package/src/checks/automatic/progressbar-name-present.js +27 -22
  67. package/src/checks/automatic/searchbox-name-present.js +27 -18
  68. package/src/checks/automatic/server-side-image-map-absent.js +15 -18
  69. package/src/checks/automatic/slider-name-present.js +26 -19
  70. package/src/checks/automatic/spinbutton-name-present.js +27 -18
  71. package/src/checks/automatic/summary-name-present.js +24 -19
  72. package/src/checks/automatic/tab-name-present.js +24 -19
  73. package/src/checks/automatic/table-headers-attr-valid.js +15 -17
  74. package/src/checks/automatic/table-th-has-data-cells.js +82 -25
  75. package/src/checks/automatic/target-size-minimum.js +104 -81
  76. package/src/checks/automatic/td-has-header.js +15 -20
  77. package/src/checks/automatic/textbox-name-present.js +23 -18
  78. package/src/checks/automatic/tooltip-name-present.js +24 -19
  79. package/src/checks/automatic/treeitem-name-present.js +24 -19
  80. package/src/checks/automatic/valid-lang.js +31 -20
  81. package/src/checks/manual/accesskeys-manual.js +18 -19
  82. package/src/checks/manual/aria-checked-state-mismatch-manual.js +19 -22
  83. package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +98 -44
  84. package/src/checks/manual/empty-heading-manual.js +15 -17
  85. package/src/checks/manual/empty-table-header-manual.js +27 -30
  86. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -9
  87. package/src/checks/manual/heading-order-manual.js +17 -22
  88. package/src/checks/manual/image-redundant-alt-manual.js +14 -17
  89. package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
  90. package/src/checks/manual/label-title-only-manual.js +15 -17
  91. package/src/checks/manual/landmark-banner-is-top-level-manual.js +25 -39
  92. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +21 -27
  93. package/src/checks/manual/landmark-main-is-top-level-manual.js +14 -17
  94. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +2 -7
  95. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +2 -7
  96. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -7
  97. package/src/checks/manual/landmark-one-main-manual.js +2 -9
  98. package/src/checks/manual/landmark-unique-manual.js +22 -27
  99. package/src/checks/manual/link-name-quality-manual.js +15 -17
  100. package/src/checks/manual/meta-viewport-large-manual.js +14 -17
  101. package/src/checks/manual/mouse-only-event-handlers-manual.js +17 -19
  102. package/src/checks/manual/page-has-heading-one-manual.js +2 -9
  103. package/src/checks/manual/presentation-role-conflict-manual.js +19 -21
  104. package/src/checks/manual/region-manual.js +13 -6
  105. package/src/checks/manual/scope-attr-valid-manual.js +14 -17
  106. package/src/checks/manual/skip-link-manual.js +39 -47
  107. package/src/checks/manual/tabindex-manual.js +14 -17
  108. package/src/checks/manual/table-duplicate-name-manual.js +14 -17
  109. package/src/core.js +8818 -4330
  110. package/src/report.js +14 -0
  111. package/src/sarif.js +2 -2
  112. package/surea11y.browser.js +4023 -3911
  113. package/surea11y.i18n.de.js +22 -0
  114. package/surea11y.i18n.es.js +22 -0
  115. package/surea11y.i18n.fr.js +22 -0
@@ -156,7 +156,7 @@ function runInPage(ctx) {
156
156
  for (const r of rules) {
157
157
  if (!r) continue;
158
158
  if (r.type === CSS_STYLE_RULE && isLockingRotation(r.style)) {
159
- findings.push({ mediaText, selectorText: trim(r.selectorText) || '(unknown selector)' });
159
+ findings.push({ mediaText, selectorText: trim(r.selectorText) });
160
160
  }
161
161
  }
162
162
  }
@@ -196,28 +196,28 @@ function runInPage(ctx) {
196
196
  }
197
197
 
198
198
  const target = document.documentElement || document.body || null;
199
- const stableSelector = helpers.buildSelector && target ? helpers.buildSelector(target) : 'html';
200
- const html =
201
- helpers.getOuterHtmlSnippet && target ? helpers.getOuterHtmlSnippet(target) : '<html>';
202
-
203
- const occurrences = findings.map((f) => ({
204
- selector: stableSelector,
205
- html,
206
- summary: `A "${f.mediaText}" media query rotates "${f.selectorText}", locking the page to one orientation.`,
207
- hint: 'Remove the rotate() transform from the orientation media query; let the page respond naturally to device orientation instead of forcing a visual rotation.',
208
- i18n: {
209
- summaryKey: 'cssOrientationLock_summary_fail',
210
- hintKey: 'cssOrientationLock_hint_fail',
211
- params: { mediaText: f.mediaText, selectorText: f.selectorText }
212
- },
213
- data: {
214
- details: {
215
- reasonCode: 'ORIENTATION_MEDIA_ROTATE_TRANSFORM',
216
- mediaText: f.mediaText,
217
- selectorText: f.selectorText
199
+ const occurrences = findings.map((f) =>
200
+ helpers.reportOccurrence(target, {
201
+ summary: f.selectorText
202
+ ? `A "${f.mediaText}" media query rotates "${f.selectorText}", locking the page to one orientation.`
203
+ : `A "${f.mediaText}" media query rotates an element with no readable selector, locking the page to one orientation.`,
204
+ hint: 'Remove the rotate() transform from the orientation media query; let the page respond naturally to device orientation instead of forcing a visual rotation.',
205
+ i18n: {
206
+ summaryKey: f.selectorText
207
+ ? 'cssOrientationLock_summary_fail'
208
+ : 'cssOrientationLock_summary_fail_unknownSelector',
209
+ hintKey: 'cssOrientationLock_hint_fail',
210
+ params: { mediaText: f.mediaText, selectorText: f.selectorText }
211
+ },
212
+ data: {
213
+ details: {
214
+ reasonCode: 'ORIENTATION_MEDIA_ROTATE_TRANSFORM',
215
+ mediaText: f.mediaText,
216
+ selectorText: f.selectorText
217
+ }
218
218
  }
219
- }
220
- }));
219
+ })
220
+ );
221
221
 
222
222
  return {
223
223
  ruleId: rule.ruleId,
@@ -121,9 +121,6 @@ function runInPage(ctx) {
121
121
  : null;
122
122
  if (!reasonCode) continue;
123
123
 
124
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
125
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
126
-
127
124
  const summary = invalidTags.length
128
125
  ? 'This description list contains a direct or wrapped child that is not part of a dt/dd group.'
129
126
  : 'This description list has no <dt>/<dd> term-definition group.';
@@ -131,24 +128,24 @@ function runInPage(ctx) {
131
128
  ? 'Only use <dt>/<dd> (optionally wrapped in one <div>), <script>, <template>, or <style> inside <dl>.'
132
129
  : 'Add at least one <dt>/<dd> pair inside this <dl>.';
133
130
 
134
- occurrences.push({
135
- selector: stableSelector,
136
- html,
137
- summary,
138
- hint,
139
- i18n: {
140
- summaryKey: invalidTags.length
141
- ? 'definitionListChildrenValid_summary_fail_invalidChild'
142
- : 'definitionListChildrenValid_summary_fail_noDtDd',
143
- hintKey: invalidTags.length
144
- ? 'definitionListChildrenValid_hint_fail_invalidChild'
145
- : 'definitionListChildrenValid_hint_fail_noDtDd',
146
- params: { invalidChildren: dedupedInvalidTags.join(', ') }
147
- },
148
- data: {
149
- details: { reasonCode, invalidChildren: invalidTags }
150
- }
151
- });
131
+ occurrences.push(
132
+ helpers.reportOccurrence(el, {
133
+ summary,
134
+ hint,
135
+ i18n: {
136
+ summaryKey: invalidTags.length
137
+ ? 'definitionListChildrenValid_summary_fail_invalidChild'
138
+ : 'definitionListChildrenValid_summary_fail_noDtDd',
139
+ hintKey: invalidTags.length
140
+ ? 'definitionListChildrenValid_hint_fail_invalidChild'
141
+ : 'definitionListChildrenValid_hint_fail_noDtDd',
142
+ params: { invalidChildren: dedupedInvalidTags.join(', ') }
143
+ },
144
+ data: {
145
+ details: { reasonCode, invalidChildren: invalidTags }
146
+ }
147
+ })
148
+ );
152
149
  }
153
150
 
154
151
  if (applicableCount === 0) {
@@ -72,23 +72,21 @@ function runInPage(ctx) {
72
72
  applicableCount += 1;
73
73
 
74
74
  const tag = el.tagName.toLowerCase();
75
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
76
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
77
75
 
78
- occurrences.push({
79
- selector: stableSelector,
80
- html,
81
- summary: 'This element’s content cannot be paused, stopped, or hidden by the user.',
82
- hint: 'Remove this element; use static content, or an animation with a user-facing pause/stop control, instead.',
83
- i18n: {
84
- summaryKey: 'deprecatedElements_summary_fail',
85
- hintKey: 'deprecatedElements_hint_fail',
86
- params: { element: tag }
87
- },
88
- data: {
89
- details: { reasonCode: 'DEPRECATED_NON_STOPPABLE_ELEMENT', element: tag }
90
- }
91
- });
76
+ occurrences.push(
77
+ helpers.reportOccurrence(el, {
78
+ summary: 'This element’s content cannot be paused, stopped, or hidden by the user.',
79
+ hint: 'Remove this element; use static content, or an animation with a user-facing pause/stop control, instead.',
80
+ i18n: {
81
+ summaryKey: 'deprecatedElements_summary_fail',
82
+ hintKey: 'deprecatedElements_hint_fail',
83
+ params: { element: tag }
84
+ },
85
+ data: {
86
+ details: { reasonCode: 'DEPRECATED_NON_STOPPABLE_ELEMENT', element: tag }
87
+ }
88
+ })
89
+ );
92
90
  }
93
91
 
94
92
  if (applicableCount === 0) {
@@ -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);
@@ -133,24 +140,22 @@ function runInPage(ctx) {
133
140
  }
134
141
  })()
135
142
  : null;
136
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
137
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
138
-
139
- occurrences.push({
140
- selector: stableSelector,
141
- html,
142
- summary: 'This dialog has no accessible name.',
143
- hint: 'Provide aria-labelledby (preferred) or aria-label so assistive technologies can announce the dialog.',
144
- i18n: {
145
- summaryKey: 'dialogNamePresent_summary_fail',
146
- hintKey: 'dialogNamePresent_hint_fail',
147
- params: { role }
148
- },
149
- data: {
150
- details: { reasonCode: 'name_missing', controlType: role, methodTried: res.method },
151
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
152
- }
153
- });
143
+
144
+ occurrences.push(
145
+ helpers.reportOccurrence(el, {
146
+ summary: 'This dialog has no accessible name.',
147
+ hint: 'Provide aria-labelledby (preferred) or aria-label so assistive technologies can announce the dialog.',
148
+ i18n: {
149
+ summaryKey: 'dialogNamePresent_summary_fail',
150
+ hintKey: 'dialogNamePresent_hint_fail',
151
+ params: { role }
152
+ },
153
+ data: {
154
+ details: { reasonCode: 'name_missing', controlType: role, methodTried: res.method },
155
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
156
+ }
157
+ })
158
+ );
154
159
  }
155
160
 
156
161
  if (applicableCount === 0) {
@@ -80,23 +80,21 @@ function runInPage(ctx) {
80
80
  if (valid) continue;
81
81
 
82
82
  const tag = el.tagName.toLowerCase();
83
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
84
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
85
-
86
- occurrences.push({
87
- selector: stableSelector,
88
- html,
89
- summary: 'This description-list item is not contained by a <dl>.',
90
- hint: 'Place this <dt>/<dd> inside a <dl>, directly or wrapped in a single <div>.',
91
- i18n: {
92
- summaryKey: 'dlitemParentValid_summary_fail',
93
- hintKey: 'dlitemParentValid_hint_fail',
94
- params: { element: tag, parentElement: parentTag }
95
- },
96
- data: {
97
- details: { reasonCode: 'DLITEM_INVALID_PARENT', element: tag, parentElement: parentTag }
98
- }
99
- });
83
+
84
+ occurrences.push(
85
+ helpers.reportOccurrence(el, {
86
+ summary: 'This description-list item is not contained by a <dl>.',
87
+ hint: 'Place this <dt>/<dd> inside a <dl>, directly or wrapped in a single <div>.',
88
+ i18n: {
89
+ summaryKey: 'dlitemParentValid_summary_fail',
90
+ hintKey: 'dlitemParentValid_hint_fail',
91
+ params: { element: tag, parentElement: parentTag }
92
+ },
93
+ data: {
94
+ details: { reasonCode: 'DLITEM_INVALID_PARENT', element: tag, parentElement: parentTag }
95
+ }
96
+ })
97
+ );
100
98
  }
101
99
 
102
100
  if (applicableCount === 0) {
@@ -15,15 +15,16 @@
15
15
  * attribute (i.e. at least one ARIA ID reference exists to resolve).
16
16
  * @expectation
17
17
  * For every id value referenced by one of those attributes, exactly one
18
- * element in the document carries that id. A duplicated id referenced by
19
- * ARIA is ambiguous: assistive technologies cannot reliably determine
20
- * which element the reference resolves to (typically the first, silently
21
- * dropping the others).
18
+ * element in the document carries that id. A duplicate does not break the
19
+ * reference: it resolves to the first element in tree order, so the name is
20
+ * still computed. Whether that element is the intended target depends on
21
+ * author intent, which markup does not carry, so the outcome is cantTell.
22
22
  * @implementation-notes
23
23
  * - Scoped deliberately to ids referenced by ARIA, not the broader/
24
24
  * deprecated page-wide duplicate-id check (see ROADMAP.md's "Skip" list).
25
25
  * - Document-wide by design: id uniqueness and ARIA id references are a
26
- * whole-document property, not scoped to a sub-root.
26
+ * whole-document property, not scoped to a sub-root. Reported occurrences are
27
+ * limited to the scanned scope.
27
28
  */
28
29
 
29
30
  const id = 'duplicate-id-aria';
@@ -95,51 +96,58 @@ function runInPage(ctx) {
95
96
  }
96
97
  }
97
98
 
98
- const occurrences = [];
99
+ // Detection is document-wide; occurrences are limited to the scanned scope.
100
+ let inScope = null;
101
+ if (helpers && typeof helpers.queryAllSmart === 'function') {
102
+ try {
103
+ const scoped = helpers.queryAllSmart('[id]');
104
+ inScope = new Set(Array.isArray(scoped) ? scoped : Array.from(scoped || []));
105
+ } catch {
106
+ inScope = null;
107
+ }
108
+ }
109
+
110
+ const cantTellOccurrences = [];
99
111
 
100
112
  for (const refId of referencedIds) {
101
113
  const els = idMap.get(refId) || [];
102
114
  if (els.length <= 1) continue;
103
115
 
104
116
  for (const el of els) {
105
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
106
- const html = helpers.getOuterHtmlSnippet
107
- ? helpers.getOuterHtmlSnippet(el)
108
- : el.outerHTML || '';
109
-
110
- occurrences.push({
111
- selector: stableSelector,
112
- html,
113
- summary: 'This id is referenced by an ARIA attribute but is used by more than one element.',
114
- hint: 'Make ids referenced by ARIA attributes unique within the document.',
115
- i18n: {
116
- summaryKey: 'duplicateIdAria_summary_fail',
117
- hintKey: 'duplicateIdAria_hint_fail',
118
- params: { id: refId, duplicateCount: String(els.length) }
119
- },
120
- data: {
121
- details: {
122
- reasonCode: 'DUPLICATE_ID_ARIA_REFERENCED',
123
- id: refId,
124
- duplicateCount: els.length
117
+ if (inScope && !inScope.has(el)) continue;
118
+
119
+ cantTellOccurrences.push(
120
+ helpers.reportOccurrence(el, {
121
+ summary:
122
+ 'This id is referenced by an ARIA attribute but is used by more than one element; the reference resolves to the first.',
123
+ hint: 'Confirm the first element carrying this id is the intended target, or make the id unique.',
124
+ i18n: {
125
+ summaryKey: 'duplicateIdAria_summary_cantTell',
126
+ hintKey: 'duplicateIdAria_hint_cantTell',
127
+ params: { id: refId, duplicateCount: String(els.length) }
128
+ },
129
+ data: {
130
+ details: {
131
+ reasonCode: 'DUPLICATE_ID_ARIA_REFERENCED',
132
+ id: refId,
133
+ duplicateCount: els.length
134
+ }
125
135
  }
126
- }
127
- });
136
+ })
137
+ );
128
138
  }
129
139
  }
130
140
 
131
141
  if (referencedIds.size === 0) {
132
142
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
133
143
  }
134
- if (occurrences.length) {
135
- return {
136
- ruleId: rule.ruleId,
137
- outcome: 'fail',
138
- severity: rule.defaultSeverity || 'serious',
139
- occurrences
140
- };
141
- }
142
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
144
+
145
+ const resolved = helpers.resolveTieredOutcome(
146
+ [],
147
+ cantTellOccurrences,
148
+ rule.defaultSeverity || 'serious'
149
+ );
150
+ return { ruleId: rule.ruleId, ...resolved };
143
151
  }
144
152
 
145
153
  module.exports = { id, meta, runInPage };
@@ -73,8 +73,15 @@ const meta = {
73
73
  function runInPage(ctx) {
74
74
  const { helpers, rule } = ctx;
75
75
 
76
- const isAccTreeEligible =
77
- 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;
78
85
  const getEligibilityInfo =
79
86
  helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
80
87
 
@@ -85,6 +92,41 @@ function runInPage(ctx) {
85
92
 
86
93
  const trim = (v) => (v == null ? '' : String(v)).trim();
87
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
+
88
130
  const metrics = {
89
131
  applicableCount: 0,
90
132
  passCount: 0,
@@ -108,9 +150,9 @@ function runInPage(ctx) {
108
150
  }
109
151
 
110
152
  function isEligibleAcc(el) {
111
- if (!isAccTreeEligible) return true;
153
+ if (!isEligibleHelper) return true;
112
154
  try {
113
- const r = isAccTreeEligible(el, ctx);
155
+ const r = isEligibleHelper(el, ctx);
114
156
  if (typeof r === 'boolean') return r;
115
157
  return !!(r && r.eligible);
116
158
  } catch {
@@ -193,6 +235,8 @@ function runInPage(ctx) {
193
235
  role = '';
194
236
  }
195
237
 
238
+ if (role && ROLE_OWNED_ELSEWHERE.indexOf(role) !== -1) continue;
239
+
196
240
  if (role === 'presentation' || role === 'none') {
197
241
  const fi = getFocusableInfo
198
242
  ? (() => {
@@ -12,20 +12,29 @@
12
12
  * Applies to labelable form controls (input, excluding
13
13
  * hidden/submit/reset/button/image; select; textarea).
14
14
  * @expectation
15
- * At most one <label> is associated with the control — either by
16
- * wrapping it, or by a <label for="..."> pointing to its id
17
- * (deduplicated: a label that both wraps the control and
18
- * self-references it via for counts once). Multiple associated labels
19
- * are ambiguous: many screen readers only announce one of them, and it
20
- * 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.
21
31
  * @implementation-notes
22
32
  * - Distinct, atomic decision from form-control-programmatic-label-present
23
- * (that rule checks a label exists at all; this one checks there is at
24
- * most one).
25
- * - A label that isn't accessibility-tree-eligible (display:none,
26
- * aria-hidden, etc.) is excluded before counting: it can't contribute to
27
- * the control's accessible name, so it can't create the ambiguity this
28
- * rule exists to catch.
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.
29
38
  */
30
39
 
31
40
  const id = 'form-control-single-label';
@@ -62,6 +71,12 @@ function runInPage(ctx) {
62
71
 
63
72
  const isAccTreeEligible =
64
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;
65
80
 
66
81
  const selector =
67
82
  'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
@@ -82,7 +97,8 @@ function runInPage(ctx) {
82
97
  labelsByFor.get(forValue).push(lab);
83
98
  }
84
99
 
85
- const occurrences = [];
100
+ const failOccurrences = [];
101
+ const cantTellOccurrences = [];
86
102
  let applicableCount = 0;
87
103
 
88
104
  for (const el of nodes) {
@@ -111,42 +127,92 @@ function runInPage(ctx) {
111
127
 
112
128
  if (eligibleLabels.size <= 1) continue;
113
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
+
114
142
  const tag = el.tagName.toLowerCase();
115
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
116
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
117
-
118
- occurrences.push({
119
- selector: stableSelector,
120
- html,
121
- summary: 'This form control is associated with more than one <label>.',
122
- hint: 'Keep only one <label> per form control (either wrapping it or referencing it via for/id).',
123
- i18n: {
124
- summaryKey: 'formControlSingleLabel_summary_fail',
125
- hintKey: 'formControlSingleLabel_hint_fail',
126
- params: { element: tag, labelCount: String(eligibleLabels.size) }
127
- },
128
- data: {
129
- details: {
130
- reasonCode: 'FORM_FIELD_MULTIPLE_LABELS',
131
- element: tag,
132
- labelCount: eligibleLabels.size
133
- }
134
- }
135
- });
143
+
144
+ if (contributing.length >= 2) {
145
+ failOccurrences.push(
146
+ helpers.reportOccurrence(el, {
147
+ summary: 'This form control is associated with more than one <label>.',
148
+ hint: 'Keep only one <label> per form control (either wrapping it or referencing it via for/id).',
149
+ occurrenceOutcome: 'fail',
150
+ i18n: {
151
+ summaryKey: 'formControlSingleLabel_summary_fail',
152
+ hintKey: 'formControlSingleLabel_hint_fail',
153
+ params: { element: tag, labelCount: String(contributing.length) }
154
+ },
155
+ data: {
156
+ details: {
157
+ reasonCode: 'FORM_FIELD_MULTIPLE_LABELS',
158
+ element: tag,
159
+ labelCount: contributing.length
160
+ }
161
+ }
162
+ })
163
+ );
164
+ } else if (contributing.length === 1) {
165
+ cantTellOccurrences.push(
166
+ helpers.reportOccurrence(el, {
167
+ summary:
168
+ 'This form control has one labelling <label> plus an extra empty <label> association.',
169
+ hint: 'Remove the redundant empty <label> so exactly one <label> is associated with the control.',
170
+ occurrenceOutcome: 'cantTell',
171
+ i18n: {
172
+ summaryKey: 'formControlSingleLabel_summary_cantTell',
173
+ hintKey: 'formControlSingleLabel_hint_cantTell',
174
+ params: { element: tag, labelCount: String(eligibleLabels.size) }
175
+ },
176
+ data: {
177
+ details: {
178
+ reasonCode: 'FORM_FIELD_EXTRA_EMPTY_LABEL',
179
+ element: tag,
180
+ labelCount: eligibleLabels.size,
181
+ contributingLabelCount: contributing.length
182
+ }
183
+ }
184
+ })
185
+ );
186
+ }
187
+ // contributing.length === 0: no label carries text, so nothing competes
188
+ // for the name. A control left unnamed is form-control-programmatic-
189
+ // label-present's concern.
136
190
  }
137
191
 
138
192
  if (applicableCount === 0) {
139
193
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
140
194
  }
141
- if (occurrences.length) {
142
- return {
143
- ruleId: rule.ruleId,
144
- outcome: 'fail',
145
- severity: rule.defaultSeverity || 'moderate',
146
- occurrences
147
- };
148
- }
149
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
195
+
196
+ const resolved = helpers.resolveTieredOutcome
197
+ ? helpers.resolveTieredOutcome(
198
+ failOccurrences,
199
+ cantTellOccurrences,
200
+ rule.defaultSeverity || 'moderate'
201
+ )
202
+ : failOccurrences.length
203
+ ? {
204
+ outcome: 'fail',
205
+ severity: rule.defaultSeverity || 'moderate',
206
+ occurrences: failOccurrences
207
+ }
208
+ : cantTellOccurrences.length
209
+ ? {
210
+ outcome: 'cantTell',
211
+ severity: rule.defaultSeverity || 'moderate',
212
+ occurrences: cantTellOccurrences
213
+ }
214
+ : { outcome: 'pass', severity: 'minor', occurrences: [] };
215
+ return { ruleId: rule.ruleId, ...resolved };
150
216
  }
151
217
 
152
218
  module.exports = { id, meta, runInPage };