@surea11y/core 1.0.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 (164) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +145 -0
  4. package/bin/core.js +244 -0
  5. package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
  6. package/docs/CLI.md +49 -0
  7. package/docs/ENGINE_OPTIONS.md +155 -0
  8. package/docs/I18N.md +47 -0
  9. package/docs/INTEGRATION.md +156 -0
  10. package/docs/LIMITATIONS.md +31 -0
  11. package/docs/OUTPUT_SCHEMA.md +237 -0
  12. package/docs/POLICY.md +71 -0
  13. package/docs/RULE_AUTHORING.md +375 -0
  14. package/docs/RULE_CATALOG.md +180 -0
  15. package/docs/RULE_TAXONOMY.md +145 -0
  16. package/docs/TROUBLESHOOTING.md +48 -0
  17. package/docs/WCAG_CONFORMANCE.md +49 -0
  18. package/package.json +60 -0
  19. package/src/catalogs/composites.wcag.js +490 -0
  20. package/src/checks/automatic/area-alt-present.js +225 -0
  21. package/src/checks/automatic/aria-allowed-attr.js +206 -0
  22. package/src/checks/automatic/aria-allowed-role.js +102 -0
  23. package/src/checks/automatic/aria-braille-equivalent.js +139 -0
  24. package/src/checks/automatic/aria-conditional-attr.js +110 -0
  25. package/src/checks/automatic/aria-deprecated-role.js +106 -0
  26. package/src/checks/automatic/aria-hidden-body.js +87 -0
  27. package/src/checks/automatic/aria-hidden-focus.js +480 -0
  28. package/src/checks/automatic/aria-prohibited-attr.js +156 -0
  29. package/src/checks/automatic/aria-prohibited-children.js +265 -0
  30. package/src/checks/automatic/aria-required-attr.js +154 -0
  31. package/src/checks/automatic/aria-required-children.js +274 -0
  32. package/src/checks/automatic/aria-required-parent.js +222 -0
  33. package/src/checks/automatic/aria-role-name-present.js +201 -0
  34. package/src/checks/automatic/aria-roles-valid.js +110 -0
  35. package/src/checks/automatic/aria-valid-attr-value.js +123 -0
  36. package/src/checks/automatic/aria-valid-attr.js +109 -0
  37. package/src/checks/automatic/autocomplete-valid.js +134 -0
  38. package/src/checks/automatic/avoid-inline-spacing.js +107 -0
  39. package/src/checks/automatic/binary-control-name-present.js +294 -0
  40. package/src/checks/automatic/button-name-present.js +146 -0
  41. package/src/checks/automatic/bypass-blocks-present.js +162 -0
  42. package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
  43. package/src/checks/automatic/combobox-name-present.js +267 -0
  44. package/src/checks/automatic/contrast-computable.js +378 -0
  45. package/src/checks/automatic/contrast-enhanced.js +517 -0
  46. package/src/checks/automatic/contrast-minimum.js +512 -0
  47. package/src/checks/automatic/css-orientation-lock.js +206 -0
  48. package/src/checks/automatic/definition-list-children-valid.js +148 -0
  49. package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
  50. package/src/checks/automatic/dialog-name-present.js +209 -0
  51. package/src/checks/automatic/dlitem-parent-valid.js +100 -0
  52. package/src/checks/automatic/duplicate-id-aria.js +126 -0
  53. package/src/checks/automatic/embed-text-alternative-present.js +190 -0
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
  55. package/src/checks/automatic/form-control-single-label.js +117 -0
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
  57. package/src/checks/automatic/iframe-focusable-content.js +141 -0
  58. package/src/checks/automatic/iframe-name-present.js +102 -0
  59. package/src/checks/automatic/iframe-title-unique.js +107 -0
  60. package/src/checks/automatic/img-alt-present.js +223 -0
  61. package/src/checks/automatic/input-image-alt-present.js +155 -0
  62. package/src/checks/automatic/label-in-name.js +326 -0
  63. package/src/checks/automatic/language-page-present.js +159 -0
  64. package/src/checks/automatic/link-in-text-block.js +218 -0
  65. package/src/checks/automatic/link-name-present.js +114 -0
  66. package/src/checks/automatic/list-children-valid.js +152 -0
  67. package/src/checks/automatic/listbox-name-present.js +236 -0
  68. package/src/checks/automatic/listitem-parent-valid.js +118 -0
  69. package/src/checks/automatic/menuitem-name-present.js +201 -0
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
  73. package/src/checks/automatic/meter-name-present.js +160 -0
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
  75. package/src/checks/automatic/object-text-alternative-present.js +193 -0
  76. package/src/checks/automatic/option-name-present.js +157 -0
  77. package/src/checks/automatic/page-title-present.js +86 -0
  78. package/src/checks/automatic/progressbar-name-present.js +165 -0
  79. package/src/checks/automatic/role-img-alt-present.js +206 -0
  80. package/src/checks/automatic/searchbox-name-present.js +236 -0
  81. package/src/checks/automatic/server-side-image-map-absent.js +88 -0
  82. package/src/checks/automatic/slider-name-present.js +276 -0
  83. package/src/checks/automatic/spinbutton-name-present.js +236 -0
  84. package/src/checks/automatic/summary-name-present.js +153 -0
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
  86. package/src/checks/automatic/svg-text-alternative-present.js +298 -0
  87. package/src/checks/automatic/tab-name-present.js +200 -0
  88. package/src/checks/automatic/table-headers-attr-valid.js +122 -0
  89. package/src/checks/automatic/table-th-has-data-cells.js +117 -0
  90. package/src/checks/automatic/target-size-minimum.js +605 -0
  91. package/src/checks/automatic/td-has-header.js +151 -0
  92. package/src/checks/automatic/textbox-name-present.js +236 -0
  93. package/src/checks/automatic/tooltip-name-present.js +158 -0
  94. package/src/checks/automatic/treeitem-name-present.js +157 -0
  95. package/src/checks/automatic/valid-lang.js +100 -0
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
  97. package/src/checks/manual/accesskeys-manual.js +93 -0
  98. package/src/checks/manual/area-alt-decorative-manual.js +247 -0
  99. package/src/checks/manual/area-alt-quality-manual.js +204 -0
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
  101. package/src/checks/manual/aria-text-manual.js +109 -0
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
  103. package/src/checks/manual/css-hidden-focus.js +259 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
  105. package/src/checks/manual/empty-heading-manual.js +182 -0
  106. package/src/checks/manual/empty-table-header-manual.js +163 -0
  107. package/src/checks/manual/focus-order-semantics-manual.js +117 -0
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
  109. package/src/checks/manual/heading-order-manual.js +130 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
  111. package/src/checks/manual/image-redundant-alt-manual.js +118 -0
  112. package/src/checks/manual/img-alt-decorative-manual.js +148 -0
  113. package/src/checks/manual/img-alt-quality-manual.js +182 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
  116. package/src/checks/manual/label-title-only-manual.js +115 -0
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
  123. package/src/checks/manual/landmark-one-main-manual.js +151 -0
  124. package/src/checks/manual/landmark-unique-manual.js +252 -0
  125. package/src/checks/manual/link-name-quality-manual.js +143 -0
  126. package/src/checks/manual/media-transcript-present-manual.js +373 -0
  127. package/src/checks/manual/meta-viewport-large-manual.js +119 -0
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
  129. package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
  131. package/src/checks/manual/p-as-heading-manual.js +163 -0
  132. package/src/checks/manual/page-has-heading-one-manual.js +110 -0
  133. package/src/checks/manual/page-title-patterns-manual.js +262 -0
  134. package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
  135. package/src/checks/manual/region-manual.js +183 -0
  136. package/src/checks/manual/scope-attr-valid-manual.js +93 -0
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
  138. package/src/checks/manual/skip-link-manual.js +150 -0
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
  140. package/src/checks/manual/tabindex-manual.js +94 -0
  141. package/src/checks/manual/table-duplicate-name-manual.js +99 -0
  142. package/src/checks/manual/table-fake-caption-manual.js +122 -0
  143. package/src/checks/manual/video-caption-manual.js +118 -0
  144. package/src/checks/manual-review.js +95 -0
  145. package/src/checks/rules-and-tags.full.csv +19 -0
  146. package/src/checks/rules-and-tags.full.json +259 -0
  147. package/src/core/aria-helpers.js +906 -0
  148. package/src/core/contrast-helpers.js +1147 -0
  149. package/src/core/dom-helpers.js +4085 -0
  150. package/src/core/dom-runner.js +627 -0
  151. package/src/core/frame-messaging.js +210 -0
  152. package/src/core/frame-scan.js +178 -0
  153. package/src/core/rollup-composites.js +135 -0
  154. package/src/core/rule-meta.js +140 -0
  155. package/src/core.js +79055 -0
  156. package/src/coverage/wcag-facets.js +1079 -0
  157. package/src/coverage/wcag-version-map.js +84 -0
  158. package/src/i18n/en.js +919 -0
  159. package/src/i18n/fr.js +527 -0
  160. package/src/index.js +4 -0
  161. package/src/policy/contracts.js +18 -0
  162. package/src/policy/resolvePolicy.js +55 -0
  163. package/src/policy/schemas/engine-options.schema.json +103 -0
  164. package/src/policy/schemas/policy-contract.schema.json +40 -0
@@ -0,0 +1,200 @@
1
+ 'use strict';
2
+
3
+ const id = 'tab-name-present';
4
+
5
+ const meta = {
6
+ title: 'Tabs have an accessible name',
7
+ description: 'Checks that elements with role="tab" expose a non-empty accessible name.',
8
+ i18n: {
9
+ titleKey: 'tabNamePresent_title',
10
+ descriptionKey: 'tabNamePresent_description'
11
+ },
12
+ helpUrl: null,
13
+ tags: ['wcag2a','wcag412','navigation','atomic','automatic','name','tab'],
14
+ wcagSc: ['4.1.2'],
15
+ normativeMappings: [
16
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
17
+ ],
18
+ defaultSeverity: 'serious',
19
+ category: 'robust',
20
+ type: 'automatic',
21
+ defaultConfidence: 'high',
22
+ coverage: { facetsBySc: { '4.1.2': ['tab-name-present'] } }
23
+ };
24
+
25
+ function runInPage(ctx) {
26
+ const { document, root, helpers, rule } = ctx;
27
+ const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
28
+ ? helpers.getEligibilityInfo
29
+ : null;
30
+ const safeRoot = root || document;
31
+
32
+
33
+ function normalizeWs(s) {
34
+ return String(s || '').replace(/\s+/g, ' ').trim();
35
+ }
36
+
37
+ function getAttr(el, name) {
38
+ try {
39
+ if (!el || !el.getAttribute) return '';
40
+ return normalizeWs(el.getAttribute(name));
41
+ } catch { return ''; }
42
+ }
43
+
44
+ function hasAttr(el, name) {
45
+ try { return !!(el && el.hasAttribute && el.hasAttribute(name)); } catch { return false; }
46
+ }
47
+
48
+ function isExplicitProgrammatic(el) {
49
+ // Programmatic name mechanisms we treat as authoritative (presence-only):
50
+ // - aria-label (non-empty)
51
+ // - aria-labelledby (non-empty)
52
+ // - title (non-empty) [weak but allowed for presence]
53
+ const al = getAttr(el, 'aria-label');
54
+ if (al) return true;
55
+ const alb = getAttr(el, 'aria-labelledby');
56
+ if (alb) return true;
57
+ const t = getAttr(el, 'title');
58
+ if (t) return true;
59
+ return false;
60
+ }
61
+
62
+ function buildLabelForMap(doc) {
63
+ const map = new Map(); // id -> label element (first)
64
+ try {
65
+ const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
66
+ for (let i = 0; i < labels.length; i += 1) {
67
+ const lab = labels[i];
68
+ if (!lab || !lab.getAttribute) continue;
69
+ const f = normalizeWs(lab.getAttribute('for'));
70
+ if (!f) continue;
71
+ if (!map.has(f)) map.set(f, lab);
72
+ }
73
+ } catch {}
74
+ return map;
75
+ }
76
+
77
+ function getConservativeSubtreeText(document, container) {
78
+ // "Name from content" — recurses into descendants and uses each one's
79
+ // own accessible name (img alt, aria-label/aria-labelledby, title) when
80
+ // it has one, not just literal text nodes. See getContentNameInfo's
81
+ // header comment in src/core/dom-helpers.js for the full rationale
82
+ // (this replaced a text-node-only TreeWalker that missed the common
83
+ // "<a><img alt='...'></a>" logo-link / "<button><img alt='...'></button>"
84
+ // icon-button pattern).
85
+ if (helpers.getContentNameInfo) {
86
+ const info = helpers.getContentNameInfo(container, ctx);
87
+ return info && info.present ? info.value : '';
88
+ }
89
+ const t = (container && container.textContent) ? String(container.textContent) : '';
90
+ return t.replace(/\s+/g, ' ').trim();
91
+ }
92
+
93
+ function resolveAriaLabelledbyText(document, el, maxRefs) {
94
+ const raw = getAttr(el, 'aria-labelledby');
95
+ if (!raw) return '';
96
+ // Delegates to the shared getTextFromIdRefs helper instead of computing
97
+ // name-from-content of the referenced element — see dialog-name-
98
+ // present.js's identical fix for the full rationale (an <iframe>
99
+ // aria-labelledby target's only name source is its title attribute,
100
+ // which name-from-content alone can never see).
101
+ if (helpers.getTextFromIdRefs) {
102
+ try {
103
+ const r = helpers.getTextFromIdRefs(raw, ctx, { maxRefs: maxRefs || 8 });
104
+ return normalizeWs(r && r.text);
105
+ } catch {}
106
+ }
107
+ return '';
108
+ }
109
+
110
+ function getInputValueName(el) {
111
+ try {
112
+ const type = getAttr(el, 'type').toLowerCase();
113
+ if (type !== 'button' && type !== 'submit' && type !== 'reset') return '';
114
+ const v = getAttr(el, 'value');
115
+ return v;
116
+ } catch { return ''; }
117
+ }
118
+
119
+ function isEligibleAcc(helpers, el, ctx) {
120
+ const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
121
+ if (!fn) return true;
122
+ try {
123
+ const r = fn(el, ctx);
124
+ if (typeof r === 'boolean') return r;
125
+ return !!(r && r.eligible);
126
+ } catch {
127
+ return true;
128
+ }
129
+ }
130
+
131
+
132
+ const occurrences = [];
133
+ let applicableCount = 0;
134
+
135
+ const selector = '[role="tab"]';
136
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
137
+
138
+
139
+ function evaluate(el) {
140
+ const ariaLabel = getAttr(el, 'aria-label');
141
+ if (ariaLabel) return { ok: true, method: 'aria-label' };
142
+
143
+ const ariaLabelledby = resolveAriaLabelledbyText(document, el, 8);
144
+ if (ariaLabelledby) return { ok: true, method: 'aria-labelledby' };
145
+
146
+ const title = getAttr(el, 'title');
147
+ if (title) return { ok: true, method: 'title' };
148
+
149
+ const t = getConservativeSubtreeText(document, el);
150
+ if (t) return { ok: true, method: 'content' };
151
+
152
+ return { ok: false, method: 'none' };
153
+ }
154
+
155
+ for (const el of nodes) {
156
+ if (!el) continue;
157
+ if (!isEligibleAcc(helpers, el, ctx)) continue;
158
+
159
+ const role = getAttr(el, 'role').toLowerCase();
160
+ if (role !== 'tab') continue;
161
+
162
+ applicableCount += 1;
163
+
164
+ const res = evaluate(el);
165
+ if (res.ok) continue;
166
+
167
+ const eligInfo = getEligibilityInfo
168
+ ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
169
+ : null;
170
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
171
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
172
+
173
+ occurrences.push({
174
+ selector: stableSelector,
175
+ html,
176
+ summary: 'This tab has no accessible name.',
177
+ hint: 'Provide tab text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
178
+ i18n: {
179
+ summaryKey: 'tabNamePresent_summary_fail',
180
+ hintKey: 'tabNamePresent_hint_fail',
181
+ params: {}
182
+ },
183
+ data: {
184
+ details: { reasonCode: 'name_missing', controlType: 'tab', methodTried: res.method },
185
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
186
+ }
187
+ });
188
+ }
189
+
190
+
191
+ if (applicableCount === 0) {
192
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
193
+ }
194
+ if (occurrences.length) {
195
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
196
+ }
197
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
198
+ }
199
+
200
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check table-headers-attr-valid
5
+ * @atomic true
6
+ * @summary Table cell "headers" attribute must reference real <th> cells in the same table
7
+ * @standard WCAG 2.2
8
+ * @sc 1.3.1
9
+ * @applicability
10
+ * Applies to <td>/<th> elements that carry a non-empty headers attribute.
11
+ * @expectation
12
+ * Every id token in the headers attribute resolves to an element that:
13
+ * (a) exists, (b) is a <th> element, (c) is inside the same <table> as
14
+ * the referencing cell, and (d) is not the cell itself.
15
+ * @implementation-notes
16
+ * - One occurrence per offending cell (not per bad token), listing every
17
+ * invalid reference — matches a widely-used reference engine's
18
+ * td-headers-attr reporting granularity.
19
+ * - Not gated on isAccTreeEligible: this is a static markup property.
20
+ */
21
+
22
+ const id = 'table-headers-attr-valid';
23
+
24
+ const meta = {
25
+ title: 'Table cell "headers" attribute must reference valid header cells',
26
+ description: '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).',
27
+ i18n: {
28
+ titleKey: 'tableHeadersAttrValid_title',
29
+ descriptionKey: 'tableHeadersAttrValid_description'
30
+ },
31
+ helpUrl: null,
32
+ tags: ['wcag2a', 'wcag131', 'structure', 'atomic', 'automatic', 'table'],
33
+ wcagSc: ['1.3.1'],
34
+ normativeMappings: [
35
+ { standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' }
36
+ ],
37
+ defaultSeverity: 'serious',
38
+ category: 'perceivable',
39
+ type: 'automatic',
40
+ defaultConfidence: 'high',
41
+ coverage: { facetsBySc: { '1.3.1': ['table-headers-attr-valid'] } }
42
+ };
43
+
44
+ function runInPage(ctx) {
45
+ const { document, root, helpers, rule } = ctx;
46
+ const safeRoot = root || document;
47
+
48
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('td[headers], th[headers]', safeRoot) : helpers.queryAll('td[headers], th[headers]', safeRoot);
49
+
50
+ const occurrences = [];
51
+ let applicableCount = 0;
52
+
53
+ for (const el of nodes) {
54
+ if (!el || !el.getAttribute) continue;
55
+
56
+ const raw = String(el.getAttribute('headers') || '').trim();
57
+ if (!raw) continue;
58
+ const ids = raw.split(/\s+/).filter(Boolean);
59
+ if (!ids.length) continue;
60
+
61
+ applicableCount += 1;
62
+
63
+ const table = el.closest ? el.closest('table') : null;
64
+ const invalid = [];
65
+
66
+ for (const headerId of ids) {
67
+ let ref = null;
68
+ try {
69
+ ref = document.getElementById(headerId);
70
+ } catch {
71
+ ref = null;
72
+ }
73
+
74
+ if (!ref) {
75
+ invalid.push({ id: headerId, reason: 'missing' });
76
+ continue;
77
+ }
78
+ if (ref === el) {
79
+ invalid.push({ id: headerId, reason: 'self-reference' });
80
+ continue;
81
+ }
82
+ if (!ref.tagName || ref.tagName.toLowerCase() !== 'th') {
83
+ invalid.push({ id: headerId, reason: 'not-a-th' });
84
+ continue;
85
+ }
86
+ if (table && (!ref.closest || ref.closest('table') !== table)) {
87
+ invalid.push({ id: headerId, reason: 'different-table' });
88
+ }
89
+ }
90
+
91
+ if (!invalid.length) continue;
92
+
93
+ const tag = el.tagName.toLowerCase();
94
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
95
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
96
+
97
+ occurrences.push({
98
+ selector: stableSelector,
99
+ html,
100
+ summary: 'This cell’s headers attribute references one or more invalid header cells.',
101
+ hint: 'Update the headers attribute so every id refers to a <th> element within the same table.',
102
+ i18n: {
103
+ summaryKey: 'tableHeadersAttrValid_summary_fail',
104
+ hintKey: 'tableHeadersAttrValid_hint_fail',
105
+ params: { element: tag, invalidIds: invalid.map((i) => i.id).join(', ') }
106
+ },
107
+ data: {
108
+ details: { reasonCode: 'TABLE_HEADERS_ATTR_INVALID', element: tag, invalid }
109
+ }
110
+ });
111
+ }
112
+
113
+ if (applicableCount === 0) {
114
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
115
+ }
116
+ if (occurrences.length) {
117
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
118
+ }
119
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
120
+ }
121
+
122
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check table-th-has-data-cells
5
+ * @atomic true
6
+ * @summary <th> elements must describe at least one data cell
7
+ * @standard WCAG 2.2
8
+ * @sc 1.3.1
9
+ * @applicability
10
+ * Applies to <table> elements that contain at least one <th>.
11
+ * @expectation
12
+ * The table also contains at least one <td> somewhere in it.
13
+ * @implementation-notes
14
+ * - DELIBERATELY SCOPED (see aria-helpers.js file header for the same
15
+ * conservative-scope rationale used throughout this engine): this rule
16
+ * does NOT implement the full HTML5 header-association algorithm
17
+ * (resolving which specific data cells a given <th scope="row"|"col">
18
+ * describes, accounting for colspan/rowspan and default-scope
19
+ * inference). That algorithm is one of the more error-prone parts of
20
+ * the HTML spec to reimplement correctly, and a wrong positional match
21
+ * would risk a false `fail` — unacceptable for this engine's fail-
22
+ * integrity bar.
23
+ * - Instead, this rule only catches the single unambiguous case: a table
24
+ * that has <th> elements but ZERO <td> elements anywhere. In that case
25
+ * every <th> in the table trivially describes no data cell — no
26
+ * positional analysis is needed to know that. A table that has at
27
+ * least one <td> somewhere is not evaluated further by this rule, even
28
+ * if some particular <th> in it doesn't actually describe any cell
29
+ * (false negative, not a false positive — acceptable under this
30
+ * engine's philosophy).
31
+ * - Not gated on isAccTreeEligible: this is a static markup property.
32
+ */
33
+
34
+ const id = 'table-th-has-data-cells';
35
+
36
+ const meta = {
37
+ title: '<th> elements must describe at least one data cell',
38
+ description: 'Checks that a table containing <th> elements also contains at least one <td> data cell for those headers to describe.',
39
+ i18n: {
40
+ titleKey: 'tableThHasDataCells_title',
41
+ descriptionKey: 'tableThHasDataCells_description'
42
+ },
43
+ helpUrl: null,
44
+ tags: ['wcag2a', 'wcag131', 'structure', 'atomic', 'automatic', 'table'],
45
+ wcagSc: ['1.3.1'],
46
+ normativeMappings: [
47
+ { standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' }
48
+ ],
49
+ defaultSeverity: 'moderate',
50
+ category: 'perceivable',
51
+ type: 'automatic',
52
+ defaultConfidence: 'high',
53
+ coverage: { facetsBySc: { '1.3.1': ['table-th-has-data-cells'] } }
54
+ };
55
+
56
+ function runInPage(ctx) {
57
+ const { document, root, helpers, rule } = ctx;
58
+ const safeRoot = root || document;
59
+
60
+ const tables = helpers.queryAllSmart ? helpers.queryAllSmart('table', safeRoot) : helpers.queryAll('table', safeRoot);
61
+
62
+ const occurrences = [];
63
+ let applicableCount = 0;
64
+
65
+ for (const table of tables) {
66
+ if (!table || !table.querySelectorAll) continue;
67
+
68
+ let ths = [];
69
+ try {
70
+ ths = table.querySelectorAll('th');
71
+ } catch {
72
+ ths = [];
73
+ }
74
+ if (!ths.length) continue;
75
+
76
+ applicableCount += 1;
77
+
78
+ let hasDataCell = false;
79
+ try {
80
+ hasDataCell = table.querySelectorAll('td').length > 0;
81
+ } catch {
82
+ hasDataCell = false;
83
+ }
84
+ if (hasDataCell) continue;
85
+
86
+ for (const th of ths) {
87
+ if (!th) continue;
88
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(th) : 'html';
89
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(th) : (th.outerHTML || '');
90
+
91
+ occurrences.push({
92
+ selector: stableSelector,
93
+ html,
94
+ summary: 'This table has header cells but no data cells for them to describe.',
95
+ hint: 'Add data cells (<td>) to the table, or remove the header cells if the table has no data.',
96
+ i18n: {
97
+ summaryKey: 'tableThHasDataCells_summary_fail',
98
+ hintKey: 'tableThHasDataCells_hint_fail',
99
+ params: {}
100
+ },
101
+ data: {
102
+ details: { reasonCode: 'TABLE_TH_NO_DATA_CELLS' }
103
+ }
104
+ });
105
+ }
106
+ }
107
+
108
+ if (applicableCount === 0) {
109
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
110
+ }
111
+ if (occurrences.length) {
112
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
113
+ }
114
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
115
+ }
116
+
117
+ module.exports = { id, meta, runInPage };