@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,276 @@
1
+ 'use strict';
2
+
3
+ const id = 'slider-name-present';
4
+
5
+ const meta = {
6
+ title: 'Sliders have an accessible name',
7
+ description: 'Checks that sliders (input[type="range"] and role="slider") expose a non-empty accessible name.',
8
+ i18n: {
9
+ titleKey: 'sliderNamePresent_title',
10
+ descriptionKey: 'sliderNamePresent_description'
11
+ },
12
+ helpUrl: null,
13
+ tags: ['wcag2a','wcag412','forms','atomic','automatic','name','slider'],
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': ['slider-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
+ // A <label> contributes a name via its own aria-label/aria-labelledby
94
+ // (checked first, same ARIA-over-content precedence any element's
95
+ // accessible name gives — e.g. <label aria-label="Search"><svg
96
+ // aria-hidden="true">...</svg></label> names its control "Search" even
97
+ // though the label's only child content is aria-hidden) or, failing
98
+ // that, its rendered content (getConservativeSubtreeText).
99
+ function getLabelText(lab) {
100
+ if (helpers.getAriaNameInfo) {
101
+ try {
102
+ const aria = helpers.getAriaNameInfo(lab, ctx);
103
+ if (aria && aria.present && aria.value) return normalizeWs(aria.value);
104
+ } catch {}
105
+ }
106
+ const content = getConservativeSubtreeText(document, lab);
107
+ if (content) return content;
108
+ // Final fallback per the general accname text-alternative algorithm,
109
+ // which applies to any element being asked for its name regardless of
110
+ // why (own aria-label, an aria-labelledby reference, or — here — native
111
+ // <label for> association): title, when nothing else yields a name.
112
+ // Purely additive (only fills in a name where there was none before),
113
+ // so it carries no false-positive risk — see dialog-name-present.js's
114
+ // identical <iframe>-title-fallback fix for the concrete real-world
115
+ // trigger this same accname step covers elsewhere.
116
+ return getAttr(lab, 'title');
117
+ }
118
+
119
+ function resolveAriaLabelledbyText(document, el, maxRefs) {
120
+ const raw = getAttr(el, 'aria-labelledby');
121
+ if (!raw) return '';
122
+ // Delegates to the shared getTextFromIdRefs helper instead of computing
123
+ // name-from-content of the referenced element — see dialog-name-
124
+ // present.js's identical fix for the full rationale (an <iframe>
125
+ // aria-labelledby target's only name source is its title attribute,
126
+ // which name-from-content alone can never see).
127
+ if (helpers.getTextFromIdRefs) {
128
+ try {
129
+ const r = helpers.getTextFromIdRefs(raw, ctx, { maxRefs: maxRefs || 8 });
130
+ return normalizeWs(r && r.text);
131
+ } catch {}
132
+ }
133
+ return '';
134
+ }
135
+
136
+ function getInputValueName(el) {
137
+ try {
138
+ const type = getAttr(el, 'type').toLowerCase();
139
+ if (type !== 'button' && type !== 'submit' && type !== 'reset') return '';
140
+ const v = getAttr(el, 'value');
141
+ return v;
142
+ } catch { return ''; }
143
+ }
144
+
145
+ function isEligibleAcc(helpers, el, ctx) {
146
+ const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
147
+ if (!fn) return true;
148
+ try {
149
+ const r = fn(el, ctx);
150
+ if (typeof r === 'boolean') return r;
151
+ return !!(r && r.eligible);
152
+ } catch {
153
+ return true;
154
+ }
155
+ }
156
+
157
+
158
+ const occurrences = [];
159
+ let applicableCount = 0;
160
+
161
+ const selector = 'input[type="range"], [role="slider"]';
162
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
163
+
164
+
165
+ // Precompute label[for] map for native range inputs.
166
+ const labelForMap = buildLabelForMap(document);
167
+
168
+ function getNativeLabelText(el) {
169
+ try {
170
+ if ('labels' in el && el.labels && el.labels.length) {
171
+ const parts = [];
172
+ const max = Math.min(4, el.labels.length);
173
+ for (let i = 0; i < max; i += 1) {
174
+ const lab = el.labels[i];
175
+ const t = lab ? getLabelText(lab) : '';
176
+ if (t) parts.push(t);
177
+ }
178
+ const joined = normalizeWs(parts.join(' '));
179
+ if (joined) return joined;
180
+ }
181
+ } catch {}
182
+ try {
183
+ if (el.closest) {
184
+ const wrap = el.closest('label');
185
+ if (wrap) {
186
+ const t = getLabelText(wrap);
187
+ if (t) return t;
188
+ }
189
+ }
190
+ } catch {}
191
+ try {
192
+ const idAttr = getAttr(el, 'id');
193
+ if (idAttr && labelForMap.has(idAttr)) {
194
+ const lab = labelForMap.get(idAttr);
195
+ const t = lab ? getLabelText(lab) : '';
196
+ if (t) return t;
197
+ }
198
+ } catch {}
199
+ return '';
200
+ }
201
+
202
+ function evaluate(el, kind) {
203
+ const ariaLabel = getAttr(el, 'aria-label');
204
+ if (ariaLabel) return { ok: true, method: 'aria-label' };
205
+
206
+ const ariaLabelledby = resolveAriaLabelledbyText(document, el, 8);
207
+ if (ariaLabelledby) return { ok: true, method: 'aria-labelledby' };
208
+
209
+ const title = getAttr(el, 'title');
210
+ if (title) return { ok: true, method: 'title' };
211
+
212
+ if (kind === 'native-slider') {
213
+ const lab = getNativeLabelText(el);
214
+ if (lab) return { ok: true, method: 'label' };
215
+ return { ok: false, method: 'none' };
216
+ }
217
+
218
+ // role="slider" is name-from-author-only per WAI-ARIA (unlike e.g.
219
+ // role="option"/"tab"), so it must NOT fall back to subtree content —
220
+ // visible text near/inside a custom slider widget is not reliably
221
+ // exposed as its accessible name by assistive technologies.
222
+ return { ok: false, method: 'none' };
223
+ }
224
+
225
+ for (const el of nodes) {
226
+ if (!el) continue;
227
+ if (!isEligibleAcc(helpers, el, ctx)) continue;
228
+
229
+ const tag = (el.tagName || '').toLowerCase();
230
+ const type = getAttr(el, 'type').toLowerCase();
231
+ const role = getAttr(el, 'role').toLowerCase();
232
+
233
+ let kind = '';
234
+ if (tag === 'input' && type === 'range') kind = 'native-slider';
235
+ else if (role === 'slider') kind = 'aria-slider';
236
+ else continue;
237
+
238
+ applicableCount += 1;
239
+
240
+ const res = evaluate(el, kind);
241
+ if (res.ok) continue;
242
+
243
+ const eligInfo = getEligibilityInfo
244
+ ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
245
+ : null;
246
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
247
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
248
+
249
+ occurrences.push({
250
+ selector: stableSelector,
251
+ html,
252
+ summary: 'This slider has no accessible name.',
253
+ hint: 'Provide a label, aria-label, or aria-labelledby so assistive technologies can identify the slider.',
254
+ i18n: {
255
+ summaryKey: 'sliderNamePresent_summary_fail',
256
+ hintKey: 'sliderNamePresent_hint_fail',
257
+ params: { kind }
258
+ },
259
+ data: {
260
+ details: { reasonCode: 'name_missing', controlType: kind, methodTried: res.method },
261
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
262
+ }
263
+ });
264
+ }
265
+
266
+
267
+ if (applicableCount === 0) {
268
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
269
+ }
270
+ if (occurrences.length) {
271
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
272
+ }
273
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
274
+ }
275
+
276
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,236 @@
1
+ 'use strict';
2
+
3
+ const id = 'spinbutton-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: 'spinbuttonNamePresent_title',
10
+ descriptionKey: 'spinbuttonNamePresent_description'
11
+ },
12
+ helpUrl: null,
13
+ tags: ["wcag2a", "wcag412", "forms", "atomic", "automatic", "name", "spinbutton"],
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': ['spinbutton-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=\"spinbutton\"]";
135
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
136
+
137
+ // Precompute label[for] map for spinbutton elements that are labelable
138
+ // native form controls (e.g. <input role="spinbutton">).
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. <input role="spinbutton">).
186
+ const lab = getNativeLabelText(el);
187
+ if (lab) return { ok: true, method: 'label' };
188
+
189
+ // role="spinbutton" 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: 'spinbuttonNamePresent_summary_fail',
216
+ hintKey: 'spinbuttonNamePresent_hint_fail',
217
+ params: { controlType: 'spinbutton' }
218
+ },
219
+ data: {
220
+ details: { reasonCode: 'name_missing', controlType: 'spinbutton', 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,153 @@
1
+ 'use strict';
2
+
3
+ const id = 'summary-name-present';
4
+
5
+ const meta = {
6
+ title: 'Summary elements have an accessible name',
7
+ description: 'Checks that <summary> elements expose a non-empty accessible name.',
8
+ i18n: {
9
+ titleKey: 'summaryNamePresent_title',
10
+ descriptionKey: 'summaryNamePresent_description'
11
+ },
12
+ helpUrl: null,
13
+ tags: ['wcag2a', 'wcag412', 'navigation', 'atomic', 'automatic', 'name'],
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': ['summary-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
+ function resolveAriaLabelledbyText(document, el, maxRefs) {
61
+ const raw = getAttr(el, 'aria-labelledby');
62
+ if (!raw) return '';
63
+ // Delegates to the shared getTextFromIdRefs helper instead of computing
64
+ // name-from-content of the referenced element — see dialog-name-
65
+ // present.js's identical fix for the full rationale (an <iframe>
66
+ // aria-labelledby target's only name source is its title attribute,
67
+ // which name-from-content alone can never see).
68
+ if (helpers.getTextFromIdRefs) {
69
+ try {
70
+ const r = helpers.getTextFromIdRefs(raw, ctx, { maxRefs: maxRefs || 8 });
71
+ return normalizeWs(r && r.text);
72
+ } catch {}
73
+ }
74
+ return '';
75
+ }
76
+
77
+ function isEligibleAcc(helpers, el, ctx) {
78
+ const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
79
+ if (!fn) return true;
80
+ try {
81
+ const r = fn(el, ctx);
82
+ if (typeof r === 'boolean') return r;
83
+ return !!(r && r.eligible);
84
+ } catch {
85
+ return true;
86
+ }
87
+ }
88
+
89
+
90
+ const occurrences = [];
91
+ let applicableCount = 0;
92
+
93
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('summary', safeRoot) : helpers.queryAll('summary', safeRoot);
94
+
95
+ function evaluate(el) {
96
+ const ariaLabel = getAttr(el, 'aria-label');
97
+ if (ariaLabel) return { ok: true, method: 'aria-label' };
98
+
99
+ const ariaLabelledby = resolveAriaLabelledbyText(document, el, 8);
100
+ if (ariaLabelledby) return { ok: true, method: 'aria-labelledby' };
101
+
102
+ const title = getAttr(el, 'title');
103
+ if (title) return { ok: true, method: 'title' };
104
+
105
+ const t = getConservativeSubtreeText(document, el);
106
+ if (t) return { ok: true, method: 'content' };
107
+
108
+ return { ok: false, method: 'none' };
109
+ }
110
+
111
+ for (const el of nodes) {
112
+ if (!el) continue;
113
+ if (!isEligibleAcc(helpers, el, ctx)) continue;
114
+
115
+ applicableCount += 1;
116
+
117
+ const res = evaluate(el);
118
+ if (res.ok) continue;
119
+
120
+ const eligInfo = getEligibilityInfo
121
+ ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
122
+ : null;
123
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
124
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
125
+
126
+ occurrences.push({
127
+ selector: stableSelector,
128
+ html,
129
+ summary: 'This summary has no accessible name.',
130
+ hint: 'Provide summary text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
131
+ i18n: {
132
+ summaryKey: 'summaryNamePresent_summary_fail',
133
+ hintKey: 'summaryNamePresent_hint_fail',
134
+ params: {}
135
+ },
136
+ data: {
137
+ details: { reasonCode: 'name_missing', controlType: 'summary', methodTried: res.method },
138
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
139
+ }
140
+ });
141
+ }
142
+
143
+
144
+ if (applicableCount === 0) {
145
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
146
+ }
147
+ if (occurrences.length) {
148
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
149
+ }
150
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
151
+ }
152
+
153
+ module.exports = { id, meta, runInPage };