@surea11y/core 1.5.0 → 1.6.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 (145) hide show
  1. package/CHANGELOG.md +193 -149
  2. package/README.md +27 -6
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +16 -4
  8. package/docs/I18N.md +4 -4
  9. package/docs/INTEGRATION.md +1 -1
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/REPORT.md +1 -1
  12. package/docs/RULE_AUTHORING.md +53 -25
  13. package/docs/RULE_CATALOG.md +1878 -169
  14. package/docs/RULE_TAXONOMY.md +2 -2
  15. package/docs/TROUBLESHOOTING.md +2 -2
  16. package/docs/WCAG_CONFORMANCE.md +25 -9
  17. package/package.json +3 -7
  18. package/src/baseline.js +3 -3
  19. package/src/checks/automatic/area-alt-present.js +2 -2
  20. package/src/checks/automatic/aria-allowed-attr.js +68 -10
  21. package/src/checks/automatic/aria-allowed-role.js +2 -2
  22. package/src/checks/automatic/aria-braille-equivalent.js +3 -3
  23. package/src/checks/automatic/aria-conditional-attr.js +5 -5
  24. package/src/checks/automatic/aria-deprecated-role.js +1 -1
  25. package/src/checks/automatic/aria-hidden-body.js +2 -2
  26. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  27. package/src/checks/automatic/aria-prohibited-attr.js +18 -18
  28. package/src/checks/automatic/aria-prohibited-children.js +130 -37
  29. package/src/checks/automatic/aria-required-attr.js +60 -12
  30. package/src/checks/automatic/aria-required-children.js +21 -14
  31. package/src/checks/automatic/aria-required-parent.js +61 -9
  32. package/src/checks/automatic/aria-role-name-present.js +36 -22
  33. package/src/checks/automatic/aria-valid-attr-value.js +15 -12
  34. package/src/checks/automatic/aria-valid-attr.js +1 -1
  35. package/src/checks/automatic/autocomplete-valid.js +2 -2
  36. package/src/checks/automatic/binary-control-name-present.js +27 -5
  37. package/src/checks/automatic/button-name-present.js +92 -6
  38. package/src/checks/automatic/combobox-name-present.js +26 -6
  39. package/src/checks/automatic/contrast-computable.js +32 -0
  40. package/src/checks/automatic/contrast-enhanced.js +21 -1
  41. package/src/checks/automatic/contrast-minimum.js +21 -1
  42. package/src/checks/automatic/css-orientation-lock.js +96 -19
  43. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  44. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  45. package/src/checks/automatic/dialog-name-present.js +20 -2
  46. package/src/checks/automatic/duplicate-id-aria.js +5 -3
  47. package/src/checks/automatic/duplicate-id.js +198 -0
  48. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  49. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  50. package/src/checks/automatic/form-control-single-label.js +1 -1
  51. package/src/checks/automatic/iframe-focusable-content.js +63 -7
  52. package/src/checks/automatic/iframe-name-present.js +37 -3
  53. package/src/checks/automatic/iframe-title-unique.js +1 -1
  54. package/src/checks/automatic/img-alt-present.js +12 -4
  55. package/src/checks/automatic/label-in-name.js +172 -18
  56. package/src/checks/automatic/link-in-text-block.js +10 -10
  57. package/src/checks/automatic/link-name-present.js +22 -1
  58. package/src/checks/automatic/list-children-valid.js +6 -6
  59. package/src/checks/automatic/listbox-name-present.js +28 -8
  60. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  61. package/src/checks/automatic/menuitem-name-present.js +20 -2
  62. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  63. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  64. package/src/checks/automatic/meter-name-present.js +23 -4
  65. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  66. package/src/checks/automatic/option-name-present.js +23 -4
  67. package/src/checks/automatic/page-title-present.js +21 -3
  68. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  69. package/src/checks/automatic/progressbar-name-present.js +23 -4
  70. package/src/checks/automatic/role-img-alt-present.js +64 -16
  71. package/src/checks/automatic/searchbox-name-present.js +28 -8
  72. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  73. package/src/checks/automatic/slider-name-present.js +27 -6
  74. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  75. package/src/checks/automatic/summary-name-present.js +18 -2
  76. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  77. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  78. package/src/checks/automatic/tab-name-present.js +21 -2
  79. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  80. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  81. package/src/checks/automatic/target-size-minimum.js +71 -53
  82. package/src/checks/automatic/td-has-header.js +5 -5
  83. package/src/checks/automatic/textbox-name-present.js +28 -8
  84. package/src/checks/automatic/tooltip-name-present.js +21 -2
  85. package/src/checks/automatic/treeitem-name-present.js +23 -4
  86. package/src/checks/automatic/valid-lang.js +92 -7
  87. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  88. package/src/checks/manual/accesskeys-manual.js +3 -3
  89. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  90. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  91. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  92. package/src/checks/manual/aria-text-manual.js +4 -4
  93. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  94. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  95. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  96. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  97. package/src/checks/manual/empty-heading-manual.js +58 -11
  98. package/src/checks/manual/empty-table-header-manual.js +8 -8
  99. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  100. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  101. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  102. package/src/checks/manual/heading-order-manual.js +3 -3
  103. package/src/checks/manual/heading-quality-manual.js +338 -0
  104. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  105. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  106. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  107. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  108. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  109. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  110. package/src/checks/manual/label-title-only-manual.js +4 -4
  111. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  112. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  113. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  114. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  115. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  116. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  117. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  118. package/src/checks/manual/landmark-unique-manual.js +9 -9
  119. package/src/checks/manual/link-name-quality-manual.js +161 -32
  120. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  121. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  122. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  123. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  124. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  125. package/src/checks/manual/p-as-heading-manual.js +4 -4
  126. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  127. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  128. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  129. package/src/checks/manual/region-manual.js +19 -19
  130. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  131. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  132. package/src/checks/manual/skip-link-manual.js +5 -5
  133. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  134. package/src/checks/manual/tabindex-manual.js +2 -2
  135. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  136. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  137. package/src/checks/manual/video-caption-manual.js +3 -3
  138. package/src/checks/manual-review.js +17 -1
  139. package/src/core.js +8965 -1647
  140. package/src/report.js +2 -2
  141. package/surea11y.browser.js +3768 -611
  142. package/surea11y.i18n.de.js +1 -1
  143. package/surea11y.i18n.es.js +1 -1
  144. package/surea11y.i18n.fr.js +1 -1
  145. package/bin/surea11y-core.js +0 -20
@@ -0,0 +1,330 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * @check presentational-children-focusable-absent
7
+ * @atomic true
8
+ * @summary A role whose children are presentational must not contain content in sequential focus navigation
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements whose semantic role is one of the WAI-ARIA roles
13
+ * defined as having presentational children (button, checkbox, img,
14
+ * menuitemcheckbox, menuitemradio, meter, option, progressbar, radio,
15
+ * scrollbar, separator, slider, switch, tab, plus doc-pagebreak and
16
+ * graphics-symbol from the DPUB-ARIA/Graphics-ARIA modules, which
17
+ * inherit the same trait). The role can be explicit (role="tab") or
18
+ * native (<button>, <meter>, <progress>, <option>).
19
+ * @expectation
20
+ * No descendant of the element is part of sequential focus navigation.
21
+ * The presentational-children mechanism removes every descendant from
22
+ * the accessibility tree, so a descendant that still takes a tab stop
23
+ * receives focus with no role and no name to announce.
24
+ * @implementation-notes
25
+ * - "Presentational children" is the implicit, role-driven mechanism from
26
+ * WAI-ARIA §5.2.7, NOT an explicit role="presentation"/"none" attribute.
27
+ * An element carrying that attribute has a semantic role of
28
+ * presentation/none, which is not in the list above, so it is out of
29
+ * scope here (presentation-role-conflict covers that case).
30
+ * - Only tab stops count, not everything focusable: a descendant with
31
+ * tabindex="-1" is programmatically focusable but is not part of
32
+ * sequential focus navigation, so it takes no tab stop and is not
33
+ * reported. helpers.getFocusableInfo's `tabbable` flag is exactly this
34
+ * distinction, and also accounts for :disabled, inert and invalid
35
+ * tabindex values.
36
+ * - Native tags are mapped only where the element can actually have
37
+ * descendants: <input type="checkbox">, <img> and <hr> also carry a
38
+ * presentational-children role, but they are void, so no descendant can
39
+ * ever exist to report. Including them would only inflate the applicable
40
+ * count.
41
+ * - The walk stops at each reported tab stop, and at a nested element that
42
+ * has a presentational-children role of its own without being a tab stop,
43
+ * that one owns whatever is inside it, and is examined as its own
44
+ * container. A tab stop therefore lands on the nearest role that dropped
45
+ * it from the accessibility tree, the element an author would fix, rather
46
+ * than on every enclosing one. A nested role that IS a tab stop
47
+ * (role="button" tabindex="0" inside a <button>) is reported here: focus
48
+ * lands inside this element.
49
+ * - Anything inside an aria-hidden="true" subtree is left to
50
+ * aria-hidden-focus, which reports that exact defect (a tab stop with no
51
+ * accessibility-tree node) as its own normative decision. Non-rendered
52
+ * descendants (display:none, hidden, visibility:hidden) take no tab stop
53
+ * at all and are skipped.
54
+ * - Browsers implement presentational children inconsistently, some
55
+ * expose the descendants anyway, especially when they are focusable, so
56
+ * the announced result varies by browser. The markup contradiction is
57
+ * the same in every one of them, which is what this rule reports.
58
+ */
59
+
60
+ const id = 'presentational-children-focusable-absent';
61
+
62
+ const meta = {
63
+ title: 'Roles with presentational children must not contain focusable content',
64
+ description:
65
+ 'Checks that an element whose role makes its children presentational (button, checkbox, img, option, radio, slider, switch, tab, ...) contains no descendant that takes a tab stop.',
66
+ i18n: {
67
+ titleKey: 'presentationalChildrenFocusableAbsent_title',
68
+ descriptionKey: 'presentationalChildrenFocusableAbsent_description'
69
+ },
70
+ helpUrl: null,
71
+ tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'focus', 'atomic', 'automatic'],
72
+ wcagSc: ['4.1.2'],
73
+ normativeMappings: [
74
+ {
75
+ standard: 'WCAG',
76
+ version: '2.2',
77
+ requirement: '4.1.2',
78
+ title: 'Name, Role, Value',
79
+ conformanceLevel: 'A'
80
+ }
81
+ ],
82
+ defaultSeverity: 'serious',
83
+ category: 'robust',
84
+ type: 'automatic',
85
+ defaultConfidence: 'high',
86
+ coverage: { facetsBySc: { '4.1.2': ['presentational-children-focusable-absent'] } }
87
+ };
88
+
89
+ function runInPage(ctx) {
90
+ const { helpers, rule } = ctx;
91
+
92
+ // Declared inside runInPage, see scripts/build-core.js header
93
+ // ("runInPage MUST be self-contained").
94
+ //
95
+ // WAI-ARIA roles with "Children Presentational: True", plus the two
96
+ // module roles that inherit the trait (doc-pagebreak from DPUB-ARIA,
97
+ // graphics-symbol from Graphics-ARIA).
98
+ const PRESENTATIONAL_CHILDREN_ROLES = [
99
+ 'button',
100
+ 'checkbox',
101
+ 'doc-pagebreak',
102
+ 'graphics-symbol',
103
+ 'img',
104
+ 'menuitemcheckbox',
105
+ 'menuitemradio',
106
+ 'meter',
107
+ 'option',
108
+ 'progressbar',
109
+ 'radio',
110
+ 'scrollbar',
111
+ 'separator',
112
+ 'slider',
113
+ 'switch',
114
+ 'tab'
115
+ ];
116
+
117
+ // Native tags whose implicit role is in the set above and that can hold
118
+ // descendants, see the void-element note in the header comment.
119
+ const NATIVE_ROLE_BY_TAG = {
120
+ button: 'button',
121
+ meter: 'meter',
122
+ option: 'option',
123
+ progress: 'progressbar'
124
+ };
125
+
126
+ const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
127
+
128
+ const getFocusableInfo =
129
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
130
+
131
+ const isDomVisibleEligible =
132
+ helpers && typeof helpers.isDomVisibleEligible === 'function'
133
+ ? helpers.isDomVisibleEligible
134
+ : null;
135
+
136
+ const getEligibilityInfo =
137
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
138
+
139
+ const isAccTreeEligible =
140
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
141
+
142
+ const roleSet = new Set(PRESENTATIONAL_CHILDREN_ROLES);
143
+
144
+ function lower(v) {
145
+ return (v == null ? '' : String(v)).trim().toLowerCase();
146
+ }
147
+
148
+ // The role attribute holds a fallback list; the first token that names a
149
+ // real role wins. A role="tab" resolves here, a role="figure tab" does
150
+ // not (figure wins and has no presentational children), and a list of
151
+ // nothing but unknown tokens falls back to the native role.
152
+ function getPresentationalChildrenRole(el) {
153
+ const raw = el.getAttribute ? el.getAttribute('role') : null;
154
+ if (raw) {
155
+ const tokens = lower(raw).split(/\s+/);
156
+ for (const token of tokens) {
157
+ if (!token) continue;
158
+ if (roleSet.has(token)) return token;
159
+ const known = ariaHelpers ? ariaHelpers.isValidConcreteRole(token) : true;
160
+ if (known) return '';
161
+ }
162
+ }
163
+ const tag = lower(el.tagName);
164
+ return Object.prototype.hasOwnProperty.call(NATIVE_ROLE_BY_TAG, tag)
165
+ ? NATIVE_ROLE_BY_TAG[tag]
166
+ : '';
167
+ }
168
+
169
+ function isExposed(node) {
170
+ if (!isAccTreeEligible) return true;
171
+ try {
172
+ const r = isAccTreeEligible(node, ctx);
173
+ if (typeof r === 'boolean') return r;
174
+ return !!(r && r.eligible);
175
+ } catch {
176
+ return true;
177
+ }
178
+ }
179
+
180
+ // Flat-tree ancestor walk, shared with every other rule via
181
+ // ctx.helpers.composedParent so a shadow host is crossed the same way here
182
+ // as elsewhere.
183
+ const composedParent =
184
+ helpers && typeof helpers.composedParent === 'function'
185
+ ? helpers.composedParent
186
+ : function (n) {
187
+ return n && n.parentElement ? n.parentElement : null;
188
+ };
189
+
190
+ // isAccTreeEligible keeps an aria-hidden element that holds
191
+ // tabbable content in the accessibility tree (reason
192
+ // "ariaHiddenOverriddenTabbable", modelling the browsers that expose it
193
+ // anyway), which is precisely the shape aria-hidden-focus owns, so the
194
+ // attribute is checked on its own here rather than read off eligibility.
195
+ function inAriaHiddenSubtree(node) {
196
+ let cur = node;
197
+ let guard = 0;
198
+ while (cur && guard++ < 200) {
199
+ if (lower(cur.getAttribute && cur.getAttribute('aria-hidden')) === 'true') return true;
200
+ cur = composedParent(cur);
201
+ }
202
+ return false;
203
+ }
204
+
205
+ function isRendered(node) {
206
+ if (!isDomVisibleEligible) return true;
207
+ try {
208
+ const vis = isDomVisibleEligible(node, ctx, {
209
+ visibilityMode: 'styleOnly',
210
+ disableGeometry: true
211
+ });
212
+ return !(vis && vis.eligible === false);
213
+ } catch {
214
+ return true;
215
+ }
216
+ }
217
+
218
+ function isTabStop(node) {
219
+ if (!getFocusableInfo) return false;
220
+ try {
221
+ const info = getFocusableInfo(node, ctx);
222
+ return !!(info && info.tabbable);
223
+ } catch {
224
+ return false;
225
+ }
226
+ }
227
+
228
+ // Shallowest tab stops inside `root`, stopping at each one and at any
229
+ // nested presentational-children role, so every tab stop is attributed to
230
+ // the nearest role that removes it from the accessibility tree.
231
+ function collectTabStops(root) {
232
+ const out = [];
233
+ const top = root && root.children;
234
+ if (!top || !top.length) return out;
235
+ const stack = [];
236
+ for (let i = top.length - 1; i >= 0; i--) stack.push(top[i]);
237
+ while (stack.length) {
238
+ const node = stack.pop();
239
+ if (!node || node.nodeType !== 1) continue;
240
+ if (lower(node.getAttribute && node.getAttribute('aria-hidden')) === 'true') continue;
241
+ if (!isRendered(node)) continue;
242
+ if (isTabStop(node)) {
243
+ out.push(node);
244
+ continue;
245
+ }
246
+ // A nested role with presentational children owns whatever tab stops
247
+ // are inside it (it is examined as its own container in the main
248
+ // loop). It is only a boundary when it is not itself a tab stop,
249
+ // a focusable one lands focus inside THIS element and belongs here.
250
+ if (getPresentationalChildrenRole(node)) continue;
251
+ const kids = node.children;
252
+ if (kids && kids.length) {
253
+ for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
254
+ }
255
+ }
256
+ return out;
257
+ }
258
+
259
+ const SELECTOR = '[role], button, meter, option, progress';
260
+ const nodes = helpers.queryAllSmart
261
+ ? helpers.queryAllSmart(SELECTOR)
262
+ : helpers.queryAll(SELECTOR);
263
+
264
+ const occurrences = [];
265
+ let applicableCount = 0;
266
+
267
+ for (const el of nodes) {
268
+ if (!el || el.nodeType !== 1) continue;
269
+
270
+ const role = getPresentationalChildrenRole(el);
271
+ if (!role) continue;
272
+ // A container that is hidden, or sits in an aria-hidden subtree, is
273
+ // aria-hidden-focus's concern, see the note on inAriaHiddenSubtree.
274
+ if (!isExposed(el) || inAriaHiddenSubtree(el)) continue;
275
+
276
+ applicableCount += 1;
277
+
278
+ const tabStops = collectTabStops(el);
279
+ if (!tabStops.length) continue;
280
+
281
+ const tabStopTags = tabStops.map((n) => (n && n.tagName ? lower(n.tagName) : 'unknown'));
282
+ const dedupedTabStopTags = [...new Set(tabStopTags)];
283
+
284
+ const eligInfo = getEligibilityInfo
285
+ ? (() => {
286
+ try {
287
+ return getEligibilityInfo(el, ctx, { targetSet: 'acc' });
288
+ } catch {
289
+ return null;
290
+ }
291
+ })()
292
+ : null;
293
+
294
+ occurrences.push(
295
+ helpers.reportOccurrence(el, {
296
+ summary: `This role="${role}" element makes its children presentational, but it contains content that is still part of sequential focus navigation (${dedupedTabStopTags.join(', ')}).`,
297
+ hint: 'Move the focusable content outside this element, or remove the role that makes the children presentational, since focus landing inside it has no role or name to announce.',
298
+ i18n: {
299
+ summaryKey: 'presentationalChildrenFocusableAbsent_summary_fail',
300
+ hintKey: 'presentationalChildrenFocusableAbsent_hint_fail',
301
+ params: { role, focusableElements: dedupedTabStopTags.join(', ') }
302
+ },
303
+ data: {
304
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
305
+ details: {
306
+ reasonCode: 'PRESENTATIONAL_CHILDREN_FOCUSABLE_CONTENT',
307
+ role,
308
+ element: lower(el.tagName),
309
+ focusableElements: tabStopTags
310
+ }
311
+ }
312
+ })
313
+ );
314
+ }
315
+
316
+ if (applicableCount === 0) {
317
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
318
+ }
319
+ if (occurrences.length) {
320
+ return {
321
+ ruleId: rule.ruleId,
322
+ outcome: 'fail',
323
+ severity: rule.defaultSeverity || 'serious',
324
+ occurrences
325
+ };
326
+ }
327
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
328
+ }
329
+
330
+ module.exports = { id, meta, runInPage };
@@ -2,6 +2,25 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check progressbar-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="progressbar" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 1.1.1
11
+ * @applicability
12
+ * Applies to elements carrying role="progressbar" (the attribute must name
13
+ * that role alone, not a fallback list) that are included in the
14
+ * accessibility tree. An element with the matching implicit role but no
15
+ * role attribute is out of scope.
16
+ * @expectation
17
+ * The element has a non-empty accessible name from aria-label, from an
18
+ * aria-labelledby that resolves to non-empty text, or from title.
19
+ * role="progressbar" is name-from-author-only, so subtree text is never
20
+ * accepted: text sitting inside a custom progressbar widget is not
21
+ * reliably exposed as its name.
22
+ */
23
+
5
24
  const id = 'progressbar-name-present';
6
25
 
7
26
  const meta = {
@@ -54,7 +73,7 @@ function runInPage(ctx) {
54
73
  const raw = getAttr(el, 'aria-labelledby');
55
74
  if (!raw) return '';
56
75
  // Delegates to the shared getTextFromIdRefs helper instead of computing
57
- // name-from-content of the referenced element — see dialog-name-
76
+ // name-from-content of the referenced element, see dialog-name-
58
77
  // present.js's identical fix for the full rationale (an <iframe>
59
78
  // aria-labelledby target's only name source is its title attribute,
60
79
  // which name-from-content alone can never see).
@@ -106,10 +125,10 @@ function runInPage(ctx) {
106
125
  if (title) return { ok: true, method: 'title' };
107
126
 
108
127
  // role="progressbar" is name-from-author-only per WAI-ARIA: aria-label,
109
- // aria-labelledby, or title — no content-based naming method at all. It
128
+ // aria-labelledby, or title, no content-based naming method at all. It
110
129
  // must NOT fall back to subtree content: e.g. a <ul role="progressbar">
111
130
  // loading skeleton with no name of its own, whose nested descendants
112
- // carry their own aria-label for a different purpose — falling back to
131
+ // carry their own aria-label for a different purpose, falling back to
113
132
  // subtree content would wrongly treat that as the progressbar's name.
114
133
  return { ok: false, method: 'none' };
115
134
  }
@@ -139,7 +158,7 @@ function runInPage(ctx) {
139
158
  occurrences.push(
140
159
  helpers.reportOccurrence(el, {
141
160
  summary: 'This progress bar has no accessible name.',
142
- hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this progress bar's accessible name.",
161
+ hint: "Provide aria-label, aria-labelledby, or a title attribute. Visible text content is not exposed as this progress bar's accessible name.",
143
162
  i18n: {
144
163
  summaryKey: 'progressbarNamePresent_summary_fail',
145
164
  hintKey: 'progressbarNamePresent_hint_fail',
@@ -5,26 +5,36 @@
5
5
  /**
6
6
  * @check role-img-text-alternative-present
7
7
  * @atomic true
8
- * @summary Accessible elements with role="img" must have an alternative text via aria-label or aria-labelledby
8
+ * @summary Accessible elements with role="img"/"graphics-symbol"/"graphics-document" must have a text alternative
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.1.1
11
11
  * @applicability
12
- * Applies to elements with role="img" that are exposed to assistive technologies.
13
- * Elements otherwise hidden from the accessibility tree remain applicable
14
- * if they are focusable or referenced by IDREF relationships (per engine eligibility checks).
12
+ * Applies to elements with role="img", role="graphics-symbol" or
13
+ * role="graphics-document" that are included in the accessibility tree
14
+ * (ACT 23a2a8's "programmatically hidden" exemption:
15
+ * display:none/visibility:hidden/aria-hidden="true" on the element or an
16
+ * ancestor, with no carve-out for focusable or IDREF-referenced elements,
17
+ * aria-hidden-focus and duplicate-id-aria own those separately). Per ACT
18
+ * 7d6734, this reaches any element carrying one of these roles, not only
19
+ * the <svg> root, e.g. a <circle role="graphics-symbol"> nested inside a
20
+ * plain <svg>. The <svg> root itself is left to svg-text-alternative-present's
21
+ * own (already ACT-clean) title/aria-name handling; the <img> tag is
22
+ * excluded here since it has its own dedicated rule.
15
23
  * @expectation
16
- * Each applicable element with role="img" has an accessible text alternative:
24
+ * Each applicable element has an accessible text alternative:
17
25
  * - aria-label with a non-empty value; OR
18
26
  * - aria-labelledby referencing at least one existing element that contributes non-empty text; OR
19
- * - a non-empty title attribute (last-resort accessible-name source per HTML-AAM).
27
+ * - a non-empty title attribute (last-resort accessible-name source per HTML-AAM); OR
28
+ * - for an SVG-namespace element, a non-empty first-child <title> (SVG-AAM's own naming mechanism, not only for the <svg> root).
20
29
  */
21
30
 
22
31
  const id = 'role-img-text-alternative-present';
23
32
 
24
33
  const meta = {
25
- title: '[role="img"] must have an accessible text alternative',
34
+ title:
35
+ '[role="img"/"graphics-symbol"/"graphics-document"] must have an accessible text alternative',
26
36
  description:
27
- 'Checks that elements with role="img" provide an accessible text alternative via aria-label, aria-labelledby, or a title attribute.',
37
+ 'Checks that elements with role="img", "graphics-symbol" or "graphics-document" provide an accessible text alternative using aria-label, aria-labelledby, a title attribute, or (for SVG elements) a first-child <title>.',
28
38
  i18n: {
29
39
  titleKey: 'roleImg_textAlternativePresent_title',
30
40
  descriptionKey: 'roleImg_textAlternativePresent_description'
@@ -86,7 +96,7 @@ function runInPage(ctx) {
86
96
 
87
97
  const imgElements = (() => {
88
98
  // do not consider element "img" because it has its own rule
89
- const sel = '[role="img" i]:not(img)';
99
+ const sel = '[role="img" i]:not(img), [role="graphics-symbol" i], [role="graphics-document" i]';
90
100
  try {
91
101
  return Array.from((queryAllSmart ? queryAllSmart(sel) : queryAll(sel)) || []);
92
102
  } catch {
@@ -101,8 +111,16 @@ function runInPage(ctx) {
101
111
  const occurrences = [];
102
112
  let applicableCount = 0;
103
113
 
114
+ // ACT 23a2a8 (same rule as native img-alt-present) exempts programmatically
115
+ // hidden images and that glossary term has no focusability carve-out, so a
116
+ // tabbable/IDREF-referenced role="img" inside aria-hidden stays out of
117
+ // scope here too; aria-hidden-focus reports that markup instead.
104
118
  const isAccTreeEligible =
105
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
119
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
120
+ ? helpers.isIncludedInAccessibilityTree
121
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
122
+ ? helpers.isAccTreeEligible
123
+ : null;
106
124
 
107
125
  const getAccessibleNameInfo =
108
126
  helpers && typeof helpers.getAccessibleNameInfo === 'function'
@@ -122,10 +140,21 @@ function runInPage(ctx) {
122
140
  }
123
141
  })();
124
142
  if (elig && elig.eligible === false) continue;
143
+ // isIncludedInAccessibilityTree returns a plain boolean, not an
144
+ // {eligible, reasons} object like isAccTreeEligible's fallback shape.
145
+ if (typeof elig === 'boolean' && elig === false) continue;
125
146
  }
126
147
 
127
148
  applicableCount += 1;
128
149
 
150
+ const matchedRole = (() => {
151
+ try {
152
+ return trim(el.getAttribute('role')).split(/\s+/)[0].toLowerCase();
153
+ } catch {
154
+ return 'img';
155
+ }
156
+ })();
157
+
129
158
  // Expectation: aria-label OR aria-labelledby. We use helper name-info when available,
130
159
  // but we also validate the source to keep this rule scoped/deterministic.
131
160
 
@@ -163,6 +192,25 @@ function runInPage(ctx) {
163
192
  })();
164
193
  const hasValidTitle = titleRaw !== null && trim(titleRaw).length > 0;
165
194
 
195
+ // SVG-AAM's own accessible-name mechanism: a first-child <title>
196
+ // element (not the HTML title attribute) is the standard way to name
197
+ // any element in the SVG namespace, not only the <svg> root -- a
198
+ // role="graphics-symbol" <circle> named only this way still has a real
199
+ // text alternative, same as a role="img" <svg>.
200
+ const svgTitleChildText = (() => {
201
+ try {
202
+ const isSvgNamespace = el.namespaceURI === 'http://www.w3.org/2000/svg';
203
+ if (!isSvgNamespace) return '';
204
+ const first = el.firstElementChild;
205
+ const firstTag = first ? (first.localName || first.tagName || '').toLowerCase() : '';
206
+ if (firstTag !== 'title') return '';
207
+ return trim(first.textContent);
208
+ } catch {
209
+ return '';
210
+ }
211
+ })();
212
+ const hasValidTitleSource = hasValidTitle || !!svgTitleChildText;
213
+
166
214
  let nameInfo = null;
167
215
 
168
216
  // Fast outcomes first (no helper needed)
@@ -170,19 +218,19 @@ function runInPage(ctx) {
170
218
  let hasName = false;
171
219
 
172
220
  if (!hasAriaLabelAttr && !hasAriaLabelledbyAttr) {
173
- if (hasValidTitle) {
221
+ if (hasValidTitleSource) {
174
222
  hasName = true;
175
223
  } else {
176
224
  reasonCode = 'missingTextAlternative';
177
225
  }
178
226
  } else if (hasAriaLabelAttr && !hasValidAriaLabel) {
179
- if (hasValidTitle) {
227
+ if (hasValidTitleSource) {
180
228
  hasName = true;
181
229
  } else {
182
230
  reasonCode = 'emptyAriaLabel';
183
231
  }
184
232
  } else if (hasAriaLabelledbyAttr && !hasValidAriaLabelledbyAttr) {
185
- if (hasValidTitle) {
233
+ if (hasValidTitleSource) {
186
234
  hasName = true;
187
235
  } else {
188
236
  reasonCode = 'emptyAriaLabelledby';
@@ -214,12 +262,12 @@ function runInPage(ctx) {
214
262
  const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
215
263
 
216
264
  const baseOccurrence = {
217
- summary: 'Missing text alternative on element with role="img".',
218
- hint: 'Provide aria-label or aria-labelledby (referencing non-empty text) to give this image a text alternative.',
265
+ summary: `Missing text alternative on element with role="${matchedRole}".`,
266
+ hint: 'Provide aria-label or aria-labelledby (referencing non-empty text) to give this element a text alternative.',
219
267
  i18n: {
220
268
  summaryKey: 'roleImg_textAlternativePresent_summary_fail',
221
269
  hintKey: 'roleImg_textAlternativePresent_hint_fail',
222
- params: { role: 'img' }
270
+ params: { role: matchedRole }
223
271
  },
224
272
  data: {
225
273
  details: {
@@ -2,11 +2,31 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check searchbox-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="searchbox" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="searchbox" (the attribute must name
13
+ * that role alone, not a fallback list) that are included in the
14
+ * accessibility tree. An element with the matching implicit role but no
15
+ * role attribute is out of scope.
16
+ * @expectation
17
+ * The element has a non-empty accessible name from aria-label, from an
18
+ * aria-labelledby that resolves to non-empty text, or from title.
19
+ * role="searchbox" is name-from-author-only, so subtree text is never
20
+ * accepted: text sitting inside a custom searchbox widget is not reliably
21
+ * exposed as its name. On a labelable element (<input role="searchbox">)
22
+ * an associated <label> counts as well.
23
+ */
24
+
5
25
  const id = 'searchbox-name-present';
6
26
 
7
27
  const meta = {
8
- title: 'Accessible name is present',
9
- description: 'Checks that elements expose a non-empty accessible name.',
28
+ title: 'Searchboxes have an accessible name',
29
+ description: 'Checks that elements with role="searchbox" expose a non-empty accessible name.',
10
30
  i18n: {
11
31
  titleKey: 'searchboxNamePresent_title',
12
32
  descriptionKey: 'searchboxNamePresent_description'
@@ -51,7 +71,7 @@ function runInPage(ctx) {
51
71
  }
52
72
 
53
73
  function getConservativeSubtreeText(document, container) {
54
- // "Name from content" — recurses into descendants and uses each one's
74
+ // "Name from content", recurses into descendants and uses each one's
55
75
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
56
76
  // it has one, not just literal text nodes. See getContentNameInfo's
57
77
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -68,7 +88,7 @@ function runInPage(ctx) {
68
88
 
69
89
  // A <label> contributes a name via its own aria-label/aria-labelledby
70
90
  // (checked first, same ARIA-over-content precedence any element's
71
- // accessible name gives — e.g. <label aria-label="Search"><svg
91
+ // accessible name gives, e.g. <label aria-label="Search"><svg
72
92
  // aria-hidden="true">...</svg></label> names its control "Search" even
73
93
  // though the label's only child content is aria-hidden) or, failing
74
94
  // that, its rendered content (getConservativeSubtreeText).
@@ -83,10 +103,10 @@ function runInPage(ctx) {
83
103
  if (content) return content;
84
104
  // Final fallback per the general accname text-alternative algorithm,
85
105
  // which applies to any element being asked for its name regardless of
86
- // why (own aria-label, an aria-labelledby reference, or — here — native
106
+ // why (own aria-label, an aria-labelledby reference, or, here, native
87
107
  // <label for> association): title, when nothing else yields a name.
88
108
  // Purely additive (only fills in a name where there was none before),
89
- // so it carries no false-positive risk — see dialog-name-present.js's
109
+ // so it carries no false-positive risk, see dialog-name-present.js's
90
110
  // identical <iframe>-title-fallback fix for the concrete real-world
91
111
  // trigger this same accname step covers elsewhere.
92
112
  return getAttr(lab, 'title');
@@ -96,7 +116,7 @@ function runInPage(ctx) {
96
116
  const raw = getAttr(el, 'aria-labelledby');
97
117
  if (!raw) return '';
98
118
  // Delegates to the shared getTextFromIdRefs helper instead of computing
99
- // name-from-content of the referenced element — see dialog-name-
119
+ // name-from-content of the referenced element, see dialog-name-
100
120
  // present.js's identical fix for the full rationale (an <iframe>
101
121
  // aria-labelledby target's only name source is its title attribute,
102
122
  // which name-from-content alone can never see).
@@ -231,7 +251,7 @@ function runInPage(ctx) {
231
251
  occurrences.push(
232
252
  helpers.reportOccurrence(el, {
233
253
  summary: 'This element has no accessible name.',
234
- hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this searchbox's accessible name.",
254
+ hint: "Provide aria-label, aria-labelledby, or a title attribute. Visible text content is not exposed as this searchbox's accessible name.",
235
255
  i18n: {
236
256
  summaryKey: 'searchboxNamePresent_summary_fail',
237
257
  hintKey: 'searchboxNamePresent_hint_fail',
@@ -13,7 +13,7 @@
13
13
  * @expectation
14
14
  * The image does not use ismap at all. Server-side image maps depend on
15
15
  * the browser sending click coordinates to the server, which has no
16
- * keyboard-operable equivalent — there is no way to determine or expose
16
+ * keyboard-operable equivalent, there is no way to determine or expose
17
17
  * individual clickable regions to assistive technology or keyboard
18
18
  * users. Client-side image maps (<map>/<area>, each with real href/alt)
19
19
  * are the accessible alternative and are not flagged by this rule.