@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
@@ -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,33 +50,28 @@
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).
52
57
  * Both cases would produce false positives under a hard `fail`, which this
53
58
  * engine reserves for high-confidence violations; `cantTell` routes them to
54
- * human review instead. This mirrors how other tools treat 2.4.1 (e.g. axe
55
- * marks the no-mechanism case "needs review" via reviewOnFail rather than
56
- * failing it), and why no ACT rule hard-fails 2.4.1 by presence alone.
59
+ * human review instead. No ACT rule hard-fails 2.4.1 by presence alone,
60
+ * for the same reason.
57
61
  * - This rule intentionally checks presence, not position, for the
58
- * same-page-anchor condition (b): a full bypass algorithm is heuristic
59
- * (see ROADMAP.md's Tier 1a note on why this rule was
60
- * deferred from the rest of that batch), and getting DOM-order /
61
- * keyboard-focus-order positioning exactly right without introducing
62
- * false positives is materially harder than the rest of Tier 1a. Being
63
- * lenient about condition (b) can only make us *miss* a review prompt
64
- * (a page whose only anchor link isn't a real skip mechanism, e.g. a
65
- * "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.
66
68
  * - Shadow DOM: all three conditions use `helpers.queryAllSmart`, which is
67
69
  * shadow-DOM-aware (when the run enables includeShadowDom) and applies the
68
70
  * engine's hidden-content policy. The same-page-anchor target is resolved
69
- * 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
70
72
  * root the link lives in) before falling back to the document, so a skip
71
73
  * link encapsulated in a web component is credited the same as one in the
72
- * light DOM. (Previously the anchor path used raw
73
- * `document.querySelectorAll`/`getElementById`, which never pierced shadow
74
- * roots — a genuine gap now closed.)
74
+ * light DOM.
75
75
  */
76
76
 
77
77
  const id = 'bypass-blocks-present';
@@ -228,9 +228,26 @@ function runInPage(ctx) {
228
228
  return false;
229
229
  }
230
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
+
231
248
  function hasHeading() {
232
249
  for (const el of queryAll('h1, h2, h3, h4, h5, h6, [role="heading"]')) {
233
- if (el && isExposedToAt(el)) return true;
250
+ if (el && isExposedToAt(el) && !isCssHidden(el)) return true;
234
251
  }
235
252
  return false;
236
253
  }
@@ -244,18 +261,11 @@ function runInPage(ctx) {
244
261
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
245
262
  }
246
263
 
247
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
248
- const html = helpers.getOuterHtmlSnippet
249
- ? helpers.getOuterHtmlSnippet(body)
250
- : (body.outerHTML || '').slice(0, 200);
251
-
252
264
  const occurrences = [
253
- {
254
- selector: stableSelector,
255
- html,
265
+ helpers.reportOccurrence(body, {
256
266
  summary:
257
- 'No recognized way to bypass repeated blocks of content was detected on this page — verify a bypass mechanism exists.',
258
- 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.)',
259
269
  i18n: {
260
270
  summaryKey: 'bypassBlocksPresent_summary_cantTell',
261
271
  hintKey: 'bypassBlocksPresent_hint_cantTell',
@@ -265,7 +275,7 @@ function runInPage(ctx) {
265
275
  details: { reasonCode: 'BYPASS_MECHANISM_ABSENT' },
266
276
  visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] }
267
277
  }
268
- }
278
+ })
269
279
  ];
270
280
 
271
281
  return {
@@ -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
  */