@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
@@ -9,8 +9,10 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 4.1.2
11
11
  * @applicability
12
- * Applies to elements with an explicit, valid, non-abstract role that
13
- * also carry at least one recognized aria-* attribute.
12
+ * Applies to elements carrying at least one recognized, non-global
13
+ * aria-* attribute, judged against the role they actually have: an
14
+ * explicit valid role, else the implicit role of their tag, else, for
15
+ * the elements HTML-AAM maps to no role at all, nothing.
14
16
  * @expectation
15
17
  * Every recognized aria-* attribute present is either: (a) globally
16
18
  * supported on any element (the "global" ARIA states/properties, e.g.
@@ -20,10 +22,19 @@
20
22
  * still allowed: it is reported as CANTTELL (see helpers.aria.isDeprecatedAttr)
21
23
  * so the author decides, not as a not-allowed FAIL.
22
24
  * @implementation-notes
23
- * - Only evaluated for elements with an explicit role, since the global
24
- * set already covers implicit-role elements without asserting anything
25
- * role-specific; scope kept deliberately narrow to avoid false positives
26
- * on implicit-role ARIA-in-HTML edge cases not modeled here.
25
+ * - Three tiers of role resolution, in order: an explicit `role`; the
26
+ * implicit role of the tag (IMPLICIT_ROLE_BY_ELEMENT, generated only for
27
+ * elements whose role is the same in every context, see
28
+ * `scripts/generate-aria-tables.js` for what is excluded and why); and
29
+ * ROLELESS_ELEMENTS, the tags HTML-AAM gives no role at all. A
30
+ * role-specific attribute on one of those is supported by nothing, which
31
+ * is exactly ACT 5c01ea's `<audio controls aria-orientation="horizontal">`.
32
+ * An element whose role is context-dependent (`<a>`, `<section>`,
33
+ * `<td>`, ...) is still skipped rather than guessed at.
34
+ * - `<div>`/`<span>` resolve to `generic`, whose supported set is empty, so
35
+ * `<div aria-expanded="true">` is reported: the attribute announces
36
+ * nothing on an element with no widget semantics, which is a real defect
37
+ * and not a spec technicality.
27
38
  * - SUPPORTED_ATTRS_BY_ROLE holds unambiguous, well-established ARIA facts:
28
39
  * subclass relationships like `searchbox`==`textbox`; same-family widget
29
40
  * properties like `columnheader`/`rowheader` sort/col-row-index/span
@@ -82,7 +93,7 @@ function runInPage(ctx) {
82
93
  // Global ARIA states/properties supported on (almost) any element,
83
94
  // regardless of role, per the WAI-ARIA "Global States and Properties" list.
84
95
  // Declared inside runInPage (rather than at module scope) because the
85
- // build inlines only this function's own source text — see
96
+ // build inlines only this function's own source text, see
86
97
  // scripts/build-core.js header ("runInPage MUST be self-contained").
87
98
  // <generated:aria-global-attrs>
88
99
  const GLOBAL_ATTRS = [
@@ -109,8 +120,8 @@ function runInPage(ctx) {
109
120
  ];
110
121
  // </generated:aria-global-attrs>
111
122
 
112
- // Per-role supported (non-global) states/properties. Deliberately
113
- // conservative — see src/core/aria-helpers.js file header for the same
123
+ // Per-role supported (non-global) states/properties.
124
+ // conservative, see src/core/aria-helpers.js file header for the same
114
125
  // confidence-scoping rationale; only well-established, unambiguous
115
126
  // role/attribute pairings from the WAI-ARIA role definitions are listed.
116
127
  // <generated:aria-implicit-roles>
@@ -125,6 +136,7 @@ function runInPage(ctx) {
125
136
  details: 'group',
126
137
  dfn: 'term',
127
138
  dialog: 'dialog',
139
+ div: 'generic',
128
140
  dt: 'term',
129
141
  em: 'emphasis',
130
142
  fieldset: 'group',
@@ -149,6 +161,7 @@ function runInPage(ctx) {
149
161
  p: 'paragraph',
150
162
  progress: 'progressbar',
151
163
  strong: 'strong',
164
+ span: 'generic',
152
165
  sub: 'subscript',
153
166
  sup: 'superscript',
154
167
  textarea: 'textbox',
@@ -173,6 +186,10 @@ function runInPage(ctx) {
173
186
  '[aria-activedescendant], [aria-autocomplete], [aria-checked], [aria-colcount], [aria-colindex], [aria-colspan], [aria-disabled], [aria-errormessage], [aria-expanded], [aria-haspopup], [aria-invalid], [aria-level], [aria-modal], [aria-multiline], [aria-multiselectable], [aria-orientation], [aria-placeholder], [aria-posinset], [aria-pressed], [aria-readonly], [aria-required], [aria-rowcount], [aria-rowindex], [aria-rowspan], [aria-selected], [aria-setsize], [aria-sort], [aria-valuemax], [aria-valuemin], [aria-valuenow], [aria-valuetext]';
174
187
  // </generated:aria-implicit-roles>
175
188
 
189
+ // <generated:aria-roleless-elements>
190
+ const ROLELESS_ELEMENTS = new Set(['audio', 'video']);
191
+ // </generated:aria-roleless-elements>
192
+
176
193
  // <generated:aria-role-attrs>
177
194
  const SUPPORTED_ATTRS_BY_ROLE = {
178
195
  alert: [],
@@ -806,6 +823,39 @@ function runInPage(ctx) {
806
823
  const cantTellOccurrences = [];
807
824
  let applicableCount = 0;
808
825
 
826
+ // An element HTML-AAM maps to no role has nothing to support a
827
+ // role-specific attribute, so every non-global one present is reported.
828
+ // Returns how many recognized aria-* attributes it carried, for the
829
+ // applicable count.
830
+ function rolelessOccurrences(el, tag) {
831
+ let seen = 0;
832
+ const attrs = el.attributes;
833
+ for (let i = 0; i < attrs.length; i++) {
834
+ const name = String(attrs[i].name || '').toLowerCase();
835
+ if (name.slice(0, 5) !== 'aria-') continue;
836
+ if (!ariaHelpers.isValidAriaAttrName(name)) continue; // aria-valid-attr's concern
837
+
838
+ seen += 1;
839
+ if (globalSet.has(name)) continue;
840
+
841
+ failOccurrences.push(
842
+ helpers.reportOccurrence(el, {
843
+ summary: `<${tag}> has no ARIA role, so nothing supports the ${name} attribute on it.`,
844
+ hint: `Remove ${name}, or move it to an element whose role supports it. A role-specific ARIA attribute on an element with no role is ignored by assistive technology.`,
845
+ i18n: {
846
+ summaryKey: 'ariaAllowedAttr_summary_fail_roleless',
847
+ hintKey: 'ariaAllowedAttr_hint_fail_roleless',
848
+ params: { attr: name, element: tag }
849
+ },
850
+ data: {
851
+ details: { reasonCode: 'ARIA_ATTR_NOT_ALLOWED_ROLELESS', attr: name, element: tag }
852
+ }
853
+ })
854
+ );
855
+ }
856
+ return seen;
857
+ }
858
+
809
859
  for (const el of nodes) {
810
860
  if (!el || !el.attributes) continue;
811
861
 
@@ -824,6 +874,14 @@ function runInPage(ctx) {
824
874
  role = Object.prototype.hasOwnProperty.call(IMPLICIT_ROLE_BY_ELEMENT, key)
825
875
  ? IMPLICIT_ROLE_BY_ELEMENT[key]
826
876
  : '';
877
+ // No role from either source: for a tag HTML-AAM maps to no role at
878
+ // all, that IS the answer. Nothing supports a role-specific
879
+ // attribute here. Any other tag has a role this table does not model
880
+ // (context-dependent ones), so it stays out of scope.
881
+ if (!role && ROLELESS_ELEMENTS.has(tag)) {
882
+ applicableCount += rolelessOccurrences(el, tag);
883
+ continue;
884
+ }
827
885
  }
828
886
  if (!role || !ariaHelpers.isValidConcreteRole(role)) continue; // aria-roles-valid's concern
829
887
 
@@ -860,7 +918,7 @@ function runInPage(ctx) {
860
918
 
861
919
  for (const name of disallowed) {
862
920
  // A property ARIA deprecated (rather than prohibited) on this role is
863
- // still allowed — surfaced as cantTell for the author to decide, not a
921
+ // still allowed, surfaced as cantTell for the author to decide, not a
864
922
  // not-allowed fail.
865
923
  const deprecated =
866
924
  typeof ariaHelpers.isDeprecatedAttr === 'function' &&
@@ -876,6 +934,12 @@ function runInPage(ctx) {
876
934
  hintKey: 'ariaAllowedAttr_hint_cantTell',
877
935
  params: { attr: name, role }
878
936
  },
937
+ uncertainty: {
938
+ code: 'spec-only',
939
+ needed:
940
+ 'Whether the deprecated attribute is still honoured by the assistive technology in use.',
941
+ evidence: { attribute: name, role, status: 'deprecated-for-role' }
942
+ },
879
943
  data: {
880
944
  details: { reasonCode: 'ARIA_ATTR_DEPRECATED', attr: name, role }
881
945
  }
@@ -6,8 +6,7 @@
6
6
  * @check aria-allowed-role
7
7
  * @atomic true
8
8
  * @summary Explicit role must be permitted by the ARIA-in-HTML spec for its host element
9
- * @standard WCAG 2.2
10
- * @sc 4.1.2
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
11
10
  * @applicability
12
11
  * Applies to elements with an explicit, valid, non-abstract role, where
13
12
  * the host element/attribute combination has an asserted permitted-roles
@@ -16,10 +15,21 @@
16
15
  * @expectation
17
16
  * The explicit role is one of the roles the ARIA-in-HTML specification
18
17
  * permits for that host element.
18
+ * Reported at CANTTELL rather than FAIL: ARIA-in-HTML's permitted-roles
19
+ * table is an author conformance requirement with no ACT rule and no WCAG
20
+ * mapping in any source. The role the author asked for is still the role
21
+ * assistive technology exposes, so whether the combination harms anyone
22
+ * depends on the widget, not on the table.
19
23
  * @implementation-notes
20
- * - Deliberately scoped to elements present in ALLOWED_ROLES_BY_ELEMENT;
24
+ * - Not WCAG-normative. ARIA-in-HTML's permitted-roles table is an author
25
+ * conformance requirement of that specification; no ACT rule covers it and
26
+ * no source maps it to a Success Criterion, so the rule reports the
27
+ * violation without claiming a criterion is failed. Deterministic all the
28
+ * same, so it stays `type: 'automatic'` and keeps deciding rather than
29
+ * deferring to a reviewer -- see docs/RULE_TAXONOMY.md 1.1.
30
+ * - Scoped to elements present in ALLOWED_ROLES_BY_ELEMENT;
21
31
  * elements without an asserted constraint are treated as "no constraint"
22
- * (not flagged) rather than guessed at — see that table's header comment.
32
+ * (not flagged) rather than guessed at, see that table's header comment.
23
33
  * - Not rule-gated on isAccTreeEligible: this remains a static-markup
24
34
  * property, while engine-level hidden-subtree filtering still applies
25
35
  * unless engineOptions.includeHiddenElements is true.
@@ -36,22 +46,14 @@ const meta = {
36
46
  descriptionKey: 'ariaAllowedRole_description'
37
47
  },
38
48
  helpUrl: null,
39
- tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
40
- wcagSc: ['4.1.2'],
41
- normativeMappings: [
42
- {
43
- standard: 'WCAG',
44
- version: '2.2',
45
- requirement: '4.1.2',
46
- title: 'Name, Role, Value',
47
- conformanceLevel: 'A'
48
- }
49
- ],
49
+ tags: ['best-practice', 'aria', 'structure', 'atomic', 'automatic'],
50
+ wcagSc: [],
51
+ normativeMappings: [],
50
52
  defaultSeverity: 'moderate',
51
53
  category: 'robust',
52
54
  type: 'automatic',
53
55
  defaultConfidence: 'high',
54
- coverage: { facetsBySc: { '4.1.2': ['aria-role-allowed-for-element'] } }
56
+ coverage: {}
55
57
  };
56
58
 
57
59
  function runInPage(ctx) {
@@ -93,6 +95,16 @@ function runInPage(ctx) {
93
95
  hintKey: 'ariaAllowedRole_hint_fail',
94
96
  params: { role, element: tag }
95
97
  },
98
+ uncertainty: {
99
+ code: 'spec-only',
100
+ needed: 'Whether the role misstates this element to assistive technology in practice.',
101
+ evidence: {
102
+ role,
103
+ element: tag,
104
+ source: 'ARIA in HTML permitted-roles table',
105
+ wcagSc: []
106
+ }
107
+ },
96
108
  data: {
97
109
  details: { reasonCode: 'ARIA_ROLE_NOT_ALLOWED_FOR_ELEMENT', role, element: tag }
98
110
  }
@@ -103,15 +115,12 @@ function runInPage(ctx) {
103
115
  if (applicableCount === 0) {
104
116
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
105
117
  }
106
- if (occurrences.length) {
107
- return {
108
- ruleId: rule.ruleId,
109
- outcome: 'fail',
110
- severity: rule.defaultSeverity || 'moderate',
111
- occurrences
112
- };
113
- }
114
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
118
+ const resolved = helpers.resolveTieredOutcome(
119
+ [],
120
+ occurrences,
121
+ rule.defaultSeverity || 'moderate'
122
+ );
123
+ return { ruleId: rule.ruleId, ...resolved };
115
124
  }
116
125
 
117
126
  module.exports = { id, meta, runInPage };
@@ -23,11 +23,16 @@
23
23
  * Using either braille-specific attribute as the ONLY naming mechanism
24
24
  * leaves non-braille assistive technology (most screen readers, voice
25
25
  * control, etc.) with no accessible name/role description at all.
26
+ * Reported at CANTTELL rather than FAIL: aria-brailleroledescription
27
+ * without aria-roledescription reaches no user at all, and a missing
28
+ * accessible name is the naming rules' decision for the roles that require
29
+ * one. The braille attribute being unpaired is worth surfacing, but it is
30
+ * not itself a criterion failing.
26
31
  * @implementation-notes
27
32
  * - `aria-braillelabel`/`aria-brailleroledescription` do not participate
28
33
  * in the standard accessible-name computation, so
29
34
  * `helpers.getAccessibleNameInfo` already reflects only the "regular"
30
- * (non-braille) name — no special-casing needed there.
35
+ * (non-braille) name, no special-casing needed there.
31
36
  */
32
37
 
33
38
  const id = 'aria-braille-equivalent';
@@ -52,7 +57,7 @@ const meta = {
52
57
  conformanceLevel: 'A'
53
58
  }
54
59
  ],
55
- defaultSeverity: 'serious',
60
+ defaultSeverity: 'moderate',
56
61
  category: 'robust',
57
62
  type: 'automatic',
58
63
  defaultConfidence: 'high',
@@ -67,7 +72,7 @@ function runInPage(ctx) {
67
72
  }
68
73
 
69
74
  function getConservativeSubtreeText(container) {
70
- // "Name from content" — recurses into descendants and uses each one's
75
+ // "Name from content", recurses into descendants and uses each one's
71
76
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
72
77
  // it has one, not just literal text nodes. See getContentNameInfo's
73
78
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -125,12 +130,18 @@ function runInPage(ctx) {
125
130
  occurrences.push(
126
131
  helpers.reportOccurrence(el, {
127
132
  summary: `This element has ${m.attr} but no ${m.requires}, its non-braille equivalent.`,
128
- hint: `${m.attr} is a Braille-specific supplement, not a replacement — also provide ${m.requires}.`,
133
+ hint: `${m.attr} is a Braille-specific supplement, not a replacement, so also provide ${m.requires}.`,
129
134
  i18n: {
130
135
  summaryKey: 'ariaBrailleEquivalent_summary_fail',
131
136
  hintKey: 'ariaBrailleEquivalent_hint_fail',
132
137
  params: { element: tag, attr: m.attr, requires: m.requires }
133
138
  },
139
+ uncertainty: {
140
+ code: 'spec-only',
141
+ needed:
142
+ 'Whether any user reaches this attribute, given no braille equivalent is exposed.',
143
+ evidence: { element: tag, attribute: m.attr, requires: m.requires }
144
+ },
134
145
  data: {
135
146
  details: {
136
147
  reasonCode: 'BRAILLE_ATTR_WITHOUT_EQUIVALENT',
@@ -146,15 +157,12 @@ function runInPage(ctx) {
146
157
  if (applicableCount === 0) {
147
158
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
148
159
  }
149
- if (occurrences.length) {
150
- return {
151
- ruleId: rule.ruleId,
152
- outcome: 'fail',
153
- severity: rule.defaultSeverity || 'serious',
154
- occurrences
155
- };
156
- }
157
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
160
+ const resolved = helpers.resolveTieredOutcome(
161
+ [],
162
+ occurrences,
163
+ rule.defaultSeverity || 'moderate'
164
+ );
165
+ return { ruleId: rule.ruleId, ...resolved };
158
166
  }
159
167
 
160
168
  module.exports = { id, meta, runInPage };
@@ -16,17 +16,22 @@
16
16
  * other than `"false"` (i.e. `"true"`, `"grammar"`, or `"spelling"`).
17
17
  * An element with `aria-errormessage` but `aria-invalid` absent or
18
18
  * `"false"` silently drops the error message from the accessibility
19
- * tree — authors almost always intend it to be exposed.
19
+ * tree, authors almost always intend it to be exposed.
20
+ * Reported at CANTTELL rather than FAIL: aria-errormessage is only
21
+ * exposed once aria-invalid is set, so the reference is currently inert.
22
+ * Whether that costs the user anything depends on whether the message is
23
+ * conveyed some other way (visible text next to the field, aria-describedby),
24
+ * which static markup does not settle.
20
25
  * @implementation-notes
21
- * - This is deliberately narrow: the broader space is a table of many
26
+ * - This is narrow: the broader space is a table of many
22
27
  * attribute/condition pairs. This rule implements only the one pairing
23
28
  * (`aria-errormessage` / `aria-invalid`) that is unambiguous and
24
- * explicitly stated in the ARIA spec, to keep `fail` high-confidence —
29
+ * explicitly stated in the ARIA spec, to keep `fail` high-confidence,
25
30
  * matches this repo's established pattern (see `aria-required-attr`/
26
31
  * `aria-prohibited-attr` for the same "narrow but zero false positives"
27
32
  * trade-off).
28
33
  * - Does not check whether the `aria-errormessage` ID reference itself
29
- * resolves to an existing element — that is `aria-valid-attr-value`'s
34
+ * resolves to an existing element. That's `aria-valid-attr-value`'s
30
35
  * concern, not this rule's.
31
36
  */
32
37
 
@@ -35,7 +40,7 @@ const id = 'aria-conditional-attr';
35
40
  const meta = {
36
41
  title: 'aria-errormessage requires aria-invalid to be set to a non-false value',
37
42
  description:
38
- 'Checks that elements with aria-errormessage also have aria-invalid set to "true", "grammar", or "spelling" — otherwise the error message is dropped from the accessibility tree.',
43
+ 'Checks that elements with aria-errormessage also have aria-invalid set to "true", "grammar", or "spelling"; otherwise the error message is dropped from the accessibility tree.',
39
44
  i18n: {
40
45
  titleKey: 'ariaConditionalAttr_title',
41
46
  descriptionKey: 'ariaConditionalAttr_description'
@@ -52,7 +57,7 @@ const meta = {
52
57
  conformanceLevel: 'A'
53
58
  }
54
59
  ],
55
- defaultSeverity: 'serious',
60
+ defaultSeverity: 'moderate',
56
61
  category: 'robust',
57
62
  type: 'automatic',
58
63
  defaultConfidence: 'high',
@@ -98,6 +103,11 @@ function runInPage(ctx) {
98
103
  hintKey: 'ariaConditionalAttr_hint_fail',
99
104
  params: { element: tag, ariaInvalid: invalidValue || '(absent)' }
100
105
  },
106
+ uncertainty: {
107
+ code: 'spec-only',
108
+ needed: 'Whether the field is ever in an invalid state that should expose this message.',
109
+ evidence: { element: tag, ariaInvalid: invalidValue || null }
110
+ },
101
111
  data: {
102
112
  details: {
103
113
  reasonCode: 'ARIA_ERRORMESSAGE_WITHOUT_TRUTHY_INVALID',
@@ -111,15 +121,12 @@ function runInPage(ctx) {
111
121
  if (applicableCount === 0) {
112
122
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
113
123
  }
114
- if (occurrences.length) {
115
- return {
116
- ruleId: rule.ruleId,
117
- outcome: 'fail',
118
- severity: rule.defaultSeverity || 'serious',
119
- occurrences
120
- };
121
- }
122
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
124
+ const resolved = helpers.resolveTieredOutcome(
125
+ [],
126
+ occurrences,
127
+ rule.defaultSeverity || 'moderate'
128
+ );
129
+ return { ruleId: rule.ruleId, ...resolved };
123
130
  }
124
131
 
125
132
  module.exports = { id, meta, runInPage };
@@ -11,7 +11,7 @@
11
11
  * @applicability
12
12
  * Applies to any element whose role attribute's first (used) token is a
13
13
  * valid, non-abstract ARIA role that authors should not explicitly
14
- * declare — either because WAI-ARIA has deprecated it (e.g. "directory",
14
+ * declare, either because WAI-ARIA has deprecated it (e.g. "directory",
15
15
  * superseded by role="list") or because it is reserved for user-agent-
16
16
  * internal use (role="generic", which ARIA 1.2 §5.4 says authors SHOULD
17
17
  * NOT use in content).
@@ -161,6 +161,12 @@ function runInPage(ctx) {
161
161
  hintKey: guidance.key,
162
162
  params: { role }
163
163
  },
164
+ uncertainty: {
165
+ code: 'spec-only',
166
+ needed:
167
+ 'Whether this user-agent-reserved role misleads the assistive technology in use.',
168
+ evidence: { role, ariaStatus: 'discouraged-for-authors' }
169
+ },
164
170
  data: {
165
171
  details: { reasonCode: 'ARIA_ROLE_AUTHOR_DISCOURAGED', role, guidance: guidance.text }
166
172
  }
@@ -178,6 +184,12 @@ function runInPage(ctx) {
178
184
  hintKey: guidance.key,
179
185
  params: { role }
180
186
  },
187
+ uncertainty: {
188
+ code: 'spec-only',
189
+ needed:
190
+ 'Whether the deprecated role is still honoured, and what it should be replaced with.',
191
+ evidence: { role, ariaStatus: 'deprecated' }
192
+ },
181
193
  data: {
182
194
  details: { reasonCode: 'ARIA_ROLE_DEPRECATED', role, guidance: guidance.text }
183
195
  }
@@ -10,13 +10,13 @@
10
10
  * @sc 1.3.1, 4.1.2
11
11
  * @applicability
12
12
  * Always applicable to any HTML document with a <body> element,
13
- * independent of contextSelector/root scoping — this is a whole-
13
+ * independent of contextSelector/root scoping, this is a whole-
14
14
  * document concern, matching page-title-present's pattern of
15
15
  * evaluating document.body directly rather than the scoped root.
16
16
  * @expectation
17
17
  * <body> does not have aria-hidden="true". Hiding the document body
18
18
  * removes the entire page's content and structure from the
19
- * accessibility tree at once — both 1.3.1 (Info and Relationships: the
19
+ * accessibility tree at once, both 1.3.1 (Info and Relationships: the
20
20
  * page's structure becomes entirely non-determinable) and 4.1.2 (Name,
21
21
  * Role, Value: nothing in the document exposes a role/name/value any
22
22
  * longer) apply.
@@ -33,7 +33,7 @@ const meta = {
33
33
  descriptionKey: 'ariaHiddenBody_description'
34
34
  },
35
35
  helpUrl: null,
36
- tags: ['wcag2a', 'wcag131', 'wcag412', 'structure', 'atomic', 'automatic'],
36
+ tags: ['wcag2a', 'wcag131', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
37
37
  wcagSc: ['1.3.1', '4.1.2'],
38
38
  normativeMappings: [
39
39
  {
@@ -109,7 +109,7 @@ function runInPage(ctx) {
109
109
  }
110
110
 
111
111
  // Flat-tree ancestor walk (assignedSlot wins over parentNode, then shadow
112
- // host) — shared with every other rule via ctx.helpers.composedParent
112
+ // host), shared with every other rule via ctx.helpers.composedParent
113
113
  // (src/core/dom-helpers.js), not reimplemented here, so a fix to the one
114
114
  // canonical definition can't drift out of sync with this rule's copy.
115
115
  const composedParent =
@@ -533,8 +533,8 @@ function runInPage(ctx) {
533
533
  if (isDisabledFormControl(el)) return false;
534
534
 
535
535
  // An explicit negative tabindex removes the element from the keyboard
536
- // tab sequence entirely, regardless of tag — the standard, WAI-
537
- // recommended technique for safely hiding focusable content behind
536
+ // tab sequence entirely, regardless of tag. It's the standard,
537
+ // WAI-recommended technique for safely hiding focusable content behind
538
538
  // aria-hidden (e.g. <button tabindex="-1"> / <a tabindex="-1"> inside
539
539
  // an aria-hidden container). This cares about tabbability, not raw
540
540
  // focusability. Such an element is still programmatically focusable
@@ -684,7 +684,7 @@ function runInPage(ctx) {
684
684
  // Cheap check first: a plain ancestor-attribute walk with no CSS
685
685
  // computation, vs. isActuallyFocusable's getComputedStyle-per-ancestor
686
686
  // cost. Both conditions are required (AND), so checking whichever is
687
- // cheaper first cannot change which elements end up in the bucket —
687
+ // cheaper first cannot change which elements end up in the bucket,
688
688
  // it only skips the expensive check for the (typically vast) majority
689
689
  // of focusable candidates that were never inside an aria-hidden root
690
690
  // in the first place. On pages with many focusable candidates and a
@@ -903,7 +903,7 @@ function runInPage(ctx) {
903
903
 
904
904
  // See helpers.resolveTieredOutcome's own header comment (src/core/dom-helpers.js):
905
905
  // a fail-tier finding never silently discards cantTell-tier findings from
906
- // the same run — both are returned together when the outcome is 'fail'.
906
+ // the same run, both are returned together when the outcome is 'fail'.
907
907
  const resolved = helpers.resolveTieredOutcome(
908
908
  failOccurrences,
909
909
  uncertainOccurrences,
@@ -14,20 +14,20 @@
14
14
  * list for naming attributes (pure text-semantics / non-naming
15
15
  * structural roles: caption, code, deletion, emphasis, generic,
16
16
  * insertion, mark, none, paragraph, presentation, strong, subscript,
17
- * suggestion, superscript, time), and (b) elements with no role at all —
17
+ * suggestion, superscript, time), and (b) elements with no role at all:
18
18
  * a curated set of native HTML tags verified to carry no implicit role
19
19
  * (see ROLELESS_NATIVE_TAGS below), or any autonomous custom element (a
20
20
  * hyphenated, author-defined tag per the Custom Elements spec; see
21
- * isRolelessCustomElementTag below) — in both cases, only elements that
21
+ * isRolelessCustomElementTag below). In both cases, only elements that
22
22
  * also carry aria-label or aria-labelledby.
23
23
  * @expectation
24
24
  * Prohibited attributes must not be present on (a); for (b), the naming
25
25
  * attribute is at best unreliable (nothing accessible-name-aware to hang
26
- * it off) and at worst silently ignored by assistive technology — see the
26
+ * it off) and at worst silently ignored by assistive technology. See the
27
27
  * roleless-branch implementation note below for the confidence split
28
28
  * this produces.
29
29
  * @implementation-notes
30
- * - Deliberately scoped to the single, well-established prohibition class
30
+ * - Scoped to the single, well-established prohibition class
31
31
  * (naming attributes on pure text-semantics roles) rather than an
32
32
  * exhaustive per-role prohibited-attribute table; see
33
33
  * src/core/aria-helpers.js file header for this engine's confidence-
@@ -41,7 +41,7 @@
41
41
  * narrower, unambiguous naming-prohibition case fires as a hard,
42
42
  * WCAG-normative fail instead, matching this engine's "one rule = one
43
43
  * normative decision" pattern.
44
- * - Deliberately excludes `definition`/`term` despite both appearing on
44
+ * - Excludes `definition`/`term` on purpose, despite both appearing on
45
45
  * MDN's aria-label "not supported" list: both support name from author
46
46
  * (`nameFrom: ['author']` for definition, `['author', 'contents']` for
47
47
  * term), and the W3C spec's §5.2.8.4 "Roles Supporting Name From Author"
@@ -50,9 +50,9 @@
50
50
  * property, while engine-level hidden-subtree filtering still applies
51
51
  * unless engineOptions.includeHiddenElements is true.
52
52
  * - Second, independent branch: naming attributes on ROLELESS elements (no
53
- * explicit role="", no implicit/native role either) — e.g. icon-only
53
+ * explicit role="", no implicit/native role either), e.g. icon-only
54
54
  * `<span aria-label="...">` tiles with no other accessible-name source.
55
- * ROLELESS_NATIVE_TAGS below is a curated, deliberately conservative
55
+ * ROLELESS_NATIVE_TAGS below is a curated, intentionally conservative
56
56
  * list of native tags confirmed to carry no implicit role (common
57
57
  * text-level tags like `<p>`/`<strong>`/`<em>`/`<code>`/`<mark>`/`<time>`
58
58
  * resolve to role `null`, same as a bare `<div>`/`<span>`);
@@ -65,7 +65,7 @@
65
65
  * already produces a non-empty accessible name from its content (via
66
66
  * `helpers.getContentNameInfo`, same as
67
67
  * link-name-present/button-name-present), the naming attribute might
68
- * just be a redundant/intentional override — reported as `cantTell`, not
68
+ * just be a redundant/intentional override, so it's reported as `cantTell`, not
69
69
  * a hard fail. Only a roleless element with no other accessible-name
70
70
  * source at all is a confident, deterministic `fail`. The
71
71
  * widget-ancestor exemption (skip when the closest real ancestor role is
@@ -119,8 +119,8 @@ function runInPage(ctx) {
119
119
  // Roles whose WAI-ARIA 1.2 definition lists a "Prohibited ARIA States and
120
120
  // Properties" entry for naming attributes (these roles must never carry an
121
121
  // accessible name). Declared inside runInPage (rather than at module
122
- // scope) because the build inlines only this function's own source text
123
- // — see scripts/build-core.js header ("runInPage MUST be self-contained").
122
+ // scope) because the build inlines only this function's own source text.
123
+ // See scripts/build-core.js header ("runInPage MUST be self-contained").
124
124
  const ROLES_PROHIBITING_NAME = new Set([
125
125
  'caption',
126
126
  'code',
@@ -190,7 +190,7 @@ function runInPage(ctx) {
190
190
  // and how ROLELESS_NATIVE_TAGS/WIDGET_TYPE_ROLES were derived) ---
191
191
 
192
192
  // Small, curated set of native tags verified to carry no explicit or
193
- // implicit ARIA role. Deliberately excludes <section>/<form>/<a> — all
193
+ // implicit ARIA role. Excludes <section>/<form>/<a>, which are
194
194
  // conditionally roleless too, but already handled with more nuance
195
195
  // elsewhere in this engine (see header comment).
196
196
  const ROLELESS_NATIVE_TAGS = new Set([
@@ -268,7 +268,7 @@ function runInPage(ctx) {
268
268
  };
269
269
 
270
270
  // Nearest ancestor's real role (explicit-if-valid, else native/implicit),
271
- // skipping roleless/presentation/none ancestors — used only to check
271
+ // skipping roleless/presentation/none ancestors. Used only to check
272
272
  // whether that role is a "widget"-type one (the roleless-branch
273
273
  // exemption). Not the same helper as aria-required-parent's containment
274
274
  // walk: this one also accepts non-required-context roles.
@@ -296,7 +296,7 @@ function runInPage(ctx) {
296
296
 
297
297
  // A small, spec-reserved set of hyphenated tag names that are NOT
298
298
  // autonomous custom elements despite containing a hyphen (legacy SVG/
299
- // MathML tags predating the Custom Elements spec) — see
299
+ // MathML tags predating the Custom Elements spec). See
300
300
  // https://html.spec.whatwg.org/#valid-custom-element-name's own
301
301
  // exclusion list. Excluded so this doesn't misclassify them as
302
302
  // always-roleless the same way a real custom element is.
@@ -336,8 +336,8 @@ function runInPage(ctx) {
336
336
  const tag = String(el.tagName || '').toLowerCase();
337
337
  if (!ROLELESS_NATIVE_TAGS.has(tag) && !isRolelessCustomElementTag(tag)) continue;
338
338
  const explicitRole = ariaHelpers.getExplicitRole(el);
339
- if (explicitRole && ariaHelpers.isValidConcreteRole(explicitRole)) continue; // has a real, recognized role — Tier 1's concern (if in ROLES_PROHIBITING_NAME) or a role this rule has no opinion on. An INVALID role token (e.g. a typo) is ignored per spec, same as no role attribute at all, and must still fall through to this branch.
340
- if (ariaHelpers.getNativeRoleForElement(el)) continue; // has a real implicit role after all — not this branch's concern
339
+ if (explicitRole && ariaHelpers.isValidConcreteRole(explicitRole)) continue; // has a real, recognized role: Tier 1's concern (if in ROLES_PROHIBITING_NAME) or a role this rule has no opinion on. An INVALID role token (e.g. a typo) is ignored per spec, same as no role attribute at all, and must still fall through to this branch.
340
+ if (ariaHelpers.getNativeRoleForElement(el)) continue; // has a real implicit role after all, not this branch's concern
341
341
 
342
342
  const present = [];
343
343
  for (const attr of PROHIBITED_NAMING_ATTRS) {
@@ -349,7 +349,7 @@ function runInPage(ctx) {
349
349
  applicableCount += 1;
350
350
 
351
351
  const ancestorRole = getNearestAncestorRole(el);
352
- if (ancestorRole && WIDGET_TYPE_ROLES.has(ancestorRole)) continue; // roleless helper node inside a real widget — not flagged
352
+ if (ancestorRole && WIDGET_TYPE_ROLES.has(ancestorRole)) continue; // roleless helper node inside a real widget, not flagged
353
353
 
354
354
  const nameInfo = helpers.getContentNameInfo ? helpers.getContentNameInfo(el, ctx) : null;
355
355
  const hasContentFallback = !!(
@@ -363,13 +363,18 @@ function runInPage(ctx) {
363
363
  cantTellOccurrences.push(
364
364
  helpers.reportOccurrence(el, {
365
365
  occurrenceOutcome: 'cantTell',
366
- summary: `This ${tag} has no role, so ${attr} may not be exposed as its accessible name by assistive technology — but the element's own content already provides one.`,
366
+ summary: `This ${tag} has no role, so ${attr} may not be exposed as its accessible name by assistive technology, but the element's own content already provides one.`,
367
367
  hint: 'Verify whether the existing text content already serves as this element’s label; if so the naming attribute is redundant, otherwise give the element a role that supports naming (e.g. role="img").',
368
368
  i18n: {
369
369
  summaryKey: 'ariaProhibitedAttr_summary_cantTell_roleless',
370
370
  hintKey: 'ariaProhibitedAttr_hint_cantTell_roleless',
371
371
  params: { attr, element: tag }
372
372
  },
373
+ uncertainty: {
374
+ code: 'judgement-required',
375
+ needed: 'Whether the element’s own content already serves as its label.',
376
+ evidence: { attribute: attr, element: tag, role: null, hasOwnContent: true }
377
+ },
373
378
  data: {
374
379
  details: {
375
380
  reasonCode: 'ARIA_ATTR_PROHIBITED_ROLELESS_NEEDS_REVIEW',
@@ -411,7 +416,7 @@ function runInPage(ctx) {
411
416
 
412
417
  // See helpers.resolveTieredOutcome's own header comment (src/core/dom-helpers.js):
413
418
  // a fail-tier finding never silently discards cantTell-tier findings from
414
- // the same run — both are returned together when the outcome is 'fail'.
419
+ // the same run. Both are returned together when the outcome is 'fail'.
415
420
  const resolved = helpers.resolveTieredOutcome(
416
421
  failOccurrences,
417
422
  cantTellOccurrences,