@surea11y/core 1.4.0 → 1.5.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 (115) hide show
  1. package/CHANGELOG.md +88 -7
  2. package/README.md +19 -3
  3. package/bin/surea11y-core.js +0 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/ARIA_DEPRECATION.md +95 -0
  6. package/docs/ENGINE_OPTIONS.md +14 -10
  7. package/docs/I18N.md +176 -20
  8. package/docs/INTEGRATION.md +28 -6
  9. package/docs/LIMITATIONS.md +1 -1
  10. package/docs/OUTPUT_SCHEMA.md +13 -3
  11. package/docs/REPORT.md +2 -0
  12. package/docs/RULE_AUTHORING.md +53 -0
  13. package/docs/RULE_CATALOG.md +6 -6
  14. package/docs/TROUBLESHOOTING.md +2 -2
  15. package/package.json +8 -1
  16. package/src/checks/automatic/aria-allowed-attr.js +661 -99
  17. package/src/checks/automatic/aria-allowed-role.js +14 -16
  18. package/src/checks/automatic/aria-braille-equivalent.js +17 -19
  19. package/src/checks/automatic/aria-conditional-attr.js +17 -19
  20. package/src/checks/automatic/aria-deprecated-role.js +122 -41
  21. package/src/checks/automatic/aria-hidden-body.js +2 -9
  22. package/src/checks/automatic/aria-hidden-focus.js +99 -18
  23. package/src/checks/automatic/aria-prohibited-attr.js +54 -55
  24. package/src/checks/automatic/aria-prohibited-children.js +26 -26
  25. package/src/checks/automatic/aria-required-attr.js +14 -17
  26. package/src/checks/automatic/aria-required-children.js +17 -20
  27. package/src/checks/automatic/aria-required-parent.js +17 -20
  28. package/src/checks/automatic/aria-roles-valid.js +66 -27
  29. package/src/checks/automatic/aria-valid-attr-value.js +18 -21
  30. package/src/checks/automatic/aria-valid-attr.js +14 -17
  31. package/src/checks/automatic/autocomplete-valid.js +52 -18
  32. package/src/checks/automatic/avoid-inline-spacing.js +192 -34
  33. package/src/checks/automatic/binary-control-name-present.js +30 -24
  34. package/src/checks/automatic/button-name-present.js +73 -45
  35. package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
  36. package/src/checks/automatic/combobox-name-present.js +23 -18
  37. package/src/checks/automatic/css-orientation-lock.js +22 -22
  38. package/src/checks/automatic/definition-list-children-valid.js +18 -21
  39. package/src/checks/automatic/deprecated-elements-not-used.js +14 -16
  40. package/src/checks/automatic/dialog-name-present.js +24 -19
  41. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  42. package/src/checks/automatic/duplicate-id-aria.js +45 -37
  43. package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
  44. package/src/checks/automatic/form-control-single-label.js +109 -43
  45. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  46. package/src/checks/automatic/iframe-focusable-content.js +29 -31
  47. package/src/checks/automatic/iframe-name-present.js +15 -17
  48. package/src/checks/automatic/iframe-title-unique.js +18 -23
  49. package/src/checks/automatic/img-alt-present.js +16 -9
  50. package/src/checks/automatic/input-image-alt-present.js +99 -48
  51. package/src/checks/automatic/label-in-name.js +102 -33
  52. package/src/checks/automatic/language-page-present.js +5 -1
  53. package/src/checks/automatic/link-in-text-block.js +19 -21
  54. package/src/checks/automatic/link-name-present.js +75 -47
  55. package/src/checks/automatic/list-children-valid.js +15 -17
  56. package/src/checks/automatic/listbox-name-present.js +23 -18
  57. package/src/checks/automatic/listitem-parent-valid.js +14 -17
  58. package/src/checks/automatic/menuitem-name-present.js +24 -19
  59. package/src/checks/automatic/meta-refresh-no-exceptions.js +44 -18
  60. package/src/checks/automatic/meta-refresh-timing-absent.js +44 -21
  61. package/src/checks/automatic/meta-viewport-zoom-enabled.js +51 -32
  62. package/src/checks/automatic/meter-name-present.js +24 -19
  63. package/src/checks/automatic/nested-interactive-controls-absent.js +179 -51
  64. package/src/checks/automatic/object-text-alternative-present.js +14 -7
  65. package/src/checks/automatic/option-name-present.js +24 -19
  66. package/src/checks/automatic/progressbar-name-present.js +27 -22
  67. package/src/checks/automatic/searchbox-name-present.js +27 -18
  68. package/src/checks/automatic/server-side-image-map-absent.js +15 -18
  69. package/src/checks/automatic/slider-name-present.js +26 -19
  70. package/src/checks/automatic/spinbutton-name-present.js +27 -18
  71. package/src/checks/automatic/summary-name-present.js +24 -19
  72. package/src/checks/automatic/tab-name-present.js +24 -19
  73. package/src/checks/automatic/table-headers-attr-valid.js +15 -17
  74. package/src/checks/automatic/table-th-has-data-cells.js +82 -25
  75. package/src/checks/automatic/target-size-minimum.js +104 -81
  76. package/src/checks/automatic/td-has-header.js +15 -20
  77. package/src/checks/automatic/textbox-name-present.js +23 -18
  78. package/src/checks/automatic/tooltip-name-present.js +24 -19
  79. package/src/checks/automatic/treeitem-name-present.js +24 -19
  80. package/src/checks/automatic/valid-lang.js +31 -20
  81. package/src/checks/manual/accesskeys-manual.js +18 -19
  82. package/src/checks/manual/aria-checked-state-mismatch-manual.js +19 -22
  83. package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +98 -44
  84. package/src/checks/manual/empty-heading-manual.js +15 -17
  85. package/src/checks/manual/empty-table-header-manual.js +27 -30
  86. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -9
  87. package/src/checks/manual/heading-order-manual.js +17 -22
  88. package/src/checks/manual/image-redundant-alt-manual.js +14 -17
  89. package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
  90. package/src/checks/manual/label-title-only-manual.js +15 -17
  91. package/src/checks/manual/landmark-banner-is-top-level-manual.js +25 -39
  92. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +21 -27
  93. package/src/checks/manual/landmark-main-is-top-level-manual.js +14 -17
  94. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +2 -7
  95. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +2 -7
  96. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -7
  97. package/src/checks/manual/landmark-one-main-manual.js +2 -9
  98. package/src/checks/manual/landmark-unique-manual.js +22 -27
  99. package/src/checks/manual/link-name-quality-manual.js +15 -17
  100. package/src/checks/manual/meta-viewport-large-manual.js +14 -17
  101. package/src/checks/manual/mouse-only-event-handlers-manual.js +17 -19
  102. package/src/checks/manual/page-has-heading-one-manual.js +2 -9
  103. package/src/checks/manual/presentation-role-conflict-manual.js +19 -21
  104. package/src/checks/manual/region-manual.js +13 -6
  105. package/src/checks/manual/scope-attr-valid-manual.js +14 -17
  106. package/src/checks/manual/skip-link-manual.js +39 -47
  107. package/src/checks/manual/tabindex-manual.js +14 -17
  108. package/src/checks/manual/table-duplicate-name-manual.js +14 -17
  109. package/src/core.js +8818 -4330
  110. package/src/report.js +14 -0
  111. package/src/sarif.js +2 -2
  112. package/surea11y.browser.js +4023 -3911
  113. package/surea11y.i18n.de.js +22 -0
  114. package/surea11y.i18n.es.js +22 -0
  115. package/surea11y.i18n.fr.js +22 -0
@@ -28,7 +28,11 @@
28
28
  * its own wrapping link/button) is one visual region, not two independent
29
29
  * targets; that pattern is nested-interactive-controls-absent's
30
30
  * concern, not a spacing one.
31
- * - Inline: a link inside a run of text (isInlineTextExceptionTarget).
31
+ * - Inline: a link inside a text-block container passes outright
32
+ * (isInlineTextExceptionTarget). An inline link whose only spacing conflict
33
+ * 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.
32
36
  * - User Agent Control: an unstyled native checkbox/radio, detected via
33
37
  * `appearance` not being reset to `none` (see isUserAgentSizedControl) —
34
38
  * scoped narrowly to checkbox/radio specifically, not every form control,
@@ -147,23 +151,10 @@ function runInPage(ctx) {
147
151
  return 'html';
148
152
  }
149
153
 
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
154
+ // A link-like target (a[href] or role="link") rendered inline/inline-* and
155
+ // carrying visible text. This is the shape the SC 2.5.8 inline exception is
156
+ // about, without the surrounding-container requirement.
157
+ function isInlineLinkTarget(el) {
167
158
  try {
168
159
  if (!el || el.nodeType !== 1) return false;
169
160
 
@@ -180,27 +171,31 @@ function runInPage(ctx) {
180
171
 
181
172
  const cs = getStyle(el);
182
173
  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.
174
+ // Many design systems use inline-block for links, so treat the
175
+ // inline-block variants as inline here too.
187
176
  const isInline =
188
177
  display === 'inline' ||
189
178
  display === 'inline-block' ||
190
179
  display === 'inline-flex' ||
191
180
  display === 'inline-grid' ||
192
181
  display === 'inline-table';
193
-
194
182
  if (!isInline) return false;
195
183
 
196
- const t = (el.textContent || '').trim();
197
- if (!t) return false;
184
+ return (el.textContent || '').trim().length > 0;
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+
190
+ function isInlineTextExceptionTarget(el) {
191
+ // SC 2.5.8 exception: an inline link that sits inside a run of text.
192
+ try {
193
+ if (!isInlineLinkTarget(el)) return false;
198
194
 
199
- // Require that it sits in a "text block" context
200
195
  const textContainer = closest(el, 'p, li, dd, dt, blockquote, figcaption, caption, td, th');
201
196
  if (textContainer) return true;
202
197
 
203
- // Fallback: sometimes links are inside spans within a paragraph-like region
198
+ // Links are often wrapped in an inline element inside the text block.
204
199
  const inlineContainer = closest(el, 'span, em, strong, small, label');
205
200
  if (inlineContainer) {
206
201
  const outerTextBlock = closest(
@@ -590,27 +585,27 @@ function runInPage(ctx) {
590
585
  // the result once any other target on the page had a confident
591
586
  // fail (see helpers.resolveTieredOutcome's header comment). Now
592
587
  // 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
588
+ cantTellOccurrences.push(
589
+ helpers.reportOccurrence(it.el, {
590
+ occurrenceOutcome: 'cantTell',
591
+ summary:
592
+ '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.',
593
+ hint: 'Manually verify the effective spacing between this target and its neighbor; increase target size or spacing if the overlap is real.',
594
+ i18n: {
595
+ summaryKey: 'targetSizeMinimum_summary_cantTell_ambiguousSpacing',
596
+ hintKey: 'targetSizeMinimum_hint_cantTell_ambiguousSpacing',
597
+ params: {}
598
+ },
599
+ data: {
600
+ details: {
601
+ measured: { width: it.rect.width, height: it.rect.height },
602
+ reasonCode: 'undersized-ambiguous-spacing',
603
+ conflictHitCount: info.hitCount,
604
+ conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
605
+ }
611
606
  }
612
- }
613
- });
607
+ })
608
+ );
614
609
  continue;
615
610
  }
616
611
 
@@ -619,50 +614,78 @@ function runInPage(ctx) {
619
614
  // Confident spacing conflict, but the target may be exempt as part
620
615
  // of an essential graphic/image-map region — same "previously
621
616
  // 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.',
617
+ cantTellOccurrences.push(
618
+ helpers.reportOccurrence(it.el, {
619
+ occurrenceOutcome: 'cantTell',
620
+ summary:
621
+ '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.',
623
+ i18n: {
624
+ summaryKey: 'targetSizeMinimum_summary_cantTell_plausiblyEssential',
625
+ hintKey: 'targetSizeMinimum_hint_cantTell_plausiblyEssential',
626
+ params: {}
627
+ },
628
+ data: {
629
+ details: {
630
+ measured: { width: it.rect.width, height: it.rect.height },
631
+ reasonCode: 'undersized-plausibly-essential',
632
+ conflictHitCount: info.hitCount,
633
+ conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
634
+ }
635
+ }
636
+ })
637
+ );
638
+ continue;
639
+ }
640
+
641
+ // This target and its conflicting neighbor are both inline links in the
642
+ // same text run, where the SC 2.5.8 inline exception may apply. That
643
+ // can't be decided from geometry alone, so defer to manual review.
644
+ if (isInlineLinkTarget(it.el) && info.conflictEl && isInlineLinkTarget(info.conflictEl)) {
645
+ cantTellOccurrences.push(
646
+ helpers.reportOccurrence(it.el, {
647
+ occurrenceOutcome: 'cantTell',
648
+ summary:
649
+ '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.',
650
+ 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.',
651
+ i18n: {
652
+ summaryKey: 'targetSizeMinimum_summary_cantTell_inlineLinkRun',
653
+ hintKey: 'targetSizeMinimum_hint_cantTell_inlineLinkRun',
654
+ params: {}
655
+ },
656
+ data: {
657
+ details: {
658
+ measured: { width: it.rect.width, height: it.rect.height },
659
+ reasonCode: 'undersized-inline-link-run',
660
+ conflictHitCount: info.hitCount,
661
+ conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
662
+ }
663
+ }
664
+ })
665
+ );
666
+ continue;
667
+ }
668
+
669
+ failOccurrences.push(
670
+ helpers.reportOccurrence(it.el, {
671
+ occurrenceOutcome: 'fail',
672
+ summary: 'Target is too small and too close to another target.',
673
+ hint: 'Increase target size to at least 24 by 24 CSS pixels, or add sufficient spacing.',
629
674
  i18n: {
630
- summaryKey: 'targetSizeMinimum_summary_cantTell_plausiblyEssential',
631
- hintKey: 'targetSizeMinimum_hint_cantTell_plausiblyEssential',
675
+ summaryKey: 'targetSizeMinimum_summary_fail',
676
+ hintKey: 'targetSizeMinimum_hint_fail',
632
677
  params: {}
633
678
  },
634
679
  data: {
635
680
  details: {
636
681
  measured: { width: it.rect.width, height: it.rect.height },
637
- reasonCode: 'undersized-plausibly-essential',
682
+ reasonCode: 'undersized-and-too-close',
638
683
  conflictHitCount: info.hitCount,
639
684
  conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
640
685
  }
641
686
  }
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
- });
687
+ })
688
+ );
666
689
  }
667
690
  }
668
691
 
@@ -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
  }
@@ -109,9 +109,16 @@ function runInPage(ctx) {
109
109
  return '';
110
110
  }
111
111
 
112
+ // Naming rules apply only to elements included in the accessibility tree
113
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
114
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
112
115
  function isEligibleAcc(helpers, el, ctx) {
113
116
  const fn =
114
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
117
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
118
+ ? helpers.isIncludedInAccessibilityTree
119
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
120
+ ? helpers.isAccTreeEligible
121
+ : null;
115
122
  if (!fn) return true;
116
123
  try {
117
124
  const r = fn(el, ctx);
@@ -220,24 +227,22 @@ function runInPage(ctx) {
220
227
  }
221
228
  })()
222
229
  : null;
223
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
224
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
225
230
 
226
- occurrences.push({
227
- selector: stableSelector,
228
- html,
229
- summary: 'This element has no accessible name.',
230
- hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this textbox's accessible name.",
231
- i18n: {
232
- summaryKey: 'textboxNamePresent_summary_fail',
233
- hintKey: 'textboxNamePresent_hint_fail',
234
- params: { controlType: 'textbox' }
235
- },
236
- data: {
237
- details: { reasonCode: 'name_missing', controlType: 'textbox', methodTried: res.method },
238
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
239
- }
240
- });
231
+ occurrences.push(
232
+ helpers.reportOccurrence(el, {
233
+ 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.",
235
+ i18n: {
236
+ summaryKey: 'textboxNamePresent_summary_fail',
237
+ hintKey: 'textboxNamePresent_hint_fail',
238
+ params: { controlType: 'textbox' }
239
+ },
240
+ data: {
241
+ details: { reasonCode: 'name_missing', controlType: 'textbox', methodTried: res.method },
242
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
243
+ }
244
+ })
245
+ );
241
246
  }
242
247
 
243
248
  if (applicableCount === 0) {
@@ -83,9 +83,16 @@ function runInPage(ctx) {
83
83
  return '';
84
84
  }
85
85
 
86
+ // Naming rules apply only to elements included in the accessibility tree
87
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
88
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
86
89
  function isEligibleAcc(helpers, el, ctx) {
87
90
  const fn =
88
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
91
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
92
+ ? helpers.isIncludedInAccessibilityTree
93
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
94
+ ? helpers.isAccTreeEligible
95
+ : null;
89
96
  if (!fn) return true;
90
97
  try {
91
98
  const r = fn(el, ctx);
@@ -141,24 +148,22 @@ function runInPage(ctx) {
141
148
  }
142
149
  })()
143
150
  : null;
144
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
145
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
146
-
147
- occurrences.push({
148
- selector: stableSelector,
149
- html,
150
- summary: 'This tooltip has no accessible name.',
151
- hint: 'Provide tooltip text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
152
- i18n: {
153
- summaryKey: 'tooltipNamePresent_summary_fail',
154
- hintKey: 'tooltipNamePresent_hint_fail',
155
- params: {}
156
- },
157
- data: {
158
- details: { reasonCode: 'name_missing', controlType: 'tooltip', methodTried: res.method },
159
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
160
- }
161
- });
151
+
152
+ occurrences.push(
153
+ helpers.reportOccurrence(el, {
154
+ summary: 'This tooltip has no accessible name.',
155
+ hint: 'Provide tooltip text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
156
+ i18n: {
157
+ summaryKey: 'tooltipNamePresent_summary_fail',
158
+ hintKey: 'tooltipNamePresent_hint_fail',
159
+ params: {}
160
+ },
161
+ data: {
162
+ details: { reasonCode: 'name_missing', controlType: 'tooltip', methodTried: res.method },
163
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
164
+ }
165
+ })
166
+ );
162
167
  }
163
168
 
164
169
  if (applicableCount === 0) {
@@ -83,9 +83,16 @@ function runInPage(ctx) {
83
83
  return '';
84
84
  }
85
85
 
86
+ // Naming rules apply only to elements included in the accessibility tree
87
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
88
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
86
89
  function isEligibleAcc(helpers, el, ctx) {
87
90
  const fn =
88
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
91
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
92
+ ? helpers.isIncludedInAccessibilityTree
93
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
94
+ ? helpers.isAccTreeEligible
95
+ : null;
89
96
  if (!fn) return true;
90
97
  try {
91
98
  const r = fn(el, ctx);
@@ -138,24 +145,22 @@ function runInPage(ctx) {
138
145
  }
139
146
  })()
140
147
  : null;
141
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
142
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
143
-
144
- occurrences.push({
145
- selector: stableSelector,
146
- html,
147
- summary: 'This element has no accessible name.',
148
- hint: 'Provide aria-label or aria-labelledby (preferred), or provide visible text that is not hidden from assistive technologies.',
149
- i18n: {
150
- summaryKey: 'treeitemNamePresent_summary_fail',
151
- hintKey: 'treeitemNamePresent_hint_fail',
152
- params: { controlType: 'treeitem' }
153
- },
154
- data: {
155
- details: { reasonCode: 'name_missing', controlType: 'treeitem', methodTried: res.method },
156
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
157
- }
158
- });
148
+
149
+ occurrences.push(
150
+ helpers.reportOccurrence(el, {
151
+ summary: 'This element has no accessible name.',
152
+ hint: 'Provide aria-label or aria-labelledby (preferred), or provide visible text that is not hidden from assistive technologies.',
153
+ i18n: {
154
+ summaryKey: 'treeitemNamePresent_summary_fail',
155
+ hintKey: 'treeitemNamePresent_hint_fail',
156
+ params: { controlType: 'treeitem' }
157
+ },
158
+ data: {
159
+ details: { reasonCode: 'name_missing', controlType: 'treeitem', methodTried: res.method },
160
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
161
+ }
162
+ })
163
+ );
159
164
  }
160
165
 
161
166
  if (applicableCount === 0) {
@@ -58,7 +58,13 @@ const meta = {
58
58
  function runInPage(ctx) {
59
59
  const { helpers, rule } = ctx;
60
60
 
61
+ // Shape alone accepts unregistered tags such as "eng" and "em-US", so the
62
+ // primary subtag is checked against the IANA registry via the shared helper.
61
63
  const BCP47_RE = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
64
+ const isValidTag =
65
+ typeof helpers.isValidLanguageTag === 'function'
66
+ ? helpers.isValidLanguageTag
67
+ : (v) => BCP47_RE.test(String(v || ''));
62
68
 
63
69
  const nodes = helpers.queryAllSmart
64
70
  ? helpers.queryAllSmart('[lang]')
@@ -71,31 +77,36 @@ function runInPage(ctx) {
71
77
  if (!el || !el.getAttribute) continue;
72
78
  if (el.tagName && el.tagName.toLowerCase() === 'html') continue;
73
79
 
74
- const raw = String(el.getAttribute('lang') || '').trim();
75
- if (!raw) continue;
80
+ const rawAttr = el.getAttribute('lang');
81
+ if (rawAttr === null || rawAttr === '') continue; // ACT de46e4: empty is out of scope
82
+
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;
76
87
 
77
88
  applicableCount += 1;
78
89
 
79
- if (BCP47_RE.test(raw)) continue;
90
+ // A whitespace-only value is in scope and has no primary language tag.
91
+ const raw = String(rawAttr).trim();
92
+ if (isValidTag(raw)) continue;
80
93
 
81
94
  const tag = el.tagName.toLowerCase();
82
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
83
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
84
-
85
- occurrences.push({
86
- selector: stableSelector,
87
- html,
88
- summary: `This lang attribute value ("${raw}") is not a syntactically valid language tag.`,
89
- hint: 'Use a valid BCP47 language tag (e.g. "fr", "es-MX").',
90
- i18n: {
91
- summaryKey: 'validLang_summary_fail',
92
- hintKey: 'validLang_hint_fail',
93
- params: { element: tag, value: raw }
94
- },
95
- data: {
96
- details: { reasonCode: 'ELEMENT_LANG_INVALID', element: tag, value: raw }
97
- }
98
- });
95
+
96
+ occurrences.push(
97
+ helpers.reportOccurrence(el, {
98
+ summary: `This lang attribute value ("${raw}") is not a syntactically valid language tag.`,
99
+ hint: 'Use a valid BCP47 language tag (e.g. "fr", "es-MX").',
100
+ i18n: {
101
+ summaryKey: 'validLang_summary_fail',
102
+ hintKey: 'validLang_hint_fail',
103
+ params: { element: tag, value: raw }
104
+ },
105
+ data: {
106
+ details: { reasonCode: 'ELEMENT_LANG_INVALID', element: tag, value: raw }
107
+ }
108
+ })
109
+ );
99
110
  }
100
111
 
101
112
  if (applicableCount === 0) {
@@ -62,25 +62,24 @@ function runInPage(ctx) {
62
62
  for (const [key, els] of groups) {
63
63
  if (els.length <= 1) continue;
64
64
  for (const el of els) {
65
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
66
- const html = helpers.getOuterHtmlSnippet
67
- ? helpers.getOuterHtmlSnippet(el)
68
- : el.outerHTML || '';
69
-
70
- occurrences.push({
71
- selector: stableSelector,
72
- html,
73
- summary: "This element's accesskey is shared with another element on the page.",
74
- hint: 'Make each accesskey value unique across the page.',
75
- i18n: {
76
- summaryKey: 'accesskeys_summary_cantTell',
77
- hintKey: 'accesskeys_hint_cantTell',
78
- params: { accesskey: key, duplicateCount: String(els.length) }
79
- },
80
- data: {
81
- details: { reasonCode: 'ACCESSKEY_DUPLICATE', accesskey: key, duplicateCount: els.length }
82
- }
83
- });
65
+ occurrences.push(
66
+ helpers.reportOccurrence(el, {
67
+ summary: "This element's accesskey is shared with another element on the page.",
68
+ hint: 'Make each accesskey value unique across the page.',
69
+ i18n: {
70
+ summaryKey: 'accesskeys_summary_cantTell',
71
+ hintKey: 'accesskeys_hint_cantTell',
72
+ params: { accesskey: key, duplicateCount: String(els.length) }
73
+ },
74
+ data: {
75
+ details: {
76
+ reasonCode: 'ACCESSKEY_DUPLICATE',
77
+ accesskey: key,
78
+ duplicateCount: els.length
79
+ }
80
+ }
81
+ })
82
+ );
84
83
  }
85
84
  }
86
85
 
@@ -114,29 +114,26 @@ function runInPage(ctx) {
114
114
 
115
115
  if (normalizedAriaChecked === actualState) continue;
116
116
 
117
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
118
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
119
-
120
- occurrences.push({
121
- selector: stableSelector,
122
- html,
123
- summary:
124
- 'This element’s aria-checked value does not match its actual checked/indeterminate state.',
125
- hint: 'Set aria-checked to match the element’s real state, or remove it — a native checkbox/radio already exposes this state without it.',
126
- i18n: {
127
- summaryKey: 'ariaCheckedStateMismatch_summary_cantTell',
128
- hintKey: 'ariaCheckedStateMismatch_hint_cantTell',
129
- params: { ariaChecked: normalizedAriaChecked, actualState, type }
130
- },
131
- data: {
132
- details: {
133
- reasonCode: 'ARIA_CHECKED_STATE_MISMATCH',
134
- ariaChecked: normalizedAriaChecked,
135
- actualState,
136
- type
117
+ occurrences.push(
118
+ helpers.reportOccurrence(el, {
119
+ summary:
120
+ 'This element’s aria-checked value does not match its actual checked/indeterminate state.',
121
+ hint: 'Set aria-checked to match the element’s real state, or remove it — a native checkbox/radio already exposes this state without it.',
122
+ i18n: {
123
+ summaryKey: 'ariaCheckedStateMismatch_summary_cantTell',
124
+ hintKey: 'ariaCheckedStateMismatch_hint_cantTell',
125
+ params: { ariaChecked: normalizedAriaChecked, actualState, type }
126
+ },
127
+ data: {
128
+ details: {
129
+ reasonCode: 'ARIA_CHECKED_STATE_MISMATCH',
130
+ ariaChecked: normalizedAriaChecked,
131
+ actualState,
132
+ type
133
+ }
137
134
  }
138
- }
139
- });
135
+ })
136
+ );
140
137
  }
141
138
 
142
139
  if (applicableCount === 0) {