@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,206 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check role-img-text-alternative-present
5
+ * @atomic true
6
+ * @summary Accessible elements with role="img" must have an alternative text via aria-label or aria-labelledby
7
+ * @standard WCAG 2.2
8
+ * @sc 1.1.1
9
+ * @applicability
10
+ * Applies to elements with role="img" that are exposed to assistive technologies.
11
+ * Elements otherwise hidden from the accessibility tree remain applicable
12
+ * if they are focusable or referenced by IDREF relationships (per engine eligibility checks).
13
+ * @expectation
14
+ * Each applicable element with role="img" has an accessible text alternative:
15
+ * - aria-label with a non-empty value; OR
16
+ * - aria-labelledby referencing at least one existing element that contributes non-empty text; OR
17
+ * - a non-empty title attribute (last-resort accessible-name source per HTML-AAM,
18
+ * also accepted by a widely-used reference engine's equivalent role-img-alt rule).
19
+ */
20
+
21
+ const id = 'role-img-text-alternative-present';
22
+
23
+ const meta = {
24
+ title: '[role="img"] must have an accessible text alternative',
25
+ description:
26
+ 'Checks that elements with role="img" provide an accessible text alternative via aria-label, aria-labelledby, or a title attribute.',
27
+ i18n: {
28
+ titleKey: 'roleImg_textAlternativePresent_title',
29
+ descriptionKey: 'roleImg_textAlternativePresent_description'
30
+ },
31
+ helpUrl: null,
32
+ tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'aria', 'atomic', 'automatic'],
33
+ wcagSc: ['1.1.1'],
34
+ normativeMappings: [
35
+ { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
36
+ ],
37
+ defaultSeverity: 'serious',
38
+ category: 'perceivable',
39
+ type: 'automatic',
40
+ defaultConfidence: 'high',
41
+ coverage: {
42
+ facetsBySc: {
43
+ '1.1.1': ['role-img-text-alternative-present']
44
+ }
45
+ }
46
+ };
47
+
48
+ function runInPage(ctx) {
49
+ const { root, helpers, rule } = ctx;
50
+ const safeRoot = root || document;
51
+
52
+ const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
53
+ const queryAll = helpers && typeof helpers.queryAll === 'function'
54
+ ? helpers.queryAll
55
+ : (sel) => {
56
+ try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
57
+ catch { return []; }
58
+ };
59
+
60
+ const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
61
+ ? helpers.getEligibilityInfo
62
+ : null;
63
+
64
+ const trim = (v) => {
65
+ try { return String(v == null ? '' : v).replace(/\s+/g, ' ').trim(); }
66
+ catch { return ''; }
67
+ };
68
+
69
+ const imgElements = (() => {
70
+ // do not consider element "img" because it has its own rule
71
+ const sel = '[role="img" i]:not(img)';
72
+ try { return Array.from((queryAllSmart ? queryAllSmart(sel) : queryAll(sel)) || []); }
73
+ catch { return queryAll(sel); }
74
+ })();
75
+
76
+ if (!imgElements.length) {
77
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
78
+ }
79
+
80
+ const occurrences = [];
81
+ let applicableCount = 0;
82
+
83
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
84
+ ? helpers.isAccTreeEligible
85
+ : null;
86
+
87
+ const getAccessibleNameInfo = helpers && typeof helpers.getAccessibleNameInfo === 'function'
88
+ ? helpers.getAccessibleNameInfo
89
+ : null;
90
+
91
+ for (const el of imgElements) {
92
+ if (!el || !el.getAttribute) continue;
93
+
94
+ // Applicability: eligible in the acc tree (with helper exceptions).
95
+ if (isAccTreeEligible) {
96
+ const elig = (() => {
97
+ try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
98
+ })();
99
+ if (elig && elig.eligible === false) continue;
100
+ }
101
+
102
+ applicableCount += 1;
103
+
104
+ // Expectation: aria-label OR aria-labelledby. We use helper name-info when available,
105
+ // but we also validate the source to keep this rule scoped/deterministic.
106
+
107
+ const ariaLabelRaw = (() => { try { return el.getAttribute('aria-label'); } catch { return null; } })();
108
+ const ariaLabel = trim(ariaLabelRaw);
109
+
110
+ const ariaLabelledbyRaw = (() => { try { return el.getAttribute('aria-labelledby'); } catch { return null; } })();
111
+ const ariaLabelledby = trim(ariaLabelledbyRaw);
112
+
113
+ const hasAriaLabelAttr = ariaLabelRaw !== null;
114
+ const hasAriaLabelledbyAttr = ariaLabelledbyRaw !== null;
115
+
116
+ const hasValidAriaLabel = hasAriaLabelAttr && ariaLabel.length > 0;
117
+ const hasValidAriaLabelledbyAttr = hasAriaLabelledbyAttr && ariaLabelledby.length > 0;
118
+
119
+ // Last-resort naming mechanism per HTML-AAM (also accepted by
120
+ // a widely-used reference engine's role-img-alt): a non-empty title attribute.
121
+ const titleRaw = (() => { try { return el.getAttribute('title'); } catch { return null; } })();
122
+ const hasValidTitle = titleRaw !== null && trim(titleRaw).length > 0;
123
+
124
+ let nameInfo = null;
125
+
126
+ // Fast outcomes first (no helper needed)
127
+ let reasonCode = '';
128
+ let hasName = false;
129
+
130
+ if (!hasAriaLabelAttr && !hasAriaLabelledbyAttr) {
131
+ if (hasValidTitle) {
132
+ hasName = true;
133
+ } else {
134
+ reasonCode = 'missingTextAlternative';
135
+ }
136
+ } else if (hasAriaLabelAttr && !hasValidAriaLabel) {
137
+ if (hasValidTitle) {
138
+ hasName = true;
139
+ } else {
140
+ reasonCode = 'emptyAriaLabel';
141
+ }
142
+ } else if (hasAriaLabelledbyAttr && !hasValidAriaLabelledbyAttr) {
143
+ if (hasValidTitle) {
144
+ hasName = true;
145
+ } else {
146
+ reasonCode = 'emptyAriaLabelledby';
147
+ }
148
+ } else {
149
+ // Mechanism present + non-empty. Optionally validate resolution via helper.
150
+ if (getAccessibleNameInfo) {
151
+ nameInfo = (() => { try { return getAccessibleNameInfo(el, ctx); } catch { return null; } })();
152
+ const helperSaysHasName = !!(nameInfo && nameInfo.present && trim(nameInfo.value));
153
+ if (helperSaysHasName) {
154
+ hasName = true;
155
+ } else {
156
+ reasonCode = 'nameNotResolved';
157
+ }
158
+ } else {
159
+ // Without helper, accept non-empty aria-label/labelledby as sufficient.
160
+ hasName = true;
161
+ }
162
+ }
163
+
164
+ if (hasName) continue;
165
+
166
+
167
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
168
+
169
+ const baseOccurrence = {
170
+ summary: 'Missing text alternative on element with role="img".',
171
+ hint: 'Provide aria-label or aria-labelledby (referencing non-empty text) to give this image a text alternative.',
172
+ i18n: {
173
+ summaryKey: 'roleImg_textAlternativePresent_summary_fail',
174
+ hintKey: 'roleImg_textAlternativePresent_hint_fail',
175
+ params: { role: 'img' }
176
+ },
177
+ data: {
178
+ details: {
179
+ reasonCode,
180
+ ariaLabel: ariaLabelRaw === null ? null : ariaLabel,
181
+ ariaLabelledby: ariaLabelledbyRaw === null ? null : ariaLabelledby,
182
+ accessibleNameInfo: nameInfo || null
183
+ },
184
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
185
+ }
186
+ };
187
+
188
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
189
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
190
+ } else {
191
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
192
+ }
193
+ }
194
+
195
+ if (applicableCount === 0) {
196
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
197
+ }
198
+
199
+ if (!occurrences.length) {
200
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
201
+ }
202
+
203
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
204
+ }
205
+
206
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,236 @@
1
+ 'use strict';
2
+
3
+ const id = 'searchbox-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: 'searchboxNamePresent_title',
10
+ descriptionKey: 'searchboxNamePresent_description'
11
+ },
12
+ helpUrl: null,
13
+ tags: ["wcag2a", "wcag412", "forms", "atomic", "automatic", "name", "searchbox"],
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': ['searchbox-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=\"searchbox\"]";
135
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
136
+
137
+ // Precompute label[for] map for searchbox elements that are labelable
138
+ // native form controls (e.g. <input role="searchbox">).
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="searchbox">).
186
+ const lab = getNativeLabelText(el);
187
+ if (lab) return { ok: true, method: 'label' };
188
+
189
+ // role="searchbox" 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: 'searchboxNamePresent_summary_fail',
216
+ hintKey: 'searchboxNamePresent_hint_fail',
217
+ params: { controlType: 'searchbox' }
218
+ },
219
+ data: {
220
+ details: { reasonCode: 'name_missing', controlType: 'searchbox', 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,88 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check server-side-image-map-absent
5
+ * @atomic true
6
+ * @summary <img> must not use a server-side image map (ismap)
7
+ * @standard WCAG 2.2
8
+ * @sc 2.1.1
9
+ * @applicability
10
+ * Applies to <img> elements that carry an ismap attribute.
11
+ * @expectation
12
+ * The image does not use ismap at all. Server-side image maps depend on
13
+ * the browser sending click coordinates to the server, which has no
14
+ * keyboard-operable equivalent — there is no way to determine or expose
15
+ * individual clickable regions to assistive technology or keyboard
16
+ * users. Client-side image maps (<map>/<area>, each with real href/alt)
17
+ * are the accessible alternative and are not flagged by this rule.
18
+ * @implementation-notes
19
+ * - Matches a widely-used reference engine's server-side-image-map: presence of ismap is itself
20
+ * the violation (there is no automatable way to verify a "usable
21
+ * alternative" exists elsewhere on the page).
22
+ */
23
+
24
+ const id = 'server-side-image-map-absent';
25
+
26
+ const meta = {
27
+ title: 'Images must not use a server-side image map',
28
+ description: 'Checks that <img> elements do not carry the ismap attribute (server-side image maps have no keyboard-operable equivalent).',
29
+ i18n: {
30
+ titleKey: 'serverSideImageMapAbsent_title',
31
+ descriptionKey: 'serverSideImageMapAbsent_description'
32
+ },
33
+ helpUrl: null,
34
+ tags: ['wcag2a', 'wcag211', 'structure', 'atomic', 'automatic', 'keyboard'],
35
+ wcagSc: ['2.1.1'],
36
+ normativeMappings: [
37
+ { standard: 'WCAG', version: '2.2', requirement: '2.1.1', title: 'Keyboard', conformanceLevel: 'A' }
38
+ ],
39
+ defaultSeverity: 'serious',
40
+ category: 'operable',
41
+ type: 'automatic',
42
+ defaultConfidence: 'high',
43
+ coverage: { facetsBySc: { '2.1.1': ['server-side-image-map-absent'] } }
44
+ };
45
+
46
+ function runInPage(ctx) {
47
+ const { document, root, helpers, rule } = ctx;
48
+ const safeRoot = root || document;
49
+
50
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('img[ismap]', safeRoot) : helpers.queryAll('img[ismap]', safeRoot);
51
+
52
+ const occurrences = [];
53
+ let applicableCount = 0;
54
+
55
+ for (const el of nodes) {
56
+ if (!el) continue;
57
+
58
+ applicableCount += 1;
59
+
60
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
61
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
62
+
63
+ occurrences.push({
64
+ selector: stableSelector,
65
+ html,
66
+ summary: 'This image uses a server-side image map, which has no keyboard-operable equivalent.',
67
+ hint: 'Replace the server-side image map (ismap) with a client-side image map (<map>/<area>) or separate accessible links/buttons.',
68
+ i18n: {
69
+ summaryKey: 'serverSideImageMapAbsent_summary_fail',
70
+ hintKey: 'serverSideImageMapAbsent_hint_fail',
71
+ params: {}
72
+ },
73
+ data: {
74
+ details: { reasonCode: 'SERVER_SIDE_IMAGE_MAP' }
75
+ }
76
+ });
77
+ }
78
+
79
+ if (applicableCount === 0) {
80
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
81
+ }
82
+ if (occurrences.length) {
83
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
84
+ }
85
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
86
+ }
87
+
88
+ module.exports = { id, meta, runInPage };