@surea11y/core 1.4.1 → 1.6.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 (151) hide show
  1. package/CHANGELOG.md +212 -128
  2. package/README.md +46 -9
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +4 -4
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +30 -14
  8. package/docs/I18N.md +176 -20
  9. package/docs/INTEGRATION.md +29 -7
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/OUTPUT_SCHEMA.md +13 -3
  12. package/docs/REPORT.md +3 -1
  13. package/docs/RULE_AUTHORING.md +104 -23
  14. package/docs/RULE_CATALOG.md +1878 -169
  15. package/docs/RULE_TAXONOMY.md +2 -2
  16. package/docs/TROUBLESHOOTING.md +4 -4
  17. package/docs/WCAG_CONFORMANCE.md +25 -9
  18. package/package.json +8 -7
  19. package/src/baseline.js +3 -3
  20. package/src/checks/automatic/area-alt-present.js +2 -2
  21. package/src/checks/automatic/aria-allowed-attr.js +95 -40
  22. package/src/checks/automatic/aria-allowed-role.js +16 -18
  23. package/src/checks/automatic/aria-braille-equivalent.js +19 -21
  24. package/src/checks/automatic/aria-conditional-attr.js +22 -24
  25. package/src/checks/automatic/aria-deprecated-role.js +63 -50
  26. package/src/checks/automatic/aria-hidden-body.js +4 -11
  27. package/src/checks/automatic/aria-hidden-focus.js +104 -23
  28. package/src/checks/automatic/aria-prohibited-attr.js +71 -72
  29. package/src/checks/automatic/aria-prohibited-children.js +154 -61
  30. package/src/checks/automatic/aria-required-attr.js +74 -29
  31. package/src/checks/automatic/aria-required-children.js +38 -34
  32. package/src/checks/automatic/aria-required-parent.js +78 -29
  33. package/src/checks/automatic/aria-role-name-present.js +36 -22
  34. package/src/checks/automatic/aria-roles-valid.js +37 -23
  35. package/src/checks/automatic/aria-valid-attr-value.js +33 -33
  36. package/src/checks/automatic/aria-valid-attr.js +15 -18
  37. package/src/checks/automatic/autocomplete-valid.js +17 -19
  38. package/src/checks/automatic/avoid-inline-spacing.js +14 -16
  39. package/src/checks/automatic/binary-control-name-present.js +46 -26
  40. package/src/checks/automatic/button-name-present.js +115 -34
  41. package/src/checks/automatic/combobox-name-present.js +40 -22
  42. package/src/checks/automatic/contrast-computable.js +32 -0
  43. package/src/checks/automatic/contrast-enhanced.js +21 -1
  44. package/src/checks/automatic/contrast-minimum.js +21 -1
  45. package/src/checks/automatic/css-orientation-lock.js +118 -41
  46. package/src/checks/automatic/definition-list-children-valid.js +25 -29
  47. package/src/checks/automatic/deprecated-elements-not-used.js +15 -17
  48. package/src/checks/automatic/dialog-name-present.js +36 -20
  49. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  50. package/src/checks/automatic/duplicate-id-aria.js +50 -40
  51. package/src/checks/automatic/duplicate-id.js +198 -0
  52. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  53. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  54. package/src/checks/automatic/form-control-single-label.js +39 -41
  55. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  56. package/src/checks/automatic/iframe-focusable-content.js +92 -38
  57. package/src/checks/automatic/iframe-name-present.js +53 -21
  58. package/src/checks/automatic/iframe-title-unique.js +19 -24
  59. package/src/checks/automatic/img-alt-present.js +12 -4
  60. package/src/checks/automatic/label-in-name.js +198 -49
  61. package/src/checks/automatic/link-in-text-block.js +29 -31
  62. package/src/checks/automatic/link-name-present.js +47 -31
  63. package/src/checks/automatic/list-children-valid.js +21 -23
  64. package/src/checks/automatic/listbox-name-present.js +42 -24
  65. package/src/checks/automatic/listitem-parent-valid.js +18 -21
  66. package/src/checks/automatic/menuitem-name-present.js +36 -20
  67. package/src/checks/automatic/meta-refresh-no-exceptions.js +39 -31
  68. package/src/checks/automatic/meta-refresh-timing-absent.js +29 -25
  69. package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
  70. package/src/checks/automatic/meter-name-present.js +38 -21
  71. package/src/checks/automatic/nested-interactive-controls-absent.js +22 -24
  72. package/src/checks/automatic/option-name-present.js +39 -22
  73. package/src/checks/automatic/page-title-present.js +21 -3
  74. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  75. package/src/checks/automatic/progressbar-name-present.js +41 -24
  76. package/src/checks/automatic/role-img-alt-present.js +64 -16
  77. package/src/checks/automatic/searchbox-name-present.js +46 -24
  78. package/src/checks/automatic/server-side-image-map-absent.js +16 -19
  79. package/src/checks/automatic/slider-name-present.js +42 -23
  80. package/src/checks/automatic/spinbutton-name-present.js +46 -24
  81. package/src/checks/automatic/summary-name-present.js +34 -20
  82. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  83. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  84. package/src/checks/automatic/tab-name-present.js +37 -20
  85. package/src/checks/automatic/table-headers-attr-valid.js +57 -24
  86. package/src/checks/automatic/table-th-has-data-cells.js +76 -24
  87. package/src/checks/automatic/target-size-minimum.js +172 -131
  88. package/src/checks/automatic/td-has-header.js +20 -25
  89. package/src/checks/automatic/textbox-name-present.js +42 -24
  90. package/src/checks/automatic/tooltip-name-present.js +37 -20
  91. package/src/checks/automatic/treeitem-name-present.js +39 -22
  92. package/src/checks/automatic/valid-lang.js +107 -24
  93. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  94. package/src/checks/manual/accesskeys-manual.js +21 -22
  95. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  96. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  97. package/src/checks/manual/aria-checked-state-mismatch-manual.js +23 -26
  98. package/src/checks/manual/aria-text-manual.js +4 -4
  99. package/src/checks/manual/bypass-blocks-present-manual.js +48 -38
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  101. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/empty-heading-manual.js +73 -28
  104. package/src/checks/manual/empty-table-header-manual.js +33 -36
  105. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  106. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  107. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +4 -11
  108. package/src/checks/manual/heading-order-manual.js +20 -25
  109. package/src/checks/manual/heading-quality-manual.js +338 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  111. package/src/checks/manual/image-redundant-alt-manual.js +18 -21
  112. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  113. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  116. package/src/checks/manual/label-title-only-manual.js +19 -21
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +21 -24
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +23 -26
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +20 -23
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +8 -13
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +8 -13
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +4 -9
  123. package/src/checks/manual/landmark-one-main-manual.js +8 -15
  124. package/src/checks/manual/landmark-unique-manual.js +31 -36
  125. package/src/checks/manual/link-name-quality-manual.js +162 -35
  126. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  127. package/src/checks/manual/meta-viewport-large-manual.js +16 -19
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +26 -28
  129. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  131. package/src/checks/manual/p-as-heading-manual.js +4 -4
  132. package/src/checks/manual/page-has-heading-one-manual.js +8 -15
  133. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  134. package/src/checks/manual/presentation-role-conflict-manual.js +74 -46
  135. package/src/checks/manual/region-manual.js +32 -25
  136. package/src/checks/manual/scope-attr-valid-manual.js +16 -19
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  138. package/src/checks/manual/skip-link-manual.js +44 -52
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  140. package/src/checks/manual/tabindex-manual.js +16 -19
  141. package/src/checks/manual/table-duplicate-name-manual.js +16 -19
  142. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  143. package/src/checks/manual/video-caption-manual.js +3 -3
  144. package/src/checks/manual-review.js +17 -1
  145. package/src/core.js +13086 -5169
  146. package/src/report.js +16 -2
  147. package/surea11y.browser.js +5665 -4219
  148. package/surea11y.i18n.de.js +22 -0
  149. package/surea11y.i18n.es.js +22 -0
  150. package/surea11y.i18n.fr.js +22 -0
  151. package/bin/surea11y-core.js +0 -20
@@ -2,6 +2,25 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check tooltip-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="tooltip" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="tooltip" (the attribute must name
13
+ * that role alone, not a fallback list) that are included in the
14
+ * accessibility tree. An element with the matching implicit role but no
15
+ * role attribute is out of scope.
16
+ * @expectation
17
+ * The element has a non-empty accessible name from aria-label, from an
18
+ * aria-labelledby that resolves to non-empty text, from title, or,
19
+ * role="tooltip" being name-from-content, from its own subtree text,
20
+ * where a descendant's own name (an <img alt>, aria-label or title) counts
21
+ * as that descendant's contribution rather than only its text nodes.
22
+ */
23
+
5
24
  const id = 'tooltip-name-present';
6
25
 
7
26
  const meta = {
@@ -51,7 +70,7 @@ function runInPage(ctx) {
51
70
  }
52
71
 
53
72
  function getConservativeSubtreeText(document, container) {
54
- // "Name from content" — recurses into descendants and uses each one's
73
+ // "Name from content", recurses into descendants and uses each one's
55
74
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
56
75
  // it has one, not just literal text nodes. See getContentNameInfo's
57
76
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -70,7 +89,7 @@ function runInPage(ctx) {
70
89
  const raw = getAttr(el, 'aria-labelledby');
71
90
  if (!raw) return '';
72
91
  // Delegates to the shared getTextFromIdRefs helper instead of computing
73
- // name-from-content of the referenced element — see dialog-name-
92
+ // name-from-content of the referenced element, see dialog-name-
74
93
  // present.js's identical fix for the full rationale (an <iframe>
75
94
  // aria-labelledby target's only name source is its title attribute,
76
95
  // which name-from-content alone can never see).
@@ -148,24 +167,22 @@ function runInPage(ctx) {
148
167
  }
149
168
  })()
150
169
  : null;
151
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
152
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
153
-
154
- occurrences.push({
155
- selector: stableSelector,
156
- html,
157
- summary: 'This tooltip has no accessible name.',
158
- hint: 'Provide tooltip text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
159
- i18n: {
160
- summaryKey: 'tooltipNamePresent_summary_fail',
161
- hintKey: 'tooltipNamePresent_hint_fail',
162
- params: {}
163
- },
164
- data: {
165
- details: { reasonCode: 'name_missing', controlType: 'tooltip', methodTried: res.method },
166
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
167
- }
168
- });
170
+
171
+ occurrences.push(
172
+ helpers.reportOccurrence(el, {
173
+ summary: 'This tooltip has no accessible name.',
174
+ hint: 'Provide tooltip text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
175
+ i18n: {
176
+ summaryKey: 'tooltipNamePresent_summary_fail',
177
+ hintKey: 'tooltipNamePresent_hint_fail',
178
+ params: {}
179
+ },
180
+ data: {
181
+ details: { reasonCode: 'name_missing', controlType: 'tooltip', methodTried: res.method },
182
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
183
+ }
184
+ })
185
+ );
169
186
  }
170
187
 
171
188
  if (applicableCount === 0) {
@@ -2,11 +2,30 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check treeitem-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="treeitem" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="treeitem" (the attribute must name
13
+ * that role alone, not a fallback list) that are included in the
14
+ * accessibility tree. An element with the matching implicit role but no
15
+ * role attribute is out of scope.
16
+ * @expectation
17
+ * The element has a non-empty accessible name from aria-label, from an
18
+ * aria-labelledby that resolves to non-empty text, from title, or,
19
+ * role="treeitem" being name-from-content, from its own subtree text,
20
+ * where a descendant's own name (an <img alt>, aria-label or title) counts
21
+ * as that descendant's contribution rather than only its text nodes.
22
+ */
23
+
5
24
  const id = 'treeitem-name-present';
6
25
 
7
26
  const meta = {
8
- title: 'Accessible name is present',
9
- description: 'Checks that elements expose a non-empty accessible name.',
27
+ title: 'Tree items have an accessible name',
28
+ description: 'Checks that elements with role="treeitem" expose a non-empty accessible name.',
10
29
  i18n: {
11
30
  titleKey: 'treeitemNamePresent_title',
12
31
  descriptionKey: 'treeitemNamePresent_description'
@@ -51,7 +70,7 @@ function runInPage(ctx) {
51
70
  }
52
71
 
53
72
  function getConservativeSubtreeText(document, container) {
54
- // "Name from content" — recurses into descendants and uses each one's
73
+ // "Name from content", recurses into descendants and uses each one's
55
74
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
56
75
  // it has one, not just literal text nodes. See getContentNameInfo's
57
76
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -70,7 +89,7 @@ function runInPage(ctx) {
70
89
  const raw = getAttr(el, 'aria-labelledby');
71
90
  if (!raw) return '';
72
91
  // Delegates to the shared getTextFromIdRefs helper instead of computing
73
- // name-from-content of the referenced element — see dialog-name-
92
+ // name-from-content of the referenced element, see dialog-name-
74
93
  // present.js's identical fix for the full rationale (an <iframe>
75
94
  // aria-labelledby target's only name source is its title attribute,
76
95
  // which name-from-content alone can never see).
@@ -145,24 +164,22 @@ function runInPage(ctx) {
145
164
  }
146
165
  })()
147
166
  : null;
148
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
149
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
150
-
151
- occurrences.push({
152
- selector: stableSelector,
153
- html,
154
- summary: 'This element has no accessible name.',
155
- hint: 'Provide aria-label or aria-labelledby (preferred), or provide visible text that is not hidden from assistive technologies.',
156
- i18n: {
157
- summaryKey: 'treeitemNamePresent_summary_fail',
158
- hintKey: 'treeitemNamePresent_hint_fail',
159
- params: { controlType: 'treeitem' }
160
- },
161
- data: {
162
- details: { reasonCode: 'name_missing', controlType: 'treeitem', methodTried: res.method },
163
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
164
- }
165
- });
167
+
168
+ occurrences.push(
169
+ helpers.reportOccurrence(el, {
170
+ summary: 'This element has no accessible name.',
171
+ hint: 'Provide aria-label or aria-labelledby (preferred), or provide visible text that is not hidden from assistive technologies.',
172
+ i18n: {
173
+ summaryKey: 'treeitemNamePresent_summary_fail',
174
+ hintKey: 'treeitemNamePresent_hint_fail',
175
+ params: { controlType: 'treeitem' }
176
+ },
177
+ data: {
178
+ details: { reasonCode: 'name_missing', controlType: 'treeitem', methodTried: res.method },
179
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
180
+ }
181
+ })
182
+ );
166
183
  }
167
184
 
168
185
  if (applicableCount === 0) {
@@ -10,11 +10,23 @@
10
10
  * @sc 3.1.2
11
11
  * @applicability
12
12
  * Applies to any element other than the root <html> with a non-empty
13
- * lang attribute.
13
+ * lang attribute AND at least some non-whitespace "governed text" that
14
+ * actually inherits its language from that element, per ACT de46e4:
15
+ * - Descendant text/alt is governed by the nearest lang-carrying
16
+ * ancestor only, a nested descendant with its own non-empty lang
17
+ * re-scopes everything inside it, so that subtree no longer counts
18
+ * toward the outer element's applicability (it counts toward the
19
+ * nested element's own, if that one is also being evaluated).
20
+ * - A non-empty alt attribute on img/area/input[type=image] counts as
21
+ * governed text, the same as a text node.
22
+ * - Text (or alt) that CSS keeps out of the render tree (display:none,
23
+ * the hidden attribute, ...) does not count. aria-hidden and
24
+ * offscreen positioning do NOT exempt text, per ACT's own failed
25
+ * examples for both, only actual non-rendering does.
14
26
  * @expectation
15
27
  * The lang value matches a valid BCP47 language-tag syntax. WCAG 3.1.2
16
28
  * (Language of Parts) requires that when a passage's language differs
17
- * from the page's default, it is identified programmatically — an
29
+ * from the page's default, it is identified programmatically. An
18
30
  * invalid tag fails to identify a real language at all.
19
31
  * @implementation-notes
20
32
  * - Distinct, atomic decision from html-lang-attr-present (that
@@ -22,7 +34,7 @@
22
34
  * covers every other element, for SC 3.1.2.
23
35
  * - Same minimal BCP47 *syntax* check as html-lang-attr-present (primary
24
36
  * subtag + optional subtags), not IANA Language Subtag Registry
25
- * validation — same documented scope limitation (syntactically
37
+ * validation, same documented scope limitation (syntactically
26
38
  * well-formed but unregistered tags like "xx-ZZ" are not flagged).
27
39
  */
28
40
 
@@ -66,6 +78,78 @@ function runInPage(ctx) {
66
78
  ? helpers.isValidLanguageTag
67
79
  : (v) => BCP47_RE.test(String(v || ''));
68
80
 
81
+ function isDomVisible(node) {
82
+ if (!node) return false;
83
+ if (helpers.isDomVisibleEligible)
84
+ return !!helpers.isDomVisibleEligible(node, ctx, { targetSet: 'dom' }).eligible;
85
+ if (helpers.getEligibilityInfo)
86
+ return !!helpers.getEligibilityInfo(node, ctx, { targetSet: 'dom' }).eligible;
87
+ return true;
88
+ }
89
+
90
+ function hasOwnNonEmptyLang(node) {
91
+ try {
92
+ const v = node.getAttribute ? node.getAttribute('lang') : null;
93
+ return v != null && v.trim() !== '';
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ function isAltBearing(node) {
100
+ const tag = (node.tagName || '').toLowerCase();
101
+ if (tag === 'img' || tag === 'area') return true;
102
+ if (tag !== 'input') return false;
103
+ try {
104
+ return (node.getAttribute('type') || '').toLowerCase() === 'image';
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ // "Governed text": non-whitespace text (or alt) that inherits its
111
+ // language from `root`, per ACT de46e4, see @applicability above. Walks
112
+ // the flat subtree, stopping at any descendant carrying its own non-empty
113
+ // lang (that subtree governs itself, not `root`), and treating
114
+ // display:none/hidden content as absent. Bails out as soon as any
115
+ // qualifying text is found; a node-visit budget guards pathological
116
+ // markup the same way other subtree walks in this engine do.
117
+ const MAX_VISITS = 5000;
118
+ function hasGovernedText(root) {
119
+ let found = false;
120
+ let visits = 0;
121
+
122
+ function walk(node, isRoot) {
123
+ if (found || visits++ > MAX_VISITS) return;
124
+ if (!node || node.nodeType !== 1) return;
125
+ if (!isRoot && hasOwnNonEmptyLang(node)) return; // re-scoped to itself
126
+
127
+ if (isAltBearing(node)) {
128
+ const alt = node.getAttribute ? node.getAttribute('alt') : null;
129
+ if (alt != null && alt.trim() !== '' && isDomVisible(node)) found = true;
130
+ return; // alt-bearing elements have no other text to walk into
131
+ }
132
+
133
+ if (!isDomVisible(node)) return;
134
+
135
+ const kids = node.childNodes ? Array.from(node.childNodes) : [];
136
+ for (const kid of kids) {
137
+ if (found) return;
138
+ if (kid.nodeType === 3) {
139
+ if (String(kid.nodeValue || '').trim()) {
140
+ found = true;
141
+ return;
142
+ }
143
+ } else if (kid.nodeType === 1) {
144
+ walk(kid, false);
145
+ }
146
+ }
147
+ }
148
+
149
+ walk(root, true);
150
+ return found;
151
+ }
152
+
69
153
  const nodes = helpers.queryAllSmart
70
154
  ? helpers.queryAllSmart('[lang]')
71
155
  : helpers.queryAll('[lang]');
@@ -80,10 +164,11 @@ function runInPage(ctx) {
80
164
  const rawAttr = el.getAttribute('lang');
81
165
  if (rawAttr === null || rawAttr === '') continue; // ACT de46e4: empty is out of scope
82
166
 
83
- // The rule applies only where text actually inherits the language, so an
84
- // element with no text, or whose text reaches no one, has nothing to
85
- // declare a language for.
86
- if (!String(el.textContent || '').trim()) continue;
167
+ // The rule applies only where text actually inherits the language from
168
+ // THIS element specifically, not merely where the subtree has any text
169
+ // at all, which could all belong to a nested element's own (possibly
170
+ // valid) lang instead. See hasGovernedText's doc comment.
171
+ if (!hasGovernedText(el)) continue;
87
172
 
88
173
  applicableCount += 1;
89
174
 
@@ -92,23 +177,21 @@ function runInPage(ctx) {
92
177
  if (isValidTag(raw)) continue;
93
178
 
94
179
  const tag = el.tagName.toLowerCase();
95
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
96
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
97
-
98
- occurrences.push({
99
- selector: stableSelector,
100
- html,
101
- summary: `This lang attribute value ("${raw}") is not a syntactically valid language tag.`,
102
- hint: 'Use a valid BCP47 language tag (e.g. "fr", "es-MX").',
103
- i18n: {
104
- summaryKey: 'validLang_summary_fail',
105
- hintKey: 'validLang_hint_fail',
106
- params: { element: tag, value: raw }
107
- },
108
- data: {
109
- details: { reasonCode: 'ELEMENT_LANG_INVALID', element: tag, value: raw }
110
- }
111
- });
180
+
181
+ occurrences.push(
182
+ helpers.reportOccurrence(el, {
183
+ summary: `This lang attribute value ("${raw}") is not a syntactically valid language tag.`,
184
+ hint: 'Use a valid BCP47 language tag (e.g. "fr", "es-MX").',
185
+ i18n: {
186
+ summaryKey: 'validLang_summary_fail',
187
+ hintKey: 'validLang_hint_fail',
188
+ params: { element: tag, value: raw }
189
+ },
190
+ data: {
191
+ details: { reasonCode: 'ELEMENT_LANG_INVALID', element: tag, value: raw }
192
+ }
193
+ })
194
+ );
112
195
  }
113
196
 
114
197
  if (applicableCount === 0) {
@@ -87,7 +87,7 @@ function runInPage(ctx) {
87
87
 
88
88
  function computeNameInfo(el) {
89
89
  // <video> is not a labelable element (no browser computes an accessible
90
- // name from <label for="...">), so only ARIA naming and title count —
90
+ // name from <label for="...">), so only ARIA naming and title count,
91
91
  // do not accept native <label> associations.
92
92
  const flags = [];
93
93
  let aria = null;
@@ -6,17 +6,17 @@
6
6
  * @check accesskeys
7
7
  * @atomic true
8
8
  * @summary accesskey values must be unique on the page
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Applies whenever two or more elements share the same non-empty
12
12
  * accesskey attribute value (case-insensitive).
13
13
  * @expectation
14
14
  * Every accesskey value on the page is unique. Duplicate accesskeys
15
- * make keyboard-shortcut activation ambiguous — only one of the
15
+ * make keyboard-shortcut activation ambiguous: only one of the
16
16
  * elements sharing the key can actually be reached by it, and which
17
17
  * one is browser/platform-dependent.
18
18
  * @implementation-notes
19
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
19
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
20
20
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
21
21
  * header comment for the shared rationale/precedent.
22
22
  */
@@ -62,25 +62,24 @@ function runInPage(ctx) {
62
62
  for (const [key, els] of groups) {
63
63
  if (els.length <= 1) continue;
64
64
  for (const el of els) {
65
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
66
- const html = helpers.getOuterHtmlSnippet
67
- ? helpers.getOuterHtmlSnippet(el)
68
- : el.outerHTML || '';
69
-
70
- occurrences.push({
71
- selector: stableSelector,
72
- html,
73
- summary: "This element's accesskey is shared with another element on the page.",
74
- hint: 'Make each accesskey value unique across the page.',
75
- i18n: {
76
- summaryKey: 'accesskeys_summary_cantTell',
77
- hintKey: 'accesskeys_hint_cantTell',
78
- params: { accesskey: key, duplicateCount: String(els.length) }
79
- },
80
- data: {
81
- details: { reasonCode: 'ACCESSKEY_DUPLICATE', accesskey: key, duplicateCount: els.length }
82
- }
83
- });
65
+ occurrences.push(
66
+ helpers.reportOccurrence(el, {
67
+ summary: "This element's accesskey is shared with another element on the page.",
68
+ hint: 'Make each accesskey value unique across the page.',
69
+ i18n: {
70
+ summaryKey: 'accesskeys_summary_cantTell',
71
+ hintKey: 'accesskeys_hint_cantTell',
72
+ params: { accesskey: key, duplicateCount: String(els.length) }
73
+ },
74
+ data: {
75
+ details: {
76
+ reasonCode: 'ACCESSKEY_DUPLICATE',
77
+ accesskey: key,
78
+ duplicateCount: els.length
79
+ }
80
+ }
81
+ })
82
+ );
84
83
  }
85
84
  }
86
85
 
@@ -9,6 +9,13 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.1.1
11
11
  * @type manual
12
+ * @applicability
13
+ * Applies to <area> elements whose alt attribute is present but empty once
14
+ * trimmed, the markup that declares a hotspot decorative. The <area> must
15
+ * belong to a <map> that an <img usemap> actually references, and both
16
+ * that <img> and the <area> itself must be included in the accessibility
17
+ * tree; an <area> in an unused map is out of scope.
18
+ * role="presentation"/"none" takes an element out unless it is focusable.
12
19
  * @expectation
13
20
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
14
21
  */
@@ -9,6 +9,12 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.1.1
11
11
  * @type manual
12
+ * @applicability
13
+ * Applies to <area> elements whose alt attribute is present and non-empty.
14
+ * The <area> must belong to a <map> that an <img usemap> actually
15
+ * references, and both that <img> and the <area> itself must be included
16
+ * in the accessibility tree; an <area> in an unused map is out of scope.
17
+ * role="presentation"/"none" takes an element out unless it is focusable.
12
18
  * @expectation
13
19
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
14
20
  */
@@ -14,13 +14,13 @@
14
14
  * @expectation
15
15
  * `aria-checked` is redundant on a native checkbox/radio (the role's
16
16
  * checked state is already exposed natively), but when an author sets
17
- * it explicitly it should agree with the element's actual state —
17
+ * it explicitly it should agree with the element's actual state,
18
18
  * otherwise assistive technology is told something different from what
19
19
  * a sighted user perceives.
20
20
  * @implementation-notes
21
- * - Deliberately authored as `type: 'manual'` (cantTell-capped, never
22
- * fail), unlike most ARIA-validity rules in this file family. This
23
- * engine analyzes STATIC markup only (no script execution) — `.checked`
21
+ * - Authored as `type: 'manual'` (cantTell-capped, never fail), unlike
22
+ * most ARIA-validity rules in this file family. This engine analyzes
23
+ * STATIC markup only (no script execution), so `.checked`
24
24
  * reliably reflects the static `checked` attribute for freshly-parsed
25
25
  * markup, but a very common, entirely legitimate real-world pattern is a
26
26
  * JS-hydrated widget whose server-rendered HTML intentionally ships
@@ -114,29 +114,26 @@ function runInPage(ctx) {
114
114
 
115
115
  if (normalizedAriaChecked === actualState) continue;
116
116
 
117
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
118
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
119
-
120
- occurrences.push({
121
- selector: stableSelector,
122
- html,
123
- summary:
124
- 'This element’s aria-checked value does not match its actual checked/indeterminate state.',
125
- hint: 'Set aria-checked to match the element’s real state, or remove it — a native checkbox/radio already exposes this state without it.',
126
- i18n: {
127
- summaryKey: 'ariaCheckedStateMismatch_summary_cantTell',
128
- hintKey: 'ariaCheckedStateMismatch_hint_cantTell',
129
- params: { ariaChecked: normalizedAriaChecked, actualState, type }
130
- },
131
- data: {
132
- details: {
133
- reasonCode: 'ARIA_CHECKED_STATE_MISMATCH',
134
- ariaChecked: normalizedAriaChecked,
135
- actualState,
136
- type
117
+ occurrences.push(
118
+ helpers.reportOccurrence(el, {
119
+ summary:
120
+ 'This element’s aria-checked value does not match its actual checked/indeterminate state.',
121
+ hint: 'Set aria-checked to match the element’s real state, or remove it; a native checkbox/radio already exposes this state without it.',
122
+ i18n: {
123
+ summaryKey: 'ariaCheckedStateMismatch_summary_cantTell',
124
+ hintKey: 'ariaCheckedStateMismatch_hint_cantTell',
125
+ params: { ariaChecked: normalizedAriaChecked, actualState, type }
126
+ },
127
+ data: {
128
+ details: {
129
+ reasonCode: 'ARIA_CHECKED_STATE_MISMATCH',
130
+ ariaChecked: normalizedAriaChecked,
131
+ actualState,
132
+ type
133
+ }
137
134
  }
138
- }
139
- });
135
+ })
136
+ );
140
137
  }
141
138
 
142
139
  if (applicableCount === 0) {
@@ -6,7 +6,7 @@
6
6
  * @check aria-text
7
7
  * @atomic true
8
8
  * @summary role="text" elements should have no focusable descendants
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Elements with an explicit `role="text"`.
12
12
  * @expectation
@@ -14,10 +14,10 @@
14
14
  * subtree as a single unit of plain text (e.g. text visually split
15
15
  * across multiple `<span>`s by styling). Per the WAI-ARIA Authoring
16
16
  * Practices, this only makes sense when that subtree contains no
17
- * focusable content — a focusable descendant inside a "this is just
17
+ * focusable content: a focusable descendant inside a "this is just
18
18
  * text" region is unreachable or confusing for keyboard/AT users.
19
19
  * @implementation-notes
20
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
20
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
21
21
  * `type: 'manual'` rule, matching the Tier 1b precedent (see
22
22
  * `landmark-unique`'s header comment for the shared rationale).
23
23
  * - "Focusable descendant" is a presence check (link/button/form
@@ -80,7 +80,7 @@ function runInPage(ctx) {
80
80
  selector: stableSelector,
81
81
  html,
82
82
  summary: 'This role="text" element contains a focusable descendant.',
83
- hint: 'Remove role="text" (or remove the focusable descendant) — a "plain text" region should not contain focusable content.',
83
+ hint: 'Remove role="text" (or remove the focusable descendant); a "plain text" region should not contain focusable content.',
84
84
  i18n: {
85
85
  summaryKey: 'ariaText_summary_cantTell',
86
86
  hintKey: 'ariaText_hint_cantTell',