@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
@@ -8,6 +8,24 @@
8
8
  * @summary Pointer-operable targets should be at least 24×24 CSS px (or meet an exception)
9
9
  * @standard WCAG 2.2
10
10
  * @sc 2.5.8
11
+ * @applicability
12
+ * Applies to <button>, <summary>, <a href>, <area href>, <input>,
13
+ * <select>, <textarea> and elements with role="button"/"link" that are
14
+ * pointer-reachable: rendered, not suppressed by pointer-events:none, and
15
+ * with a measurable box of non-zero size. Accessibility-tree exclusion isn't
16
+ * a filter here: an aria-hidden control is still a target a pointer can hit.
17
+ * <area> is matched but never actually evaluated, for the reason given below.
18
+ * @expectation
19
+ * Each target is at least 24 by 24 CSS pixels, or meets one of the SC
20
+ * 2.5.8 exceptions this rule can establish from geometry: spacing (a
21
+ * 24px-diameter circle centred on the target reaches no unrelated target),
22
+ * the inline exception for a link inside a run of text, or user-agent
23
+ * sizing (an unstyled native checkbox or radio, detected by appearance not
24
+ * having been reset to none). An undersized target too close to a
25
+ * neighbour fails. Where an exception may apply but geometry cannot
26
+ * confirm it (two inline links in one run of text, or a target inside an
27
+ * SVG, canvas or image map that may be essential), the result is cantTell
28
+ * rather than a guess.
11
29
  *
12
30
  * Notes (engine intent):
13
31
  * - This rule is DOM-based and measures pointer hit regions available to sighted pointer users.
@@ -19,42 +37,43 @@
19
37
  * - Spacing: a 24px-diameter circle centered on an undersized target must not
20
38
  * intersect another (unrelated) target's box or another undersized
21
39
  * target's own circle. Two passes: a fast center-distance check (exact for
22
- * undersized-vs-undersized; a reasonable proxy otherwise) and a 16-point
40
+ * undersized-vs-undersized, a reasonable proxy otherwise) and a 16-point
23
41
  * perimeter sample via elementFromPoint as a more precise fallback for
24
42
  * cases the distance check under-detects (e.g. a small target adjacent to
25
43
  * a large, elongated neighbor). Ancestor/descendant relationships between
26
- * the target and the "other" element are never treated as a conflict —
27
- * see isRelated — since a nested-interactive shape (a small control inside
28
- * its own wrapping link/button) is one visual region, not two independent
29
- * targets; that pattern is nested-interactive-controls-absent's
30
- * concern, not a spacing one.
44
+ * the target and the "other" element are never treated as a conflict (see
45
+ * isRelated): a nested-interactive shape, a small control inside its own
46
+ * wrapping link/button, is one visual region, not two independent targets.
47
+ * That pattern is nested-interactive-controls-absent's concern, not a
48
+ * spacing one.
31
49
  * - Inline: a link inside a text-block container passes outright
32
50
  * (isInlineTextExceptionTarget). An inline link whose only spacing conflict
33
51
  * is another inline link in the same run is reported as cantTell
34
- * (isInlineLinkTarget) — the inline exception may cover it, but geometry
35
- * can't confirm that.
52
+ * (isInlineLinkTarget), since the inline exception may cover it but
53
+ * geometry can't confirm that.
36
54
  * - User Agent Control: an unstyled native checkbox/radio, detected via
37
- * `appearance` not being reset to `none` (see isUserAgentSizedControl) —
38
- * scoped narrowly to checkbox/radio specifically, not every form control,
55
+ * `appearance` not being reset to `none` (see isUserAgentSizedControl).
56
+ * Scoped narrowly to checkbox/radio specifically, not every form control,
39
57
  * since those are the only types with unambiguous native rendering.
40
58
  * - Essential/Equivalent: only a narrow, high-confidence subset is asserted
41
- * (SVG/canvas/map-embedded controls) — see isPlausiblyEssentialOrEquivalent;
59
+ * (SVG/canvas/map-embedded controls, see isPlausiblyEssentialOrEquivalent);
42
60
  * anything else defers to cantTell rather than guessing "essential" from a
43
61
  * layout container.
44
62
  *
45
- * Known, deliberately unimplemented gap: `<area>` (image-map hotspot)
63
+ * Known gap, left unimplemented on purpose: `<area>` (image-map hotspot)
46
64
  * elements are not evaluated at all. `area[href]` is in CANDIDATE_SELECTOR
47
- * for forward-compatibility, but it's currently a no-op: `<area>` has no
65
+ * for forward-compatibility, but it's currently a no-op. `<area>` has no
48
66
  * CSS box of its own (`display: none` by the HTML spec's default UA
49
- * stylesheet — verified, not a jsdom quirk), so `getBoundingClientRect()`
50
- * always reports zero geometry and `isPointerReachable`'s existing
51
- * `display:none` check rejects it before any size/exception logic runs. A
52
- * real `<area>` hit-region is computed by the browser from its `shape`/
53
- * `coords` attributes against the associated `<img>`'s *rendered* size —
54
- * an entirely different measurement path than every other candidate here.
55
- * Implementing that properly (parsing `coords`, resolving the owning
56
- * `<img>` via its `usemap`, accounting for the image's CSS-scaled render
57
- * size) is a separate, larger feature, not attempted in this pass.
67
+ * stylesheet, confirmed against the spec rather than a jsdom quirk), so
68
+ * `getBoundingClientRect()` always reports zero geometry and
69
+ * `isPointerReachable`'s existing `display:none` check rejects it before any
70
+ * size/exception logic runs. A real `<area>` hit-region is computed by the
71
+ * browser from its `shape`/`coords` attributes against the associated
72
+ * `<img>`'s *rendered* size, an entirely different measurement path than
73
+ * every other candidate here. Implementing that properly (parsing `coords`,
74
+ * resolving the owning `<img>` via its `usemap`, accounting for the image's
75
+ * CSS-scaled render size) is a separate, larger feature, not attempted in
76
+ * this pass.
58
77
  *
59
78
  * This is an automatic, deterministic approximation intended to be:
60
79
  * - strict on clear failures,
@@ -64,7 +83,8 @@
64
83
  const id = 'target-size-minimum';
65
84
 
66
85
  const meta = {
67
- title: 'Pointer targets meet minimum size (AA)',
86
+ title:
87
+ 'Pointer targets must be at least 24x24px large, or leave sufficient distance to other targets',
68
88
  description:
69
89
  'Checks that pointer-operable targets have an effective hit region of at least 24 by 24 CSS pixels, or meet an allowed exception (e.g. sufficient spacing).',
70
90
  i18n: {
@@ -354,11 +374,11 @@ function runInPage(ctx) {
354
374
  // element, or either one is an ancestor of the other. A nested-interactive
355
375
  // pattern (e.g. a small <button> inside a wrapping <a href>, or vice
356
376
  // versa) is a single visual/interactive region, not two independently
357
- // placed targets — the spacing exception's "does the circle intersect
377
+ // placed targets. The spacing exception's "does the circle intersect
358
378
  // ANOTHER target" language is about separate targets, not an element and
359
379
  // its own container. (Nested interactive controls are their own,
360
- // separately-flagged anti-pattern — nested-interactive-controls-
361
- // absent — not a target-size spacing concern.)
380
+ // separately-flagged anti-pattern, nested-interactive-controls-absent,
381
+ // not a target-size spacing concern.)
362
382
  function isRelated(a, b) {
363
383
  try {
364
384
  if (!a || !b) return false;
@@ -415,7 +435,7 @@ function runInPage(ctx) {
415
435
  function hasSpacingConflict(target) {
416
436
  // 0) Pure geometry: deterministic center-distance check against ANY
417
437
  // nearby target, not just other undersized ones. Per WCAG 2.5.8, the
418
- // spacing exception depends on proximity to any adjacent target — an
438
+ // spacing exception depends on proximity to any adjacent target, so an
419
439
  // undersized target sitting flush against an adequately-sized one still
420
440
  // fails the exception, which an undersized-only comparison would miss.
421
441
  for (const other of items) {
@@ -439,8 +459,8 @@ function runInPage(ctx) {
439
459
  // as a confident conflict. Perimeter sampling is an approximation
440
460
  // (rounded corners, border-radius, and sub-pixel geometry can shift a
441
461
  // sample point in or out of a neighboring element), so a result that
442
- // merely reaches HIT_THRESHOLD is not asserted as a deterministic
443
- // fail — see the ambiguous band below.
462
+ // merely reaches HIT_THRESHOLD is not asserted as a deterministic fail.
463
+ // See the ambiguous band below.
444
464
  const CONFIDENT_THRESHOLD = 5;
445
465
  let hitCount = 0;
446
466
  let firstConflictEl = null;
@@ -486,20 +506,20 @@ function runInPage(ctx) {
486
506
 
487
507
  // WCAG 2.5.8 "User Agent Control" exception: the target's size requirement
488
508
  // does not apply at all when its size is determined by the user agent and
489
- // not modified by the author — the canonical example being an unstyled
490
- // native checkbox/radio (browsers render these well under 24px by
491
- // default, and that's not the author's choice). Scoped narrowly to
492
- // input[type=checkbox]/[type=radio] specifically (the only form-control
493
- // types with a universally-recognized, unambiguous native rendering) —
494
- // deliberately not extended to select/range/color/file, whose "default"
495
- // sizing varies enough across browsers/OSes that a wrong exemption there
496
- // risks masking a real author-introduced undersized target.
509
+ // not modified by the author. The canonical example is an unstyled native
510
+ // checkbox/radio (browsers render these well under 24px by default, and
511
+ // that's not the author's choice). Scoped narrowly to
512
+ // input[type=checkbox]/[type=radio] specifically, the only form-control
513
+ // types with a universally-recognized, unambiguous native rendering, and
514
+ // not extended to select/range/color/file, whose "default" sizing varies
515
+ // enough across browsers/OSes that a wrong exemption there risks masking a
516
+ // real author-introduced undersized target.
497
517
  //
498
518
  // Detection signal: `appearance` (or the legacy `-webkit-appearance`)
499
519
  // computed as `none` is the near-universal first step of custom
500
- // checkbox/radio styling across every CSS framework/design system —
501
- // if the author hasn't reset it, the browser is still rendering its own
502
- // default control chrome, so the size is genuinely UA-determined.
520
+ // checkbox/radio styling across every CSS framework/design system. If the
521
+ // author hasn't reset it, the browser is still rendering its own default
522
+ // control chrome, so the size is UA-determined rather than authored.
503
523
  function isUserAgentSizedControl(el) {
504
524
  try {
505
525
  if (!el || el.nodeType !== 1) return false;
@@ -545,8 +565,8 @@ function runInPage(ctx) {
545
565
  // Image map targets are often constrained by the underlying image.
546
566
  // Currently unreachable in practice: <area> never becomes a
547
567
  // measurable candidate at all (see the file header's "Known,
548
- // deliberately unimplemented gap" note) — kept for forward
549
- // compatibility if that gap is closed later.
568
+ // unimplemented gap" note). Kept for forward compatibility if that
569
+ // gap is closed later.
550
570
  if (tag === 'area') return true;
551
571
 
552
572
  // Graphics / spatial interaction regions are commonly essential by design.
@@ -570,7 +590,7 @@ function runInPage(ctx) {
570
590
 
571
591
  // User Agent Control exception: size isn't the author's choice, so the
572
592
  // size requirement (and therefore any spacing conflict stemming from
573
- // it) doesn't apply at all — skip straight to pass, no need to even
593
+ // it) doesn't apply at all. Skip straight to pass, no need to even
574
594
  // evaluate spacing.
575
595
  if (isUserAgentSizedControl(it.el)) {
576
596
  continue;
@@ -579,12 +599,10 @@ function runInPage(ctx) {
579
599
  const info = hasSpacingConflict(it);
580
600
 
581
601
  if (!info.conflict && info.confident === false) {
582
- // Ambiguous perimeter-sampling result near the decision threshold —
583
- // previously recorded only as a page-level boolean with no per-target
584
- // occurrence at all, so this specific target was unrecoverable from
585
- // the result once any other target on the page had a confident
586
- // fail (see helpers.resolveTieredOutcome's header comment). Now
587
- // reported as its own cantTell-tier occurrence instead.
602
+ // Ambiguous perimeter-sampling result near the decision threshold: report
603
+ // it as its own cantTell-tier occurrence for this target so it isn't lost
604
+ // once any other target on the page has a confident fail (see
605
+ // helpers.resolveTieredOutcome's header comment).
588
606
  cantTellOccurrences.push(
589
607
  helpers.reportOccurrence(it.el, {
590
608
  occurrenceOutcome: 'cantTell',
@@ -612,14 +630,14 @@ function runInPage(ctx) {
612
630
  if (info.conflict) {
613
631
  if (isPlausiblyEssentialOrEquivalent(it.el)) {
614
632
  // Confident spacing conflict, but the target may be exempt as part
615
- // of an essential graphic/image-map region — same "previously
616
- // unrecoverable" gap as above, now reported instead of dropped.
633
+ // of an essential graphic/image-map region, so report it as
634
+ // cantTell rather than dropping it.
617
635
  cantTellOccurrences.push(
618
636
  helpers.reportOccurrence(it.el, {
619
637
  occurrenceOutcome: 'cantTell',
620
638
  summary:
621
639
  'Target is too small and too close to another target, but may be exempt as part of an essential graphic or image-map region.',
622
- hint: 'Verify whether this target’s size is genuinely essential to its function (e.g. part of an SVG/canvas/image map); if not, increase target size or spacing.',
640
+ hint: 'Verify whether this target’s size is essential to its function (e.g. part of an SVG/canvas/image map); if not, increase target size or spacing.',
623
641
  i18n: {
624
642
  summaryKey: 'targetSizeMinimum_summary_cantTell_plausiblyEssential',
625
643
  hintKey: 'targetSizeMinimum_hint_cantTell_plausiblyEssential',
@@ -691,7 +709,7 @@ function runInPage(ctx) {
691
709
 
692
710
  // See helpers.resolveTieredOutcome's own header comment (src/core/dom-helpers.js):
693
711
  // a fail-tier finding never silently discards cantTell-tier findings from
694
- // the same run — both are returned together when the outcome is 'fail'.
712
+ // the same run. Both are returned together when the outcome is 'fail'.
695
713
  const resolved = helpers.resolveTieredOutcome(
696
714
  failOccurrences,
697
715
  cantTellOccurrences,
@@ -11,7 +11,7 @@
11
11
  * @applicability
12
12
  * `<table>` elements with at least 4 rows and at least 4 columns
13
13
  * (a "large" table, where implicit row/column header association is
14
- * genuinely useful — small tables are usually self-evident), and with
14
+ * useful; small tables are usually self-evident), and with
15
15
  * NO `colspan`/`rowspan` anywhere in the table.
16
16
  * @expectation
17
17
  * Every `<td>` has an associated header, via one of:
@@ -22,14 +22,14 @@
22
22
  * earlier row, OR
23
23
  * - an implicit row header: some `<th>` earlier in the same row.
24
24
  * @implementation-notes
25
- * - Closes the gap `table-th-has-data-cells` deliberately deferred (see
25
+ * - Closes the gap `table-th-has-data-cells` deferred (see
26
26
  * that rule's own implementation notes): this is the fuller positional
27
- * header-association algorithm, but still intentionally scoped —
27
+ * header-association algorithm, but still intentionally scoped,
28
28
  * tables with any `colspan`/`rowspan` are skipped entirely (marked
29
29
  * `notApplicable`) rather than risk a wrong column-index computation
30
30
  * producing a false `fail`.
31
31
  * - The `headers`-attribute branch does not itself validate that the
32
- * referenced ids exist or point at `<th>` elements — that's already
32
+ * referenced ids exist or point at `<th>` elements, that's already
33
33
  * `table-headers-attr-valid`'s job.
34
34
  */
35
35
 
@@ -38,7 +38,7 @@ const id = 'td-has-header';
38
38
  const meta = {
39
39
  title: 'Data cells in large tables must have an associated header',
40
40
  description:
41
- 'Checks that every <td> in a large, simple (no colspan/rowspan) table has an associated header — via a headers attribute, an implicit column <th> above it, or an implicit row <th> to its left.',
41
+ 'Checks that every <td> in a large, simple (no colspan/rowspan) table has an associated header, via a headers attribute, an implicit column <th> above it, or an implicit row <th> to its left.',
42
42
  i18n: {
43
43
  titleKey: 'tdHasHeader_title',
44
44
  descriptionKey: 'tdHasHeader_description'
@@ -2,11 +2,31 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check textbox-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="textbox" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="textbox" (the attribute must name
13
+ * that role alone, not a fallback list) that are included in the
14
+ * accessibility tree. An element with the matching implicit role but no
15
+ * role attribute is out of scope.
16
+ * @expectation
17
+ * The element has a non-empty accessible name from aria-label, from an
18
+ * aria-labelledby that resolves to non-empty text, or from title.
19
+ * role="textbox" is name-from-author-only, so subtree text is never
20
+ * accepted: text sitting inside a custom textbox widget is not reliably
21
+ * exposed as its name. On a labelable element (<input role="textbox">) an
22
+ * associated <label> counts as well.
23
+ */
24
+
5
25
  const id = 'textbox-name-present';
6
26
 
7
27
  const meta = {
8
- title: 'Accessible name is present',
9
- description: 'Checks that elements expose a non-empty accessible name.',
28
+ title: 'Textboxes have an accessible name',
29
+ description: 'Checks that elements with role="textbox" expose a non-empty accessible name.',
10
30
  i18n: {
11
31
  titleKey: 'textboxNamePresent_title',
12
32
  descriptionKey: 'textboxNamePresent_description'
@@ -51,7 +71,7 @@ function runInPage(ctx) {
51
71
  }
52
72
 
53
73
  function getConservativeSubtreeText(document, container) {
54
- // "Name from content" — recurses into descendants and uses each one's
74
+ // "Name from content", recurses into descendants and uses each one's
55
75
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
56
76
  // it has one, not just literal text nodes. See getContentNameInfo's
57
77
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -68,7 +88,7 @@ function runInPage(ctx) {
68
88
 
69
89
  // A <label> contributes a name via its own aria-label/aria-labelledby
70
90
  // (checked first, same ARIA-over-content precedence any element's
71
- // accessible name gives — e.g. <label aria-label="Search"><svg
91
+ // accessible name gives, e.g. <label aria-label="Search"><svg
72
92
  // aria-hidden="true">...</svg></label> names its control "Search" even
73
93
  // though the label's only child content is aria-hidden) or, failing
74
94
  // that, its rendered content (getConservativeSubtreeText).
@@ -83,10 +103,10 @@ function runInPage(ctx) {
83
103
  if (content) return content;
84
104
  // Final fallback per the general accname text-alternative algorithm,
85
105
  // which applies to any element being asked for its name regardless of
86
- // why (own aria-label, an aria-labelledby reference, or — here — native
106
+ // why (own aria-label, an aria-labelledby reference, or, here, native
87
107
  // <label for> association): title, when nothing else yields a name.
88
108
  // Purely additive (only fills in a name where there was none before),
89
- // so it carries no false-positive risk — see dialog-name-present.js's
109
+ // so it carries no false-positive risk, see dialog-name-present.js's
90
110
  // identical <iframe>-title-fallback fix for the concrete real-world
91
111
  // trigger this same accname step covers elsewhere.
92
112
  return getAttr(lab, 'title');
@@ -96,7 +116,7 @@ function runInPage(ctx) {
96
116
  const raw = getAttr(el, 'aria-labelledby');
97
117
  if (!raw) return '';
98
118
  // Delegates to the shared getTextFromIdRefs helper instead of computing
99
- // name-from-content of the referenced element — see dialog-name-
119
+ // name-from-content of the referenced element, see dialog-name-
100
120
  // present.js's identical fix for the full rationale (an <iframe>
101
121
  // aria-labelledby target's only name source is its title attribute,
102
122
  // which name-from-content alone can never see).
@@ -231,7 +251,7 @@ function runInPage(ctx) {
231
251
  occurrences.push(
232
252
  helpers.reportOccurrence(el, {
233
253
  summary: 'This element has no accessible name.',
234
- hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this textbox's accessible name.",
254
+ hint: "Provide aria-label, aria-labelledby, or a title attribute. Visible text content is not exposed as this textbox's accessible name.",
235
255
  i18n: {
236
256
  summaryKey: 'textboxNamePresent_summary_fail',
237
257
  hintKey: 'textboxNamePresent_hint_fail',
@@ -2,6 +2,25 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check tooltip-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="tooltip" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="tooltip" (the attribute must name
13
+ * that role alone, not a fallback list) that are included in the
14
+ * accessibility tree. An element with the matching implicit role but no
15
+ * role attribute is out of scope.
16
+ * @expectation
17
+ * The element has a non-empty accessible name from aria-label, from an
18
+ * aria-labelledby that resolves to non-empty text, from title, or,
19
+ * role="tooltip" being name-from-content, from its own subtree text,
20
+ * where a descendant's own name (an <img alt>, aria-label or title) counts
21
+ * as that descendant's contribution rather than only its text nodes.
22
+ */
23
+
5
24
  const id = 'tooltip-name-present';
6
25
 
7
26
  const meta = {
@@ -51,7 +70,7 @@ function runInPage(ctx) {
51
70
  }
52
71
 
53
72
  function getConservativeSubtreeText(document, container) {
54
- // "Name from content" — recurses into descendants and uses each one's
73
+ // "Name from content", recurses into descendants and uses each one's
55
74
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
56
75
  // it has one, not just literal text nodes. See getContentNameInfo's
57
76
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -70,7 +89,7 @@ function runInPage(ctx) {
70
89
  const raw = getAttr(el, 'aria-labelledby');
71
90
  if (!raw) return '';
72
91
  // Delegates to the shared getTextFromIdRefs helper instead of computing
73
- // name-from-content of the referenced element — see dialog-name-
92
+ // name-from-content of the referenced element, see dialog-name-
74
93
  // present.js's identical fix for the full rationale (an <iframe>
75
94
  // aria-labelledby target's only name source is its title attribute,
76
95
  // which name-from-content alone can never see).
@@ -2,11 +2,30 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check treeitem-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="treeitem" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="treeitem" (the attribute must name
13
+ * that role alone, not a fallback list) that are included in the
14
+ * accessibility tree. An element with the matching implicit role but no
15
+ * role attribute is out of scope.
16
+ * @expectation
17
+ * The element has a non-empty accessible name from aria-label, from an
18
+ * aria-labelledby that resolves to non-empty text, from title, or,
19
+ * role="treeitem" being name-from-content, from its own subtree text,
20
+ * where a descendant's own name (an <img alt>, aria-label or title) counts
21
+ * as that descendant's contribution rather than only its text nodes.
22
+ */
23
+
5
24
  const id = 'treeitem-name-present';
6
25
 
7
26
  const meta = {
8
- title: 'Accessible name is present',
9
- description: 'Checks that elements expose a non-empty accessible name.',
27
+ title: 'Tree items have an accessible name',
28
+ description: 'Checks that elements with role="treeitem" expose a non-empty accessible name.',
10
29
  i18n: {
11
30
  titleKey: 'treeitemNamePresent_title',
12
31
  descriptionKey: 'treeitemNamePresent_description'
@@ -51,7 +70,7 @@ function runInPage(ctx) {
51
70
  }
52
71
 
53
72
  function getConservativeSubtreeText(document, container) {
54
- // "Name from content" — recurses into descendants and uses each one's
73
+ // "Name from content", recurses into descendants and uses each one's
55
74
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
56
75
  // it has one, not just literal text nodes. See getContentNameInfo's
57
76
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -70,7 +89,7 @@ function runInPage(ctx) {
70
89
  const raw = getAttr(el, 'aria-labelledby');
71
90
  if (!raw) return '';
72
91
  // Delegates to the shared getTextFromIdRefs helper instead of computing
73
- // name-from-content of the referenced element — see dialog-name-
92
+ // name-from-content of the referenced element, see dialog-name-
74
93
  // present.js's identical fix for the full rationale (an <iframe>
75
94
  // aria-labelledby target's only name source is its title attribute,
76
95
  // which name-from-content alone can never see).
@@ -10,11 +10,23 @@
10
10
  * @sc 3.1.2
11
11
  * @applicability
12
12
  * Applies to any element other than the root <html> with a non-empty
13
- * lang attribute.
13
+ * lang attribute AND at least some non-whitespace "governed text" that
14
+ * actually inherits its language from that element, per ACT de46e4:
15
+ * - Descendant text/alt is governed by the nearest lang-carrying
16
+ * ancestor only, a nested descendant with its own non-empty lang
17
+ * re-scopes everything inside it, so that subtree no longer counts
18
+ * toward the outer element's applicability (it counts toward the
19
+ * nested element's own, if that one is also being evaluated).
20
+ * - A non-empty alt attribute on img/area/input[type=image] counts as
21
+ * governed text, the same as a text node.
22
+ * - Text (or alt) that CSS keeps out of the render tree (display:none,
23
+ * the hidden attribute, ...) does not count. aria-hidden and
24
+ * offscreen positioning do NOT exempt text, per ACT's own failed
25
+ * examples for both, only actual non-rendering does.
14
26
  * @expectation
15
27
  * The lang value matches a valid BCP47 language-tag syntax. WCAG 3.1.2
16
28
  * (Language of Parts) requires that when a passage's language differs
17
- * from the page's default, it is identified programmatically — an
29
+ * from the page's default, it is identified programmatically. An
18
30
  * invalid tag fails to identify a real language at all.
19
31
  * @implementation-notes
20
32
  * - Distinct, atomic decision from html-lang-attr-present (that
@@ -22,7 +34,7 @@
22
34
  * covers every other element, for SC 3.1.2.
23
35
  * - Same minimal BCP47 *syntax* check as html-lang-attr-present (primary
24
36
  * subtag + optional subtags), not IANA Language Subtag Registry
25
- * validation — same documented scope limitation (syntactically
37
+ * validation, same documented scope limitation (syntactically
26
38
  * well-formed but unregistered tags like "xx-ZZ" are not flagged).
27
39
  */
28
40
 
@@ -66,6 +78,78 @@ function runInPage(ctx) {
66
78
  ? helpers.isValidLanguageTag
67
79
  : (v) => BCP47_RE.test(String(v || ''));
68
80
 
81
+ function isDomVisible(node) {
82
+ if (!node) return false;
83
+ if (helpers.isDomVisibleEligible)
84
+ return !!helpers.isDomVisibleEligible(node, ctx, { targetSet: 'dom' }).eligible;
85
+ if (helpers.getEligibilityInfo)
86
+ return !!helpers.getEligibilityInfo(node, ctx, { targetSet: 'dom' }).eligible;
87
+ return true;
88
+ }
89
+
90
+ function hasOwnNonEmptyLang(node) {
91
+ try {
92
+ const v = node.getAttribute ? node.getAttribute('lang') : null;
93
+ return v != null && v.trim() !== '';
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ function isAltBearing(node) {
100
+ const tag = (node.tagName || '').toLowerCase();
101
+ if (tag === 'img' || tag === 'area') return true;
102
+ if (tag !== 'input') return false;
103
+ try {
104
+ return (node.getAttribute('type') || '').toLowerCase() === 'image';
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ // "Governed text": non-whitespace text (or alt) that inherits its
111
+ // language from `root`, per ACT de46e4, see @applicability above. Walks
112
+ // the flat subtree, stopping at any descendant carrying its own non-empty
113
+ // lang (that subtree governs itself, not `root`), and treating
114
+ // display:none/hidden content as absent. Bails out as soon as any
115
+ // qualifying text is found; a node-visit budget guards pathological
116
+ // markup the same way other subtree walks in this engine do.
117
+ const MAX_VISITS = 5000;
118
+ function hasGovernedText(root) {
119
+ let found = false;
120
+ let visits = 0;
121
+
122
+ function walk(node, isRoot) {
123
+ if (found || visits++ > MAX_VISITS) return;
124
+ if (!node || node.nodeType !== 1) return;
125
+ if (!isRoot && hasOwnNonEmptyLang(node)) return; // re-scoped to itself
126
+
127
+ if (isAltBearing(node)) {
128
+ const alt = node.getAttribute ? node.getAttribute('alt') : null;
129
+ if (alt != null && alt.trim() !== '' && isDomVisible(node)) found = true;
130
+ return; // alt-bearing elements have no other text to walk into
131
+ }
132
+
133
+ if (!isDomVisible(node)) return;
134
+
135
+ const kids = node.childNodes ? Array.from(node.childNodes) : [];
136
+ for (const kid of kids) {
137
+ if (found) return;
138
+ if (kid.nodeType === 3) {
139
+ if (String(kid.nodeValue || '').trim()) {
140
+ found = true;
141
+ return;
142
+ }
143
+ } else if (kid.nodeType === 1) {
144
+ walk(kid, false);
145
+ }
146
+ }
147
+ }
148
+
149
+ walk(root, true);
150
+ return found;
151
+ }
152
+
69
153
  const nodes = helpers.queryAllSmart
70
154
  ? helpers.queryAllSmart('[lang]')
71
155
  : helpers.queryAll('[lang]');
@@ -80,10 +164,11 @@ function runInPage(ctx) {
80
164
  const rawAttr = el.getAttribute('lang');
81
165
  if (rawAttr === null || rawAttr === '') continue; // ACT de46e4: empty is out of scope
82
166
 
83
- // The rule applies only where text actually inherits the language, so an
84
- // element with no text, or whose text reaches no one, has nothing to
85
- // declare a language for.
86
- if (!String(el.textContent || '').trim()) continue;
167
+ // The rule applies only where text actually inherits the language from
168
+ // THIS element specifically, not merely where the subtree has any text
169
+ // at all, which could all belong to a nested element's own (possibly
170
+ // valid) lang instead. See hasGovernedText's doc comment.
171
+ if (!hasGovernedText(el)) continue;
87
172
 
88
173
  applicableCount += 1;
89
174
 
@@ -87,7 +87,7 @@ function runInPage(ctx) {
87
87
 
88
88
  function computeNameInfo(el) {
89
89
  // <video> is not a labelable element (no browser computes an accessible
90
- // name from <label for="...">), so only ARIA naming and title count —
90
+ // name from <label for="...">), so only ARIA naming and title count,
91
91
  // do not accept native <label> associations.
92
92
  const flags = [];
93
93
  let aria = null;
@@ -6,17 +6,17 @@
6
6
  * @check accesskeys
7
7
  * @atomic true
8
8
  * @summary accesskey values must be unique on the page
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Applies whenever two or more elements share the same non-empty
12
12
  * accesskey attribute value (case-insensitive).
13
13
  * @expectation
14
14
  * Every accesskey value on the page is unique. Duplicate accesskeys
15
- * make keyboard-shortcut activation ambiguous — only one of the
15
+ * make keyboard-shortcut activation ambiguous: only one of the
16
16
  * elements sharing the key can actually be reached by it, and which
17
17
  * one is browser/platform-dependent.
18
18
  * @implementation-notes
19
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
19
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
20
20
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
21
21
  * header comment for the shared rationale/precedent.
22
22
  */
@@ -9,6 +9,13 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.1.1
11
11
  * @type manual
12
+ * @applicability
13
+ * Applies to <area> elements whose alt attribute is present but empty once
14
+ * trimmed, the markup that declares a hotspot decorative. The <area> must
15
+ * belong to a <map> that an <img usemap> actually references, and both
16
+ * that <img> and the <area> itself must be included in the accessibility
17
+ * tree; an <area> in an unused map is out of scope.
18
+ * role="presentation"/"none" takes an element out unless it is focusable.
12
19
  * @expectation
13
20
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
14
21
  */
@@ -9,6 +9,12 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.1.1
11
11
  * @type manual
12
+ * @applicability
13
+ * Applies to <area> elements whose alt attribute is present and non-empty.
14
+ * The <area> must belong to a <map> that an <img usemap> actually
15
+ * references, and both that <img> and the <area> itself must be included
16
+ * in the accessibility tree; an <area> in an unused map is out of scope.
17
+ * role="presentation"/"none" takes an element out unless it is focusable.
12
18
  * @expectation
13
19
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
14
20
  */