@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,480 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check aria-hidden-focus
5
+ * @atomic true
6
+ * @summary aria-hidden elements must not be focusable or contain focusable elements
7
+ * @standard WCAG 2.2
8
+ * @sc 4.1.2
9
+ * @applicability
10
+ * Applies to elements that have aria-hidden="true".
11
+ * @expectation
12
+ * No element with aria-hidden="true" may itself be focusable, and no focusable element
13
+ * may exist within an aria-hidden="true" subtree.
14
+ *
15
+ * Notes on parity with a widely-used reference engine's aria-hidden-focus check:
16
+ * - Focusability is computed via ctx.helpers.getFocusableInfo (native + tabindex + contenteditable).
17
+ * - Elements that are not rendered (e.g., display:none, visibility:hidden, [hidden]) are excluded.
18
+ * - Elements hidden via CSS in ways that still allow keyboard focus (e.g., opacity:0, off-screen, clip)
19
+ * remain in-scope and will be flagged when focusable.
20
+ */
21
+
22
+ const id = 'aria-hidden-focus';
23
+
24
+ const meta = {
25
+ title: 'ARIA hidden elements must not be focusable',
26
+ description:
27
+ 'Checks that aria-hidden="true" elements are not focusable and do not contain focusable descendants.',
28
+ i18n: {
29
+ titleKey: 'ariaHidden_focus_title',
30
+ descriptionKey: 'ariaHidden_focus_description'
31
+ },
32
+ helpUrl: null,
33
+ // NOTE: taxonomy contract requires exactly one content category tag.
34
+ // This rule is about ARIA subtree exposure / structural AT tree consistency.
35
+ tags: ['wcag2a', 'wcag2aa', 'wcag412', 'structure', 'aria', 'focus', 'atomic', 'automatic'],
36
+ wcagSc: ['4.1.2'],
37
+ normativeMappings: [
38
+ { standard: 'WCAG', version: '2.2', requirement: '2.4.7', title: 'Focus Visible', conformanceLevel: 'AA' },
39
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
40
+ ],
41
+ defaultSeverity: 'serious',
42
+ category: 'robust',
43
+ type: 'automatic',
44
+ defaultConfidence: 'high',
45
+ coverage: {
46
+ facetsBySc: {
47
+ '4.1.2': ['aria-hidden-focusable']
48
+ }
49
+ }
50
+ };
51
+
52
+ function runInPage(ctx) {
53
+ const { document, root, helpers, rule } = ctx;
54
+ const safeRoot = root || document;
55
+
56
+ const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
57
+
58
+ const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
59
+
60
+ // Used only to exclude non-rendered elements; we explicitly DO NOT exclude opacity:0.
61
+ const isDomVisibleEligible = helpers && typeof helpers.isDomVisibleEligible === 'function' ? helpers.isDomVisibleEligible : null;
62
+
63
+ const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
64
+
65
+ // Prefer helper occurrence wrapper so the engine can attach selector/snippet later.
66
+ const reportOccurrence = helpers && typeof helpers.reportOccurrence === 'function'
67
+ ? helpers.reportOccurrence
68
+ : null;
69
+
70
+ const trim = (v) => (v == null ? '' : String(v)).trim();
71
+ const lower = (v) => trim(v).toLowerCase();
72
+
73
+ function qAll(sel) {
74
+ try {
75
+ if (queryAllSmart) {
76
+ const r = queryAllSmart(sel);
77
+ if (Array.isArray(r)) return r;
78
+ return Array.from(r || []);
79
+ }
80
+ } catch {
81
+ // fall through
82
+ }
83
+ try {
84
+ if (safeRoot && typeof safeRoot.querySelectorAll === 'function') return Array.from(safeRoot.querySelectorAll(sel));
85
+ } catch {
86
+ // fall through
87
+ }
88
+ return [];
89
+ }
90
+
91
+ // Flat-tree ancestor walk (assignedSlot wins over parentNode, then shadow
92
+ // host) — shared with every other rule via ctx.helpers.composedParent
93
+ // (src/core/dom-helpers.js), not reimplemented here, so a fix to the one
94
+ // canonical definition can't drift out of sync with this rule's copy.
95
+ const composedParent = helpers && typeof helpers.composedParent === 'function'
96
+ ? helpers.composedParent
97
+ : function (n) { return n && n.parentElement ? n.parentElement : null; };
98
+
99
+ function closestAriaHiddenTrue(node) {
100
+ let cur = node;
101
+ let guard = 0;
102
+ while (cur && guard++ < 200) {
103
+ try {
104
+ if (cur.getAttribute) {
105
+ const v = cur.getAttribute('aria-hidden');
106
+ if (v != null && lower(v) === 'true') return cur;
107
+ }
108
+ } catch {
109
+ // ignore
110
+ }
111
+ cur = composedParent(cur);
112
+ }
113
+ return null;
114
+ }
115
+
116
+ // Lightweight "invisible but still focusable" hints.
117
+ // Only computed for a capped set of offenders per aria-hidden root.
118
+ function getVisibilityHints(el) {
119
+ const out = [];
120
+ if (!el) return out;
121
+
122
+ let cs = null;
123
+ try {
124
+ const w = (document && document.defaultView) ? document.defaultView : (typeof window !== 'undefined' ? window : null);
125
+ cs = w && w.getComputedStyle ? w.getComputedStyle(el) : null;
126
+ } catch {
127
+ cs = null;
128
+ }
129
+
130
+ // opacity:0 (still focusable)
131
+ try {
132
+ const rawOp = cs && cs.opacity != null ? String(cs.opacity).trim() : '';
133
+ const op = rawOp ? Number.parseFloat(rawOp) : 1;
134
+ if (Number.isFinite(op) && op <= 0.0001) out.push('opacityZero');
135
+ } catch {
136
+ // ignore
137
+ }
138
+
139
+ // clip / clip-path (still focusable)
140
+ try {
141
+ const clip = cs && cs.clip != null ? String(cs.clip).trim() : '';
142
+ const clipPath = cs && cs.clipPath != null ? String(cs.clipPath).trim() : '';
143
+ const clipLow = (clip || '').toLowerCase();
144
+ const clipPathLow = (clipPath || '').toLowerCase();
145
+
146
+ // Common visually-hidden patterns
147
+ if (clipLow && clipLow !== 'auto') {
148
+ // Examples: rect(0px, 0px, 0px, 0px) / rect(0,0,0,0)
149
+ if (clipLow.indexOf('rect(') !== -1 && clipLow.replace(/\s+/g, '').indexOf('rect(0') !== -1) out.push('clipped');
150
+ }
151
+ if (clipPathLow && clipPathLow !== 'none') {
152
+ // Examples: inset(100%) / inset(50%)
153
+ if (clipPathLow.indexOf('inset(') !== -1 && (clipPathLow.indexOf('100%') !== -1 || clipPathLow.indexOf('50%') !== -1)) {
154
+ out.push('clipped');
155
+ }
156
+ }
157
+ } catch {
158
+ // ignore
159
+ }
160
+
161
+ // zero-size + overflow hidden/clip (still focusable)
162
+ try {
163
+ const wv = cs && cs.width != null ? String(cs.width).trim() : '';
164
+ const hv = cs && cs.height != null ? String(cs.height).trim() : '';
165
+ const ov = cs && cs.overflow != null ? String(cs.overflow).trim().toLowerCase() : '';
166
+ const isZeroW = wv === '0px' || wv === '0';
167
+ const isZeroH = hv === '0px' || hv === '0';
168
+ const hidesOverflow = ov === 'hidden' || ov === 'clip';
169
+ if ((isZeroW || isZeroH) && hidesOverflow) out.push('zeroSizeOverflowHidden');
170
+ } catch {
171
+ // ignore
172
+ }
173
+
174
+ // Off-screen heuristic (still focusable)
175
+ // Deterministic string parsing; does not require layout geometry.
176
+ try {
177
+ const pos = cs && cs.position != null ? String(cs.position).trim().toLowerCase() : '';
178
+ const left = cs && cs.left != null ? String(cs.left).trim().toLowerCase() : '';
179
+ const top = cs && cs.top != null ? String(cs.top).trim().toLowerCase() : '';
180
+ const ti = cs && cs.textIndent != null ? String(cs.textIndent).trim().toLowerCase() : '';
181
+
182
+ const parsePx = (s) => {
183
+ if (!s || s === 'auto') return null;
184
+ const m = String(s).match(/-?\d+(\.\d+)?/);
185
+ if (!m) return null;
186
+ const n = Number.parseFloat(m[0]);
187
+ return Number.isFinite(n) ? n : null;
188
+ };
189
+
190
+ const l = parsePx(left);
191
+ const t = parsePx(top);
192
+ const ind = parsePx(ti);
193
+
194
+ if (pos === 'absolute' || pos === 'fixed') {
195
+ if ((l != null && l <= -5000) || (t != null && t <= -5000)) out.push('offscreen');
196
+ }
197
+ // Common text-indent technique
198
+ if (ind != null && ind <= -5000) out.push('offscreen');
199
+ } catch {
200
+ // ignore
201
+ }
202
+
203
+ // Dedup while preserving stable order
204
+ const seen = new Set();
205
+ const uniq = [];
206
+ for (const k of out) {
207
+ const kk = String(k);
208
+ if (!seen.has(kk)) { seen.add(kk); uniq.push(kk); }
209
+ }
210
+ return uniq;
211
+ }
212
+
213
+ // DOM-visibility gate to avoid false positives:
214
+ // Exclude structural/CSS hidden cases that prevent focus (display:none, visibility:hidden, hidden attr, etc.).
215
+ // IMPORTANT: Do NOT exclude opacity-based invisibility; opacity:0 remains in-scope.
216
+ function isActuallyFocusable(el) {
217
+ if (!el || !el.getAttribute) return false;
218
+
219
+ // Hard blockers that should always win (even if fallback logic would say "focusable")
220
+ if (hasInertAncestor(el)) return false;
221
+ if (isDisabledFormControl(el)) return false;
222
+
223
+ // An explicit negative tabindex removes the element from the keyboard
224
+ // tab sequence entirely, regardless of tag — the standard, WAI-
225
+ // recommended technique for safely hiding focusable content behind
226
+ // aria-hidden (verified against a widely-used reference engine's own aria-hidden-focus check,
227
+ // which requires tabbability — not raw focusability — via its
228
+ // `focusable-not-tabbable` sub-check; confirmed via a real page:
229
+ // Wikipedia's sticky header uses <button tabindex="-1">/<a tabindex="-1">
230
+ // inside aria-hidden divs, a correct pattern this rule was previously
231
+ // flagging as a false positive). Such an element is still
232
+ // programmatically focusable (script could call .focus()), but that's
233
+ // not what "no focusable content behind aria-hidden" cares about.
234
+ const explicitTabindex = trim(el.getAttribute('tabindex'));
235
+ if (explicitTabindex !== '' && !Number.isNaN(Number(explicitTabindex)) && Number(explicitTabindex) < 0) {
236
+ return false;
237
+ }
238
+
239
+ // 1) "DOM focusability" check (ignore aria-hidden)
240
+ // Prefer helper for broad coverage, but do not let aria-hidden flip focusable->false.
241
+ let helperInfo = null;
242
+ if (getFocusableInfo) {
243
+ try { helperInfo = getFocusableInfo(el, ctx); } catch { helperInfo = null; }
244
+ }
245
+
246
+ // Local fallback that does NOT care about aria-hidden
247
+ const tag = lower(el.tagName || '');
248
+ let fallbackFocusable = false;
249
+
250
+ if (tag === 'a' || tag === 'area') {
251
+ const href = trim(el.getAttribute('href'));
252
+ fallbackFocusable = !!href;
253
+ } else if (tag === 'button' || tag === 'select' || tag === 'textarea' || tag === 'summary') {
254
+ fallbackFocusable = true;
255
+ } else if (tag === 'input') {
256
+ const type = lower(el.getAttribute('type') || '');
257
+ fallbackFocusable = type !== 'hidden';
258
+ } else if (tag === 'iframe') {
259
+ fallbackFocusable = true;
260
+ } else if ((tag === 'audio' || tag === 'video') && el.hasAttribute && el.hasAttribute('controls')) {
261
+ fallbackFocusable = true;
262
+ } else if (el.hasAttribute && el.hasAttribute('contenteditable')) {
263
+ // contenteditable="false" explicitly disables the editing host and
264
+ // does not by itself add the element to the tab order; only treat
265
+ // presence/""/"true"/"plaintext-only" as focus-enabling.
266
+ const ceVal = lower(trim(el.getAttribute('contenteditable')));
267
+ fallbackFocusable = ceVal !== 'false';
268
+ } else {
269
+ const ti = el.getAttribute('tabindex');
270
+ const s = trim(ti);
271
+ if (ti != null && s !== '' && !Number.isNaN(Number(s))) {
272
+ fallbackFocusable = true; // tabindex makes it programmatically focusable
273
+ }
274
+ }
275
+
276
+ // Use helper focusable if it says true; otherwise use fallback focusable.
277
+ // This specifically prevents aria-hidden from suppressing "self focusable".
278
+ const focusable = (helperInfo && helperInfo.focusable === true) || fallbackFocusable;
279
+ if (!focusable) return false;
280
+
281
+ // 2) exclude non-rendered / non-visible-by-style blockers
282
+ // IMPORTANT: Do NOT exclude opacity-based invisibility; opacity:0 remains in-scope.
283
+ if (isDomVisibleEligible) {
284
+ try {
285
+ const vis = isDomVisibleEligible(el, ctx, { visibilityMode: 'styleOnly', disableGeometry: true });
286
+ if (vis && vis.eligible === false) {
287
+ const rs = Array.isArray(vis.reasons) ? vis.reasons : [];
288
+ const nonOpacity = rs.filter((r) => String(r) !== 'opacityZero');
289
+ if (nonOpacity.length) return false;
290
+ }
291
+ } catch {
292
+ // ignore
293
+ }
294
+ }
295
+
296
+ return true;
297
+ }
298
+
299
+ function hasInertAncestor(el) {
300
+ let cur = el;
301
+ let guard = 0;
302
+ while (cur && guard++ < 200) {
303
+ try {
304
+ if (cur.nodeType === 1 && cur.hasAttribute && cur.hasAttribute('inert')) return true;
305
+ } catch {
306
+ // ignore
307
+ }
308
+ cur = composedParent(cur);
309
+ }
310
+ return false;
311
+ }
312
+
313
+ function isDisabledFormControl(el) {
314
+ try {
315
+ // Covers button/input/select/textarea/option/optgroup/fieldset etc.
316
+ if (typeof el.disabled === 'boolean' && el.disabled) return true;
317
+ } catch {
318
+ // ignore
319
+ }
320
+ try {
321
+ const tag = lower(el.tagName || '');
322
+ if ((tag === 'button' || tag === 'input' || tag === 'select' || tag === 'textarea' || tag === 'option' || tag === 'optgroup') &&
323
+ el.hasAttribute && el.hasAttribute('disabled')) {
324
+ return true;
325
+ }
326
+ } catch {
327
+ // ignore
328
+ }
329
+ return false;
330
+ }
331
+
332
+ // 1) Find aria-hidden="true" roots.
333
+ const ariaHiddenRoots = qAll('[aria-hidden="true"]');
334
+ if (!ariaHiddenRoots.length) {
335
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
336
+ }
337
+
338
+ // 2) Find focusable candidates once (performance) and bucket those inside aria-hidden.
339
+ // Keep selector fairly small to avoid huge candidate sets while still covering the reference-engine cases.
340
+ const focusableCandidates = qAll(
341
+ 'a[href],area[href],button,input,select,textarea,summary,iframe,audio[controls],video[controls],[tabindex],[contenteditable]'
342
+ );
343
+
344
+ const bucket = new Map(); // ariaHiddenRoot -> { rootEl, count, offenders: [], hints:Set, rootIsFocusable }
345
+ const maxOffendersPerRoot = 5;
346
+
347
+ for (let i = 0; i < focusableCandidates.length; i++) {
348
+ const el = focusableCandidates[i];
349
+ if (!el || !el.getAttribute) continue;
350
+
351
+ // Cheap check first: a plain ancestor-attribute walk with no CSS
352
+ // computation, vs. isActuallyFocusable's getComputedStyle-per-ancestor
353
+ // cost. Both conditions are required (AND), so checking whichever is
354
+ // cheaper first cannot change which elements end up in the bucket —
355
+ // it only skips the expensive check for the (typically vast) majority
356
+ // of focusable candidates that were never inside an aria-hidden root
357
+ // in the first place. On a real page with a large focusable-candidate
358
+ // count and a complex stylesheet (Daily Mail: ~1400 links, ~3600 CSS
359
+ // rules), this cut this check's runtime from ~30s to well under 1s —
360
+ // a pure ordering change, not a behavior change.
361
+ const rootEl = closestAriaHiddenTrue(el);
362
+ if (!rootEl) continue;
363
+
364
+ if (!isActuallyFocusable(el)) continue;
365
+
366
+ let entry = bucket.get(rootEl);
367
+ if (!entry) {
368
+ entry = { rootEl, count: 0, offenders: [], hints: new Set(), rootIsFocusable: false };
369
+ bucket.set(rootEl, entry);
370
+ }
371
+ entry.count += 1;
372
+
373
+ // Capture a small, deterministic offender summary + visibility hints.
374
+ if (entry.offenders.length < maxOffendersPerRoot) {
375
+ let tag = '';
376
+ let ti = null;
377
+ let href = null;
378
+ let type = null;
379
+
380
+ try { tag = lower(el.tagName || ''); } catch { tag = ''; }
381
+ try { ti = el.getAttribute('tabindex'); } catch { ti = null; }
382
+ try { href = (tag === 'a' || tag === 'area') ? trim(el.getAttribute('href')) : null; } catch { href = null; }
383
+ try { type = tag === 'input' ? lower(el.getAttribute('type') || '') : null; } catch { type = null; }
384
+
385
+ const hints = getVisibilityHints(el);
386
+ for (const h of hints) entry.hints.add(h);
387
+
388
+ entry.offenders.push({
389
+ tag: tag || null,
390
+ tabindex: ti == null ? null : String(ti),
391
+ href: href || null,
392
+ type: type || null,
393
+ visibilityHints: hints
394
+ });
395
+ }
396
+ }
397
+
398
+ if (!bucket.size) {
399
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
400
+ }
401
+
402
+ // Ensure deterministic root focusability computation (done once per failing root).
403
+ for (const [, entry] of bucket) {
404
+ const el = entry.rootEl;
405
+ entry.rootIsFocusable = isActuallyFocusable(el);
406
+ }
407
+
408
+ // 3) Report one occurrence per aria-hidden root that contains focusable content.
409
+ const occurrences = [];
410
+ for (const [, entry] of bucket) {
411
+ const el = entry.rootEl;
412
+
413
+ const eligInfo = getEligibilityInfo
414
+ ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
415
+ : null;
416
+
417
+ // stable visibility hint ordering
418
+ const hintOrder = ['opacityZero', 'offscreen', 'clipped', 'zeroSizeOverflowHidden'];
419
+ const hintsArr = [];
420
+ for (const k of hintOrder) if (entry.hints.has(k)) hintsArr.push(k);
421
+
422
+ const tagName = (() => { try { return lower(el.tagName || ''); } catch { return ''; } })();
423
+
424
+ // Split counts deterministically
425
+ const selfFocusable = !!entry.rootIsFocusable;
426
+ const totalFocusable = entry.count;
427
+ const descendantFocusable = selfFocusable ? Math.max(0, totalFocusable - 1) : totalFocusable;
428
+
429
+ const summaryKey = selfFocusable
430
+ ? (descendantFocusable > 0
431
+ ? 'ariaHidden_focus_summary_fail_self_and_desc'
432
+ : 'ariaHidden_focus_summary_fail_self')
433
+ : 'ariaHidden_focus_summary_fail_desc';
434
+
435
+ const reasonCode = selfFocusable
436
+ ? (descendantFocusable > 0 ? 'ariaHiddenSelfAndDescendantsFocusable' : 'ariaHiddenSelfFocusable')
437
+ : 'ariaHiddenContainsFocusable';
438
+
439
+ const summaryText = selfFocusable
440
+ ? (descendantFocusable > 0
441
+ ? `aria-hidden ${tagName} is focusable and contains ${descendantFocusable} focusable descendant(s) (${totalFocusable} focusable element(s) total).`
442
+ : `aria-hidden ${tagName} is focusable (${totalFocusable} focusable element(s)).`)
443
+ : `aria-hidden ${tagName} contains ${totalFocusable} focusable element(s).`;
444
+
445
+ const baseOccurrence = {
446
+ summary: summaryText,
447
+ hint: 'Remove focusability from descendants or remove aria-hidden; ensure focus and accessibility trees stay aligned.',
448
+ i18n: {
449
+ summaryKey,
450
+ hintKey: 'ariaHidden_focus_hint_fail',
451
+ params: {
452
+ element: tagName,
453
+ focusableCount: String(totalFocusable),
454
+ descendantFocusableCount: String(descendantFocusable)
455
+ }
456
+ },
457
+ data: {
458
+ details: {
459
+ reasonCode,
460
+ metrics: {
461
+ focusableTotal: totalFocusable,
462
+ focusableDescendants: descendantFocusable,
463
+ rootIsFocusable: selfFocusable,
464
+ offendersCaptured: entry.offenders.length,
465
+ visibilityHints: hintsArr.slice(0)
466
+ },
467
+ offenders: entry.offenders.slice(0)
468
+ },
469
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
470
+ }
471
+ };
472
+
473
+ if (reportOccurrence) occurrences.push(reportOccurrence(el, baseOccurrence));
474
+ else occurrences.push({ __node: el, selector: '', html: '', ...baseOccurrence });
475
+ }
476
+
477
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
478
+ }
479
+
480
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check aria-prohibited-attr
5
+ * @atomic true
6
+ * @summary Certain ARIA naming attributes are explicitly prohibited on specific roles
7
+ * @standard WCAG 2.2
8
+ * @sc 4.1.2
9
+ * @applicability
10
+ * Applies to elements whose explicit, valid role is one of the small set
11
+ * of WAI-ARIA 1.2 roles with a documented "Prohibited ARIA States and
12
+ * Properties" list (pure text-semantics / non-naming structural roles:
13
+ * caption, code, deletion, emphasis, generic, insertion, mark, none,
14
+ * paragraph, presentation, strong, subscript, suggestion, superscript,
15
+ * time), and that also carry aria-label or aria-labelledby.
16
+ * @expectation
17
+ * Prohibited attributes must not be present, since these roles are
18
+ * defined to never carry an accessible name — a naming attribute on them
19
+ * is a real, deterministic spec violation, not a style preference.
20
+ * @implementation-notes
21
+ * - Deliberately scoped to the single, well-established prohibition class
22
+ * (naming attributes on pure text-semantics roles) rather than
23
+ * attempting an exhaustive per-role prohibited-attribute table; see
24
+ * src/core/aria-helpers.js file header for this engine's confidence-
25
+ * scoping rationale.
26
+ * - Role list widened 2026-07-19 (Tier 4) from 10 to 13 roles, adding
27
+ * `mark`, `suggestion`, and `time` — the other ARIA 1.2 "HTML-alignment"
28
+ * text-level roles that share the same documented prohibition as the
29
+ * original 10. Still deliberately not claiming full parity with a widely-used
30
+ * reference engine:
31
+ * only roles/attrs this engine has high confidence in from the spec
32
+ * text are included, per the file's own "wrong entries cause false-
33
+ * positive fails" caution.
34
+ * - Widened again 2026-07-21 to add `presentation`/`none`, verified
35
+ * directly against a widely-used reference engine's own role data table
36
+ * (both have `prohibitedAttrs: ['aria-label', 'aria-labelledby']`), and
37
+ * corroborated by the W3C
38
+ * WAI-ARIA 1.2 spec's own §5.2.8.6 "Roles which cannot be named"
39
+ * listing `presentation` explicitly (`none` is `presentation`'s
40
+ * documented 1.2-introduced alias, identical semantics). The
41
+ * pre-existing `presentation-role-conflict` rule already treats
42
+ * aria-label/aria-labelledby as conflicting on these two roles, but at
43
+ * `manual`/cantTell confidence across a ~24-attribute general list —
44
+ * this addition lets the specific, unambiguous naming-prohibition case
45
+ * also fire as a hard, WCAG-normative `fail` via this rule, matching
46
+ * this engine's "one rule = one normative decision" pattern rather than
47
+ * only ever surfacing it as advisory.
48
+ * - Investigated, but deliberately did NOT add, `definition`/`term`
49
+ * despite both appearing on MDN's aria-label reference page's
50
+ * "not supported" list: that MDN list is demonstrably wrong for these
51
+ * two — a widely-used reference engine's own role data explicitly declares
52
+ * `nameFrom: ['author']` (`definition`) / `nameFrom: ['author',
53
+ * 'contents']` (`term`), and the W3C spec's own §5.2.8.4 "Roles
54
+ * Supporting Name From Author" index lists both by name; MDN's
55
+ * `definition_role` page even demonstrates `aria-labelledby` usage on
56
+ * it directly. A real, confirmed documentation bug on MDN's side, not
57
+ * a gap here.
58
+ * - Not gated on isAccTreeEligible: this is a static markup property.
59
+ */
60
+
61
+ const id = 'aria-prohibited-attr';
62
+
63
+ const meta = {
64
+ title: 'ARIA naming attributes must not be used on roles that prohibit them',
65
+ description: 'Checks that aria-label/aria-labelledby are not present on WAI-ARIA roles whose specification explicitly prohibits ARIA naming (e.g. generic, emphasis, strong, paragraph).',
66
+ i18n: {
67
+ titleKey: 'ariaProhibitedAttr_title',
68
+ descriptionKey: 'ariaProhibitedAttr_description'
69
+ },
70
+ helpUrl: null,
71
+ tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
72
+ wcagSc: ['4.1.2'],
73
+ normativeMappings: [
74
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
75
+ ],
76
+ defaultSeverity: 'moderate',
77
+ category: 'robust',
78
+ type: 'automatic',
79
+ defaultConfidence: 'high',
80
+ coverage: { facetsBySc: { '4.1.2': ['aria-attr-not-prohibited'] } }
81
+ };
82
+
83
+ function runInPage(ctx) {
84
+ const { document, root, helpers, rule } = ctx;
85
+ const safeRoot = root || document;
86
+
87
+ const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
88
+ if (!ariaHelpers) {
89
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
90
+ }
91
+
92
+ // Roles whose WAI-ARIA 1.2 definition lists a "Prohibited ARIA States and
93
+ // Properties" entry for naming attributes (these roles must never carry an
94
+ // accessible name). Declared inside runInPage (rather than at module
95
+ // scope) because the build inlines only this function's own source text
96
+ // — see scripts/build-core.js header ("runInPage MUST be self-contained").
97
+ const ROLES_PROHIBITING_NAME = new Set([
98
+ 'caption', 'code', 'deletion', 'emphasis', 'generic', 'insertion',
99
+ 'mark', 'none', 'paragraph', 'presentation', 'strong', 'subscript',
100
+ 'suggestion', 'superscript', 'time'
101
+ ]);
102
+
103
+ const PROHIBITED_NAMING_ATTRS = ['aria-label', 'aria-labelledby'];
104
+
105
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
106
+
107
+ const occurrences = [];
108
+ let applicableCount = 0;
109
+
110
+ for (const el of nodes) {
111
+ if (!el || !el.getAttribute) continue;
112
+
113
+ const role = ariaHelpers.getExplicitRole(el);
114
+ if (!role || !ROLES_PROHIBITING_NAME.has(role)) continue;
115
+
116
+ applicableCount += 1;
117
+
118
+ const present = [];
119
+ for (const attr of PROHIBITED_NAMING_ATTRS) {
120
+ const v = el.getAttribute(attr);
121
+ if (v != null && String(v).trim() !== '') present.push(attr);
122
+ }
123
+
124
+ if (!present.length) continue;
125
+
126
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
127
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
128
+
129
+ for (const attr of present) {
130
+ occurrences.push({
131
+ selector: stableSelector,
132
+ html,
133
+ summary: 'This attribute is prohibited on this element’s role.',
134
+ hint: 'Remove this attribute; this role must not carry an accessible name.',
135
+ i18n: {
136
+ summaryKey: 'ariaProhibitedAttr_summary_fail',
137
+ hintKey: 'ariaProhibitedAttr_hint_fail',
138
+ params: { attr, role }
139
+ },
140
+ data: {
141
+ details: { reasonCode: 'ARIA_ATTR_PROHIBITED', attr, role }
142
+ }
143
+ });
144
+ }
145
+ }
146
+
147
+ if (applicableCount === 0) {
148
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
149
+ }
150
+ if (occurrences.length) {
151
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
152
+ }
153
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
154
+ }
155
+
156
+ module.exports = { id, meta, runInPage };