@surea11y/core 1.5.0 → 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 (145) hide show
  1. package/CHANGELOG.md +193 -149
  2. package/README.md +27 -6
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +16 -4
  8. package/docs/I18N.md +4 -4
  9. package/docs/INTEGRATION.md +1 -1
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/REPORT.md +1 -1
  12. package/docs/RULE_AUTHORING.md +53 -25
  13. package/docs/RULE_CATALOG.md +1878 -169
  14. package/docs/RULE_TAXONOMY.md +2 -2
  15. package/docs/TROUBLESHOOTING.md +2 -2
  16. package/docs/WCAG_CONFORMANCE.md +25 -9
  17. package/package.json +3 -7
  18. package/src/baseline.js +3 -3
  19. package/src/checks/automatic/area-alt-present.js +2 -2
  20. package/src/checks/automatic/aria-allowed-attr.js +68 -10
  21. package/src/checks/automatic/aria-allowed-role.js +2 -2
  22. package/src/checks/automatic/aria-braille-equivalent.js +3 -3
  23. package/src/checks/automatic/aria-conditional-attr.js +5 -5
  24. package/src/checks/automatic/aria-deprecated-role.js +1 -1
  25. package/src/checks/automatic/aria-hidden-body.js +2 -2
  26. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  27. package/src/checks/automatic/aria-prohibited-attr.js +18 -18
  28. package/src/checks/automatic/aria-prohibited-children.js +130 -37
  29. package/src/checks/automatic/aria-required-attr.js +60 -12
  30. package/src/checks/automatic/aria-required-children.js +21 -14
  31. package/src/checks/automatic/aria-required-parent.js +61 -9
  32. package/src/checks/automatic/aria-role-name-present.js +36 -22
  33. package/src/checks/automatic/aria-valid-attr-value.js +15 -12
  34. package/src/checks/automatic/aria-valid-attr.js +1 -1
  35. package/src/checks/automatic/autocomplete-valid.js +2 -2
  36. package/src/checks/automatic/binary-control-name-present.js +27 -5
  37. package/src/checks/automatic/button-name-present.js +92 -6
  38. package/src/checks/automatic/combobox-name-present.js +26 -6
  39. package/src/checks/automatic/contrast-computable.js +32 -0
  40. package/src/checks/automatic/contrast-enhanced.js +21 -1
  41. package/src/checks/automatic/contrast-minimum.js +21 -1
  42. package/src/checks/automatic/css-orientation-lock.js +96 -19
  43. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  44. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  45. package/src/checks/automatic/dialog-name-present.js +20 -2
  46. package/src/checks/automatic/duplicate-id-aria.js +5 -3
  47. package/src/checks/automatic/duplicate-id.js +198 -0
  48. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  49. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  50. package/src/checks/automatic/form-control-single-label.js +1 -1
  51. package/src/checks/automatic/iframe-focusable-content.js +63 -7
  52. package/src/checks/automatic/iframe-name-present.js +37 -3
  53. package/src/checks/automatic/iframe-title-unique.js +1 -1
  54. package/src/checks/automatic/img-alt-present.js +12 -4
  55. package/src/checks/automatic/label-in-name.js +172 -18
  56. package/src/checks/automatic/link-in-text-block.js +10 -10
  57. package/src/checks/automatic/link-name-present.js +22 -1
  58. package/src/checks/automatic/list-children-valid.js +6 -6
  59. package/src/checks/automatic/listbox-name-present.js +28 -8
  60. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  61. package/src/checks/automatic/menuitem-name-present.js +20 -2
  62. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  63. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  64. package/src/checks/automatic/meter-name-present.js +23 -4
  65. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  66. package/src/checks/automatic/option-name-present.js +23 -4
  67. package/src/checks/automatic/page-title-present.js +21 -3
  68. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  69. package/src/checks/automatic/progressbar-name-present.js +23 -4
  70. package/src/checks/automatic/role-img-alt-present.js +64 -16
  71. package/src/checks/automatic/searchbox-name-present.js +28 -8
  72. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  73. package/src/checks/automatic/slider-name-present.js +27 -6
  74. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  75. package/src/checks/automatic/summary-name-present.js +18 -2
  76. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  77. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  78. package/src/checks/automatic/tab-name-present.js +21 -2
  79. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  80. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  81. package/src/checks/automatic/target-size-minimum.js +71 -53
  82. package/src/checks/automatic/td-has-header.js +5 -5
  83. package/src/checks/automatic/textbox-name-present.js +28 -8
  84. package/src/checks/automatic/tooltip-name-present.js +21 -2
  85. package/src/checks/automatic/treeitem-name-present.js +23 -4
  86. package/src/checks/automatic/valid-lang.js +92 -7
  87. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  88. package/src/checks/manual/accesskeys-manual.js +3 -3
  89. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  90. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  91. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  92. package/src/checks/manual/aria-text-manual.js +4 -4
  93. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  94. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  95. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  96. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  97. package/src/checks/manual/empty-heading-manual.js +58 -11
  98. package/src/checks/manual/empty-table-header-manual.js +8 -8
  99. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  100. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  101. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  102. package/src/checks/manual/heading-order-manual.js +3 -3
  103. package/src/checks/manual/heading-quality-manual.js +338 -0
  104. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  105. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  106. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  107. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  108. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  109. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  110. package/src/checks/manual/label-title-only-manual.js +4 -4
  111. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  112. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  113. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  114. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  115. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  116. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  117. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  118. package/src/checks/manual/landmark-unique-manual.js +9 -9
  119. package/src/checks/manual/link-name-quality-manual.js +161 -32
  120. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  121. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  122. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  123. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  124. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  125. package/src/checks/manual/p-as-heading-manual.js +4 -4
  126. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  127. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  128. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  129. package/src/checks/manual/region-manual.js +19 -19
  130. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  131. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  132. package/src/checks/manual/skip-link-manual.js +5 -5
  133. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  134. package/src/checks/manual/tabindex-manual.js +2 -2
  135. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  136. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  137. package/src/checks/manual/video-caption-manual.js +3 -3
  138. package/src/checks/manual-review.js +17 -1
  139. package/src/core.js +8965 -1647
  140. package/src/report.js +2 -2
  141. package/surea11y.browser.js +3768 -611
  142. package/surea11y.i18n.de.js +1 -1
  143. package/surea11y.i18n.es.js +1 -1
  144. package/surea11y.i18n.fr.js +1 -1
  145. package/bin/surea11y-core.js +0 -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
@@ -118,7 +118,7 @@ function runInPage(ctx) {
118
118
  helpers.reportOccurrence(el, {
119
119
  summary:
120
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.',
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
122
  i18n: {
123
123
  summaryKey: 'ariaCheckedStateMismatch_summary_cantTell',
124
124
  hintKey: 'ariaCheckedStateMismatch_hint_cantTell',
@@ -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',
@@ -9,31 +9,36 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 2.4.1
11
11
  * @applicability
12
- * Always applicable to any HTML document with a <body> element —
12
+ * Always applicable to any HTML document with a <body> element:
13
13
  * "bypass blocks" is a whole-page concern, matching
14
14
  * aria-hidden-body / page-title-present's pattern of
15
15
  * evaluating the document directly rather than a scoped root.
16
16
  * @expectation
17
17
  * At least one of the following recognized WCAG 2.4.1 techniques is
18
18
  * present:
19
- * (a) a main landmark (<main> or [role="main"]) — technique ARIA11: a
19
+ * (a) a main landmark (<main> or [role="main"]), technique ARIA11: a
20
20
  * screen reader user can jump straight to it, bypassing everything
21
21
  * before it (nav, header, repeated blocks) in one step;
22
- * (b) a working same-page anchor link — technique G1/G123: an
22
+ * (b) a working same-page anchor link, technique G1/G123: an
23
23
  * <a href="#id"> (or legacy <a name="id">) whose target resolves to
24
24
  * a real element in the link's own tree (light DOM or the same shadow
25
- * root). Deliberately NOT required to be positioned before a <nav> or
26
- * be keyboard-focus-order-first — see implementation notes;
27
- * (c) at least one heading (<h1>-<h6> or [role="heading"]) — technique
28
- * H69: heading navigation is itself a standards-recognized bypass
29
- * mechanism (e.g. a screen reader's "jump by heading" command).
25
+ * root). Not required to be positioned before a <nav> or be
26
+ * keyboard-focus-order-first (see implementation notes);
27
+ * (c) at least one heading (<h1>-<h6> or [role="heading"]) that is both
28
+ * included in the accessibility tree AND visible (not off-screen,
29
+ * clipped, opacity:0, or zero-size-overflow-hidden), technique H69:
30
+ * heading navigation is itself a standards-recognized bypass
31
+ * mechanism (e.g. a screen reader's "jump by heading" command), but
32
+ * ACT 047fe0's own Expectation requires visibility too, since a
33
+ * screen-reader-only heading leaves sighted keyboard users with no
34
+ * equivalent way to locate the start of non-repeated content.
30
35
  * @implementation-notes
31
36
  * - Outcome model: this rule is `type: 'manual'` (cantTell-capped, never
32
37
  * `fail`). When a recognized mechanism is found the page has nothing to
33
38
  * review here → `notApplicable` (matching page-has-heading-one-manual /
34
39
  * skip-link-manual's "nothing to flag" convention). When none is found we
35
- * return `cantTell` — "we could not detect a bypass mechanism, please
36
- * verify" — rather than a hard `fail`. The absence of a *detectable*
40
+ * return `cantTell`: "we could not detect a bypass mechanism, please
41
+ * verify," rather than a hard `fail`. The absence of a *detectable*
37
42
  * mechanism is NOT high-confidence evidence that 2.4.1 is violated, for
38
43
  * several reasons the engine cannot resolve from a single static snapshot:
39
44
  * • Applicability itself is undecidable in-page. 2.4.1 governs blocks of
@@ -45,7 +50,7 @@
45
50
  * rest of the page is routinely made `inert` or `aria-hidden="true"`,
46
51
  * so the page's real <main>/headings are (correctly) filtered out by
47
52
  * isAccTreeEligible for the duration of that state and only the dialog
48
- * is exposed — a snapshot taken then would see "no mechanism" though
53
+ * is exposed, so a snapshot taken then would see "no mechanism" though
49
54
  * the page has one once the dialog closes. The same applies to content
50
55
  * that is display:none until revealed by script (tabs, accordions, an
51
56
  * unmounted SPA view).
@@ -54,23 +59,19 @@
54
59
  * human review instead. No ACT rule hard-fails 2.4.1 by presence alone,
55
60
  * for the same reason.
56
61
  * - This rule intentionally checks presence, not position, for the
57
- * same-page-anchor condition (b): a full bypass algorithm is heuristic
58
- * (see ROADMAP.md's Tier 1a note on why this rule was
59
- * deferred from the rest of that batch), and getting DOM-order /
60
- * keyboard-focus-order positioning exactly right without introducing
61
- * false positives is materially harder than the rest of Tier 1a. Being
62
- * lenient about condition (b) can only make us *miss* a review prompt
63
- * (a page whose only anchor link isn't a real skip mechanism, e.g. a
64
- * "back to top" link) — never raise a spurious one.
62
+ * same-page-anchor condition (b): a full bypass algorithm is heuristic,
63
+ * and getting DOM-order / keyboard-focus-order positioning exactly right
64
+ * without introducing false positives is materially harder than checking
65
+ * presence alone. Being lenient about condition (b) can only make us
66
+ * *miss* a review prompt (a page whose only anchor link isn't a real
67
+ * skip mechanism, e.g. a "back to top" link), never raising a spurious one.
65
68
  * - Shadow DOM: all three conditions use `helpers.queryAllSmart`, which is
66
69
  * shadow-DOM-aware (when the run enables includeShadowDom) and applies the
67
70
  * engine's hidden-content policy. The same-page-anchor target is resolved
68
- * in the link's own root (`getRootNode()` — the document, or the shadow
71
+ * in the link's own root (`getRootNode()`: the document, or the shadow
69
72
  * root the link lives in) before falling back to the document, so a skip
70
73
  * link encapsulated in a web component is credited the same as one in the
71
- * light DOM. (Previously the anchor path used raw
72
- * `document.querySelectorAll`/`getElementById`, which never pierced shadow
73
- * roots — a genuine gap now closed.)
74
+ * light DOM.
74
75
  */
75
76
 
76
77
  const id = 'bypass-blocks-present';
@@ -227,9 +228,26 @@ function runInPage(ctx) {
227
228
  return false;
228
229
  }
229
230
 
231
+ // ACT 047fe0's own Expectation requires the heading to be visible, not
232
+ // only included in the accessibility tree: a screen-reader-only heading
233
+ // still leaves sighted keyboard users with no way to locate the start of
234
+ // non-repeated content. "Visible" per ACT's own glossary: making it fully
235
+ // transparent would change rendered pixels, which every CSS-only hiding
236
+ // technique (off-screen positioning, clip/clip-path, opacity:0, a
237
+ // zero-size overflow:hidden box) fails.
238
+ function isCssHidden(el) {
239
+ if (!helpers || typeof helpers.getVisibilityHintsInfo !== 'function') return false;
240
+ try {
241
+ const info = helpers.getVisibilityHintsInfo(el, ctx, {});
242
+ return !!(info && Array.isArray(info.hints) && info.hints.length > 0);
243
+ } catch {
244
+ return false;
245
+ }
246
+ }
247
+
230
248
  function hasHeading() {
231
249
  for (const el of queryAll('h1, h2, h3, h4, h5, h6, [role="heading"]')) {
232
- if (el && isExposedToAt(el)) return true;
250
+ if (el && isExposedToAt(el) && !isCssHidden(el)) return true;
233
251
  }
234
252
  return false;
235
253
  }
@@ -246,8 +264,8 @@ function runInPage(ctx) {
246
264
  const occurrences = [
247
265
  helpers.reportOccurrence(body, {
248
266
  summary:
249
- 'No recognized way to bypass repeated blocks of content was detected on this page — verify a bypass mechanism exists.',
250
- hint: 'Confirm the page offers a bypass mechanism: a main landmark (<main> or role="main"), a working "skip to content" link, or heading elements that assistive technology can use to jump past repeated content. (A mechanism may be temporarily hidden — e.g. while a modal dialog makes the page inert — or provided on a per-site basis; this needs human confirmation.)',
267
+ 'No recognized way to bypass repeated blocks of content was detected on this page. Verify a bypass mechanism exists.',
268
+ hint: 'Confirm the page offers a bypass mechanism: a main landmark (<main> or role="main"), a working "skip to content" link, or heading elements that assistive technology can use to jump past repeated content. (A mechanism may be temporarily hidden, e.g. while a modal dialog makes the page inert, or provided on a per-site basis; this needs human confirmation.)',
251
269
  i18n: {
252
270
  summaryKey: 'bypassBlocksPresent_summary_cantTell',
253
271
  hintKey: 'bypassBlocksPresent_hint_cantTell',
@@ -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 <canvas> elements that already carry a text alternative:
14
+ * fallback content inside the element, an ARIA name, or a title. A
15
+ * <canvas> with none of those has no alternative whose quality could be
16
+ * judged; that's canvas-text-alternative-present's failure. The element
17
+ * must be included in the accessibility tree, and
18
+ * role="presentation"/"none" takes it out of scope unless it is focusable,
19
+ * 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
  */
@@ -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
  */