@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
@@ -0,0 +1,444 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * @check css-focus-indicator-suppressed
7
+ * @atomic true
8
+ * @summary CSS must not remove the focus indicator without drawing a replacement
9
+ * @standard WCAG 2.2
10
+ * @sc 2.4.7
11
+ * @applicability
12
+ * Elements in sequential focus navigation (tabbable and rendered) on a
13
+ * page whose accessible stylesheets contain at least one `:focus` or
14
+ * `:focus-visible` rule. With no focus rule anywhere, every element
15
+ * keeps the user agent's own indicator and there is nothing to check.
16
+ * @expectation
17
+ * No element is matched by a `:focus`/`:focus-visible` rule that removes
18
+ * the outline (`outline: none`, `outline: 0`, `outline-color:
19
+ * transparent`, ...) unless some other focus rule matching it draws a
20
+ * replacement: a border, box-shadow, background, color change, a
21
+ * positive outline of its own, or a `::before`/`::after` decoration.
22
+ * @implementation-notes
23
+ * - Authored as `type: 'manual'` (cantTell-capped, never fail). CSS is
24
+ * only one of the ways a page can indicate focus: ACT oj04fd's own
25
+ * passed examples suppress the outline in CSS and then paint an
26
+ * indicator from an `onfocus` handler, on a sibling element. Static
27
+ * markup cannot see that, so a suppressed outline is a strong review
28
+ * signal rather than a proven failure.
29
+ * - Suppression is only read off the rule's SUBJECT: in `.a:focus .b`,
30
+ * the declarations apply to `.b` while `.a` has focus, so it says
31
+ * nothing about `.b`'s own focus indicator. A replacement, by contrast,
32
+ * is accepted from any rule whose focused compound matches the element:
33
+ * that's exactly the "focus me, paint something elsewhere" pattern
34
+ * (`#link:focus + .indicator { background: navy }`), which does give
35
+ * the user a visible change.
36
+ * - Replacement properties are a curated list of the ones that change
37
+ * pixels (border, box-shadow, background, color, text-decoration,
38
+ * filter, opacity, transform, and `content` for a pseudo-element
39
+ * part), matching this engine's other curated-list checks. A rule
40
+ * setting only `outline-offset` alongside `outline: none` is not a
41
+ * replacement: it offsets an outline that is no longer drawn.
42
+ * - Cross-origin stylesheets throw on `.cssRules` access and are skipped,
43
+ * same limitation as `css-orientation-lock`. A page whose only focus
44
+ * styles live in one of those is reported as having no focus rules at
45
+ * all, so it is not flagged.
46
+ * - Selector matching goes through `el.matches()` on the focus pseudo
47
+ * stripped out of the selector. A selector the engine cannot parse
48
+ * (vendor pseudo-elements, `:host`, unsupported `:is()` forms) throws
49
+ * there and is skipped rather than guessed at.
50
+ */
51
+
52
+ const id = 'css-focus-indicator-suppressed';
53
+
54
+ const meta = {
55
+ title: 'Focus indicator must not be removed without a replacement',
56
+ description:
57
+ 'Flags elements in the tab order whose focus outline is removed by a :focus/:focus-visible rule with no replacement indicator (border, box-shadow, background, ...) in any other focus rule matching them.',
58
+ i18n: {
59
+ titleKey: 'cssFocusIndicatorSuppressed_title',
60
+ descriptionKey: 'cssFocusIndicatorSuppressed_description'
61
+ },
62
+ helpUrl: null,
63
+ tags: ['wcag2aa', 'wcag247', 'navigation', 'focus', 'css', 'atomic', 'manual'],
64
+ wcagSc: ['2.4.7'],
65
+ normativeMappings: [
66
+ {
67
+ standard: 'WCAG',
68
+ version: '2.2',
69
+ requirement: '2.4.7',
70
+ title: 'Focus Visible',
71
+ conformanceLevel: 'AA'
72
+ }
73
+ ],
74
+ defaultSeverity: 'serious',
75
+ category: 'operable',
76
+ type: 'manual',
77
+ defaultConfidence: 'medium',
78
+ coverage: { facetsBySc: { '2.4.7': ['focus-indicator-not-suppressed'] } }
79
+ };
80
+
81
+ function runInPage(ctx) {
82
+ const { document, helpers, rule } = ctx;
83
+
84
+ // Declared inside runInPage; see scripts/build-core.js header
85
+ // ("runInPage MUST be self-contained").
86
+ const CSS_STYLE_RULE = 1;
87
+
88
+ // Properties whose presence in a focus rule changes what the user sees.
89
+ // `outline` is handled separately, since the same property is both the
90
+ // suppression and the most common replacement.
91
+ const REPLACEMENT_PROPS = [
92
+ 'background',
93
+ 'background-color',
94
+ 'background-image',
95
+ 'border',
96
+ 'border-color',
97
+ 'border-style',
98
+ 'border-width',
99
+ 'border-top',
100
+ 'border-right',
101
+ 'border-bottom',
102
+ 'border-left',
103
+ 'border-radius',
104
+ 'box-shadow',
105
+ 'color',
106
+ 'content',
107
+ 'filter',
108
+ 'font-weight',
109
+ 'opacity',
110
+ 'text-decoration',
111
+ 'text-decoration-line',
112
+ 'text-decoration-color',
113
+ 'text-shadow',
114
+ 'transform'
115
+ ];
116
+
117
+ const MAX_DEPTH = 10;
118
+
119
+ function trim(v) {
120
+ return (v == null ? '' : String(v)).trim();
121
+ }
122
+
123
+ function lower(v) {
124
+ return trim(v).toLowerCase();
125
+ }
126
+
127
+ function getProp(style, name) {
128
+ if (!style) return '';
129
+ try {
130
+ if (typeof style.getPropertyValue === 'function') return lower(style.getPropertyValue(name));
131
+ } catch {
132
+ return '';
133
+ }
134
+ return '';
135
+ }
136
+
137
+ function isZeroLength(v) {
138
+ return /^0(\.0+)?(px|em|rem|pt|pc|in|cm|mm|ex|ch|vw|vh|vmin|vmax|%)?$/.test(v);
139
+ }
140
+
141
+ // outline: none | 0 | transparent, in shorthand or longhand form.
142
+ function suppressesOutline(style) {
143
+ const outlineStyle = getProp(style, 'outline-style');
144
+ if (outlineStyle === 'none' || outlineStyle === 'hidden') return true;
145
+
146
+ const outlineWidth = getProp(style, 'outline-width');
147
+ if (outlineWidth && isZeroLength(outlineWidth)) return true;
148
+
149
+ if (getProp(style, 'outline-color') === 'transparent') return true;
150
+
151
+ const outline = getProp(style, 'outline');
152
+ if (outline) {
153
+ const tokens = outline.split(/\s+/).filter(Boolean);
154
+ for (const token of tokens) {
155
+ if (token === 'none' || token === 'hidden' || token === 'transparent') return true;
156
+ if (isZeroLength(token)) return true;
157
+ }
158
+ }
159
+ return false;
160
+ }
161
+
162
+ // A positive outline counts as a replacement: `*:focus { outline: none }`
163
+ // followed by `a:focus { outline: 2px solid }` leaves links indicated.
164
+ function drawsOutline(style) {
165
+ if (suppressesOutline(style)) return false;
166
+ return !!(
167
+ getProp(style, 'outline') ||
168
+ getProp(style, 'outline-style') ||
169
+ getProp(style, 'outline-width') ||
170
+ getProp(style, 'outline-color')
171
+ );
172
+ }
173
+
174
+ function providesReplacement(style) {
175
+ if (drawsOutline(style)) return true;
176
+ for (const prop of REPLACEMENT_PROPS) {
177
+ if (getProp(style, prop)) return true;
178
+ }
179
+ return false;
180
+ }
181
+
182
+ // Splits a selector list on top-level commas only, so a comma inside
183
+ // :not(...)/:is(...) does not break a selector in half.
184
+ function splitSelectorList(selectorText) {
185
+ const parts = [];
186
+ let depth = 0;
187
+ let current = '';
188
+ for (const ch of String(selectorText || '')) {
189
+ if (ch === '(') depth += 1;
190
+ if (ch === ')') depth = Math.max(0, depth - 1);
191
+ if (ch === ',' && depth === 0) {
192
+ parts.push(current);
193
+ current = '';
194
+ continue;
195
+ }
196
+ current += ch;
197
+ }
198
+ if (trim(current)) parts.push(current);
199
+ return parts.map(trim).filter(Boolean);
200
+ }
201
+
202
+ // :focus and :focus-visible, but never :focus-within: that one fires on
203
+ // an ancestor of the focused element and says nothing about whether the
204
+ // element itself is indicated.
205
+ const FOCUS_PSEUDO = /:focus(-visible)?(?![-\w])/g;
206
+
207
+ function hasFocusPseudo(part) {
208
+ FOCUS_PSEUDO.lastIndex = 0;
209
+ return FOCUS_PSEUDO.test(part);
210
+ }
211
+
212
+ // Splits a complex selector into its compounds, keeping the combinators
213
+ // out: "a:focus + .indicator" -> ["a:focus", ".indicator"]. Descendant
214
+ // combinators inside :not(...)/:is(...) are left alone.
215
+ function splitCompounds(part) {
216
+ const compounds = [];
217
+ let depth = 0;
218
+ let current = '';
219
+ for (const ch of part) {
220
+ if (ch === '(') depth += 1;
221
+ if (ch === ')') depth = Math.max(0, depth - 1);
222
+ if (depth === 0 && (ch === ' ' || ch === '>' || ch === '+' || ch === '~')) {
223
+ if (trim(current)) compounds.push(trim(current));
224
+ current = '';
225
+ continue;
226
+ }
227
+ current += ch;
228
+ }
229
+ if (trim(current)) compounds.push(trim(current));
230
+ return compounds;
231
+ }
232
+
233
+ function stripFocusPseudo(compound) {
234
+ const stripped = trim(String(compound).replace(FOCUS_PSEUDO, ''));
235
+ return stripped || '*';
236
+ }
237
+
238
+ // A pseudo-element part styles generated content rather than the element,
239
+ // so it can draw a replacement but can never be the thing suppressing the
240
+ // element's own outline.
241
+ function hasPseudoElement(part) {
242
+ return /::[a-z-]+/.test(part) || /:(before|after)\b/.test(part);
243
+ }
244
+
245
+ function matchesSafe(el, selector) {
246
+ if (!el || typeof el.matches !== 'function' || !selector) return false;
247
+ try {
248
+ return el.matches(selector);
249
+ } catch {
250
+ return false; // selector this engine cannot parse, skip rather than guess
251
+ }
252
+ }
253
+
254
+ function closestSafe(el, selector) {
255
+ if (!el || typeof el.closest !== 'function' || !selector) return false;
256
+ try {
257
+ return !!el.closest(selector);
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+
263
+ const suppressors = []; // { selector, base }
264
+ const providers = []; // { base, subject }
265
+
266
+ function collectFromStyleRule(cssRule) {
267
+ const style = cssRule.style;
268
+ if (!style) return;
269
+
270
+ const suppresses = suppressesOutline(style);
271
+ const provides = providesReplacement(style);
272
+ if (!suppresses && !provides) return;
273
+
274
+ for (const part of splitSelectorList(cssRule.selectorText)) {
275
+ if (!hasFocusPseudo(part)) continue;
276
+
277
+ const compounds = splitCompounds(part);
278
+ let focusIndex = -1;
279
+ for (let i = 0; i < compounds.length; i++) {
280
+ if (hasFocusPseudo(compounds[i])) {
281
+ focusIndex = i;
282
+ break;
283
+ }
284
+ }
285
+ if (focusIndex === -1) continue;
286
+
287
+ const isSubject = focusIndex === compounds.length - 1;
288
+ const focusedBase =
289
+ stripFocusPseudo(compounds[focusIndex]).replace(/::?[a-z-]+$/i, '') || '*';
290
+
291
+ if (suppresses && isSubject && !hasPseudoElement(part)) {
292
+ suppressors.push({ selector: trim(part), base: stripFocusPseudo(part) });
293
+ }
294
+
295
+ // A replacement is credited to the element that takes focus, wherever
296
+ // the rule paints it: on the element itself, its pseudo-element, a
297
+ // sibling, or a descendant.
298
+ if (provides) providers.push({ base: focusedBase, subject: isSubject });
299
+ }
300
+ }
301
+
302
+ function walkRules(rules, depth) {
303
+ if (!rules || depth > MAX_DEPTH) return;
304
+ for (const cssRule of rules) {
305
+ if (!cssRule) continue;
306
+ if (cssRule.type === CSS_STYLE_RULE && cssRule.selectorText) {
307
+ collectFromStyleRule(cssRule);
308
+ continue;
309
+ }
310
+ // @media, @supports, @layer, ...: recurse into grouping rules.
311
+ let nested;
312
+ try {
313
+ nested = cssRule.cssRules || null;
314
+ } catch {
315
+ nested = null;
316
+ }
317
+ if (nested) walkRules(nested, depth + 1);
318
+ }
319
+ }
320
+
321
+ let sheetCount = 0;
322
+ try {
323
+ const sheets = document.styleSheets || [];
324
+ for (const sheet of sheets) {
325
+ let rules = null;
326
+ try {
327
+ rules = sheet && sheet.cssRules ? sheet.cssRules : null;
328
+ } catch {
329
+ continue; // cross-origin stylesheet, not inspectable
330
+ }
331
+ if (!rules) continue;
332
+ sheetCount += 1;
333
+ walkRules(rules, 0);
334
+ }
335
+ } catch {
336
+ // no-throw: treat as no accessible stylesheets
337
+ }
338
+
339
+ if (sheetCount === 0 || !suppressors.length) {
340
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
341
+ }
342
+
343
+ const getFocusableInfo =
344
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
345
+ const isDomVisibleEligible =
346
+ helpers && typeof helpers.isDomVisibleEligible === 'function'
347
+ ? helpers.isDomVisibleEligible
348
+ : null;
349
+
350
+ function isTabbable(el) {
351
+ if (!getFocusableInfo) return false;
352
+ try {
353
+ const info = getFocusableInfo(el, ctx);
354
+ return !!(info && info.tabbable);
355
+ } catch {
356
+ return false;
357
+ }
358
+ }
359
+
360
+ function isRendered(el) {
361
+ if (!isDomVisibleEligible) return true;
362
+ try {
363
+ const vis = isDomVisibleEligible(el, ctx, {
364
+ visibilityMode: 'styleOnly',
365
+ disableGeometry: true
366
+ });
367
+ return !(vis && vis.eligible === false);
368
+ } catch {
369
+ return true;
370
+ }
371
+ }
372
+
373
+ const CANDIDATE_SELECTOR =
374
+ 'a[href],area[href],button,input,select,textarea,summary,[tabindex],[contenteditable]';
375
+ const candidates = helpers.queryAllSmart
376
+ ? helpers.queryAllSmart(CANDIDATE_SELECTOR)
377
+ : helpers.queryAll(CANDIDATE_SELECTOR);
378
+
379
+ const occurrences = [];
380
+ let applicableCount = 0;
381
+
382
+ for (const el of candidates) {
383
+ if (!el || el.nodeType !== 1) continue;
384
+ if (!isTabbable(el)) continue;
385
+ if (!isRendered(el)) continue;
386
+
387
+ applicableCount += 1;
388
+
389
+ const suppressing = suppressors.filter((s) => matchesSafe(el, s.base));
390
+ if (!suppressing.length) continue;
391
+
392
+ const indicated = providers.some((p) =>
393
+ p.subject ? matchesSafe(el, p.base) : matchesSafe(el, p.base) || closestSafe(el, p.base)
394
+ );
395
+ if (indicated) continue;
396
+
397
+ const selectors = [...new Set(suppressing.map((s) => s.selector))];
398
+ const eligInfo = helpers.getEligibilityInfo
399
+ ? (() => {
400
+ try {
401
+ return helpers.getEligibilityInfo(el, ctx, { targetSet: 'dom' });
402
+ } catch {
403
+ return null;
404
+ }
405
+ })()
406
+ : null;
407
+
408
+ occurrences.push(
409
+ helpers.reportOccurrence(el, {
410
+ summary: `This element takes a tab stop, and "${selectors.join(', ')}" removes its focus outline with no replacement indicator in any other focus rule matching it.`,
411
+ hint: 'Draw a replacement indicator in the same rule (a visible outline, border, box-shadow, or background change), or drop the outline reset. If the indicator is applied from script instead, confirm it appears for keyboard users.',
412
+ i18n: {
413
+ summaryKey: 'cssFocusIndicatorSuppressed_summary_cantTell',
414
+ hintKey: 'cssFocusIndicatorSuppressed_hint_cantTell',
415
+ params: { selectors: selectors.join(', ') }
416
+ },
417
+ data: {
418
+ details: {
419
+ reasonCode: 'FOCUS_INDICATOR_SUPPRESSED',
420
+ suppressingSelectors: selectors
421
+ },
422
+ visibilityFilter: eligInfo || { targetSet: 'dom', accEligible: null, reasons: [] }
423
+ }
424
+ })
425
+ );
426
+ }
427
+
428
+ if (applicableCount === 0) {
429
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
430
+ }
431
+
432
+ if (occurrences.length) {
433
+ return {
434
+ ruleId: rule.ruleId,
435
+ outcome: 'cantTell',
436
+ severity: rule.defaultSeverity || 'serious',
437
+ occurrences
438
+ };
439
+ }
440
+
441
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
442
+ }
443
+
444
+ module.exports = { id, meta, runInPage };
@@ -9,6 +9,14 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.1.1
11
11
  * @type manual
12
+ * @applicability
13
+ * Applies to <embed> elements that already carry a text alternative: a
14
+ * non-empty aria-label, an aria-labelledby that resolves to non-empty
15
+ * text, or a non-empty title. An aria-labelledby pointing at a missing id
16
+ * resolves to nothing and so is not a text alternative to review; that
17
+ * element is embed-text-alternative-present's failure. The element must be
18
+ * included in the accessibility tree, and role="presentation"/"none" takes
19
+ * it out of scope unless it is focusable, which restores its role.
12
20
  * @expectation
13
21
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
14
22
  */
@@ -6,7 +6,7 @@
6
6
  * @check empty-heading
7
7
  * @atomic true
8
8
  * @summary Heading elements must not be empty
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 heading role: native <h1>-<h6>, or any
12
12
  * element with explicit role="heading" (unless overridden by another
@@ -18,20 +18,20 @@
18
18
  * heading is announced as "heading, level N" with nothing else, which
19
19
  * is confusing when navigating by heading.
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.
24
24
  * - This is also the reconciliation point for the ACT-rules
25
- * "heading-name-present" requirement (see ROADMAP.md "Tier 5
26
- * candidates"): already covered by this pre-existing rule under a
27
- * different name, not a separate gap. `title` is accepted as a naming
25
+ * "heading-name-present" requirement: already covered by this
26
+ * pre-existing rule under a different name, not a separate gap.
27
+ * `title` is accepted as a naming
28
28
  * fallback, and hidden/aria-hidden/display:none headings are excluded
29
29
  * (gated on `isAccTreeEligible`), so an empty heading no AT user could
30
30
  * ever reach is not flagged.
31
31
  * - Descendant name resolution uses the shared, accname-aligned
32
- * `helpers.getContentNameInfo` (see dom-helpers.js) — the same "name
32
+ * `helpers.getContentNameInfo` (see dom-helpers.js), the same "name
33
33
  * from content" implementation the 19 `-name-present` rules already
34
- * use — rather than a narrower hand-rolled walker, so an `<img alt="...">`
34
+ * use, rather than a narrower hand-rolled walker, so an `<img alt="...">`
35
35
  * descendant's alt text (e.g. a
36
36
  * `<h1><a><div><img alt="..."></div></a></h1>` logo header) is correctly
37
37
  * picked up as the heading's name instead of producing a false "empty
@@ -74,11 +74,58 @@ function runInPage(ctx) {
74
74
  return raw.split(/\s+/)[0].toLowerCase();
75
75
  }
76
76
 
77
+ // Same Global States and Properties set used elsewhere in this engine
78
+ // (aria-required-parent.js, aria-prohibited-children.js) for the same
79
+ // presentational-roles-conflict-resolution concept: a native h1-h6
80
+ // marked role="none"/"presentation" still reverts to its native heading
81
+ // role when it carries a global ARIA attribute (even one with an empty
82
+ // value, like aria-label="" -- the attribute's presence is what
83
+ // triggers conflict resolution, not its value).
84
+ const GLOBAL_ARIA_ATTRS = [
85
+ 'aria-atomic',
86
+ 'aria-braillelabel',
87
+ 'aria-brailleroledescription',
88
+ 'aria-busy',
89
+ 'aria-controls',
90
+ 'aria-current',
91
+ 'aria-describedby',
92
+ 'aria-description',
93
+ 'aria-details',
94
+ 'aria-disabled',
95
+ 'aria-dropeffect',
96
+ 'aria-errormessage',
97
+ 'aria-flowto',
98
+ 'aria-grabbed',
99
+ 'aria-haspopup',
100
+ 'aria-hidden',
101
+ 'aria-invalid',
102
+ 'aria-keyshortcuts',
103
+ 'aria-label',
104
+ 'aria-labelledby',
105
+ 'aria-live',
106
+ 'aria-owns',
107
+ 'aria-relevant',
108
+ 'aria-roledescription'
109
+ ];
110
+
111
+ function hasGlobalAriaAttr(el) {
112
+ for (const attr of GLOBAL_ARIA_ATTRS) {
113
+ if (el.getAttribute && el.getAttribute(attr) != null) return true;
114
+ }
115
+ return false;
116
+ }
117
+
77
118
  function isHeading(el) {
78
- const explicit = getExplicitRoleToken(el);
79
- if (explicit) return explicit === 'heading';
80
119
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
81
- return /^h[1-6]$/.test(tag);
120
+ const isNativeHeadingTag = /^h[1-6]$/.test(tag);
121
+
122
+ const explicit = getExplicitRoleToken(el);
123
+ if (!explicit) return isNativeHeadingTag;
124
+ if (explicit === 'heading') return true;
125
+ if ((explicit === 'none' || explicit === 'presentation') && isNativeHeadingTag) {
126
+ return hasGlobalAriaAttr(el);
127
+ }
128
+ return false;
82
129
  }
83
130
 
84
131
  function getAccessibleNameText(el) {
@@ -100,7 +147,7 @@ function runInPage(ctx) {
100
147
  if (joined) return joined;
101
148
  }
102
149
  // Shared, accname-aligned "name from content" implementation (see
103
- // dom-helpers.js's getContentNameInfo header comment) — resolves an
150
+ // dom-helpers.js's getContentNameInfo header comment). Resolves an
104
151
  // <img> descendant's own alt text, an aria-label/aria-labelledby'd
105
152
  // descendant's own name, etc., and already gates every descendant on
106
153
  // full accessibility-tree eligibility (aria-hidden, display:none,
@@ -6,7 +6,7 @@
6
6
  * @check empty-table-header
7
7
  * @atomic true
8
8
  * @summary Table header cells must not be empty
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 <th> elements that don't carry a conflicting explicit role,
12
12
  * plus any element (native <th> or not) with role="columnheader" or
@@ -19,19 +19,19 @@
19
19
  * @expectation
20
20
  * The header cell has visible text content. A <th> named only via
21
21
  * aria-label/aria-labelledby (no visible text) is ALSO flagged, not
22
- * treated as equivalent — aria-label support on <th> is genuinely
23
- * inconsistent in practice: NVDA+Firefox and iOS VoiceOver+Safari ignore
22
+ * treated as equivalent: aria-label support on <th> is inconsistent
23
+ * in practice. NVDA+Firefox and iOS VoiceOver+Safari ignore
24
24
  * it entirely (only visible text is announced), JAWS+Chrome/IE11 also only
25
25
  * announce visible text in the header cell itself. Visible text is the one
26
26
  * mechanism confirmed to work across every tested combination. See
27
27
  * https://html5accessibility.com/stuff/2024/05/22/not-so-short-note-on-aria-label-usage-big-table-edition/.
28
28
  * @implementation-notes
29
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
29
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
30
30
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
31
31
  * header comment for the shared rationale/precedent.
32
32
  * - Two distinct reasonCodes: TABLE_HEADER_EMPTY (no accessible name at
33
33
  * all) vs. TABLE_HEADER_NAME_NOT_VISIBLE_TEXT (has aria-label/
34
- * aria-labelledby but no visible text) — the latter gets a different
34
+ * aria-labelledby but no visible text), the latter gets a different
35
35
  * summary/hint explaining the AT-support gap, since the header isn't
36
36
  * literally nameless, just unreliably named in practice.
37
37
  */
@@ -41,7 +41,7 @@ const id = 'empty-table-header';
41
41
  const meta = {
42
42
  title: 'Table header cells must not be empty',
43
43
  description:
44
- 'Checks that table header cells (<th>, or any element with role="columnheader"/"rowheader") have visible text content — a header named only via aria-label/aria-labelledby is also flagged, since real screen-reader/browser support for that is inconsistent.',
44
+ 'Checks that table header cells (<th>, or any element with role="columnheader"/"rowheader") have visible text content. A header named only via aria-label/aria-labelledby is also flagged, since real screen-reader/browser support for that is inconsistent.',
45
45
  i18n: {
46
46
  titleKey: 'emptyTableHeader_title',
47
47
  descriptionKey: 'emptyTableHeader_description'
@@ -146,8 +146,8 @@ function runInPage(ctx) {
146
146
  occurrences.push(
147
147
  helpers.reportOccurrence(el, {
148
148
  summary:
149
- 'This table header cell has no visible text — its only accessible name comes from aria-label/aria-labelledby, which real screen-reader/browser combinations (e.g. NVDA+Firefox, iOS VoiceOver+Safari) are known to ignore on <th> elements.',
150
- hint: 'Add visible text content to this header cell (in addition to, or instead of, aria-label/aria-labelledby) — visible text is the only naming mechanism confirmed to work across tested screen readers.',
149
+ 'This table header cell has no visible text. Its only accessible name comes from aria-label/aria-labelledby, which real screen-reader/browser combinations (e.g. NVDA+Firefox, iOS VoiceOver+Safari) are known to ignore on <th> elements.',
150
+ hint: 'Add visible text content to this header cell (in addition to, or instead of, aria-label/aria-labelledby); visible text is the only naming mechanism confirmed to work across tested screen readers.',
151
151
  i18n: {
152
152
  summaryKey: 'emptyTableHeader_summary_cantTell_ariaOnly',
153
153
  hintKey: 'emptyTableHeader_hint_cantTell_ariaOnly',
@@ -6,32 +6,32 @@
6
6
  * @check focus-order-semantics
7
7
  * @atomic true
8
8
  * @summary Elements added to the tab order should have interactive semantics
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 `tabindex` of `0` or greater (in the tab
12
12
  * order) AND an explicit `role` attribute that is one of a curated set
13
13
  * of clearly non-interactive, structural/document roles.
14
14
  * @expectation
15
- * An element deliberately placed in the tab order should communicate
16
- * why it's focusable — a role like `heading`, `list`, `region`, or
15
+ * An element placed in the tab order on purpose should communicate
16
+ * why it's focusable: a role like `heading`, `list`, `region`, or
17
17
  * `presentation` gives assistive technology no interactive semantic to
18
18
  * announce, which is confusing for keyboard users who land on it and
19
19
  * get no indication of what activating it (if anything) would do.
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.
23
- * - The non-interactive role list is deliberately curated and
24
- * conservative (structural/document roles only) — legitimate custom
25
- * widget patterns using `tabindex` with a genuinely interactive role
26
- * (`option`, `tab`, `menuitem`, etc.) are never flagged. Elements with
27
- * `tabindex` and NO role at all are also not flagged: native semantics
28
- * or an intentionally generic custom-interactive pattern cannot be
23
+ * - The non-interactive role list is curated and conservative
24
+ * (structural/document roles only). Legitimate custom widget patterns
25
+ * using `tabindex` with an actually interactive role (`option`, `tab`,
26
+ * `menuitem`, etc.) are never flagged. Elements with `tabindex` and NO
27
+ * role at all are also not flagged: native semantics or an
28
+ * intentionally generic custom-interactive pattern cannot be
29
29
  * distinguished from markup alone with the same confidence.
30
- * - `region` is deliberately NOT in the non-interactive role list: a
31
- * tabbable `role="region"` is a real, WCAG 2.1.1/2.1.3-grounded pattern
32
- * this engine's own `scrollable-region-focusable` check exists to
33
- * RECOMMEND (a scrollable landmark with no other focusable content
34
- * needs `tabindex="0"` to be keyboard-reachable at all) — flagging it
30
+ * - `region` is intentionally excluded from the non-interactive role
31
+ * list: a tabbable `role="region"` is a real, WCAG 2.1.1/2.1.3-grounded
32
+ * pattern this engine's own `scrollable-region-focusable` check exists
33
+ * to RECOMMEND (a scrollable landmark with no other focusable content
34
+ * needs `tabindex="0"` to be keyboard-reachable at all), so flagging it
35
35
  * here would be internally inconsistent with that sibling check. A
36
36
  * `role="region"` is also commonly made tabbable on its own merits
37
37
  * (e.g. a cookie-consent banner or notification/toast region a keyboard