@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,204 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check embed-text-alternative-quality
5
+ * @atomic true
6
+ * @summary Manual review: text alternative appropriateness (WCAG 1.1.1)
7
+ * @standard WCAG 2.2
8
+ * @sc 1.1.1
9
+ * @type manual
10
+ * @expectation
11
+ * Human review is required to confirm that the provided text alternative is accurate and appropriate.
12
+ */
13
+
14
+ const id = "embed-text-alternative-quality";
15
+
16
+ const meta = {
17
+ title: "<embed> text alternative must be appropriate (manual review)",
18
+ description: "Flags <embed> elements with a detected name for human review of appropriateness.",
19
+ i18n: {
20
+ titleKey: "embed_textAltQuality_title",
21
+ descriptionKey: "embed_textAltQuality_description"
22
+ },
23
+ helpUrl: null,
24
+ tags: ["wcag2a", "wcag111", "nontext", "embed", "manual", "atomic"],
25
+ wcagSc: ['1.1.1'],
26
+ normativeMappings: [
27
+ {standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A'}
28
+ ],
29
+ defaultSeverity: 'minor',
30
+ category: 'perceivable',
31
+ type: 'manual',
32
+ defaultConfidence: 'medium',
33
+ coverage: {
34
+ facetsBySc: {
35
+ '1.1.1': ['text-alternative-quality']
36
+ }
37
+ }
38
+ };
39
+
40
+ function runInPage(ctx) {
41
+ const {document, root, helpers, rule} = ctx;
42
+ const safeRoot = root || document;
43
+
44
+ const trim = (v) => (v == null ? '' : String(v)).trim();
45
+ const getTextFromIdRefs =
46
+ helpers && typeof helpers.getTextFromIdRefs === 'function' ? helpers.getTextFromIdRefs : null;
47
+
48
+ const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
49
+ const queryAll = helpers && typeof helpers.queryAll === 'function'
50
+ ? helpers.queryAll
51
+ : (sel) => {
52
+ try {
53
+ return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : [];
54
+ } catch {
55
+ return [];
56
+ }
57
+ };
58
+
59
+ const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
60
+ ? helpers.getEligibilityInfo
61
+ : null;
62
+
63
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
64
+ ? helpers.isAccTreeEligible
65
+ : null;
66
+
67
+ const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
68
+ ? helpers.getFocusableInfo
69
+ : null;
70
+
71
+ function isRolePresentationExcluded(el) {
72
+ const role = (() => {
73
+ try {
74
+ return String(el.getAttribute('role') || '').trim().toLowerCase();
75
+ } catch {
76
+ return '';
77
+ }
78
+ })();
79
+ if (role !== 'presentation' && role !== 'none') return false;
80
+
81
+ // Exclude only when NOT focusable (mirrors img-alt-present policy)
82
+ let focusable = false;
83
+ if (getFocusableInfo) {
84
+ const fi = (() => {
85
+ try {
86
+ return getFocusableInfo(el, ctx);
87
+ } catch {
88
+ return null;
89
+ }
90
+ })();
91
+ focusable = !!(fi && fi.focusable);
92
+ } else {
93
+ const tabindex = el.getAttribute('tabindex');
94
+ focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
95
+ }
96
+ return !focusable;
97
+ }
98
+
99
+
100
+ const els = (() => {
101
+ try {
102
+ return Array.from((queryAllSmart ? queryAllSmart('embed') : queryAll('embed')) || []);
103
+ } catch {
104
+ return queryAll('embed');
105
+ }
106
+ })();
107
+
108
+ if (!els.length) {
109
+ return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
110
+ }
111
+
112
+ const occurrences = [];
113
+ let applicableCount = 0;
114
+
115
+ for (const el of els) {
116
+ if (!el || !el.getAttribute) continue;
117
+
118
+ if (isAccTreeEligible) {
119
+ const elig = (() => {
120
+ try {
121
+ return isAccTreeEligible(el, ctx);
122
+ } catch {
123
+ return {eligible: true, reasons: []};
124
+ }
125
+ })();
126
+ if (elig && elig.eligible === false) continue;
127
+ }
128
+
129
+ if (isRolePresentationExcluded(el)) continue;
130
+
131
+ let ariaLabel = '';
132
+ let ariaLabelledBy = '';
133
+ let title = '';
134
+ try {
135
+ ariaLabel = trim(el.getAttribute('aria-label'));
136
+ ariaLabelledBy = trim(el.getAttribute('aria-labelledby'));
137
+ title = trim(el.getAttribute('title'));
138
+ } catch {
139
+ ariaLabel = '';
140
+ ariaLabelledBy = '';
141
+ title = '';
142
+ }
143
+
144
+ // Only resolve IDREF text if aria-labelledby is present and aria-label is not already sufficient
145
+ let labelledByText = '';
146
+ let hasLabelledByMechanism = false;
147
+
148
+ if (!ariaLabel && ariaLabelledBy) {
149
+ hasLabelledByMechanism = true;
150
+
151
+ if (getTextFromIdRefs) {
152
+ try {
153
+ const t = getTextFromIdRefs(ariaLabelledBy, ctx);
154
+ labelledByText = trim(t && t.text);
155
+ // If it resolves to empty, still treat as a mechanism present for manual review.
156
+ } catch {
157
+ labelledByText = '';
158
+ }
159
+ }
160
+ }
161
+
162
+ const hasNameMechanism = !!(ariaLabel || title || hasLabelledByMechanism);
163
+ if (!hasNameMechanism) continue;
164
+
165
+ const details = {
166
+ ariaLabel: ariaLabel || null,
167
+ ariaLabelledBy: ariaLabelledBy || null,
168
+ ariaLabelledByText: labelledByText || null,
169
+ title: title || null
170
+ };
171
+
172
+ applicableCount += 1;
173
+
174
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, {targetSet: 'acc'}) : null;
175
+
176
+ const baseOccurrence = {
177
+ summary: 'Review text alternative for <embed> for accuracy and appropriateness.',
178
+ hint: 'Confirm the ARIA name or title accurately identifies the embedded content in context.',
179
+ i18n: {
180
+ summaryKey: 'embed_textAltQuality_summary_cantTell',
181
+ hintKey: 'embed_textAltQuality_hint_cantTell',
182
+ params: {element: (el.tagName || '').toLowerCase()}
183
+ },
184
+ data: {
185
+ visibilityFilter: eligInfo || {targetSet: 'acc', accEligible: null, reasons: []},
186
+ details
187
+ }
188
+ };
189
+
190
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
191
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
192
+ } else {
193
+ occurrences.push({selector: '', html: '', ...baseOccurrence});
194
+ }
195
+ }
196
+
197
+ if (applicableCount === 0) {
198
+ return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
199
+ }
200
+
201
+ return {ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences};
202
+ }
203
+
204
+ module.exports = {id, meta, runInPage};
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check empty-heading
5
+ * @atomic true
6
+ * @summary Heading elements must not be empty
7
+ * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
+ * @applicability
9
+ * Applies to elements with a heading role: native <h1>-<h6>, or any
10
+ * element with explicit role="heading" (unless overridden by another
11
+ * explicit role).
12
+ * @expectation
13
+ * The heading has a non-empty accessible name: aria-label,
14
+ * aria-labelledby, visible text content not hidden from assistive
15
+ * technology, or (as a last resort) a title attribute. An empty
16
+ * heading is announced as "heading, level N" with nothing else, which
17
+ * is confusing when navigating by heading.
18
+ * @implementation-notes
19
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
20
+ * `type: 'manual'` rule; see landmark-banner-is-top-level's
21
+ * header comment for the shared rationale/precedent.
22
+ * - This is also the reconciliation point for the "heading-name-present"
23
+ * gap surfaced by the 2026-07-19 ACT-rules research pass (see
24
+ * ROADMAP.md "Tier 5 candidates"): that gap was already closed by this
25
+ * pre-existing rule under a different name, not a real gap. Fixed two
26
+ * real false-positive bugs found while confirming that (2026-07-19):
27
+ * `title` wasn't accepted as a naming fallback, and hidden/aria-hidden/
28
+ * display:none headings weren't excluded (not gated on
29
+ * `isAccTreeEligible`), so an empty heading no AT user could ever
30
+ * reach was still flagged.
31
+ * - 2026-07-21: the subtree-text walker's own hand-rolled descendant
32
+ * handling (aria-label/aria-labelledby only) never checked an `<img>`
33
+ * descendant's `alt` text — found via Party City's
34
+ * `<h1><a><div><img alt="..."></div></a></h1>` logo header, a false
35
+ * "empty heading" cantTell. Replaced with the shared, accname-aligned
36
+ * `helpers.getContentNameInfo` (see dom-helpers.js) — the same "name
37
+ * from content" implementation the 19 `-name-present` rules already use
38
+ * — instead of maintaining a third, narrower copy of this logic here.
39
+ */
40
+
41
+ const id = 'empty-heading';
42
+
43
+ const meta = {
44
+ title: 'Headings must not be empty',
45
+ description: 'Checks that heading elements (<h1>-<h6> or role="heading") have a non-empty accessible name.',
46
+ i18n: {
47
+ titleKey: 'emptyHeading_title',
48
+ descriptionKey: 'emptyHeading_description'
49
+ },
50
+ helpUrl: null,
51
+ tags: ['best-practice', 'headings', 'structure', 'atomic', 'manual'],
52
+ wcagSc: [],
53
+ normativeMappings: [],
54
+ defaultSeverity: 'minor',
55
+ category: 'operable',
56
+ type: 'manual',
57
+ defaultConfidence: 'medium',
58
+ coverage: {}
59
+ };
60
+
61
+ function runInPage(ctx) {
62
+ const { document, helpers, rule } = ctx;
63
+
64
+ function normalizeWs(s) {
65
+ return String(s || '').replace(/\s+/g, ' ').trim();
66
+ }
67
+
68
+ function getExplicitRoleToken(el) {
69
+ const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
70
+ if (!raw) return '';
71
+ return raw.split(/\s+/)[0].toLowerCase();
72
+ }
73
+
74
+ function isHeading(el) {
75
+ const explicit = getExplicitRoleToken(el);
76
+ if (explicit) return explicit === 'heading';
77
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
78
+ return /^h[1-6]$/.test(tag);
79
+ }
80
+
81
+ function getAccessibleNameText(el) {
82
+ const al = normalizeWs(el.getAttribute && el.getAttribute('aria-label'));
83
+ if (al) return al;
84
+ const alb = normalizeWs(el.getAttribute && el.getAttribute('aria-labelledby'));
85
+ if (alb) {
86
+ const parts = [];
87
+ for (const refId of alb.split(/\s+/).filter(Boolean)) {
88
+ try {
89
+ const ref = document.getElementById(refId);
90
+ if (ref) {
91
+ const t = normalizeWs(ref.textContent);
92
+ if (t) parts.push(t);
93
+ }
94
+ } catch {}
95
+ }
96
+ const joined = normalizeWs(parts.join(' '));
97
+ if (joined) return joined;
98
+ }
99
+ // Shared, accname-aligned "name from content" implementation (see
100
+ // dom-helpers.js's getContentNameInfo header comment) — resolves an
101
+ // <img> descendant's own alt text, an aria-label/aria-labelledby'd
102
+ // descendant's own name, etc., and already gates every descendant on
103
+ // full accessibility-tree eligibility (aria-hidden, display:none,
104
+ // visibility:hidden, [hidden], inert, ...) the same way this rule
105
+ // needs. Used instead of a fourth hand-rolled subtree walker here.
106
+ if (helpers && typeof helpers.getContentNameInfo === 'function') {
107
+ try {
108
+ const info = helpers.getContentNameInfo(el, ctx);
109
+ if (info && info.present && info.value) return info.value;
110
+ } catch {}
111
+ }
112
+ return normalizeWs(el.getAttribute && el.getAttribute('title'));
113
+ }
114
+
115
+ let nodes = [];
116
+ try {
117
+ nodes = document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role]');
118
+ } catch {
119
+ nodes = [];
120
+ }
121
+
122
+ const occurrences = [];
123
+ let applicableCount = 0;
124
+ const seen = new Set();
125
+
126
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
127
+
128
+ function isEligible(el) {
129
+ if (!isAccTreeEligible) return true;
130
+ try {
131
+ const r = isAccTreeEligible(el, ctx);
132
+ if (typeof r === 'boolean') return r;
133
+ return !!(r && r.eligible);
134
+ } catch {
135
+ return true;
136
+ }
137
+ }
138
+
139
+ for (const el of nodes) {
140
+ if (!el || seen.has(el)) continue;
141
+ seen.add(el);
142
+ if (!isHeading(el)) continue;
143
+ if (!isEligible(el)) continue;
144
+
145
+ applicableCount += 1;
146
+
147
+ const name = getAccessibleNameText(el);
148
+ if (name) continue;
149
+
150
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
151
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
152
+ const eligInfo = helpers.getEligibilityInfo
153
+ ? (() => { try { return helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
154
+ : null;
155
+
156
+ occurrences.push({
157
+ selector: stableSelector,
158
+ html,
159
+ summary: 'This heading has no accessible name.',
160
+ hint: 'Add text content (or aria-label/aria-labelledby) to this heading, or remove it if it is not needed.',
161
+ i18n: {
162
+ summaryKey: 'emptyHeading_summary_cantTell',
163
+ hintKey: 'emptyHeading_hint_cantTell',
164
+ params: {}
165
+ },
166
+ data: {
167
+ details: { reasonCode: 'HEADING_EMPTY' },
168
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
169
+ }
170
+ });
171
+ }
172
+
173
+ if (applicableCount === 0) {
174
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
175
+ }
176
+ if (occurrences.length) {
177
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
178
+ }
179
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
180
+ }
181
+
182
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check empty-table-header
5
+ * @atomic true
6
+ * @summary Table header cells must not be empty
7
+ * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
+ * @applicability
9
+ * Applies to <th> elements that don't carry a conflicting explicit role,
10
+ * plus any element (native <th> or not) with role="columnheader" or
11
+ * role="rowheader" — matches a widely-used reference engine's own
12
+ * `empty-table-header` selector exactly (`th:not([role]), [role="columnheader"], [role="rowheader"]`,
13
+ * verified directly against its rule descriptor): a <th> that explicitly
14
+ * restates role="columnheader"/"rowheader" is still covered via the second
15
+ * clause, but a <th role="presentation"> (no longer meaningfully a header)
16
+ * is correctly excluded, and an ARIA-role-only header (e.g. a <div
17
+ * role="columnheader"> in a role="grid"/role="table" widget) is caught too
18
+ * — found missing entirely via the cross-engine diff tool 2026-07-23.
19
+ * @expectation
20
+ * The header cell has visible text content. A <th> named only via
21
+ * aria-label/aria-labelledby (no visible text) is ALSO flagged, not
22
+ * treated as equivalent — real screen-reader/browser testing (found via
23
+ * a live-DOM cross-engine run 2026-07-21, verified against
24
+ * https://butterpep.com/table-header-naming.html and
25
+ * https://html5accessibility.com/stuff/2024/05/22/not-so-short-note-on-aria-label-usage-big-table-edition/)
26
+ * confirms aria-label support on <th> is genuinely inconsistent in
27
+ * practice: NVDA+Firefox and iOS VoiceOver+Safari ignore it entirely
28
+ * (only visible text is announced), JAWS+Chrome/IE11 also only announce
29
+ * visible text in the header cell itself. Visible text is the one
30
+ * mechanism confirmed to work across every tested combination. A widely-used
31
+ * reference engine's own equivalent check (`has-visible-text` only, no aria-label/
32
+ * aria-labelledby alternative — confirmed directly against its rule
33
+ * descriptor) reaches the same conclusion.
34
+ * @implementation-notes
35
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
36
+ * `type: 'manual'` rule; see landmark-banner-is-top-level's
37
+ * header comment for the shared rationale/precedent.
38
+ * - Two distinct reasonCodes: TABLE_HEADER_EMPTY (no accessible name at
39
+ * all) vs. TABLE_HEADER_NAME_NOT_VISIBLE_TEXT (has aria-label/
40
+ * aria-labelledby but no visible text) — the latter gets a different
41
+ * summary/hint explaining the AT-support gap, since the header isn't
42
+ * literally nameless, just unreliably named in practice.
43
+ */
44
+
45
+ const id = 'empty-table-header';
46
+
47
+ const meta = {
48
+ title: 'Table header cells must not be empty',
49
+ description: 'Checks that table header cells (<th>, or any element with role="columnheader"/"rowheader") have visible text content — a header named only via aria-label/aria-labelledby is also flagged, since real screen-reader/browser support for that is inconsistent.',
50
+ i18n: {
51
+ titleKey: 'emptyTableHeader_title',
52
+ descriptionKey: 'emptyTableHeader_description'
53
+ },
54
+ helpUrl: null,
55
+ tags: ['best-practice', 'tables', 'structure', 'atomic', 'manual'],
56
+ wcagSc: [],
57
+ normativeMappings: [],
58
+ defaultSeverity: 'minor',
59
+ category: 'perceivable',
60
+ type: 'manual',
61
+ defaultConfidence: 'medium',
62
+ coverage: {}
63
+ };
64
+
65
+ function runInPage(ctx) {
66
+ const { document, helpers, rule } = ctx;
67
+
68
+ function normalizeWs(s) {
69
+ return String(s || '').replace(/\s+/g, ' ').trim();
70
+ }
71
+
72
+ function getVisibleText(el) {
73
+ return normalizeWs(el.textContent);
74
+ }
75
+
76
+ function getAriaOnlyName(el) {
77
+ const al = normalizeWs(el.getAttribute && el.getAttribute('aria-label'));
78
+ if (al) return al;
79
+ const alb = normalizeWs(el.getAttribute && el.getAttribute('aria-labelledby'));
80
+ if (alb) {
81
+ const parts = [];
82
+ for (const refId of alb.split(/\s+/).filter(Boolean)) {
83
+ try {
84
+ const ref = document.getElementById(refId);
85
+ if (ref) {
86
+ const t = normalizeWs(ref.textContent);
87
+ if (t) parts.push(t);
88
+ }
89
+ } catch {}
90
+ }
91
+ const joined = normalizeWs(parts.join(' '));
92
+ if (joined) return joined;
93
+ }
94
+ return '';
95
+ }
96
+
97
+ let nodes = [];
98
+ try {
99
+ // Matches a widely-used reference engine's own empty-table-header selector exactly: a <th> with
100
+ // no conflicting explicit role, plus any element carrying an explicit
101
+ // columnheader/rowheader role (native or not).
102
+ nodes = document.querySelectorAll('th:not([role]), [role="columnheader"], [role="rowheader"]');
103
+ } catch {
104
+ nodes = [];
105
+ }
106
+
107
+ const occurrences = [];
108
+ let applicableCount = 0;
109
+
110
+ for (const el of nodes) {
111
+ if (!el) continue;
112
+ applicableCount += 1;
113
+
114
+ if (getVisibleText(el)) continue;
115
+
116
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
117
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
118
+
119
+ const ariaName = getAriaOnlyName(el);
120
+ if (ariaName) {
121
+ occurrences.push({
122
+ selector: stableSelector,
123
+ html,
124
+ summary: 'This table header cell has no visible text — its only accessible name comes from aria-label/aria-labelledby, which real screen-reader/browser combinations (e.g. NVDA+Firefox, iOS VoiceOver+Safari) are known to ignore on <th> elements.',
125
+ hint: 'Add visible text content to this header cell (in addition to, or instead of, aria-label/aria-labelledby) — visible text is the only naming mechanism confirmed to work across tested screen readers.',
126
+ i18n: {
127
+ summaryKey: 'emptyTableHeader_summary_cantTell_ariaOnly',
128
+ hintKey: 'emptyTableHeader_hint_cantTell_ariaOnly',
129
+ params: {}
130
+ },
131
+ data: {
132
+ details: { reasonCode: 'TABLE_HEADER_NAME_NOT_VISIBLE_TEXT', ariaName }
133
+ }
134
+ });
135
+ continue;
136
+ }
137
+
138
+ occurrences.push({
139
+ selector: stableSelector,
140
+ html,
141
+ summary: 'This table header cell has no accessible name.',
142
+ hint: 'Add text content (or aria-label/aria-labelledby) to this header cell, or remove it if it is not needed.',
143
+ i18n: {
144
+ summaryKey: 'emptyTableHeader_summary_cantTell',
145
+ hintKey: 'emptyTableHeader_hint_cantTell',
146
+ params: {}
147
+ },
148
+ data: {
149
+ details: { reasonCode: 'TABLE_HEADER_EMPTY' }
150
+ }
151
+ });
152
+ }
153
+
154
+ if (applicableCount === 0) {
155
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
156
+ }
157
+ if (occurrences.length) {
158
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
159
+ }
160
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
161
+ }
162
+
163
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check focus-order-semantics
5
+ * @atomic true
6
+ * @summary Elements added to the tab order should have interactive semantics
7
+ * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
+ * @applicability
9
+ * Elements with an explicit `tabindex` of `0` or greater (in the tab
10
+ * order) AND an explicit `role` attribute that is one of a curated set
11
+ * of clearly non-interactive, structural/document roles.
12
+ * @expectation
13
+ * An element deliberately placed in the tab order should communicate
14
+ * why it's focusable — a role like `heading`, `list`, `region`, or
15
+ * `presentation` gives assistive technology no interactive semantic to
16
+ * announce, which is confusing for keyboard users who land on it and
17
+ * get no indication of what activating it (if anything) would do.
18
+ * @implementation-notes
19
+ * - Not WCAG-normative by a widely-used reference engine's own classification — authored as an
20
+ * advisory, cantTell-capped `type: 'manual'` rule.
21
+ * - The non-interactive role list is deliberately curated and
22
+ * conservative (structural/document roles only) — legitimate custom
23
+ * widget patterns using `tabindex` with a genuinely interactive role
24
+ * (`option`, `tab`, `menuitem`, etc.) are never flagged. Elements with
25
+ * `tabindex` and NO role at all are also not flagged: native semantics
26
+ * or an intentionally generic custom-interactive pattern cannot be
27
+ * distinguished from markup alone with the same confidence.
28
+ */
29
+
30
+ const id = 'focus-order-semantics';
31
+
32
+ const meta = {
33
+ title: 'Elements added to the tab order should have interactive semantics',
34
+ description: 'Flags elements with tabindex >= 0 whose explicit role is a non-interactive structural/document role (e.g. heading, list, region, presentation), for manual review.',
35
+ i18n: {
36
+ titleKey: 'focusOrderSemantics_title',
37
+ descriptionKey: 'focusOrderSemantics_description'
38
+ },
39
+ helpUrl: null,
40
+ tags: ['best-practice', 'aria', 'structure', 'atomic', 'manual'],
41
+ wcagSc: [],
42
+ normativeMappings: [],
43
+ defaultSeverity: 'minor',
44
+ category: 'operable',
45
+ type: 'manual',
46
+ defaultConfidence: 'medium',
47
+ coverage: {}
48
+ };
49
+
50
+ function runInPage(ctx) {
51
+ const { document, root, helpers, rule } = ctx;
52
+ const safeRoot = root || document;
53
+
54
+ const NON_INTERACTIVE_ROLES = new Set([
55
+ 'generic', 'group', 'text', 'presentation', 'none', 'img', 'heading',
56
+ 'region', 'article', 'banner', 'contentinfo', 'main', 'navigation',
57
+ 'complementary', 'document', 'note', 'list', 'listitem', 'table',
58
+ 'row', 'cell', 'columnheader', 'rowheader', 'figure', 'term',
59
+ 'definition', 'paragraph', 'caption', 'status', 'alert', 'log', 'tooltip'
60
+ ]);
61
+
62
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[tabindex][role]', safeRoot) : helpers.queryAll('[tabindex][role]', safeRoot);
63
+
64
+ const occurrences = [];
65
+ let applicableCount = 0;
66
+
67
+ for (const el of nodes) {
68
+ if (!el || !el.getAttribute) continue;
69
+
70
+ const tabindexAttr = el.getAttribute('tabindex');
71
+ const tabindex = Number.parseInt(tabindexAttr, 10);
72
+ if (!Number.isFinite(tabindex) || tabindex < 0) continue;
73
+
74
+ const role = (el.getAttribute('role') || '').trim().toLowerCase();
75
+ if (!role) continue;
76
+
77
+ applicableCount += 1;
78
+
79
+ if (!NON_INTERACTIVE_ROLES.has(role)) continue;
80
+
81
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
82
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
83
+
84
+ const baseOccurrence = {
85
+ selector: stableSelector,
86
+ html,
87
+ summary: `This element is in the tab order (tabindex="${tabindex}") but has a non-interactive role ("${role}").`,
88
+ hint: 'Remove tabindex if this element is not meant to be interactive, or use an interactive role that matches its actual behavior.',
89
+ i18n: {
90
+ summaryKey: 'focusOrderSemantics_summary_cantTell',
91
+ hintKey: 'focusOrderSemantics_hint_cantTell',
92
+ params: { tabindex: String(tabindex), role }
93
+ },
94
+ data: {
95
+ details: { reasonCode: 'TABBABLE_WITH_NON_INTERACTIVE_ROLE', tabindex, role }
96
+ }
97
+ };
98
+
99
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
100
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
101
+ } else {
102
+ occurrences.push(baseOccurrence);
103
+ }
104
+ }
105
+
106
+ if (applicableCount === 0) {
107
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
108
+ }
109
+
110
+ if (occurrences.length) {
111
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
112
+ }
113
+
114
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
115
+ }
116
+
117
+ module.exports = { id, meta, runInPage };