@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
@@ -6,28 +6,29 @@
6
6
  * @check presentation-role-conflict
7
7
  * @atomic true
8
8
  * @summary role="presentation"/"none" must not be combined with a global ARIA naming attribute or focusability
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 to elements with an explicit role="presentation" or
12
- * role="none", OR an <img alt=""> (empty alt gives an <img> an implicit
13
- * presentation role per HTML-AAM, even with no explicit role attribute
14
- * at all — `img[alt=''], [role="none"], [role="presentation"]`).
12
+ * role="none", OR an <img alt=""> carrying no explicit role of its own
13
+ * (empty alt gives an <img> an implicit presentation role per HTML-AAM,
14
+ * even with no explicit role attribute at all: `img[alt=''],
15
+ * [role="none"], [role="presentation"]`).
15
16
  * @expectation
16
17
  * The element does not also carry a WAI-ARIA *global* state/property
17
18
  * (aria-label, aria-hidden, aria-describedby, aria-live, aria-current,
18
- * ... — the full global-attribute set, not just the naming ones), AND
19
+ * ...; the full global-attribute set, not just the naming ones), AND
19
20
  * is not focusable. Per the WAI-ARIA spec's Presentational Roles
20
21
  * Conflict Resolution section, a presentational role is "restored" to
21
- * the element's implicit semantic role when either condition holds —
22
+ * the element's implicit semantic role when either condition holds:
22
23
  * the presentation/none role silently stops working, contradicting the
23
24
  * author's evident intent to hide the element from the accessibility
24
25
  * tree.
25
26
  * @implementation-notes
26
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
27
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
27
28
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
28
29
  * header comment for the shared rationale/precedent.
29
30
  * - `aria-hidden="true"` (the exact valid truthy value) on the
30
- * presentational element itself is deliberately EXCLUDED as a trigger,
31
+ * presentational element itself is EXCLUDED as a trigger on purpose,
31
32
  * even though it's a global ARIA attribute: it removes the element from
32
33
  * the accessibility tree unconditionally, so the "role restoration"
33
34
  * this rule warns about never actually reaches assistive tech, making a
@@ -38,18 +39,23 @@
38
39
  * exemption (see the code comment at the check site).
39
40
  * - The conflicting-attribute set is the full list of ARIA attributes
40
41
  * marked `global: true`, not a narrower naming-only list.
41
- * - Deliberately NOT applying an implicit-role applicability gate that
42
+ * - Not applying an implicit-role applicability gate on purpose, one that
42
43
  * would make the check inapplicable to role="presentation" on elements
43
44
  * with no native implicit role to suppress (e.g. `<div
44
- * role="presentation" aria-hidden="true">` — a <div> has no native role,
45
+ * role="presentation" aria-hidden="true">`: a <div> has no native role,
45
46
  * so there's nothing for the presentational role to "conflict" with).
46
47
  * surea11y stays broader/more cautious here rather than narrower, which
47
- * is the safer direction to diverge in. The native-implicit-role table
48
- * needed to add that gate, if this scope decision is ever revisited, is
49
- * in ROADMAP.md §7 item 9.
48
+ * is the safer direction to diverge in. Adding that gate later would
49
+ * need a native-implicit-role table, which doesn't exist yet.
50
50
  * - Focusability is computed via helpers.getFocusableInfo (native +
51
- * tabindex), same helper aria-hidden-focus already relies on — a
51
+ * tabindex), same helper aria-hidden-focus already relies on. A
52
52
  * `:disabled` or otherwise non-focusable element is not flagged.
53
+ * - An `<img alt="">` carrying an explicit role of its own (e.g. `<img
54
+ * alt="" role="img" aria-label="Logo">`) is out of scope: the explicit
55
+ * role wins over the presentation role empty alt would otherwise confer,
56
+ * so nothing presentational is left to conflict with. An explicit role
57
+ * whose tokens are all unknown confers nothing either, and the empty alt
58
+ * still applies.
53
59
  */
54
60
 
55
61
  const id = 'presentation-role-conflict';
@@ -77,7 +83,7 @@ function runInPage(ctx) {
77
83
  const { helpers, rule } = ctx;
78
84
 
79
85
  // The full set of ARIA attributes marked `global: true` per the WAI-ARIA
80
- // spec — any of these present on a presentational element restores its
86
+ // spec. Any of these present on a presentational element restores its
81
87
  // implicit role, not just the naming ones.
82
88
  const CONFLICTING_ATTRS = [
83
89
  'aria-atomic',
@@ -109,6 +115,26 @@ function runInPage(ctx) {
109
115
  const getFocusableInfo =
110
116
  helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
111
117
 
118
+ const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
119
+
120
+ // The role attribute holds a fallback list; the first token naming a real
121
+ // role wins, and unknown tokens are skipped over. Returns '' when the
122
+ // element has no role attribute or none of its tokens name a role: the
123
+ // cases where an <img alt=""> keeps the presentation role empty alt gives
124
+ // it.
125
+ function getEffectiveRoleToken(el) {
126
+ const raw = el.getAttribute ? el.getAttribute('role') : null;
127
+ if (!raw) return '';
128
+ const tokens = String(raw).trim().toLowerCase().split(/\s+/);
129
+ for (const token of tokens) {
130
+ if (!token) continue;
131
+ if (token === 'presentation' || token === 'none') return token;
132
+ const known = ariaHelpers ? ariaHelpers.isValidConcreteRole(token) : true;
133
+ if (known) return token;
134
+ }
135
+ return '';
136
+ }
137
+
112
138
  const nodes = helpers.queryAllSmart
113
139
  ? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]')
114
140
  : helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]');
@@ -119,30 +145,37 @@ function runInPage(ctx) {
119
145
  for (const el of nodes) {
120
146
  if (!el || !el.getAttribute) continue;
121
147
 
148
+ // Only reachable via the img[alt=""] branch of the selector: an explicit
149
+ // role other than presentation/none overrides the presentation role that
150
+ // empty alt would confer, leaving no presentational intent to conflict
151
+ // with.
152
+ const roleToken = getEffectiveRoleToken(el);
153
+ if (roleToken && roleToken !== 'presentation' && roleToken !== 'none') continue;
154
+
122
155
  applicableCount += 1;
123
156
 
124
157
  // Presence, not value truthiness: the WAI-ARIA role-conflict-resolution
125
158
  // rule triggers on a global ARIA attribute being SPECIFIED at all, even
126
- // with an empty value — e.g. <img alt="" aria-hidden="">, where
159
+ // with an empty value, e.g. <img alt="" aria-hidden="">, where
127
160
  // aria-hidden="" (empty string) is still a specified attribute. A
128
161
  // truthy-value check would miss this.
129
162
  let present = CONFLICTING_ATTRS.filter((attr) =>
130
163
  el.hasAttribute ? el.hasAttribute(attr) : el.getAttribute(attr) != null
131
164
  );
132
165
 
133
- // aria-hidden="true" (the exact, valid truthy value — not the
166
+ // aria-hidden="true" (the exact, valid truthy value, not the
134
167
  // empty-string case above, which never actually hides anything) is a
135
168
  // special case: it removes the element and its subtree from the
136
169
  // accessibility tree unconditionally, independent of role. That makes
137
170
  // the "role restoration" this rule warns about ("...which restores its
138
171
  // implicit role and cancels the presentational intent") factually
139
- // inert — no AT will ever expose the restored role OR any of the other
172
+ // inert. No AT will ever expose the restored role OR any of the other
140
173
  // conflicting attributes (aria-label, aria-describedby, ...) present
141
174
  // alongside it, since the whole element stays out of the tree
142
175
  // regardless. This pattern is extremely common (e.g. <svg
143
- // role="presentation" aria-hidden="true"> decorative icons — a
176
+ // role="presentation" aria-hidden="true"> decorative icons, a
144
177
  // defensive belt-and-suspenders double-hide, not an authoring mistake).
145
- // Focusability is NOT covered by this exemption — a keyboard user can
178
+ // Focusability is NOT covered by this exemption: a keyboard user can
146
179
  // still tab onto an aria-hidden="true" focusable element (the
147
180
  // aria-hidden-focus anti-pattern), a real, independent hazard
148
181
  // aria-hidden does nothing to prevent.
@@ -165,34 +198,29 @@ function runInPage(ctx) {
165
198
  const parts = present.slice();
166
199
  if (isFocusable) parts.push('focusable');
167
200
 
168
- // No explicit role attribute means this matched via the img[alt=""]
201
+ // No role token means this matched via the img[alt=""]
169
202
  // implicit-presentation case.
170
- const role =
171
- String(el.getAttribute('role') || '')
172
- .trim()
173
- .toLowerCase() || 'presentation';
174
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
175
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
176
-
177
- occurrences.push({
178
- selector: stableSelector,
179
- html,
180
- summary: `This role="${role}" element also has a conflicting condition (${parts.join(', ')}), which restores its implicit role and cancels the presentational intent.`,
181
- hint: 'Remove the conflicting naming attribute(s) and/or focusability (tabindex/native) if the element should stay presentational, or remove role="presentation"/"none" if it should be exposed to assistive technology.',
182
- i18n: {
183
- summaryKey: 'presentationRoleConflict_summary_cantTell',
184
- hintKey: 'presentationRoleConflict_hint_cantTell',
185
- params: { role, attrs: parts.join(', ') }
186
- },
187
- data: {
188
- details: {
189
- reasonCode: 'PRESENTATION_ROLE_CONFLICT',
190
- role,
191
- conflictingAttrs: present,
192
- focusable: isFocusable
203
+ const role = roleToken || 'presentation';
204
+
205
+ occurrences.push(
206
+ helpers.reportOccurrence(el, {
207
+ summary: `This role="${role}" element also has a conflicting condition (${parts.join(', ')}), which restores its implicit role and cancels the presentational intent.`,
208
+ hint: 'Remove the conflicting naming attribute(s) and/or focusability (tabindex/native) if the element should stay presentational, or remove role="presentation"/"none" if it should be exposed to assistive technology.',
209
+ i18n: {
210
+ summaryKey: 'presentationRoleConflict_summary_cantTell',
211
+ hintKey: 'presentationRoleConflict_hint_cantTell',
212
+ params: { role, attrs: parts.join(', ') }
213
+ },
214
+ data: {
215
+ details: {
216
+ reasonCode: 'PRESENTATION_ROLE_CONFLICT',
217
+ role,
218
+ conflictingAttrs: present,
219
+ focusable: isFocusable
220
+ }
193
221
  }
194
- }
195
- });
222
+ })
223
+ );
196
224
  }
197
225
 
198
226
  if (applicableCount === 0) {
@@ -6,10 +6,10 @@
6
6
  * @check region
7
7
  * @atomic true
8
8
  * @summary Page content should be contained within a landmark region
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 to any element under <body> that directly carries visible text
12
- * (or other own content — see @implementation-notes) and is not itself a
12
+ * (or other own content, see @implementation-notes) and is not itself a
13
13
  * landmark, live region, dialog, button, <svg>, <iframe>/<frame>, or a
14
14
  * resolvable skip-link.
15
15
  * @expectation
@@ -18,14 +18,14 @@
18
18
  * search), so assistive technology users navigating by landmark do not
19
19
  * miss content that was never placed inside one.
20
20
  * @implementation-notes
21
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
21
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
22
22
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
23
23
  * header comment for the shared rationale/precedent and the landmark-
24
24
  * detection model.
25
25
  * - Recursive tree walk, not a direct-<body>-children-only scan: a
26
26
  * direct-children-only scope is nearly inert on the single most common
27
27
  * real-world page shape, a modern framework's single root mount div
28
- * (`<body><div id="root">...everything...</div></body>`) — that shape
28
+ * (`<body><div id="root">...everything...</div></body>`). That shape
29
29
  * gives at most one candidate for the entire page and either misses
30
30
  * every real gap inside it or collapses the whole page into one
31
31
  * undifferentiated report.
@@ -37,30 +37,30 @@
37
37
  * live region, dialog, button, <svg>, <iframe>/<frame>, or a
38
38
  * resolvable skip-link), mark it and every ancestor up to <body> as
39
39
  * "has a stopper" and don't recurse further into it (an <iframe>/
40
- * <frame> is additionally reported as its own occurrence — its
40
+ * <frame> is additionally reported as its own occurrence, since its
41
41
  * content is opaque to this engine, so from the outer page's
42
42
  * perspective it IS unplaced content).
43
43
  * 3. Otherwise, if the node has OWN content (a direct child text node,
44
- * being an inherently visual element, or an aria-label) — checked
45
- * non-recursively, so a plain wrapper <div> with only nested
46
- * children never short-circuits the walk into its descendants —
44
+ * being an inherently visual element, or an aria-label), checked
45
+ * non-recursively so a plain wrapper <div> with only nested
46
+ * children never short-circuits the walk into its descendants,
47
47
  * collect it as a candidate and stop recursing into it.
48
48
  * 4. Otherwise recurse into its element children.
49
49
  * Each collected candidate is then walked back UP through parents while
50
50
  * the parent has no "stopper" marker and isn't <body> itself, collapsing
51
51
  * contiguous unplaced content into one occurrence per real gap instead
52
- * of reporting every individual text-bearing leaf — this is what keeps
52
+ * of reporting every individual text-bearing leaf. This is what keeps
53
53
  * the walk from being noisy on ordinary pages that mix landmarked and
54
54
  * stray content.
55
55
  * - "Stopper" exemptions (button, dialog, <svg>, resolvable skip-links)
56
- * are a deliberate scope choice, not an oversight: these
56
+ * are a scope choice, not an oversight: these
57
57
  * are extremely common real-world patterns (floating action buttons,
58
58
  * modal dialogs, decorative/icon SVGs, "skip to content" links) that
59
59
  * aren't the kind of "content organization" gap this rule exists to
60
60
  * catch, and flagging them would reintroduce the false-positive noise
61
61
  * the original narrow scope was trying to avoid.
62
- * - The "own content" check for aria-label deliberately does NOT resolve
63
- * aria-labelledby — a known, narrow scope gap (an element named only via aria-labelledby, with no
62
+ * - The "own content" check for aria-label does NOT resolve
63
+ * aria-labelledby, a known, narrow scope gap (an element named only via aria-labelledby, with no
64
64
  * own text/aria-label, and no other content anywhere in its subtree,
65
65
  * could be silently skipped) accepted to avoid a full accessible-name
66
66
  * computation (recursive itself) inside an already-recursive structural
@@ -137,11 +137,11 @@ function runInPage(ctx) {
137
137
 
138
138
  // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
139
139
  // question "does this element sit inside a sectioning-content/<main>
140
- // ancestor that suppresses its conditional implicit role" — role-aware
140
+ // ancestor that suppresses its conditional implicit role": role-aware
141
141
  // (an ancestor's bare TAG only counts when it carries no role attribute
142
142
  // at all; an explicit role="dialog"-style override no longer suppresses)
143
143
  // rather than a local tag-only copy. See that function's header comment
144
- // in src/core/aria-helpers.js for the full algorithm — e.g. an
144
+ // in src/core/aria-helpers.js for the full algorithm, e.g. an
145
145
  // <aside role="dialog"> containing its own <header>, where the <header>
146
146
  // keeps its banner role.
147
147
  function hasSectioningAncestor(el, includeMain) {
@@ -189,7 +189,7 @@ function runInPage(ctx) {
189
189
  const SKIP_TAGS = new Set(['script', 'style', 'template', 'noscript', 'link', 'meta', 'title']);
190
190
 
191
191
  // Roles/attributes that make an element its own self-contained
192
- // announced area — not literally a WAI-ARIA landmark, but not "content
192
+ // announced area: not literally a WAI-ARIA landmark, but not "content
193
193
  // that needs a landmark" either.
194
194
  const LIVE_REGION_ROLES = new Set(['alert', 'status', 'log', 'marquee', 'timer']);
195
195
 
@@ -217,8 +217,8 @@ function runInPage(ctx) {
217
217
  return false;
218
218
  }
219
219
 
220
- // A "skip to content" link is deliberately placed outside the main
221
- // content flow at the very top of the page — exempting it (when its
220
+ // A "skip to content" link is placed outside the main content flow
221
+ // on purpose, at the very top of the page. Exempting it (when its
222
222
  // fragment actually resolves to a real target, not a dead "#"
223
223
  // placeholder) avoids flagging a helpful, common accessibility pattern
224
224
  // as the very thing this rule is meant to catch.
@@ -249,8 +249,8 @@ function runInPage(ctx) {
249
249
 
250
250
  const VISUAL_CONTENT_TAGS = new Set(['img', 'video', 'audio', 'canvas', 'object', 'embed']);
251
251
 
252
- // Non-recursive "does THIS element, on its own, carry content" check —
253
- // deliberately mirrors only the direct-content half of getContentNameInfo,
252
+ // Non-recursive "does THIS element, on its own, carry content" check.
253
+ // Mirrors only the direct-content half of getContentNameInfo on purpose,
254
254
  // not a full name-from-content recursion: the whole point is to keep
255
255
  // recursing through plain wrapper elements (a framework's root mount
256
256
  // <div> included) until reaching the actual content-bearing node, rather
@@ -352,14 +352,11 @@ function runInPage(ctx) {
352
352
  }
353
353
  }
354
354
 
355
+ // Report the element itself: without a node reference the engine re-finds
356
+ // each one with document.querySelector to build its structuralPath.
355
357
  const occurrences = collapsed.map((el) => {
356
358
  const tag = el.tagName ? lower(el.tagName) : '';
357
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
358
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
359
-
360
- return {
361
- selector: stableSelector,
362
- html,
359
+ const partial = {
363
360
  summary: 'This content is not contained within a landmark region.',
364
361
  hint: 'Move this content inside a landmark region (main, nav, aside, a labeled section, etc.).',
365
362
  i18n: {
@@ -371,6 +368,16 @@ function runInPage(ctx) {
371
368
  details: { reasonCode: 'CONTENT_OUTSIDE_LANDMARK', element: tag }
372
369
  }
373
370
  };
371
+
372
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
373
+ return helpers.reportOccurrence(el, partial);
374
+ }
375
+
376
+ return {
377
+ selector: helpers.buildSelector ? helpers.buildSelector(el) : 'html',
378
+ html: helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '',
379
+ ...partial
380
+ };
374
381
  });
375
382
 
376
383
  if (occurrences.length === 0) {
@@ -6,7 +6,7 @@
6
6
  * @check scope-attr-valid
7
7
  * @atomic true
8
8
  * @summary The scope attribute must have a valid value
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 to elements with a non-empty scope attribute.
12
12
  * @expectation
@@ -15,7 +15,7 @@
15
15
  * assistive technology, silently losing the row/column header
16
16
  * association it was meant to declare.
17
17
  * @implementation-notes
18
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
18
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
19
19
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
20
20
  * header comment for the shared rationale/precedent.
21
21
  */
@@ -61,23 +61,20 @@ function runInPage(ctx) {
61
61
 
62
62
  if (VALID_SCOPES.has(raw.toLowerCase())) continue;
63
63
 
64
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
65
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
66
-
67
- occurrences.push({
68
- selector: stableSelector,
69
- html,
70
- summary: 'This scope attribute value is not recognized.',
71
- hint: 'Use one of row, col, rowgroup, or colgroup for the scope attribute.',
72
- i18n: {
73
- summaryKey: 'scopeAttrValid_summary_cantTell',
74
- hintKey: 'scopeAttrValid_hint_cantTell',
75
- params: { value: raw }
76
- },
77
- data: {
78
- details: { reasonCode: 'SCOPE_ATTR_INVALID', value: raw }
79
- }
80
- });
64
+ occurrences.push(
65
+ helpers.reportOccurrence(el, {
66
+ summary: 'This scope attribute value is not recognized.',
67
+ hint: 'Use one of row, col, rowgroup, or colgroup for the scope attribute.',
68
+ i18n: {
69
+ summaryKey: 'scopeAttrValid_summary_cantTell',
70
+ hintKey: 'scopeAttrValid_hint_cantTell',
71
+ params: { value: raw }
72
+ },
73
+ data: {
74
+ details: { reasonCode: 'SCOPE_ATTR_INVALID', value: raw }
75
+ }
76
+ })
77
+ );
81
78
  }
82
79
 
83
80
  if (applicableCount === 0) {
@@ -9,10 +9,10 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 2.1.1, 2.1.3
11
11
  * @applicability
12
- * Deliberately scoped to a fixed set of likely-to-scroll container tags
12
+ * Scoped on purpose to a fixed set of likely-to-scroll container tags
13
13
  * (div, section, article, aside, main, nav, pre, table, blockquote, ul,
14
14
  * ol, textarea) with computed `overflow-x`/`overflow-y` of `auto` or
15
- * `scroll` — not every element on the page, to keep this deterministic
15
+ * `scroll`, not every element on the page, to keep this deterministic
16
16
  * and performant (same style of scope-down as `region`).
17
17
  * @expectation
18
18
  * A region whose CSS declares it may scroll (`auto`/`scroll`) should be
@@ -22,18 +22,18 @@
22
22
  * scroll from), or the region itself carries a non-negative `tabindex`.
23
23
  * @implementation-notes
24
24
  * - jsdom does not perform layout, so `scrollHeight`/`clientHeight` are
25
- * not available to confirm the region's content actually overflows —
25
+ * not available to confirm the region's content actually overflows,
26
26
  * only that the CSS declares it *may*. Many elements declare
27
27
  * `overflow: auto` defensively without their content ever actually
28
28
  * overflowing, which would be a false positive if treated as a hard
29
29
  * `fail`. For that reason this is authored as `type: 'manual'`
30
- * (cantTell-capped, never fail) rather than `automatic` — same class of
31
- * layout-dependent gap as `iframe-focusable-content`'s
30
+ * (cantTell-capped, never fail) rather than `automatic`, the same class
31
+ * of layout-dependent gap as `iframe-focusable-content`'s
32
32
  * `contentDocument` limitation.
33
33
  * - "Has a focusable descendant" is a presence check (link/button/form
34
34
  * control/`[tabindex]`/`iframe`/`[contenteditable]`), not a full
35
- * focusability computation (disabled state, visibility, etc.) — a
36
- * deliberate simplification to keep this rule self-contained and fast.
35
+ * focusability computation (disabled state, visibility, etc.). Kept
36
+ * simple on purpose, to keep this rule self-contained and fast.
37
37
  */
38
38
 
39
39
  const id = 'scrollable-region-focusable';
@@ -6,17 +6,17 @@
6
6
  * @check skip-link
7
7
  * @atomic true
8
8
  * @summary A "skip" link must resolve to a real, usable target
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 to <a href="#fragment"> elements whose accessible name
12
12
  * matches a common "skip to ..." / "jump to ..." authoring convention
13
- * (case-insensitive "skip" or "jump to" in the name) — the recognizable
13
+ * (case-insensitive "skip" or "jump to" in the name), the recognizable
14
14
  * pattern for a skip-navigation link, not every same-page anchor link
15
15
  * on the page. "jump to" is included alongside "skip" since real skip
16
16
  * links use both conventions (e.g. a "Jump to section" link, which a
17
17
  * purely positional match would catch but a "skip"-only text pattern
18
- * would miss). Text-pattern matching itself stays deliberate (see
19
- * implementation-notes) — this only widens the known-convention list.
18
+ * would miss). Text-pattern matching itself stays intentional (see
19
+ * implementation-notes); this only widens the known-convention list.
20
20
  * @expectation
21
21
  * The link's fragment resolves to a real element in the document
22
22
  * (via a matching id, or a legacy <a name="...">), and that target is
@@ -25,7 +25,7 @@
25
25
  * whose target is missing or effectively unusable does not provide a
26
26
  * reliable bypass destination.
27
27
  * @implementation-notes
28
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
28
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
29
29
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
30
30
  * header comment for the shared rationale/precedent.
31
31
  * - Keyed on the "skip" text-pattern convention rather than positional
@@ -184,59 +184,51 @@ function runInPage(ctx) {
184
184
  const unusableByGeometry = !!geometryReasonCode;
185
185
  if (!unusableByAcc && !unusableByGeometry) continue;
186
186
 
187
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
188
- const html = helpers.getOuterHtmlSnippet
189
- ? helpers.getOuterHtmlSnippet(el)
190
- : el.outerHTML || '';
187
+ occurrences.push(
188
+ helpers.reportOccurrence(el, {
189
+ summary: 'This skip link points to a target that exists but is not currently usable.',
190
+ hint: 'Point this skip link to a target that is exposed and usable as a navigation destination.',
191
+ i18n: {
192
+ summaryKey: 'skipLink_summary_unusableTarget_cantTell',
193
+ hintKey: 'skipLink_hint_unusableTarget_cantTell',
194
+ params: { href }
195
+ },
196
+ data: {
197
+ details: {
198
+ reasonCode: 'SKIP_LINK_TARGET_UNUSABLE',
199
+ href,
200
+ unusableReasonCode: unusableByAcc ? 'ACC_TREE_INELIGIBLE' : geometryReasonCode,
201
+ targetSelector: helpers.buildSelector ? helpers.buildSelector(target) : null,
202
+ geometryCheckEnabled: geometrySupported
203
+ },
204
+ visibilityFilter: {
205
+ targetSet: 'acc',
206
+ accEligible: accEligibility.eligible,
207
+ reasons: accEligibility.reasons
208
+ },
209
+ targetGeometry: geometryEligibility
210
+ ? { eligible: geometryEligibility.eligible, reasons: geometryEligibility.reasons }
211
+ : { eligible: null, reasons: [] }
212
+ }
213
+ })
214
+ );
215
+ continue;
216
+ }
191
217
 
192
- occurrences.push({
193
- selector: stableSelector,
194
- html,
195
- summary: 'This skip link points to a target that exists but is not currently usable.',
196
- hint: 'Point this skip link to a target that is exposed and usable as a navigation destination.',
218
+ occurrences.push(
219
+ helpers.reportOccurrence(el, {
220
+ summary: "This skip link's target does not exist.",
221
+ hint: "Point the skip link's href at an id that exists in the document, or add the missing target element.",
197
222
  i18n: {
198
- summaryKey: 'skipLink_summary_unusableTarget_cantTell',
199
- hintKey: 'skipLink_hint_unusableTarget_cantTell',
223
+ summaryKey: 'skipLink_summary_cantTell',
224
+ hintKey: 'skipLink_hint_cantTell',
200
225
  params: { href }
201
226
  },
202
227
  data: {
203
- details: {
204
- reasonCode: 'SKIP_LINK_TARGET_UNUSABLE',
205
- href,
206
- unusableReasonCode: unusableByAcc ? 'ACC_TREE_INELIGIBLE' : geometryReasonCode,
207
- targetSelector: helpers.buildSelector ? helpers.buildSelector(target) : null,
208
- geometryCheckEnabled: geometrySupported
209
- },
210
- visibilityFilter: {
211
- targetSet: 'acc',
212
- accEligible: accEligibility.eligible,
213
- reasons: accEligibility.reasons
214
- },
215
- targetGeometry: geometryEligibility
216
- ? { eligible: geometryEligibility.eligible, reasons: geometryEligibility.reasons }
217
- : { eligible: null, reasons: [] }
228
+ details: { reasonCode: 'SKIP_LINK_TARGET_MISSING', href }
218
229
  }
219
- });
220
- continue;
221
- }
222
-
223
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
224
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
225
-
226
- occurrences.push({
227
- selector: stableSelector,
228
- html,
229
- summary: "This skip link's target does not exist.",
230
- hint: "Point the skip link's href at an id that exists in the document, or add the missing target element.",
231
- i18n: {
232
- summaryKey: 'skipLink_summary_cantTell',
233
- hintKey: 'skipLink_hint_cantTell',
234
- params: { href }
235
- },
236
- data: {
237
- details: { reasonCode: 'SKIP_LINK_TARGET_MISSING', href }
238
- }
239
- });
230
+ })
231
+ );
240
232
  }
241
233
 
242
234
  if (applicableCount === 0) {
@@ -9,6 +9,15 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.1.1
11
11
  * @type manual
12
+ * @applicability
13
+ * Applies to inline <svg> elements that already carry a text alternative:
14
+ * non-empty <title> or <desc> text, a non-empty aria-label, or an
15
+ * aria-labelledby that resolves to non-empty text. <desc> counts here as
16
+ * something to review even though it never contributes to the accessible
17
+ * name: that distinction is svg-text-alternative-present's. The element
18
+ * must be included in the accessibility tree, and
19
+ * role="presentation"/"none" takes it out of scope unless it is focusable,
20
+ * which restores its role.
12
21
  * @expectation
13
22
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
14
23
  */
@@ -6,7 +6,7 @@
6
6
  * @check tabindex
7
7
  * @atomic true
8
8
  * @summary tabindex should not be greater than 0
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 to elements with a tabindex attribute whose value parses as
12
12
  * a valid integer.
@@ -16,7 +16,7 @@
16
16
  * page changes and usually indicates the natural DOM order should be
17
17
  * fixed instead.
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,23 +62,20 @@ function runInPage(ctx) {
62
62
 
63
63
  if (n <= 0) continue;
64
64
 
65
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
66
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
67
-
68
- occurrences.push({
69
- selector: stableSelector,
70
- html,
71
- summary: 'This element has a positive tabindex, overriding the natural tab order.',
72
- hint: 'Use tabindex="0" (or a negative value to remove from tab order) instead of a positive number; fix the DOM order if a different tab order is needed.',
73
- i18n: {
74
- summaryKey: 'tabindex_summary_cantTell',
75
- hintKey: 'tabindex_hint_cantTell',
76
- params: { value: String(n) }
77
- },
78
- data: {
79
- details: { reasonCode: 'TABINDEX_POSITIVE', value: n }
80
- }
81
- });
65
+ occurrences.push(
66
+ helpers.reportOccurrence(el, {
67
+ summary: 'This element has a positive tabindex, overriding the natural tab order.',
68
+ hint: 'Use tabindex="0" (or a negative value to remove from tab order) instead of a positive number; fix the DOM order if a different tab order is needed.',
69
+ i18n: {
70
+ summaryKey: 'tabindex_summary_cantTell',
71
+ hintKey: 'tabindex_hint_cantTell',
72
+ params: { value: String(n) }
73
+ },
74
+ data: {
75
+ details: { reasonCode: 'TABINDEX_POSITIVE', value: n }
76
+ }
77
+ })
78
+ );
82
79
  }
83
80
 
84
81
  if (applicableCount === 0) {