@surea11y/core 1.5.0 → 1.7.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 +240 -149
  2. package/README.md +51 -44
  3. package/docs/ACT_RULE_MAPPING.md +245 -0
  4. package/docs/API_STABILITY.md +53 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +106 -4
  6. package/docs/DESIGN_CHALLENGES.md +367 -0
  7. package/docs/EARL.md +100 -0
  8. package/docs/ENGINE_OPTIONS.md +42 -4
  9. package/docs/I18N.md +4 -4
  10. package/docs/INTEGRATION.md +4 -2
  11. package/docs/LIMITATIONS.md +9 -5
  12. package/docs/OUTPUT_SCHEMA.md +44 -6
  13. package/docs/POLICY.md +1 -1
  14. package/docs/REPORT.md +1 -1
  15. package/docs/RULE_AUTHORING.md +63 -36
  16. package/docs/RULE_CATALOG.md +1928 -169
  17. package/docs/RULE_HELPERS.md +333 -0
  18. package/docs/RULE_TAXONOMY.md +27 -6
  19. package/docs/SARIF.md +21 -2
  20. package/docs/TROUBLESHOOTING.md +2 -2
  21. package/docs/WCAG_CONFORMANCE.md +34 -10
  22. package/package.json +11 -9
  23. package/src/baseline.js +3 -3
  24. package/src/checks/automatic/area-alt-present.js +2 -2
  25. package/src/checks/automatic/aria-allowed-attr.js +74 -10
  26. package/src/checks/automatic/aria-allowed-role.js +34 -25
  27. package/src/checks/automatic/aria-braille-equivalent.js +21 -13
  28. package/src/checks/automatic/aria-conditional-attr.js +22 -15
  29. package/src/checks/automatic/aria-deprecated-role.js +13 -1
  30. package/src/checks/automatic/aria-hidden-body.js +3 -3
  31. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  32. package/src/checks/automatic/aria-prohibited-attr.js +23 -18
  33. package/src/checks/automatic/aria-prohibited-children.js +136 -43
  34. package/src/checks/automatic/aria-required-attr.js +119 -24
  35. package/src/checks/automatic/aria-required-children.js +54 -30
  36. package/src/checks/automatic/aria-required-parent.js +93 -15
  37. package/src/checks/automatic/aria-role-name-present.js +37 -23
  38. package/src/checks/automatic/aria-roles-valid.js +52 -21
  39. package/src/checks/automatic/aria-valid-attr-value.js +89 -33
  40. package/src/checks/automatic/aria-valid-attr.js +15 -10
  41. package/src/checks/automatic/autocomplete-valid.js +2 -2
  42. package/src/checks/automatic/avoid-inline-spacing.js +133 -6
  43. package/src/checks/automatic/binary-control-name-present.js +27 -5
  44. package/src/checks/automatic/button-name-present.js +92 -6
  45. package/src/checks/automatic/combobox-name-present.js +26 -6
  46. package/src/checks/automatic/contrast-computable.js +42 -0
  47. package/src/checks/automatic/contrast-enhanced.js +33 -1
  48. package/src/checks/automatic/contrast-minimum.js +33 -1
  49. package/src/checks/automatic/css-orientation-lock.js +138 -24
  50. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  51. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  52. package/src/checks/automatic/dialog-name-present.js +20 -2
  53. package/src/checks/automatic/duplicate-id-aria.js +10 -3
  54. package/src/checks/automatic/duplicate-id.js +203 -0
  55. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  56. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  57. package/src/checks/automatic/form-control-single-label.js +10 -1
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +68 -7
  60. package/src/checks/automatic/iframe-name-present.js +37 -3
  61. package/src/checks/automatic/iframe-title-unique.js +1 -1
  62. package/src/checks/automatic/img-alt-present.js +12 -4
  63. package/src/checks/automatic/label-in-name.js +204 -68
  64. package/src/checks/automatic/link-in-text-block.js +285 -29
  65. package/src/checks/automatic/link-name-present.js +22 -1
  66. package/src/checks/automatic/list-children-valid.js +6 -6
  67. package/src/checks/automatic/listbox-name-present.js +28 -8
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  69. package/src/checks/automatic/menuitem-name-present.js +20 -2
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  72. package/src/checks/automatic/meter-name-present.js +23 -4
  73. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  74. package/src/checks/automatic/option-name-present.js +23 -4
  75. package/src/checks/automatic/page-title-present.js +21 -3
  76. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  77. package/src/checks/automatic/progressbar-name-present.js +23 -4
  78. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +64 -16
  79. package/src/checks/automatic/searchbox-name-present.js +28 -8
  80. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  81. package/src/checks/automatic/slider-name-present.js +27 -6
  82. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  83. package/src/checks/automatic/summary-name-present.js +18 -2
  84. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  85. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  86. package/src/checks/automatic/tab-name-present.js +21 -2
  87. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  88. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  89. package/src/checks/automatic/target-size-minimum.js +155 -58
  90. package/src/checks/automatic/td-has-header.js +24 -23
  91. package/src/checks/automatic/textbox-name-present.js +28 -8
  92. package/src/checks/automatic/tooltip-name-present.js +21 -2
  93. package/src/checks/automatic/treeitem-name-present.js +23 -4
  94. package/src/checks/automatic/valid-lang.js +92 -7
  95. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  96. package/src/checks/manual/accesskeys-manual.js +3 -3
  97. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  98. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  99. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  100. package/src/checks/manual/aria-text-manual.js +4 -4
  101. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  105. package/src/checks/manual/empty-heading-manual.js +58 -11
  106. package/src/checks/manual/empty-table-header-manual.js +8 -8
  107. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  108. package/src/checks/manual/form-control-label-quality-manual.js +563 -0
  109. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  110. package/src/checks/manual/heading-order-manual.js +3 -3
  111. package/src/checks/manual/heading-quality-manual.js +338 -0
  112. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  113. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  114. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  115. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  116. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  117. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  118. package/src/checks/manual/label-title-only-manual.js +4 -4
  119. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  120. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  121. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  122. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  123. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  124. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  125. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  126. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  127. package/src/checks/manual/landmark-unique-manual.js +9 -9
  128. package/src/checks/manual/link-name-quality-manual.js +161 -32
  129. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  130. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  131. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  132. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  133. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  134. package/src/checks/manual/p-as-heading-manual.js +4 -4
  135. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  136. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  137. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  138. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  139. package/src/checks/manual/region-manual.js +19 -19
  140. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  141. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  142. package/src/checks/manual/skip-link-manual.js +5 -5
  143. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  144. package/src/checks/manual/tabindex-manual.js +2 -2
  145. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  146. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  147. package/src/checks/manual/video-caption-manual.js +3 -3
  148. package/src/checks/manual-review.js +17 -1
  149. package/src/core.js +8880 -41883
  150. package/src/earl.js +144 -0
  151. package/src/report.js +2 -2
  152. package/src/sarif.js +22 -2
  153. package/surea11y.browser.js +10 -37882
  154. package/surea11y.i18n.de.js +2 -21
  155. package/surea11y.i18n.es.js +2 -21
  156. package/surea11y.i18n.fr.js +2 -21
  157. package/bin/surea11y-core.js +0 -20
@@ -15,9 +15,14 @@
15
15
  * @expectation
16
16
  * Each attribute's value conforms to its WAI-ARIA-declared value type:
17
17
  * boolean ("true"/"false"), tristate ("true"/"false"/"mixed"), a token
18
- * from a fixed enumerated set, an integer, a real number, or an empty
19
- * value or ID reference (list) that resolves to an existing element in
20
- * the document.
18
+ * from a fixed enumerated set, an integer, a real number, or an ID
19
+ * reference (list) that resolves to an existing element in the document.
20
+ * Per ACT 6a7281's own applicability ("any state or property that is
21
+ * NOT empty"), an explicitly empty value, including a bare boolean-style
22
+ * attribute with no "=value" at all, e.g. `aria-checked` alone, is out
23
+ * of scope for every value type, not a violation: a common, deliberate
24
+ * pattern in templated markup (e.g. React conditionally rendering
25
+ * `aria-describedby={hasError ? errorId : ''}`).
21
26
  * @implementation-notes
22
27
  * - Not rule-gated on isAccTreeEligible: this remains a static-markup
23
28
  * property, while engine-level hidden-subtree filtering still applies
@@ -25,16 +30,27 @@
25
30
  * - ID-reference resolution (see aria-helpers.js's idExists) only flags
26
31
  * idref-list attributes (aria-labelledby, aria-describedby,
27
32
  * aria-controls, aria-owns, etc.) when NONE of the space-separated ids
28
- * resolve — a partially-dangling list (some ids exist, some don't) is
33
+ * resolve, a partially-dangling list (some ids exist, some don't) is
29
34
  * left unflagged (only invalidate when every token fails to resolve).
30
- * Single-idref attributes (aria-activedescendant, aria-errormessage) are
31
- * flagged whenever their one id doesn't resolve.
32
- * - An explicitly-EMPTY idref/idref-list value (e.g.
33
- * `aria-describedby=""`) is valid, not a violation — `allowEmpty` holds
34
- * for every idref/idref-list ARIA attribute. A common, deliberate pattern
35
- * in templated markup (e.g. React conditionally rendering
36
- * `aria-describedby={hasError ? errorId : ''}`); flagging it would be a
37
- * false positive.
35
+ * Of the two single-idref attributes, aria-activedescendant is flagged
36
+ * whenever its one id doesn't resolve; aria-errormessage's existence is
37
+ * never checked (format only), ACT 6a7281's own Background
38
+ * text names it as a non-required property whose target "may be created
39
+ * in response to an event that may or may not happen" (a validation
40
+ * error message rendered only once the error actually occurs).
41
+ * - aria-controls is never a fail on a target that doesn't resolve, and
42
+ * this is the one place the rule reports two tiers. The controlled
43
+ * element is routinely built when the widget opens, so a static scan
44
+ * that cannot find it has not found a defect; it has found markup it
45
+ * cannot decide. A collapsed widget (aria-expanded="false" or
46
+ * aria-selected="false") passes outright, since the absence is exactly
47
+ * what that state means; anything else is a `cantTell` for human
48
+ * review. Every other idref/idref-list attribute keeps its fail: a
49
+ * dangling aria-labelledby or aria-owns names content that was supposed
50
+ * to be there already.
51
+ * - Two tiers in one run means helpers.resolveTieredOutcome decides the
52
+ * aggregate: a real fail elsewhere on the page still reports fail, and
53
+ * the aria-controls occurrences ride along rather than being dropped.
38
54
  */
39
55
 
40
56
  const id = 'aria-valid-attr-value';
@@ -76,13 +92,15 @@ function runInPage(ctx) {
76
92
 
77
93
  const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*') : helpers.queryAll('*');
78
94
 
79
- const occurrences = [];
95
+ const failOccurrences = [];
96
+ const cantTellOccurrences = [];
80
97
  let applicableCount = 0;
81
98
 
82
99
  for (const el of nodes) {
83
100
  if (!el || !el.attributes || !el.getAttribute) continue;
84
101
 
85
102
  let invalid = null;
103
+ let review = null;
86
104
  const attrs = el.attributes;
87
105
  for (let i = 0; i < attrs.length; i++) {
88
106
  const name = String(attrs[i].name || '').toLowerCase();
@@ -92,21 +110,26 @@ function runInPage(ctx) {
92
110
  applicableCount += 1;
93
111
 
94
112
  const rawValue = el.getAttribute(name);
95
- const result = ariaHelpers.validateAttrValue(name, rawValue);
96
- if (!result.valid) {
113
+ const result = ariaHelpers.validateAttrValue(name, rawValue, el);
114
+ if (result.valid) continue;
115
+
116
+ const item = {
117
+ name,
118
+ value: rawValue == null ? '' : String(rawValue),
119
+ reason: result.reason
120
+ };
121
+
122
+ if (result.review) {
123
+ if (!review) review = [];
124
+ review.push(item);
125
+ } else {
97
126
  if (!invalid) invalid = [];
98
- invalid.push({
99
- name,
100
- value: rawValue == null ? '' : String(rawValue),
101
- reason: result.reason
102
- });
127
+ invalid.push(item);
103
128
  }
104
129
  }
105
130
 
106
- if (!invalid || !invalid.length) continue;
107
-
108
- for (const item of invalid) {
109
- occurrences.push(
131
+ for (const item of invalid || []) {
132
+ failOccurrences.push(
110
133
  helpers.reportOccurrence(el, {
111
134
  summary: 'This element has an ARIA attribute with an invalid value.',
112
135
  hint: 'Use a value that matches the attribute’s expected type (see the WAI-ARIA specification for this attribute).',
@@ -126,20 +149,53 @@ function runInPage(ctx) {
126
149
  })
127
150
  );
128
151
  }
152
+
153
+ for (const item of review || []) {
154
+ cantTellOccurrences.push(
155
+ helpers.reportOccurrence(el, {
156
+ summary:
157
+ 'No element with this id exists right now, so the engine cannot tell whether this reference is wrong.',
158
+ hint: 'Confirm the controlled element is created when the widget opens; if it never exists, remove or correct the reference.',
159
+ i18n: {
160
+ summaryKey: 'ariaValidAttrValue_summary_cantTell_idref',
161
+ hintKey: 'ariaValidAttrValue_hint_cantTell_idref',
162
+ params: { attr: item.name, value: item.value }
163
+ },
164
+ uncertainty: {
165
+ code: 'runtime-dependent',
166
+ needed: 'Whether the widget creates the referenced element when it opens.',
167
+ evidence: {
168
+ attribute: item.name,
169
+ referencedId: item.value,
170
+ resolvedAtScanTime: false
171
+ }
172
+ },
173
+ data: {
174
+ details: {
175
+ reasonCode: 'ARIA_ATTR_VALUE_TARGET_ABSENT',
176
+ attr: item.name,
177
+ value: item.value,
178
+ valueReason: item.reason
179
+ }
180
+ }
181
+ })
182
+ );
183
+ }
129
184
  }
130
185
 
131
186
  if (applicableCount === 0) {
132
187
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
133
188
  }
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: [] };
189
+
190
+ // See helpers.resolveTieredOutcome's own header comment
191
+ // (src/core/dom-helpers.js): a fail-tier finding never silently discards
192
+ // cantTell-tier findings from the same run.
193
+ const resolved = helpers.resolveTieredOutcome(
194
+ failOccurrences,
195
+ cantTellOccurrences,
196
+ rule.defaultSeverity || 'serious'
197
+ );
198
+ return { ruleId: rule.ruleId, ...resolved };
143
199
  }
144
200
 
145
201
  module.exports = { id, meta, runInPage };
@@ -16,9 +16,15 @@
16
16
  * specification (catches typos / made-up attribute names, which are
17
17
  * silently ignored by assistive technology and therefore a real,
18
18
  * deterministic defect).
19
+ * Reported at CANTTELL rather than FAIL: an aria-* attribute the spec
20
+ * does not define is inert, so nothing about the element's exposed name,
21
+ * role or value changes because it is there. Where the author meant a real
22
+ * attribute and the element ends up without a name, that absence is the
23
+ * naming rules' decision, not this one's. ACT 5f99a7 maps 1.3.1/4.1.2 as
24
+ * secondary requirements, "less strict" than the rule itself.
19
25
  * @implementation-notes
20
26
  * - Distinct from aria-valid-attr-value (which validates the VALUE
21
- * of a recognized attribute) — this rule only validates the attribute
27
+ * of a recognized attribute), this rule only validates the attribute
22
28
  * NAME.
23
29
  * - Not rule-gated on isAccTreeEligible: this remains a static-markup
24
30
  * property, while engine-level hidden-subtree filtering still applies
@@ -96,6 +102,12 @@ function runInPage(ctx) {
96
102
  hintKey: 'ariaValidAttr_hint_fail',
97
103
  params: { attr: name }
98
104
  },
105
+ uncertainty: {
106
+ code: 'spec-only',
107
+ needed:
108
+ 'Whether the misspelling cost the element a name or state it has no other source for.',
109
+ evidence: { attribute: name, definedInAria: false, exposedEffect: 'none' }
110
+ },
99
111
  data: {
100
112
  details: { reasonCode: 'ARIA_ATTR_INVALID', attr: name }
101
113
  }
@@ -107,15 +119,8 @@ function runInPage(ctx) {
107
119
  if (applicableCount === 0) {
108
120
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
109
121
  }
110
- if (occurrences.length) {
111
- return {
112
- ruleId: rule.ruleId,
113
- outcome: 'fail',
114
- severity: rule.defaultSeverity || 'serious',
115
- occurrences
116
- };
117
- }
118
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
122
+ const resolved = helpers.resolveTieredOutcome([], occurrences, rule.defaultSeverity || 'serious');
123
+ return { ruleId: rule.ruleId, ...resolved };
119
124
  }
120
125
 
121
126
  module.exports = { id, meta, runInPage };
@@ -26,7 +26,7 @@
26
26
  * order) with the full fixed field-name vocabulary from the HTML
27
27
  * Standard, rather than validating every field-specific constraint
28
28
  * (e.g. which contact-modality tokens are legal for which field
29
- * names) — matches this engine's established "deliberately scoped"
29
+ * names), matches this engine's established "scoped"
30
30
  * precedent (see aria-helpers.js) for keeping high-confidence fail
31
31
  * without reimplementing the entire spec.
32
32
  */
@@ -63,7 +63,7 @@ const meta = {
63
63
  function runInPage(ctx) {
64
64
  const { helpers, rule } = ctx;
65
65
 
66
- // Declared inside runInPage — see scripts/build-core.js header
66
+ // Declared inside runInPage, see scripts/build-core.js header
67
67
  // ("runInPage MUST be self-contained").
68
68
  const FIELD_NAMES = new Set([
69
69
  'name',
@@ -25,8 +25,12 @@
25
25
  * - Within one declaration block, importance outranks order, so the effective
26
26
  * declaration is the last `!important` one for that property.
27
27
  * - ACT 78fd32/24afc2/9e45ec additionally require the text to contain a soft
28
- * wrap break, which cannot be determined without layout. Not implemented, so
29
- * a forced value on text that never wraps is still reported.
28
+ * wrap break, which layout would settle and this cannot. Two shapes do
29
+ * establish that no wrap is possible -- text not allowed to wrap, and a
30
+ * fixed-width element inside a horizontally scrolling ancestor -- and those
31
+ * are reported for review rather than failed. Anything else is treated as
32
+ * wrapping, so a forced value on text that never wraps for some other reason
33
+ * is still reported.
30
34
  */
31
35
 
32
36
  const id = 'avoid-inline-spacing';
@@ -83,6 +87,8 @@ function runInPage(ctx) {
83
87
 
84
88
  const occurrences = [];
85
89
  let applicableCount = 0;
90
+ const undecided = [];
91
+ const noWrap = [];
86
92
 
87
93
  // Within one declaration block, importance wins over order, so the last
88
94
  // important declaration is the one that takes effect. Passed Example 5 of ACT
@@ -148,6 +154,36 @@ function runInPage(ctx) {
148
154
  return null;
149
155
  }
150
156
 
157
+ // ACT 78fd32/24afc2/9e45ec apply only to text that contains a soft wrap
158
+ // break. Layout settles that; short of it, two shapes establish that no wrap
159
+ // can happen -- text not allowed to wrap, and a fixed-width element inside a
160
+ // horizontally scrolling ancestor, which narrowing the viewport cannot reach.
161
+ function cannotSoftWrap(el) {
162
+ const cs = computedStyleOf(el);
163
+ const whiteSpace = cs ? String(cs.whiteSpace || '').toLowerCase() : '';
164
+ if (whiteSpace === 'nowrap' || whiteSpace === 'pre') return true;
165
+
166
+ if (!/(^|;)\s*width\s*:/i.test(String(el.getAttribute('style') || ''))) return false;
167
+
168
+ const chain =
169
+ helpers && typeof helpers.ancestorsIncludingSelf === 'function'
170
+ ? helpers.ancestorsIncludingSelf(el)
171
+ : null;
172
+ const ancestors = chain || [];
173
+ if (!chain) {
174
+ for (let a = el.parentElement; a; a = a.parentElement) ancestors.push(a);
175
+ }
176
+
177
+ for (const ancestor of ancestors) {
178
+ if (ancestor === el) continue;
179
+ const acs = computedStyleOf(ancestor);
180
+ if (!acs) continue;
181
+ const overflowX = String(acs.overflowX || acs.overflow || '').toLowerCase();
182
+ if (overflowX === 'scroll' || overflowX === 'auto') return true;
183
+ }
184
+ return false;
185
+ }
186
+
151
187
  // ACT scopes these rules to text visible on screen, and text pushed far off
152
188
  // canvas is the one hidden shape the shared eligibility check keeps eligible.
153
189
  function isOffScreen(el) {
@@ -228,6 +264,7 @@ function runInPage(ctx) {
228
264
  if (!hasVisibleTextChild(el) || !isRendered(el) || isOffScreen(el)) continue;
229
265
 
230
266
  const flagged = [];
267
+ const unresolved = [];
231
268
  let inScope = false;
232
269
  for (const prop of SPACING_PROPS) {
233
270
  const decl = effectiveDeclaration(raw, prop);
@@ -235,14 +272,28 @@ function runInPage(ctx) {
235
272
  if (INHERITED_KEYWORDS.indexOf(decl.value.toLowerCase()) !== -1) continue;
236
273
  inScope = true;
237
274
  const ratio = spacingRatio(el, prop, decl.value);
238
- // Unresolvable spacing is left alone: this engine reserves fail for
239
- // high-confidence violations.
240
- if (ratio === null) continue;
275
+ // Not flagged, since `fail` needs a measured value -- but recorded, so
276
+ // the element does not fall through to `pass` unmeasured.
277
+ if (ratio === null) {
278
+ unresolved.push(prop);
279
+ continue;
280
+ }
241
281
  if (ratio < MIN_RATIO[prop]) flagged.push(prop);
242
282
  }
243
283
 
244
284
  if (inScope) applicableCount += 1;
245
- if (!flagged.length) continue;
285
+ if (!flagged.length) {
286
+ if (unresolved.length) undecided.push({ el, props: unresolved.slice() });
287
+ continue;
288
+ }
289
+
290
+ // A forced value on text that cannot wrap is outside these ACT rules'
291
+ // applicability, and whether it wraps is not decidable here, so it is
292
+ // reported for review rather than failed.
293
+ if (cannotSoftWrap(el)) {
294
+ noWrap.push({ el, props: flagged.slice() });
295
+ continue;
296
+ }
246
297
 
247
298
  const tag = el.tagName.toLowerCase();
248
299
 
@@ -273,6 +324,82 @@ function runInPage(ctx) {
273
324
  occurrences
274
325
  };
275
326
  }
327
+ if (noWrap.length) {
328
+ return {
329
+ ruleId: rule.ruleId,
330
+ outcome: 'cantTell',
331
+ severity: rule.defaultSeverity || 'moderate',
332
+ confidence: 'low',
333
+ occurrences: noWrap.map(({ el, props }) =>
334
+ helpers.reportOccurrence(el, {
335
+ summary: `This element's inline style forces ${props.join(', ')} with !important, but its text does not appear able to wrap, so the text-spacing criterion may not apply to it.`,
336
+ hint: 'Confirm whether this text ever wraps. If it cannot, the criterion does not apply; if it can, remove !important or set a value that already meets the metric.',
337
+ i18n: {
338
+ summaryKey: 'avoidInlineSpacing_summary_cantTell_noSoftWrap',
339
+ hintKey: 'avoidInlineSpacing_hint_cantTell_noSoftWrap',
340
+ params: {
341
+ element: (el.tagName || '').toLowerCase(),
342
+ properties: props.join(', ')
343
+ }
344
+ },
345
+ uncertainty: {
346
+ code: 'not-computable',
347
+ needed: 'Whether this text ever contains a soft wrap break, which needs layout.',
348
+ evidence: {
349
+ element: (el.tagName || '').toLowerCase(),
350
+ properties: props,
351
+ reasonCode: 'INLINE_SPACING_NO_SOFT_WRAP'
352
+ }
353
+ },
354
+ data: {
355
+ details: {
356
+ reasonCode: 'INLINE_SPACING_NO_SOFT_WRAP',
357
+ element: (el.tagName || '').toLowerCase(),
358
+ properties: props
359
+ }
360
+ }
361
+ })
362
+ )
363
+ };
364
+ }
365
+ if (undecided.length) {
366
+ return {
367
+ ruleId: rule.ruleId,
368
+ outcome: 'cantTell',
369
+ severity: rule.defaultSeverity || 'moderate',
370
+ confidence: 'low',
371
+ occurrences: undecided.map(({ el, props }) =>
372
+ helpers.reportOccurrence(el, {
373
+ summary: `This element's inline style sets ${props.join(', ')} with !important, but the value could not be resolved, so whether it meets the WCAG text-spacing metric could not be determined.`,
374
+ hint: 'Check this value by hand against the metric (line-height 1.5, letter-spacing 0.12em, word-spacing 0.16em), or express it in a unit the engine can resolve against the element’s computed font size.',
375
+ i18n: {
376
+ summaryKey: 'avoidInlineSpacing_summary_cantTell',
377
+ hintKey: 'avoidInlineSpacing_hint_cantTell',
378
+ params: {
379
+ element: (el.tagName || '').toLowerCase(),
380
+ properties: props.join(', ')
381
+ }
382
+ },
383
+ uncertainty: {
384
+ code: 'not-computable',
385
+ needed: 'A resolved value for the spacing declarations marked !important.',
386
+ evidence: {
387
+ element: (el.tagName || '').toLowerCase(),
388
+ properties: props,
389
+ reasonCode: 'INLINE_SPACING_NOT_RESOLVABLE'
390
+ }
391
+ },
392
+ data: {
393
+ details: {
394
+ reasonCode: 'INLINE_SPACING_NOT_RESOLVABLE',
395
+ element: (el.tagName || '').toLowerCase(),
396
+ properties: props
397
+ }
398
+ }
399
+ })
400
+ )
401
+ };
402
+ }
276
403
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
277
404
  }
278
405
 
@@ -2,6 +2,28 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check binary-control-name-present
7
+ * @atomic true
8
+ * @summary Checkboxes, radios and switches must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="checkbox", role="radio" or
13
+ * role="switch" (the attribute must name one of those roles alone, not a
14
+ * fallback list) that are included in the accessibility tree. A native
15
+ * <input type="checkbox">/<input type="radio"> is in scope only when it
16
+ * carries one of those roles explicitly; without a role attribute it
17
+ * belongs to form-control-programmatic-label-present.
18
+ * @expectation
19
+ * The control has a non-empty accessible name from aria-label, from an
20
+ * aria-labelledby that resolves to non-empty text, or from title. A native
21
+ * checkbox or radio additionally accepts an associated <label>, the
22
+ * labels API, a wrapping <label>, or label[for], with at most four labels
23
+ * read for determinism, and any other element accepts its own subtree
24
+ * text, those roles being name-from-content.
25
+ */
26
+
5
27
  const id = 'binary-control-name-present';
6
28
 
7
29
  const meta = {
@@ -79,7 +101,7 @@ function runInPage(ctx) {
79
101
  }
80
102
 
81
103
  function getConservativeSubtreeText(document, container) {
82
- // "Name from content" — recurses into descendants and uses each one's
104
+ // "Name from content", recurses into descendants and uses each one's
83
105
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
84
106
  // it has one, not just literal text nodes. See getContentNameInfo's
85
107
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -96,7 +118,7 @@ function runInPage(ctx) {
96
118
 
97
119
  // A <label> contributes a name via its own aria-label/aria-labelledby
98
120
  // (checked first, same ARIA-over-content precedence any element's
99
- // accessible name gives — e.g. <label aria-label="Search"><svg
121
+ // accessible name gives, e.g. <label aria-label="Search"><svg
100
122
  // aria-hidden="true">...</svg></label> names its control "Search" even
101
123
  // though the label's only child content is aria-hidden) or, failing
102
124
  // that, its rendered content (getConservativeSubtreeText).
@@ -111,10 +133,10 @@ function runInPage(ctx) {
111
133
  if (content) return content;
112
134
  // Final fallback per the general accname text-alternative algorithm,
113
135
  // which applies to any element being asked for its name regardless of
114
- // why (own aria-label, an aria-labelledby reference, or — here — native
136
+ // why (own aria-label, an aria-labelledby reference, or, here, native
115
137
  // <label for> association): title, when nothing else yields a name.
116
138
  // Purely additive (only fills in a name where there was none before),
117
- // so it carries no false-positive risk — see dialog-name-present.js's
139
+ // so it carries no false-positive risk, see dialog-name-present.js's
118
140
  // identical <iframe>-title-fallback fix for the concrete real-world
119
141
  // trigger this same accname step covers elsewhere.
120
142
  return getAttr(lab, 'title');
@@ -124,7 +146,7 @@ function runInPage(ctx) {
124
146
  const raw = getAttr(el, 'aria-labelledby');
125
147
  if (!raw) return '';
126
148
  // Delegates to the shared getTextFromIdRefs helper instead of computing
127
- // name-from-content of the referenced element — see dialog-name-
149
+ // name-from-content of the referenced element, see dialog-name-
128
150
  // present.js's identical fix for the full rationale (an <iframe>
129
151
  // aria-labelledby target's only name source is its title attribute,
130
152
  // which name-from-content alone can never see).
@@ -2,6 +2,29 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check button-name-present
7
+ * @atomic true
8
+ * @summary Buttons must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to <button>, <input type="button">, <input type="submit">,
13
+ * <input type="reset"> and elements with role="button", where the element
14
+ * is included in the accessibility tree. role="presentation"/"none" takes
15
+ * an element out of scope unless a global ARIA attribute or focusability
16
+ * restores its role, per presentational roles conflict resolution.
17
+ * @expectation
18
+ * The element has a non-empty accessible name. A programmatic name is
19
+ * taken first (aria-labelledby, aria-label, an associated <label>, title).
20
+ * Failing that, an <input> button falls back to its value attribute, and
21
+ * type="submit"/type="reset" fall back to the user agent's own
22
+ * "Submit"/"Reset" default, which is why those two are never nameless.
23
+ * Failing both, a button whose role is name-from-content falls back to its
24
+ * subtree text, counting each descendant's own name (an <img alt>,
25
+ * aria-label or title) rather than only text nodes.
26
+ */
27
+
5
28
  // NOTE: Repo ruleId contract requires ENGINE_TAG prefix in the rule id.
6
29
  // File name intentionally has no prefix, per request.
7
30
  const id = 'button-name-present';
@@ -45,7 +68,7 @@ function runInPage(ctx) {
45
68
  }
46
69
 
47
70
  function getConservativeSubtreeText(container) {
48
- // "Name from content" — recurses into descendants and uses each one's
71
+ // "Name from content", recurses into descendants and uses each one's
49
72
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
50
73
  // it has one, not just literal text nodes. See getContentNameInfo's
51
74
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -64,7 +87,16 @@ function runInPage(ctx) {
64
87
  const type = normalizeWs(el.getAttribute ? el.getAttribute('type') : '').toLowerCase();
65
88
  if (type !== 'button' && type !== 'submit' && type !== 'reset') return '';
66
89
  const vAttr = el.getAttribute ? el.getAttribute('value') : '';
67
- return normalizeWs(vAttr != null ? vAttr : typeof el.value === 'string' ? el.value : '');
90
+ const explicit = normalizeWs(
91
+ vAttr != null ? vAttr : typeof el.value === 'string' ? el.value : ''
92
+ );
93
+ if (explicit) return explicit;
94
+ // HTML spec: input[type=submit]/[type=reset] with no `value` fall back
95
+ // to a UA-supplied default label ("Submit"/"Reset"), so they are never
96
+ // actually nameless -- unlike type=button, whose value defaults to "".
97
+ if (type === 'submit') return 'Submit';
98
+ if (type === 'reset') return 'Reset';
99
+ return '';
68
100
  } catch {
69
101
  return '';
70
102
  }
@@ -90,17 +122,71 @@ function runInPage(ctx) {
90
122
  typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
91
123
  if (!eligible) continue;
92
124
 
93
- applicableCount += 1;
94
-
95
125
  const tag = (el.tagName || '').toLowerCase();
96
126
  const role = el.getAttribute ? el.getAttribute('role') : null;
97
127
  const roleNorm = normalizeWs(role).toLowerCase();
98
128
 
129
+ // role="none"/"presentation" removes this element from the accessibility
130
+ // tree as a button (WAI-ARIA Presentational Roles Conflict Resolution),
131
+ // UNLESS a conflicting global ARIA attribute or focusability restores
132
+ // its native/explicit role -- mirrors presentation-role-conflict-manual.js's
133
+ // detection logic. Kept local to this rule rather than routed through
134
+ // the shared eligibility helper: several other rules rely
135
+ // on that helper staying permissive for role="none" wrappers they walk
136
+ // through themselves (e.g. aria-prohibited-children's "transparent
137
+ // wrapper" traversal).
138
+ if (roleNorm === 'none' || roleNorm === 'presentation') {
139
+ const ariaHiddenTrue = el.getAttribute && el.getAttribute('aria-hidden') === 'true';
140
+ if (!ariaHiddenTrue) {
141
+ const GLOBAL_ARIA_ATTRS = [
142
+ 'aria-atomic',
143
+ 'aria-braillelabel',
144
+ 'aria-brailleroledescription',
145
+ 'aria-busy',
146
+ 'aria-controls',
147
+ 'aria-current',
148
+ 'aria-describedby',
149
+ 'aria-description',
150
+ 'aria-details',
151
+ 'aria-disabled',
152
+ 'aria-dropeffect',
153
+ 'aria-errormessage',
154
+ 'aria-flowto',
155
+ 'aria-grabbed',
156
+ 'aria-haspopup',
157
+ 'aria-hidden',
158
+ 'aria-invalid',
159
+ 'aria-keyshortcuts',
160
+ 'aria-label',
161
+ 'aria-labelledby',
162
+ 'aria-live',
163
+ 'aria-owns',
164
+ 'aria-relevant',
165
+ 'aria-roledescription'
166
+ ];
167
+ const hasConflict = GLOBAL_ARIA_ATTRS.some((a) =>
168
+ el.hasAttribute ? el.hasAttribute(a) : false
169
+ );
170
+ let isFocusable = false;
171
+ if (!hasConflict && helpers.getFocusableInfo) {
172
+ try {
173
+ const fi = helpers.getFocusableInfo(el, ctx);
174
+ isFocusable = !!(fi && fi.focusable);
175
+ } catch {
176
+ isFocusable = false;
177
+ }
178
+ }
179
+ if (!hasConflict && !isFocusable) continue;
180
+ }
181
+ }
182
+
183
+ applicableCount += 1;
184
+
99
185
  const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
100
186
 
101
187
  // getAccessibleNameInfo only resolves programmatic mechanisms (aria-labelledby,
102
- // aria-label, native <label> association, title) — it never falls back to
103
- // subtree content — so it's safe to trust directly whenever present.
188
+ // aria-label, native <label> association, title), it never falls back to
189
+ // subtree content, so it's safe to trust directly whenever present.
104
190
  const trustedProgrammaticName = normalizeWs(
105
191
  nameInfo && nameInfo.present && typeof nameInfo.value === 'string' ? nameInfo.value : ''
106
192
  );