@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,236 @@
1
+ 'use strict';
2
+
3
+ const id = 'listbox-name-present';
4
+
5
+ const meta = {
6
+ title: 'Accessible name is present',
7
+ description: 'Checks that elements expose a non-empty accessible name.',
8
+ i18n: {
9
+ titleKey: 'listboxNamePresent_title',
10
+ descriptionKey: 'listboxNamePresent_description'
11
+ },
12
+ helpUrl: null,
13
+ tags: ["wcag2a", "wcag412", "forms", "atomic", "automatic", "name", "listbox"],
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': ['listbox-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 getConservativeSubtreeText(document, container) {
45
+ // "Name from content" — recurses into descendants and uses each one's
46
+ // own accessible name (img alt, aria-label/aria-labelledby, title) when
47
+ // it has one, not just literal text nodes. See getContentNameInfo's
48
+ // header comment in src/core/dom-helpers.js for the full rationale
49
+ // (this replaced a text-node-only TreeWalker that missed the common
50
+ // "<a><img alt='...'></a>" logo-link / "<button><img alt='...'></button>"
51
+ // icon-button pattern).
52
+ if (helpers.getContentNameInfo) {
53
+ const info = helpers.getContentNameInfo(container, ctx);
54
+ return info && info.present ? info.value : '';
55
+ }
56
+ const t = (container && container.textContent) ? String(container.textContent) : '';
57
+ return t.replace(/\s+/g, ' ').trim();
58
+ }
59
+
60
+ // A <label> contributes a name via its own aria-label/aria-labelledby
61
+ // (checked first, same ARIA-over-content precedence any element's
62
+ // accessible name gives — e.g. <label aria-label="Search"><svg
63
+ // aria-hidden="true">...</svg></label> names its control "Search" even
64
+ // though the label's only child content is aria-hidden) or, failing
65
+ // that, its rendered content (getConservativeSubtreeText).
66
+ function getLabelText(lab) {
67
+ if (helpers.getAriaNameInfo) {
68
+ try {
69
+ const aria = helpers.getAriaNameInfo(lab, ctx);
70
+ if (aria && aria.present && aria.value) return normalizeWs(aria.value);
71
+ } catch {}
72
+ }
73
+ const content = getConservativeSubtreeText(document, lab);
74
+ if (content) return content;
75
+ // Final fallback per the general accname text-alternative algorithm,
76
+ // which applies to any element being asked for its name regardless of
77
+ // why (own aria-label, an aria-labelledby reference, or — here — native
78
+ // <label for> association): title, when nothing else yields a name.
79
+ // Purely additive (only fills in a name where there was none before),
80
+ // so it carries no false-positive risk — see dialog-name-present.js's
81
+ // identical <iframe>-title-fallback fix for the concrete real-world
82
+ // trigger this same accname step covers elsewhere.
83
+ return getAttr(lab, 'title');
84
+ }
85
+
86
+ function resolveAriaLabelledbyText(document, el, maxRefs) {
87
+ const raw = getAttr(el, 'aria-labelledby');
88
+ if (!raw) return '';
89
+ // Delegates to the shared getTextFromIdRefs helper instead of computing
90
+ // name-from-content of the referenced element — see dialog-name-
91
+ // present.js's identical fix for the full rationale (an <iframe>
92
+ // aria-labelledby target's only name source is its title attribute,
93
+ // which name-from-content alone can never see).
94
+ if (helpers.getTextFromIdRefs) {
95
+ try {
96
+ const r = helpers.getTextFromIdRefs(raw, ctx, { maxRefs: maxRefs || 8 });
97
+ return normalizeWs(r && r.text);
98
+ } catch {}
99
+ }
100
+ return '';
101
+ }
102
+
103
+ function isEligibleAcc(helpers, el, ctx) {
104
+ const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
105
+ if (!fn) return true;
106
+ try {
107
+ const r = fn(el, ctx);
108
+ if (typeof r === 'boolean') return r;
109
+ return !!(r && r.eligible);
110
+ } catch {
111
+ return true;
112
+ }
113
+ }
114
+
115
+ function buildLabelForMap(doc) {
116
+ const map = new Map(); // id -> label element (first)
117
+ try {
118
+ const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
119
+ for (let i = 0; i < labels.length; i += 1) {
120
+ const lab = labels[i];
121
+ if (!lab || !lab.getAttribute) continue;
122
+ const f = normalizeWs(lab.getAttribute('for'));
123
+ if (!f) continue;
124
+ if (!map.has(f)) map.set(f, lab);
125
+ }
126
+ } catch {}
127
+ return map;
128
+ }
129
+
130
+
131
+ const occurrences = [];
132
+ let applicableCount = 0;
133
+
134
+ const selector = "[role=\"listbox\"]";
135
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
136
+
137
+ // Precompute label[for] map for listbox elements that are labelable
138
+ // native form controls (e.g. <select multiple role="listbox">).
139
+ const labelForMap = buildLabelForMap(document);
140
+
141
+ function getNativeLabelText(el) {
142
+ try {
143
+ if ('labels' in el && el.labels && el.labels.length) {
144
+ const parts = [];
145
+ const max = Math.min(4, el.labels.length);
146
+ for (let i = 0; i < max; i += 1) {
147
+ const lab = el.labels[i];
148
+ const t = lab ? getLabelText(lab) : '';
149
+ if (t) parts.push(t);
150
+ }
151
+ const joined = normalizeWs(parts.join(' '));
152
+ if (joined) return joined;
153
+ }
154
+ } catch {}
155
+ try {
156
+ if (el.closest) {
157
+ const wrap = el.closest('label');
158
+ if (wrap) {
159
+ const t = getLabelText(wrap);
160
+ if (t) return t;
161
+ }
162
+ }
163
+ } catch {}
164
+ try {
165
+ const idAttr = getAttr(el, 'id');
166
+ if (idAttr && labelForMap.has(idAttr)) {
167
+ const lab = labelForMap.get(idAttr);
168
+ const t = lab ? getLabelText(lab) : '';
169
+ if (t) return t;
170
+ }
171
+ } catch {}
172
+ return '';
173
+ }
174
+
175
+ function hasName(el) {
176
+ const ariaLabel = getAttr(el, 'aria-label');
177
+ if (ariaLabel) return { ok: true, method: 'aria-label' };
178
+
179
+ const labelled = resolveAriaLabelledbyText(document, el, 8);
180
+ if (labelled) return { ok: true, method: 'aria-labelledby' };
181
+
182
+ const title = getAttr(el, 'title');
183
+ if (title) return { ok: true, method: 'title' };
184
+
185
+ // Native <label> association (e.g. <select multiple role="listbox">).
186
+ const lab = getNativeLabelText(el);
187
+ if (lab) return { ok: true, method: 'label' };
188
+
189
+ // role="listbox" is name-from-author-only per WAI-ARIA: it must NOT
190
+ // fall back to subtree content.
191
+ return { ok: false, method: 'none' };
192
+ }
193
+
194
+ for (const el of nodes) {
195
+ if (!el) continue;
196
+ if (!isEligibleAcc(helpers, el, ctx)) continue;
197
+
198
+ applicableCount += 1;
199
+
200
+ const res = hasName(el);
201
+ if (res.ok) continue;
202
+
203
+ const eligInfo = getEligibilityInfo
204
+ ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
205
+ : null;
206
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
207
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
208
+
209
+ occurrences.push({
210
+ selector: stableSelector,
211
+ html,
212
+ summary: 'This element has no accessible name.',
213
+ hint: 'Provide aria-label or aria-labelledby (preferred), or provide visible text that is not hidden from assistive technologies.',
214
+ i18n: {
215
+ summaryKey: 'listboxNamePresent_summary_fail',
216
+ hintKey: 'listboxNamePresent_hint_fail',
217
+ params: { controlType: 'listbox' }
218
+ },
219
+ data: {
220
+ details: { reasonCode: 'name_missing', controlType: 'listbox', methodTried: res.method },
221
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
222
+ }
223
+ });
224
+ }
225
+
226
+
227
+ if (applicableCount === 0) {
228
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
229
+ }
230
+ if (occurrences.length) {
231
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
232
+ }
233
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
234
+ }
235
+
236
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check listitem-parent-valid
5
+ * @atomic true
6
+ * @summary <li> elements must be contained by a list container
7
+ * @standard WCAG 2.2
8
+ * @sc 1.3.1
9
+ * @applicability
10
+ * Applies to <li> elements that have a parent element.
11
+ * @expectation
12
+ * The parent is <ul>/<ol> with no role override, or an element with an
13
+ * explicit role of "list", "presentation", or "none". An <li> used
14
+ * outside a real list container (e.g. as a generic flex/grid item under
15
+ * a <div>) is not exposed as a list item to assistive technologies.
16
+ * @implementation-notes
17
+ * - Distinct, atomic decision from list-children-valid (the
18
+ * inverse relationship: does a given list container have valid
19
+ * children).
20
+ * - Role check is scoped to the parent's first explicit role token only
21
+ * (matching this engine's existing role-reading convention elsewhere,
22
+ * e.g. aria-helpers.js getExplicitRole).
23
+ * - An explicit role on the parent WINS over its tag name, in either
24
+ * direction: a <ul role="menu"> no longer exposes role "list" (its own
25
+ * native role is fully replaced by the explicit one — the same "any
26
+ * explicit role overrides the element's native role" ARIA principle
27
+ * applied elsewhere in this engine), so an <li> inside it is invalid
28
+ * despite the <ul> tag — found on a real site, Nike's desktop nav
29
+ * dropdown (`<ul class="desktop-category" role="menu"><li>...`).
30
+ * Conversely role="presentation"/"none" on the parent is still a valid
31
+ * (list-semantics-suppressing) parent, matching a widely-used reference
32
+ * engine's own `listitem` check (`['presentation', 'none', 'list'].includes(parentRole)`,
33
+ * confirmed by reading its source directly).
34
+ */
35
+
36
+ const id = 'listitem-parent-valid';
37
+
38
+ const meta = {
39
+ title: 'List items must be inside a list container',
40
+ description: 'Checks that <li> elements are contained by <ul>, <ol>, or an element with role="list".',
41
+ i18n: {
42
+ titleKey: 'listitemParentValid_title',
43
+ descriptionKey: 'listitemParentValid_description'
44
+ },
45
+ helpUrl: null,
46
+ tags: ['wcag2a', 'wcag131', 'structure', 'atomic', 'automatic', 'list'],
47
+ wcagSc: ['1.3.1'],
48
+ normativeMappings: [
49
+ { standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' }
50
+ ],
51
+ defaultSeverity: 'serious',
52
+ category: 'perceivable',
53
+ type: 'automatic',
54
+ defaultConfidence: 'high',
55
+ coverage: { facetsBySc: { '1.3.1': ['listitem-parent-valid'] } }
56
+ };
57
+
58
+ function runInPage(ctx) {
59
+ const { document, root, helpers, rule } = ctx;
60
+ const safeRoot = root || document;
61
+
62
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('li', safeRoot) : helpers.queryAll('li', safeRoot);
63
+
64
+ const occurrences = [];
65
+ let applicableCount = 0;
66
+
67
+ for (const el of nodes) {
68
+ if (!el) continue;
69
+ const parent = el.parentElement;
70
+ if (!parent) continue;
71
+
72
+ applicableCount += 1;
73
+
74
+ const parentTag = parent.tagName ? parent.tagName.toLowerCase() : '';
75
+
76
+ const roleAttr = parent.getAttribute ? String(parent.getAttribute('role') || '').trim() : '';
77
+ const explicitRole = roleAttr ? (roleAttr.split(/\s+/)[0] || '').toLowerCase() : '';
78
+
79
+ let valid;
80
+ if (explicitRole) {
81
+ // An explicit role always wins over the tag's native role, in either
82
+ // direction — see the header comment's Nike example.
83
+ valid = explicitRole === 'list' || explicitRole === 'presentation' || explicitRole === 'none';
84
+ } else {
85
+ valid = parentTag === 'ul' || parentTag === 'ol';
86
+ }
87
+
88
+ if (valid) continue;
89
+
90
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
91
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
92
+
93
+ occurrences.push({
94
+ selector: stableSelector,
95
+ html,
96
+ summary: 'This list item is not contained by a list container.',
97
+ hint: 'Place this <li> inside a <ul>/<ol>, or give its parent role="list".',
98
+ i18n: {
99
+ summaryKey: 'listitemParentValid_summary_fail',
100
+ hintKey: 'listitemParentValid_hint_fail',
101
+ params: { parentElement: parentTag }
102
+ },
103
+ data: {
104
+ details: { reasonCode: 'LISTITEM_INVALID_PARENT', parentElement: parentTag }
105
+ }
106
+ });
107
+ }
108
+
109
+ if (applicableCount === 0) {
110
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
111
+ }
112
+ if (occurrences.length) {
113
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
114
+ }
115
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
116
+ }
117
+
118
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,201 @@
1
+ 'use strict';
2
+
3
+ const id = 'menuitem-name-present';
4
+
5
+ const meta = {
6
+ title: 'Menu items have an accessible name',
7
+ description: 'Checks that menu items (role="menuitem*", including checkbox/radio variants) expose a non-empty accessible name.',
8
+ i18n: {
9
+ titleKey: 'menuitemNamePresent_title',
10
+ descriptionKey: 'menuitemNamePresent_description'
11
+ },
12
+ helpUrl: null,
13
+ tags: ['wcag2a','wcag412','navigation','atomic','automatic','name','menuitem'],
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': ['menuitem-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="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"]';
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
+ // Menuitem name is commonly from content.
150
+ const t = getConservativeSubtreeText(document, el);
151
+ if (t) return { ok: true, method: 'content' };
152
+
153
+ return { ok: false, method: 'none' };
154
+ }
155
+
156
+ for (const el of nodes) {
157
+ if (!el) continue;
158
+ if (!isEligibleAcc(helpers, el, ctx)) continue;
159
+
160
+ const role = getAttr(el, 'role').toLowerCase();
161
+ if (role !== 'menuitem' && role !== 'menuitemcheckbox' && role !== 'menuitemradio') continue;
162
+
163
+ applicableCount += 1;
164
+
165
+ const res = evaluate(el);
166
+ if (res.ok) continue;
167
+
168
+ const eligInfo = getEligibilityInfo
169
+ ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
170
+ : null;
171
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
172
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
173
+
174
+ occurrences.push({
175
+ selector: stableSelector,
176
+ html,
177
+ summary: 'This menu item has no accessible name.',
178
+ hint: 'Provide visible text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
179
+ i18n: {
180
+ summaryKey: 'menuitemNamePresent_summary_fail',
181
+ hintKey: 'menuitemNamePresent_hint_fail',
182
+ params: { role }
183
+ },
184
+ data: {
185
+ details: { reasonCode: 'name_missing', controlType: role, methodTried: res.method },
186
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
187
+ }
188
+ });
189
+ }
190
+
191
+
192
+ if (applicableCount === 0) {
193
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
194
+ }
195
+ if (occurrences.length) {
196
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
197
+ }
198
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
199
+ }
200
+
201
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check meta-refresh-no-exceptions
5
+ * @atomic true
6
+ * @summary At the AAA level, a meta refresh must not be used at all
7
+ * @standard WCAG 2.2
8
+ * @sc 2.2.4, 3.2.5
9
+ * @applicability
10
+ * Applies to any <meta http-equiv="refresh"> element with a non-empty
11
+ * content attribute.
12
+ * @expectation
13
+ * No meta refresh is present, regardless of delay. This is the
14
+ * stricter AAA-level counterpart of meta-refresh-timing-absent
15
+ * (the A-level rule, which only forbids a positive delay and allows
16
+ * delay="0" as an immediate redirect). At AAA, WCAG 2.2.4
17
+ * (Interruptions) and 3.2.5 (Change on Request) require that automatic
18
+ * context changes — including an immediate meta-refresh redirect —
19
+ * happen only at the user's request, with no exception for a zero
20
+ * delay.
21
+ * @implementation-notes
22
+ * - Distinct, atomic decision from meta-refresh-timing-absent:
23
+ * that rule's delay="0" pass case is this rule's fail case.
24
+ * - A <meta> nested inside <noscript> is excluded: it only ever takes
25
+ * effect when scripting is disabled, which is never the case for any
26
+ * context capable of running accessibility tooling in the first place
27
+ * (confirmed 2026-07-21 via a real-world false positive: Slack's
28
+ * homepage has a <noscript><meta http-equiv="refresh" content="0;
29
+ * URL=/?nojsmode=1"></noscript> JS-disabled fallback — a widely-used
30
+ * reference engine's own same-named check correctly doesn't flag it either).
31
+ */
32
+
33
+ const id = 'meta-refresh-no-exceptions';
34
+
35
+ const meta = {
36
+ title: 'Page must not use a meta refresh at all (AAA)',
37
+ description: 'Checks that <meta http-equiv="refresh"> is not present at all, regardless of delay — the stricter AAA-level counterpart of the A-level positive-delay-only check.',
38
+ i18n: {
39
+ titleKey: 'metaRefreshNoExceptions_title',
40
+ descriptionKey: 'metaRefreshNoExceptions_description'
41
+ },
42
+ helpUrl: null,
43
+ tags: ['wcag2aaa', 'wcag224', 'wcag325', 'structure', 'atomic', 'automatic'],
44
+ wcagSc: ['2.2.4', '3.2.5'],
45
+ normativeMappings: [
46
+ { standard: 'WCAG', version: '2.2', requirement: '2.2.4', title: 'Interruptions', conformanceLevel: 'AAA' },
47
+ { standard: 'WCAG', version: '2.2', requirement: '3.2.5', title: 'Change on Request', conformanceLevel: 'AAA' }
48
+ ],
49
+ defaultSeverity: 'moderate',
50
+ category: 'operable',
51
+ type: 'automatic',
52
+ defaultConfidence: 'high',
53
+ coverage: {
54
+ facetsBySc: {
55
+ '2.2.4': ['meta-refresh-no-exceptions'],
56
+ '3.2.5': ['meta-refresh-no-exceptions']
57
+ }
58
+ }
59
+ };
60
+
61
+ function runInPage(ctx) {
62
+ const { document, helpers, rule } = ctx;
63
+
64
+ const nodes = document.querySelectorAll ? document.querySelectorAll('meta[http-equiv="refresh" i]') : [];
65
+
66
+ const occurrences = [];
67
+ let applicableCount = 0;
68
+
69
+ for (const el of nodes) {
70
+ if (!el || !el.getAttribute) continue;
71
+ if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled
72
+ const raw = String(el.getAttribute('content') || '').trim();
73
+ if (!raw) continue;
74
+
75
+ applicableCount += 1;
76
+
77
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
78
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
79
+
80
+ occurrences.push({
81
+ selector: stableSelector,
82
+ html,
83
+ summary: 'This page uses a meta refresh, which is an automatic context change not initiated by the user.',
84
+ hint: 'Remove the meta refresh; trigger the redirect/refresh only in response to a user action instead.',
85
+ i18n: {
86
+ summaryKey: 'metaRefreshNoExceptions_summary_fail',
87
+ hintKey: 'metaRefreshNoExceptions_hint_fail',
88
+ params: {}
89
+ },
90
+ data: {
91
+ details: { reasonCode: 'META_REFRESH_PRESENT' }
92
+ }
93
+ });
94
+ }
95
+
96
+ if (applicableCount === 0) {
97
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
98
+ }
99
+ if (occurrences.length) {
100
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
101
+ }
102
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
103
+ }
104
+
105
+ module.exports = { id, meta, runInPage };