@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
@@ -2,6 +2,27 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check slider-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="slider" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="slider" (the attribute must name that
13
+ * role alone, not a fallback list) that are included in the accessibility
14
+ * tree. An element with the matching implicit role but no role attribute
15
+ * is out of scope. A native <input type="range"> without the role belongs
16
+ * to form-control-programmatic-label-present.
17
+ * @expectation
18
+ * The element has a non-empty accessible name from aria-label, from an
19
+ * aria-labelledby that resolves to non-empty text, or from title.
20
+ * role="slider" is name-from-author-only, so subtree text is never
21
+ * accepted: text sitting inside a custom slider widget is not reliably
22
+ * exposed as its name. On a labelable element (<input role="slider">) an
23
+ * associated <label> counts as well.
24
+ */
25
+
5
26
  const id = 'slider-name-present';
6
27
 
7
28
  const meta = {
@@ -67,7 +88,7 @@ function runInPage(ctx) {
67
88
  }
68
89
 
69
90
  function getConservativeSubtreeText(document, container) {
70
- // "Name from content" — recurses into descendants and uses each one's
91
+ // "Name from content", recurses into descendants and uses each one's
71
92
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
72
93
  // it has one, not just literal text nodes. See getContentNameInfo's
73
94
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -84,7 +105,7 @@ function runInPage(ctx) {
84
105
 
85
106
  // A <label> contributes a name via its own aria-label/aria-labelledby
86
107
  // (checked first, same ARIA-over-content precedence any element's
87
- // accessible name gives — e.g. <label aria-label="Search"><svg
108
+ // accessible name gives, e.g. <label aria-label="Search"><svg
88
109
  // aria-hidden="true">...</svg></label> names its control "Search" even
89
110
  // though the label's only child content is aria-hidden) or, failing
90
111
  // that, its rendered content (getConservativeSubtreeText).
@@ -99,10 +120,10 @@ function runInPage(ctx) {
99
120
  if (content) return content;
100
121
  // Final fallback per the general accname text-alternative algorithm,
101
122
  // which applies to any element being asked for its name regardless of
102
- // why (own aria-label, an aria-labelledby reference, or — here — native
123
+ // why (own aria-label, an aria-labelledby reference, or, here, native
103
124
  // <label for> association): title, when nothing else yields a name.
104
125
  // Purely additive (only fills in a name where there was none before),
105
- // so it carries no false-positive risk — see dialog-name-present.js's
126
+ // so it carries no false-positive risk, see dialog-name-present.js's
106
127
  // identical <iframe>-title-fallback fix for the concrete real-world
107
128
  // trigger this same accname step covers elsewhere.
108
129
  return getAttr(lab, 'title');
@@ -112,7 +133,7 @@ function runInPage(ctx) {
112
133
  const raw = getAttr(el, 'aria-labelledby');
113
134
  if (!raw) return '';
114
135
  // Delegates to the shared getTextFromIdRefs helper instead of computing
115
- // name-from-content of the referenced element — see dialog-name-
136
+ // name-from-content of the referenced element, see dialog-name-
116
137
  // present.js's identical fix for the full rationale (an <iframe>
117
138
  // aria-labelledby target's only name source is its title attribute,
118
139
  // which name-from-content alone can never see).
@@ -209,7 +230,7 @@ function runInPage(ctx) {
209
230
  }
210
231
 
211
232
  // role="slider" is name-from-author-only per WAI-ARIA (unlike e.g.
212
- // role="option"/"tab"), so it must NOT fall back to subtree content —
233
+ // role="option"/"tab"), so it must NOT fall back to subtree content,
213
234
  // visible text near/inside a custom slider widget is not reliably
214
235
  // exposed as its accessible name by assistive technologies.
215
236
  return { ok: false, method: 'none' };
@@ -2,11 +2,31 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check spinbutton-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="spinbutton" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="spinbutton" (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="spinbutton" is name-from-author-only, so subtree text is never
20
+ * accepted: text sitting inside a custom spinbutton widget is not reliably
21
+ * exposed as its name. On a labelable element (<input role="spinbutton">)
22
+ * an associated <label> counts as well.
23
+ */
24
+
5
25
  const id = 'spinbutton-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: 'Spinbuttons have an accessible name',
29
+ description: 'Checks that elements with role="spinbutton" expose a non-empty accessible name.',
10
30
  i18n: {
11
31
  titleKey: 'spinbuttonNamePresent_title',
12
32
  descriptionKey: 'spinbuttonNamePresent_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 spinbutton's accessible name.",
254
+ hint: "Provide aria-label, aria-labelledby, or a title attribute. Visible text content is not exposed as this spinbutton's accessible name.",
235
255
  i18n: {
236
256
  summaryKey: 'spinbuttonNamePresent_summary_fail',
237
257
  hintKey: 'spinbuttonNamePresent_hint_fail',
@@ -2,6 +2,22 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check summary-name-present
7
+ * @atomic true
8
+ * @summary <summary> elements must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to <summary> elements included in the accessibility tree,
13
+ * wherever they appear, a <summary> outside a <details> is still matched.
14
+ * @expectation
15
+ * The element has a non-empty accessible name from aria-label, from an
16
+ * aria-labelledby that resolves to non-empty text, from title, or from its
17
+ * own subtree text, where a descendant's own name (an <img alt>,
18
+ * aria-label or title) counts as that descendant's contribution.
19
+ */
20
+
5
21
  const id = 'summary-name-present';
6
22
 
7
23
  const meta = {
@@ -51,7 +67,7 @@ function runInPage(ctx) {
51
67
  }
52
68
 
53
69
  function getConservativeSubtreeText(document, container) {
54
- // "Name from content" — recurses into descendants and uses each one's
70
+ // "Name from content", recurses into descendants and uses each one's
55
71
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
56
72
  // it has one, not just literal text nodes. See getContentNameInfo's
57
73
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -70,7 +86,7 @@ function runInPage(ctx) {
70
86
  const raw = getAttr(el, 'aria-labelledby');
71
87
  if (!raw) return '';
72
88
  // Delegates to the shared getTextFromIdRefs helper instead of computing
73
- // name-from-content of the referenced element — see dialog-name-
89
+ // name-from-content of the referenced element, see dialog-name-
74
90
  // present.js's identical fix for the full rationale (an <iframe>
75
91
  // aria-labelledby target's only name source is its title attribute,
76
92
  // which name-from-content alone can never see).
@@ -109,7 +109,7 @@ function runInPage(ctx) {
109
109
  }
110
110
 
111
111
  // <desc> counts when it is the first child, or the second child
112
- // immediately following a <title> — the standard <title>+<desc> pairing.
112
+ // immediately following a <title>, the standard <title>+<desc> pairing.
113
113
  // A <desc> appearing later than that is not reliably read by AT.
114
114
  function descText(el) {
115
115
  try {
@@ -13,12 +13,15 @@
13
13
  * "Intended to be conveyed" is approximated deterministically by at least one of:
14
14
  * - role="img", role="graphics-symbol", or role="graphics-document"
15
15
  * on the SVG root element itself (the WAI-ARIA Graphics Module
16
- * roles, alongside img). Deliberately does NOT extend to arbitrary
17
- * role="graphics-symbol" descendants nested inside an <svg> — this
18
- * check's scope is the <svg> root only; a broader feature, not
19
- * attempted here.
16
+ * roles, alongside img). Does NOT extend to arbitrary
17
+ * role="img"/"graphics-symbol"/"graphics-document" descendants
18
+ * nested inside an <svg>. This check's scope is the <svg> root
19
+ * only; role-img-text-alternative-present covers those same three
20
+ * roles on any other element, including nested SVG shapes (ACT
21
+ * 7d6734's own failed example: a bare `<svg>` root with a nested
22
+ * `<circle role="graphics-symbol">`).
20
23
  * - aria-label / aria-labelledby present
21
- * - <title> or <desc> present (desc alone is an applicability signal only — see @expectation)
24
+ * - <title> or <desc> present (desc alone is an applicability signal only, see @expectation)
22
25
  * - focusable/tabbable (e.g., tabindex, native focusability)
23
26
  *
24
27
  * Images with role="presentation" or role="none" are excluded only when they are not focusable.
@@ -28,7 +31,7 @@
28
31
  * Each applicable <svg> element provides a text alternative via:
29
32
  * - non-empty <title> text, OR
30
33
  * - an ARIA name (aria-label / aria-labelledby).
31
- * A <desc> element alone does NOT satisfy this — per the SVG Accessibility
34
+ * A <desc> element alone does NOT satisfy this, per the SVG Accessibility
32
35
  * API Mappings spec §7.1, <desc> only ever contributes to the accessible
33
36
  * DESCRIPTION, never the accessible NAME. An <svg> with only a
34
37
  * <desc> and no <title>/ARIA name is still "applicable" (desc signals
@@ -40,7 +43,7 @@ const id = 'svg-text-alternative-present';
40
43
  const meta = {
41
44
  title: '<svg> must provide a text alternative',
42
45
  description:
43
- 'Checks that inline <svg> elements provide a text alternative via a <title> element or an ARIA name (a <desc> element alone does not count — it only contributes to the accessible description, not the name).',
46
+ 'Checks that inline <svg> elements provide a text alternative via a <title> element or an ARIA name (a <desc> element alone does not count).',
44
47
  i18n: {
45
48
  titleKey: 'svg_textAltPresent_title',
46
49
  descriptionKey: 'svg_textAltPresent_description'
@@ -124,7 +127,7 @@ function runInPage(ctx) {
124
127
  }
125
128
 
126
129
  // <desc> counts when it is the first child, or the second child
127
- // immediately following a <title> — the standard <title>+<desc> pairing
130
+ // immediately following a <title>, the standard <title>+<desc> pairing
128
131
  // (e.g. <svg><title>...</title><desc>...</desc>...</svg>). A <desc>
129
132
  // appearing later than that is not reliably read by AT.
130
133
  function nonEmptyDescText(svg) {
@@ -277,7 +280,7 @@ function runInPage(ctx) {
277
280
  }
278
281
 
279
282
  // Per SVG-AAM §7.1: <desc> contributes only to the accessible
280
- // DESCRIPTION, never the accessible NAME — so descText does not
283
+ // DESCRIPTION, never the accessible NAME, so descText does not
281
284
  // count here even though it does count toward applicability above.
282
285
  const ok = !!titleText || hasAriaName;
283
286
  if (ok) continue;
@@ -293,7 +296,7 @@ function runInPage(ctx) {
293
296
 
294
297
  const baseOccurrence = {
295
298
  summary: 'Missing text alternative for <svg>.',
296
- hint: 'Provide a <title> element with text, or an ARIA name (aria-label/aria-labelledby) — a <desc> element alone does not provide an accessible name.',
299
+ hint: 'Provide a <title> element with text, or an ARIA name (aria-label/aria-labelledby); a <desc> element alone does not provide an accessible name.',
297
300
  i18n: {
298
301
  summaryKey: 'svg_textAltPresent_summary_fail',
299
302
  hintKey: 'svg_textAltPresent_hint_fail',
@@ -2,6 +2,25 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check tab-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="tab" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="tab" (the attribute must name that
13
+ * role alone, not a fallback list) that are included in the accessibility
14
+ * tree. An element with the matching implicit role but no role attribute
15
+ * 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="tab" being name-from-content, from its own subtree text, where a
20
+ * descendant's own name (an <img alt>, aria-label or title) counts as that
21
+ * descendant's contribution rather than only its text nodes.
22
+ */
23
+
5
24
  const id = 'tab-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).
@@ -9,11 +9,18 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.3.1
11
11
  * @applicability
12
- * Applies to <td>/<th> elements that carry a non-empty headers attribute.
12
+ * Applies to <td>/<th> elements that carry a non-empty headers attribute,
13
+ * within a <table> whose semantic role is still table/grid/treegrid --
14
+ * an explicit role of anything else (role="presentation"/"none", but
15
+ * also role="heading" or any other real role) replaces the native table
16
+ * semantics, leaving no table for headers to describe. Matches ACT
17
+ * a25f45's applicability.
13
18
  * @expectation
14
19
  * Every id token in the headers attribute resolves to an element that:
15
- * (a) exists, (b) is a <th> element, (c) is inside the same <table> as
16
- * the referencing cell, and (d) is not the cell itself.
20
+ * (a) exists, (b) is a cell (<td> or <th>) of the same <table> as the
21
+ * referencing cell, and (c) is not the cell itself. A <td> serving as a
22
+ * header via role="columnheader"/"rowheader" is a valid target, same as
23
+ * a plain <th> -- ACT a25f45 does not require the native tag.
17
24
  * @implementation-notes
18
25
  * - One occurrence per offending cell (not per bad token), listing every
19
26
  * invalid reference.
@@ -27,7 +34,7 @@ const id = 'table-headers-attr-valid';
27
34
  const meta = {
28
35
  title: 'Table cell "headers" attribute must reference valid header cells',
29
36
  description:
30
- 'Checks that each id in a <td>/<th> headers attribute resolves to a <th> element within the same table (not missing, not a non-th element, not itself).',
37
+ 'Checks that each id in a <td>/<th> headers attribute resolves to a cell (<td> or <th>) within the same table (not missing, not a non-cell element, not itself).',
31
38
  i18n: {
32
39
  titleKey: 'tableHeadersAttrValid_title',
33
40
  descriptionKey: 'tableHeadersAttrValid_description'
@@ -54,6 +61,23 @@ const meta = {
54
61
  function runInPage(ctx) {
55
62
  const { document, helpers, rule } = ctx;
56
63
 
64
+ const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
65
+
66
+ // The role attribute holds a fallback list; the first token naming a real
67
+ // role wins, and unknown tokens are skipped over.
68
+ function getExplicitRole(el) {
69
+ const raw = el && el.getAttribute ? el.getAttribute('role') : null;
70
+ if (!raw) return '';
71
+ const tokens = String(raw).trim().toLowerCase().split(/\s+/);
72
+ for (const token of tokens) {
73
+ if (!token) continue;
74
+ if (token === 'presentation' || token === 'none') return token;
75
+ const known = ariaHelpers ? ariaHelpers.isValidConcreteRole(token) : true;
76
+ if (known) return token;
77
+ }
78
+ return '';
79
+ }
80
+
57
81
  const nodes = helpers.queryAllSmart
58
82
  ? helpers.queryAllSmart('td[headers], th[headers]')
59
83
  : helpers.queryAll('td[headers], th[headers]');
@@ -69,9 +93,19 @@ function runInPage(ctx) {
69
93
  const ids = raw.split(/\s+/).filter(Boolean);
70
94
  if (!ids.length) continue;
71
95
 
96
+ const table = el.closest ? el.closest('table') : null;
97
+
98
+ // An explicit role on the <table> replaces its native table role. Only
99
+ // the three roles that still describe a table keep the cell's headers
100
+ // attribute meaningful; anything else (presentation/none, heading, ...)
101
+ // takes the whole table out of scope. Unknown tokens name no role, so
102
+ // the native one stands.
103
+ const tableRole = table ? getExplicitRole(table) : '';
104
+ if (tableRole && tableRole !== 'table' && tableRole !== 'grid' && tableRole !== 'treegrid')
105
+ continue;
106
+
72
107
  applicableCount += 1;
73
108
 
74
- const table = el.closest ? el.closest('table') : null;
75
109
  const invalid = [];
76
110
 
77
111
  for (const headerId of ids) {
@@ -90,8 +124,9 @@ function runInPage(ctx) {
90
124
  invalid.push({ id: headerId, reason: 'self-reference' });
91
125
  continue;
92
126
  }
93
- if (!ref.tagName || ref.tagName.toLowerCase() !== 'th') {
94
- invalid.push({ id: headerId, reason: 'not-a-th' });
127
+ const refTag = ref.tagName ? ref.tagName.toLowerCase() : '';
128
+ if (refTag !== 'th' && refTag !== 'td') {
129
+ invalid.push({ id: headerId, reason: 'not-a-cell' });
95
130
  continue;
96
131
  }
97
132
  if (table && (!ref.closest || ref.closest('table') !== table)) {
@@ -108,7 +143,7 @@ function runInPage(ctx) {
108
143
  occurrences.push(
109
144
  helpers.reportOccurrence(el, {
110
145
  summary: 'This cell’s headers attribute references one or more invalid header cells.',
111
- hint: 'Update the headers attribute so every id refers to a <th> element within the same table.',
146
+ hint: 'Update the headers attribute so every id refers to a cell (<td> or <th>) within the same table.',
112
147
  i18n: {
113
148
  summaryKey: 'tableHeadersAttrValid_summary_fail',
114
149
  hintKey: 'tableHeadersAttrValid_hint_fail',
@@ -12,26 +12,29 @@
12
12
  * Applies to <table> elements that keep their table semantics and are included
13
13
  * in the accessibility tree, and that contain at least one <th> which is
14
14
  * visible, included in the accessibility tree, and not overridden by an
15
- * explicit role other than rowheader/columnheader.
15
+ * explicit role other than rowheader/columnheader. Also applies to the
16
+ * ARIA-only equivalent: an element with role="grid"/"treegrid" (no
17
+ * native <table> involved) that contains at least one in-scope
18
+ * columnheader/rowheader-role element.
16
19
  * @expectation
17
20
  * The table also contains at least one <td> somewhere in it.
18
21
  * @implementation-notes
19
- * - DELIBERATELY SCOPED (see aria-helpers.js file header for the same
22
+ * - SCOPED (see aria-helpers.js file header for the same
20
23
  * conservative-scope rationale used throughout this engine): this rule
21
24
  * does NOT implement the full HTML5 header-association algorithm
22
25
  * (resolving which specific data cells a given <th scope="row"|"col">
23
26
  * describes, accounting for colspan/rowspan and default-scope
24
27
  * inference). That algorithm is one of the more error-prone parts of
25
28
  * the HTML spec to reimplement correctly, and a wrong positional match
26
- * would risk a false `fail` — unacceptable for this engine's fail-
29
+ * would risk a false `fail`, unacceptable for this engine's fail-
27
30
  * integrity bar.
28
31
  * - Instead, this rule only catches the single unambiguous case: a table
29
32
  * that has <th> elements but ZERO <td> elements anywhere. In that case
30
- * every <th> in the table trivially describes no data cell — no
33
+ * every <th> in the table trivially describes no data cell, no
31
34
  * positional analysis is needed to know that. A table that has at
32
35
  * least one <td> somewhere is not evaluated further by this rule, even
33
36
  * if some particular <th> in it doesn't actually describe any cell
34
- * (false negative, not a false positive — acceptable under this
37
+ * (false negative, not a false positive, acceptable under this
35
38
  * engine's philosophy).
36
39
  * - Applicability IS gated, because the zero-<td> case above turns any
37
40
  * over-broad scope straight into a false fail: a layout table marked
@@ -174,6 +177,59 @@ function runInPage(ctx) {
174
177
  }
175
178
  }
176
179
 
180
+ // ARIA-only equivalent: a role="grid"/"treegrid" container with no
181
+ // backing <table> at all. Same trivial "zero data cells anywhere" check,
182
+ // just keyed off ARIA roles instead of native tags -- a genuine
183
+ // columnheader/rowheader with zero gridcell/cell-role elements anywhere
184
+ // in the container is exactly as unambiguous as a <th>-only <table>.
185
+ const grids = helpers.queryAllSmart
186
+ ? helpers.queryAllSmart('[role="grid"], [role="treegrid"]')
187
+ : helpers.queryAll('[role="grid"], [role="treegrid"]');
188
+
189
+ for (const grid of grids) {
190
+ if (!grid || !grid.querySelectorAll) continue;
191
+ if (grid.tagName && grid.tagName.toLowerCase() === 'table') continue; // already handled above
192
+ if (!isIncludedInTree(grid)) continue;
193
+
194
+ let headerNodes;
195
+ try {
196
+ headerNodes = grid.querySelectorAll('[role="columnheader"], [role="rowheader"]');
197
+ } catch {
198
+ headerNodes = [];
199
+ }
200
+ const headers = Array.from(headerNodes).filter(isHeaderCellInScope);
201
+ if (!headers.length) continue;
202
+
203
+ applicableCount += 1;
204
+
205
+ let hasDataCell;
206
+ try {
207
+ hasDataCell = grid.querySelectorAll('[role="gridcell"], [role="cell"]').length > 0;
208
+ } catch {
209
+ hasDataCell = false;
210
+ }
211
+ if (hasDataCell) continue;
212
+
213
+ for (const th of headers) {
214
+ if (!th) continue;
215
+
216
+ occurrences.push(
217
+ helpers.reportOccurrence(th, {
218
+ summary: 'This table has header cells but no data cells for them to describe.',
219
+ hint: 'Add data cells (<td>) to the table, or remove the header cells if the table has no data.',
220
+ i18n: {
221
+ summaryKey: 'tableThHasDataCells_summary_fail',
222
+ hintKey: 'tableThHasDataCells_hint_fail',
223
+ params: {}
224
+ },
225
+ data: {
226
+ details: { reasonCode: 'TABLE_TH_NO_DATA_CELLS' }
227
+ }
228
+ })
229
+ );
230
+ }
231
+ }
232
+
177
233
  if (applicableCount === 0) {
178
234
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
179
235
  }