@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
@@ -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,38 +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.
31
- * - Inline: a link inside a run of text (isInlineTextExceptionTarget).
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.
49
+ * - Inline: a link inside a text-block container passes outright
50
+ * (isInlineTextExceptionTarget). An inline link whose only spacing conflict
51
+ * is another inline link in the same run is reported as cantTell
52
+ * (isInlineLinkTarget), since the inline exception may cover it but
53
+ * geometry can't confirm that.
32
54
  * - User Agent Control: an unstyled native checkbox/radio, detected via
33
- * `appearance` not being reset to `none` (see isUserAgentSizedControl) —
34
- * 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,
35
57
  * since those are the only types with unambiguous native rendering.
36
58
  * - Essential/Equivalent: only a narrow, high-confidence subset is asserted
37
- * (SVG/canvas/map-embedded controls) — see isPlausiblyEssentialOrEquivalent;
59
+ * (SVG/canvas/map-embedded controls, see isPlausiblyEssentialOrEquivalent);
38
60
  * anything else defers to cantTell rather than guessing "essential" from a
39
61
  * layout container.
40
62
  *
41
- * Known, deliberately unimplemented gap: `<area>` (image-map hotspot)
63
+ * Known gap, left unimplemented on purpose: `<area>` (image-map hotspot)
42
64
  * elements are not evaluated at all. `area[href]` is in CANDIDATE_SELECTOR
43
- * 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
44
66
  * CSS box of its own (`display: none` by the HTML spec's default UA
45
- * stylesheet — verified, not a jsdom quirk), so `getBoundingClientRect()`
46
- * always reports zero geometry and `isPointerReachable`'s existing
47
- * `display:none` check rejects it before any size/exception logic runs. A
48
- * real `<area>` hit-region is computed by the browser from its `shape`/
49
- * `coords` attributes against the associated `<img>`'s *rendered* size —
50
- * an entirely different measurement path than every other candidate here.
51
- * Implementing that properly (parsing `coords`, resolving the owning
52
- * `<img>` via its `usemap`, accounting for the image's CSS-scaled render
53
- * 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.
54
77
  *
55
78
  * This is an automatic, deterministic approximation intended to be:
56
79
  * - strict on clear failures,
@@ -60,7 +83,8 @@
60
83
  const id = 'target-size-minimum';
61
84
 
62
85
  const meta = {
63
- 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',
64
88
  description:
65
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).',
66
90
  i18n: {
@@ -147,23 +171,10 @@ function runInPage(ctx) {
147
171
  return 'html';
148
172
  }
149
173
 
150
- function htmlSnippet(el) {
151
- try {
152
- if (helpers && typeof helpers.getOuterHtmlSnippet === 'function')
153
- return helpers.getOuterHtmlSnippet(el);
154
- } catch {}
155
- try {
156
- return el && el.outerHTML ? String(el.outerHTML) : '';
157
- } catch {}
158
- return '';
159
- }
160
-
161
- function isInlineTextExceptionTarget(el) {
162
- // SC 2.5.8 exception: target is in a sentence/block of text (inline)
163
- // Deterministic heuristic:
164
- // - must be a link-like target
165
- // - must be rendered as inline/inline-*
166
- // - must be inside a typical text container
174
+ // A link-like target (a[href] or role="link") rendered inline/inline-* and
175
+ // carrying visible text. This is the shape the SC 2.5.8 inline exception is
176
+ // about, without the surrounding-container requirement.
177
+ function isInlineLinkTarget(el) {
167
178
  try {
168
179
  if (!el || el.nodeType !== 1) return false;
169
180
 
@@ -180,27 +191,31 @@ function runInPage(ctx) {
180
191
 
181
192
  const cs = getStyle(el);
182
193
  const display = cs && cs.display ? String(cs.display) : '';
183
- if (!display) return false;
184
-
185
- // Inline exception is for inline text runs; many design systems use inline-block for links,
186
- // so treat inline-block variants as eligible for this exception.
194
+ // Many design systems use inline-block for links, so treat the
195
+ // inline-block variants as inline here too.
187
196
  const isInline =
188
197
  display === 'inline' ||
189
198
  display === 'inline-block' ||
190
199
  display === 'inline-flex' ||
191
200
  display === 'inline-grid' ||
192
201
  display === 'inline-table';
193
-
194
202
  if (!isInline) return false;
195
203
 
196
- const t = (el.textContent || '').trim();
197
- if (!t) return false;
204
+ return (el.textContent || '').trim().length > 0;
205
+ } catch {
206
+ return false;
207
+ }
208
+ }
209
+
210
+ function isInlineTextExceptionTarget(el) {
211
+ // SC 2.5.8 exception: an inline link that sits inside a run of text.
212
+ try {
213
+ if (!isInlineLinkTarget(el)) return false;
198
214
 
199
- // Require that it sits in a "text block" context
200
215
  const textContainer = closest(el, 'p, li, dd, dt, blockquote, figcaption, caption, td, th');
201
216
  if (textContainer) return true;
202
217
 
203
- // Fallback: sometimes links are inside spans within a paragraph-like region
218
+ // Links are often wrapped in an inline element inside the text block.
204
219
  const inlineContainer = closest(el, 'span, em, strong, small, label');
205
220
  if (inlineContainer) {
206
221
  const outerTextBlock = closest(
@@ -359,11 +374,11 @@ function runInPage(ctx) {
359
374
  // element, or either one is an ancestor of the other. A nested-interactive
360
375
  // pattern (e.g. a small <button> inside a wrapping <a href>, or vice
361
376
  // versa) is a single visual/interactive region, not two independently
362
- // placed targets — the spacing exception's "does the circle intersect
377
+ // placed targets. The spacing exception's "does the circle intersect
363
378
  // ANOTHER target" language is about separate targets, not an element and
364
379
  // its own container. (Nested interactive controls are their own,
365
- // separately-flagged anti-pattern — nested-interactive-controls-
366
- // absent — not a target-size spacing concern.)
380
+ // separately-flagged anti-pattern, nested-interactive-controls-absent,
381
+ // not a target-size spacing concern.)
367
382
  function isRelated(a, b) {
368
383
  try {
369
384
  if (!a || !b) return false;
@@ -420,7 +435,7 @@ function runInPage(ctx) {
420
435
  function hasSpacingConflict(target) {
421
436
  // 0) Pure geometry: deterministic center-distance check against ANY
422
437
  // nearby target, not just other undersized ones. Per WCAG 2.5.8, the
423
- // spacing exception depends on proximity to any adjacent target — an
438
+ // spacing exception depends on proximity to any adjacent target, so an
424
439
  // undersized target sitting flush against an adequately-sized one still
425
440
  // fails the exception, which an undersized-only comparison would miss.
426
441
  for (const other of items) {
@@ -444,8 +459,8 @@ function runInPage(ctx) {
444
459
  // as a confident conflict. Perimeter sampling is an approximation
445
460
  // (rounded corners, border-radius, and sub-pixel geometry can shift a
446
461
  // sample point in or out of a neighboring element), so a result that
447
- // merely reaches HIT_THRESHOLD is not asserted as a deterministic
448
- // fail — see the ambiguous band below.
462
+ // merely reaches HIT_THRESHOLD is not asserted as a deterministic fail.
463
+ // See the ambiguous band below.
449
464
  const CONFIDENT_THRESHOLD = 5;
450
465
  let hitCount = 0;
451
466
  let firstConflictEl = null;
@@ -491,20 +506,20 @@ function runInPage(ctx) {
491
506
 
492
507
  // WCAG 2.5.8 "User Agent Control" exception: the target's size requirement
493
508
  // does not apply at all when its size is determined by the user agent and
494
- // not modified by the author — the canonical example being an unstyled
495
- // native checkbox/radio (browsers render these well under 24px by
496
- // default, and that's not the author's choice). Scoped narrowly to
497
- // input[type=checkbox]/[type=radio] specifically (the only form-control
498
- // types with a universally-recognized, unambiguous native rendering) —
499
- // deliberately not extended to select/range/color/file, whose "default"
500
- // sizing varies enough across browsers/OSes that a wrong exemption there
501
- // 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.
502
517
  //
503
518
  // Detection signal: `appearance` (or the legacy `-webkit-appearance`)
504
519
  // computed as `none` is the near-universal first step of custom
505
- // checkbox/radio styling across every CSS framework/design system —
506
- // if the author hasn't reset it, the browser is still rendering its own
507
- // 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.
508
523
  function isUserAgentSizedControl(el) {
509
524
  try {
510
525
  if (!el || el.nodeType !== 1) return false;
@@ -550,8 +565,8 @@ function runInPage(ctx) {
550
565
  // Image map targets are often constrained by the underlying image.
551
566
  // Currently unreachable in practice: <area> never becomes a
552
567
  // measurable candidate at all (see the file header's "Known,
553
- // deliberately unimplemented gap" note) — kept for forward
554
- // compatibility if that gap is closed later.
568
+ // unimplemented gap" note). Kept for forward compatibility if that
569
+ // gap is closed later.
555
570
  if (tag === 'area') return true;
556
571
 
557
572
  // Graphics / spatial interaction regions are commonly essential by design.
@@ -575,7 +590,7 @@ function runInPage(ctx) {
575
590
 
576
591
  // User Agent Control exception: size isn't the author's choice, so the
577
592
  // size requirement (and therefore any spacing conflict stemming from
578
- // 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
579
594
  // evaluate spacing.
580
595
  if (isUserAgentSizedControl(it.el)) {
581
596
  continue;
@@ -584,91 +599,117 @@ function runInPage(ctx) {
584
599
  const info = hasSpacingConflict(it);
585
600
 
586
601
  if (!info.conflict && info.confident === false) {
587
- // Ambiguous perimeter-sampling result near the decision threshold —
588
- // previously recorded only as a page-level boolean with no per-target
589
- // occurrence at all, so this specific target was unrecoverable from
590
- // the result once any other target on the page had a confident
591
- // fail (see helpers.resolveTieredOutcome's header comment). Now
592
- // reported as its own cantTell-tier occurrence instead.
593
- cantTellOccurrences.push({
594
- selector: buildSelector(it.el),
595
- html: htmlSnippet(it.el),
596
- occurrenceOutcome: 'cantTell',
597
- summary:
598
- 'Target may be too small and too close to another target, but the overlap is near the detection threshold and could not be confidently measured.',
599
- hint: 'Manually verify the effective spacing between this target and its neighbor; increase target size or spacing if the overlap is real.',
600
- i18n: {
601
- summaryKey: 'targetSizeMinimum_summary_cantTell_ambiguousSpacing',
602
- hintKey: 'targetSizeMinimum_hint_cantTell_ambiguousSpacing',
603
- params: {}
604
- },
605
- data: {
606
- details: {
607
- measured: { width: it.rect.width, height: it.rect.height },
608
- reasonCode: 'undersized-ambiguous-spacing',
609
- conflictHitCount: info.hitCount,
610
- conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
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).
606
+ cantTellOccurrences.push(
607
+ helpers.reportOccurrence(it.el, {
608
+ occurrenceOutcome: 'cantTell',
609
+ summary:
610
+ 'Target may be too small and too close to another target, but the overlap is near the detection threshold and could not be confidently measured.',
611
+ hint: 'Manually verify the effective spacing between this target and its neighbor; increase target size or spacing if the overlap is real.',
612
+ i18n: {
613
+ summaryKey: 'targetSizeMinimum_summary_cantTell_ambiguousSpacing',
614
+ hintKey: 'targetSizeMinimum_hint_cantTell_ambiguousSpacing',
615
+ params: {}
616
+ },
617
+ data: {
618
+ details: {
619
+ measured: { width: it.rect.width, height: it.rect.height },
620
+ reasonCode: 'undersized-ambiguous-spacing',
621
+ conflictHitCount: info.hitCount,
622
+ conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
623
+ }
611
624
  }
612
- }
613
- });
625
+ })
626
+ );
614
627
  continue;
615
628
  }
616
629
 
617
630
  if (info.conflict) {
618
631
  if (isPlausiblyEssentialOrEquivalent(it.el)) {
619
632
  // Confident spacing conflict, but the target may be exempt as part
620
- // of an essential graphic/image-map region — same "previously
621
- // unrecoverable" gap as above, now reported instead of dropped.
622
- cantTellOccurrences.push({
623
- selector: buildSelector(it.el),
624
- html: htmlSnippet(it.el),
625
- occurrenceOutcome: 'cantTell',
626
- summary:
627
- 'Target is too small and too close to another target, but may be exempt as part of an essential graphic or image-map region.',
628
- 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.',
633
+ // of an essential graphic/image-map region, so report it as
634
+ // cantTell rather than dropping it.
635
+ cantTellOccurrences.push(
636
+ helpers.reportOccurrence(it.el, {
637
+ occurrenceOutcome: 'cantTell',
638
+ summary:
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.',
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.',
641
+ i18n: {
642
+ summaryKey: 'targetSizeMinimum_summary_cantTell_plausiblyEssential',
643
+ hintKey: 'targetSizeMinimum_hint_cantTell_plausiblyEssential',
644
+ params: {}
645
+ },
646
+ data: {
647
+ details: {
648
+ measured: { width: it.rect.width, height: it.rect.height },
649
+ reasonCode: 'undersized-plausibly-essential',
650
+ conflictHitCount: info.hitCount,
651
+ conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
652
+ }
653
+ }
654
+ })
655
+ );
656
+ continue;
657
+ }
658
+
659
+ // This target and its conflicting neighbor are both inline links in the
660
+ // same text run, where the SC 2.5.8 inline exception may apply. That
661
+ // can't be decided from geometry alone, so defer to manual review.
662
+ if (isInlineLinkTarget(it.el) && info.conflictEl && isInlineLinkTarget(info.conflictEl)) {
663
+ cantTellOccurrences.push(
664
+ helpers.reportOccurrence(it.el, {
665
+ occurrenceOutcome: 'cantTell',
666
+ summary:
667
+ 'Target is smaller than 24×24 CSS px and close to another inline link in the same run of text, where the inline exception may apply.',
668
+ hint: 'Confirm whether these links form a run of inline text (which is exempt); otherwise increase the target size to at least 24×24 CSS px or add spacing.',
669
+ i18n: {
670
+ summaryKey: 'targetSizeMinimum_summary_cantTell_inlineLinkRun',
671
+ hintKey: 'targetSizeMinimum_hint_cantTell_inlineLinkRun',
672
+ params: {}
673
+ },
674
+ data: {
675
+ details: {
676
+ measured: { width: it.rect.width, height: it.rect.height },
677
+ reasonCode: 'undersized-inline-link-run',
678
+ conflictHitCount: info.hitCount,
679
+ conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
680
+ }
681
+ }
682
+ })
683
+ );
684
+ continue;
685
+ }
686
+
687
+ failOccurrences.push(
688
+ helpers.reportOccurrence(it.el, {
689
+ occurrenceOutcome: 'fail',
690
+ summary: 'Target is too small and too close to another target.',
691
+ hint: 'Increase target size to at least 24 by 24 CSS pixels, or add sufficient spacing.',
629
692
  i18n: {
630
- summaryKey: 'targetSizeMinimum_summary_cantTell_plausiblyEssential',
631
- hintKey: 'targetSizeMinimum_hint_cantTell_plausiblyEssential',
693
+ summaryKey: 'targetSizeMinimum_summary_fail',
694
+ hintKey: 'targetSizeMinimum_hint_fail',
632
695
  params: {}
633
696
  },
634
697
  data: {
635
698
  details: {
636
699
  measured: { width: it.rect.width, height: it.rect.height },
637
- reasonCode: 'undersized-plausibly-essential',
700
+ reasonCode: 'undersized-and-too-close',
638
701
  conflictHitCount: info.hitCount,
639
702
  conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
640
703
  }
641
704
  }
642
- });
643
- continue;
644
- }
645
-
646
- failOccurrences.push({
647
- selector: buildSelector(it.el),
648
- html: htmlSnippet(it.el),
649
- occurrenceOutcome: 'fail',
650
- summary: 'Target is too small and too close to another target.',
651
- hint: 'Increase target size to at least 24 by 24 CSS pixels, or add sufficient spacing.',
652
- i18n: {
653
- summaryKey: 'targetSizeMinimum_summary_fail',
654
- hintKey: 'targetSizeMinimum_hint_fail',
655
- params: {}
656
- },
657
- data: {
658
- details: {
659
- measured: { width: it.rect.width, height: it.rect.height },
660
- reasonCode: 'undersized-and-too-close',
661
- conflictHitCount: info.hitCount,
662
- conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
663
- }
664
- }
665
- });
705
+ })
706
+ );
666
707
  }
667
708
  }
668
709
 
669
710
  // See helpers.resolveTieredOutcome's own header comment (src/core/dom-helpers.js):
670
711
  // a fail-tier finding never silently discards cantTell-tier findings from
671
- // 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'.
672
713
  const resolved = helpers.resolveTieredOutcome(
673
714
  failOccurrences,
674
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'
@@ -150,26 +150,21 @@ function runInPage(ctx) {
150
150
  if (hasColumnHeaderAbove(r, c)) continue;
151
151
  if (hasRowHeaderBefore(r, c)) continue;
152
152
 
153
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(cell) : 'html';
154
- const html = helpers.getOuterHtmlSnippet
155
- ? helpers.getOuterHtmlSnippet(cell)
156
- : cell.outerHTML || '';
157
-
158
- occurrences.push({
159
- selector: stableSelector,
160
- html,
161
- summary:
162
- 'This data cell has no associated header (no headers attribute, no column <th> above it, no row <th> to its left).',
163
- hint: 'Add a headers attribute referencing the relevant <th> id(s), or restructure the table so this cell has an implicit row/column header.',
164
- i18n: {
165
- summaryKey: 'tdHasHeader_summary_fail',
166
- hintKey: 'tdHasHeader_hint_fail',
167
- params: { row: String(r), column: String(c) }
168
- },
169
- data: {
170
- details: { reasonCode: 'TD_NO_ASSOCIATED_HEADER', row: r, column: c }
171
- }
172
- });
153
+ occurrences.push(
154
+ helpers.reportOccurrence(cell, {
155
+ summary:
156
+ 'This data cell has no associated header (no headers attribute, no column <th> above it, no row <th> to its left).',
157
+ hint: 'Add a headers attribute referencing the relevant <th> id(s), or restructure the table so this cell has an implicit row/column header.',
158
+ i18n: {
159
+ summaryKey: 'tdHasHeader_summary_fail',
160
+ hintKey: 'tdHasHeader_hint_fail',
161
+ params: { row: String(r), column: String(c) }
162
+ },
163
+ data: {
164
+ details: { reasonCode: 'TD_NO_ASSOCIATED_HEADER', row: r, column: c }
165
+ }
166
+ })
167
+ );
173
168
  }
174
169
  }
175
170
  }
@@ -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).
@@ -227,24 +247,22 @@ function runInPage(ctx) {
227
247
  }
228
248
  })()
229
249
  : null;
230
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
231
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
232
250
 
233
- occurrences.push({
234
- selector: stableSelector,
235
- html,
236
- summary: 'This element has no accessible name.',
237
- hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this textbox's accessible name.",
238
- i18n: {
239
- summaryKey: 'textboxNamePresent_summary_fail',
240
- hintKey: 'textboxNamePresent_hint_fail',
241
- params: { controlType: 'textbox' }
242
- },
243
- data: {
244
- details: { reasonCode: 'name_missing', controlType: 'textbox', methodTried: res.method },
245
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
246
- }
247
- });
251
+ occurrences.push(
252
+ helpers.reportOccurrence(el, {
253
+ summary: 'This element has no 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.",
255
+ i18n: {
256
+ summaryKey: 'textboxNamePresent_summary_fail',
257
+ hintKey: 'textboxNamePresent_hint_fail',
258
+ params: { controlType: 'textbox' }
259
+ },
260
+ data: {
261
+ details: { reasonCode: 'name_missing', controlType: 'textbox', methodTried: res.method },
262
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
263
+ }
264
+ })
265
+ );
248
266
  }
249
267
 
250
268
  if (applicableCount === 0) {