@surea11y/core 1.3.0 → 1.4.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 (163) hide show
  1. package/CHANGELOG.md +46 -2
  2. package/README.md +109 -35
  3. package/bin/surea11y-core.js +20 -0
  4. package/docs/API_STABILITY.md +26 -0
  5. package/docs/CI_INTEGRATIONS.md +7 -7
  6. package/docs/ENGINE_OPTIONS.md +1 -1
  7. package/docs/I18N.md +12 -9
  8. package/docs/INTEGRATION.md +1 -1
  9. package/docs/LIMITATIONS.md +1 -1
  10. package/docs/REPORT.md +1 -1
  11. package/package.json +50 -16
  12. package/src/baseline.js +0 -0
  13. package/src/checks/automatic/area-alt-present.js +4 -6
  14. package/src/checks/automatic/aria-allowed-attr.js +15 -51
  15. package/src/checks/automatic/aria-allowed-role.js +2 -0
  16. package/src/checks/automatic/aria-braille-equivalent.js +2 -0
  17. package/src/checks/automatic/aria-conditional-attr.js +8 -7
  18. package/src/checks/automatic/aria-deprecated-role.js +4 -3
  19. package/src/checks/automatic/aria-hidden-body.js +6 -4
  20. package/src/checks/automatic/aria-hidden-focus.js +12 -13
  21. package/src/checks/automatic/aria-prohibited-attr.js +98 -105
  22. package/src/checks/automatic/aria-prohibited-children.js +56 -87
  23. package/src/checks/automatic/aria-required-attr.js +6 -7
  24. package/src/checks/automatic/aria-required-children.js +7 -10
  25. package/src/checks/automatic/aria-required-parent.js +20 -25
  26. package/src/checks/automatic/aria-role-name-present.js +2 -0
  27. package/src/checks/automatic/aria-roles-valid.js +2 -0
  28. package/src/checks/automatic/aria-valid-attr-value.js +18 -15
  29. package/src/checks/automatic/aria-valid-attr.js +2 -0
  30. package/src/checks/automatic/autocomplete-valid.js +2 -0
  31. package/src/checks/automatic/avoid-inline-spacing.js +3 -2
  32. package/src/checks/automatic/binary-control-name-present.js +2 -0
  33. package/src/checks/automatic/button-name-present.js +7 -7
  34. package/src/checks/automatic/bypass-blocks-present.js +9 -7
  35. package/src/checks/automatic/canvas-text-alternative-present.js +2 -0
  36. package/src/checks/automatic/combobox-name-present.js +2 -0
  37. package/src/checks/automatic/contrast-computable.js +2 -0
  38. package/src/checks/automatic/contrast-enhanced.js +2 -0
  39. package/src/checks/automatic/contrast-minimum.js +2 -0
  40. package/src/checks/automatic/css-orientation-lock.js +21 -27
  41. package/src/checks/automatic/definition-list-children-valid.js +6 -6
  42. package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
  43. package/src/checks/automatic/dialog-name-present.js +10 -10
  44. package/src/checks/automatic/dlitem-parent-valid.js +2 -0
  45. package/src/checks/automatic/duplicate-id-aria.js +4 -3
  46. package/src/checks/automatic/embed-text-alternative-present.js +2 -0
  47. package/src/checks/automatic/form-control-programmatic-label-present.js +2 -0
  48. package/src/checks/automatic/form-control-single-label.js +6 -7
  49. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
  50. package/src/checks/automatic/iframe-focusable-content.js +246 -18
  51. package/src/checks/automatic/iframe-name-present.js +2 -0
  52. package/src/checks/automatic/iframe-title-unique.js +3 -1
  53. package/src/checks/automatic/img-alt-present.js +7 -9
  54. package/src/checks/automatic/input-image-alt-present.js +4 -6
  55. package/src/checks/automatic/label-in-name.js +15 -19
  56. package/src/checks/automatic/language-page-present.js +2 -0
  57. package/src/checks/automatic/link-in-text-block.js +2 -0
  58. package/src/checks/automatic/link-name-present.js +2 -0
  59. package/src/checks/automatic/list-children-valid.js +14 -24
  60. package/src/checks/automatic/listbox-name-present.js +2 -0
  61. package/src/checks/automatic/listitem-parent-valid.js +30 -7
  62. package/src/checks/automatic/menuitem-name-present.js +2 -0
  63. package/src/checks/automatic/meta-refresh-no-exceptions.js +4 -4
  64. package/src/checks/automatic/meta-refresh-timing-absent.js +2 -0
  65. package/src/checks/automatic/meta-viewport-zoom-enabled.js +2 -0
  66. package/src/checks/automatic/meter-name-present.js +4 -3
  67. package/src/checks/automatic/nested-interactive-controls-absent.js +27 -5
  68. package/src/checks/automatic/object-text-alternative-present.js +2 -0
  69. package/src/checks/automatic/option-name-present.js +2 -0
  70. package/src/checks/automatic/page-title-present.js +2 -0
  71. package/src/checks/automatic/progressbar-name-present.js +8 -10
  72. package/src/checks/automatic/role-img-alt-present.js +4 -4
  73. package/src/checks/automatic/searchbox-name-present.js +2 -0
  74. package/src/checks/automatic/server-side-image-map-absent.js +4 -3
  75. package/src/checks/automatic/slider-name-present.js +2 -0
  76. package/src/checks/automatic/spinbutton-name-present.js +2 -0
  77. package/src/checks/automatic/summary-name-present.js +2 -0
  78. package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
  79. package/src/checks/automatic/svg-text-alternative-present.js +17 -5
  80. package/src/checks/automatic/tab-name-present.js +2 -0
  81. package/src/checks/automatic/table-headers-attr-valid.js +3 -2
  82. package/src/checks/automatic/table-th-has-data-cells.js +2 -0
  83. package/src/checks/automatic/target-size-minimum.js +5 -0
  84. package/src/checks/automatic/td-has-header.js +24 -1
  85. package/src/checks/automatic/textbox-name-present.js +2 -0
  86. package/src/checks/automatic/tooltip-name-present.js +2 -0
  87. package/src/checks/automatic/treeitem-name-present.js +2 -0
  88. package/src/checks/automatic/valid-lang.js +2 -0
  89. package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
  90. package/src/checks/manual/accesskeys-manual.js +3 -1
  91. package/src/checks/manual/area-alt-decorative-manual.js +2 -0
  92. package/src/checks/manual/area-alt-quality-manual.js +2 -0
  93. package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
  94. package/src/checks/manual/aria-text-manual.js +6 -5
  95. package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
  96. package/src/checks/manual/css-hidden-focus.js +184 -9
  97. package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
  98. package/src/checks/manual/empty-heading-manual.js +17 -17
  99. package/src/checks/manual/empty-table-header-manual.js +52 -25
  100. package/src/checks/manual/focus-order-semantics-manual.js +16 -4
  101. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
  102. package/src/checks/manual/heading-order-manual.js +28 -1
  103. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
  104. package/src/checks/manual/image-redundant-alt-manual.js +21 -1
  105. package/src/checks/manual/img-alt-decorative-manual.js +2 -0
  106. package/src/checks/manual/img-alt-quality-manual.js +2 -0
  107. package/src/checks/manual/input-image-alt-decorative-manual.js +2 -0
  108. package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
  109. package/src/checks/manual/label-title-only-manual.js +29 -22
  110. package/src/checks/manual/landmark-banner-is-top-level-manual.js +54 -47
  111. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +42 -26
  112. package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
  113. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
  114. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
  115. package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
  116. package/src/checks/manual/landmark-one-main-manual.js +12 -23
  117. package/src/checks/manual/landmark-unique-manual.js +37 -52
  118. package/src/checks/manual/link-name-quality-manual.js +2 -0
  119. package/src/checks/manual/media-transcript-present-manual.js +2 -0
  120. package/src/checks/manual/meta-viewport-large-manual.js +3 -1
  121. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
  122. package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
  123. package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
  124. package/src/checks/manual/p-as-heading-manual.js +2 -0
  125. package/src/checks/manual/page-has-heading-one-manual.js +12 -11
  126. package/src/checks/manual/page-title-patterns-manual.js +2 -0
  127. package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
  128. package/src/checks/manual/region-manual.js +27 -36
  129. package/src/checks/manual/scope-attr-valid-manual.js +3 -1
  130. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
  131. package/src/checks/manual/skip-link-manual.js +7 -6
  132. package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
  133. package/src/checks/manual/tabindex-manual.js +3 -1
  134. package/src/checks/manual/table-duplicate-name-manual.js +5 -4
  135. package/src/checks/manual/table-fake-caption-manual.js +24 -3
  136. package/src/checks/manual/video-caption-manual.js +2 -0
  137. package/src/checks/manual-review.js +2 -0
  138. package/src/core.js +6772 -2158
  139. package/src/index.js +2 -0
  140. package/src/report.js +51 -9
  141. package/src/sarif.js +18 -3
  142. package/surea11y.browser.js +2731 -999
  143. package/bin/core.js +0 -473
  144. package/docs/CLI.md +0 -128
  145. package/src/catalogs/composites.wcag.js +0 -454
  146. package/src/checks/rules-and-tags.full.csv +0 -19
  147. package/src/checks/rules-and-tags.full.json +0 -259
  148. package/src/core/aria-helpers.js +0 -1211
  149. package/src/core/contrast-helpers.js +0 -1302
  150. package/src/core/dom-helpers.js +0 -4493
  151. package/src/core/dom-runner.js +0 -787
  152. package/src/core/frame-messaging.js +0 -261
  153. package/src/core/frame-scan.js +0 -190
  154. package/src/core/rollup-composites.js +0 -127
  155. package/src/core/rule-meta.js +0 -176
  156. package/src/coverage/wcag-facets.js +0 -1079
  157. package/src/coverage/wcag-version-map.js +0 -84
  158. package/src/i18n/en.js +0 -1228
  159. package/src/i18n/fr.js +0 -1185
  160. package/src/policy/contracts.js +0 -18
  161. package/src/policy/resolvePolicy.js +0 -59
  162. package/src/policy/schemas/engine-options.schema.json +0 -103
  163. package/src/policy/schemas/policy-contract.schema.json +0 -40
@@ -1,1211 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * Shared WAI-ARIA 1.2 role/attribute reference data + validation helpers,
5
- * exposed to rules via ctx.helpers.aria.
6
- *
7
- * SCOPE AND CONFIDENCE NOTES (read before extending)
8
- * ----------------------------------------------------
9
- * This data was hand-authored from the WAI-ARIA 1.2 specification and the
10
- * ARIA in HTML specification, not generated from an official machine-
11
- * readable feed. To protect FAIL integrity (automatic `fail` must only be
12
- * emitted for deterministic, high-confidence violations), this module is
13
- * DELIBERATELY CONSERVATIVE in two specific places:
14
- *
15
- * 1) REQUIRED_PROPS_BY_ROLE only lists a required state/property when the
16
- * spec is unambiguous and context-independent. Several roles have
17
- * required properties that are context-dependent (e.g. option's
18
- * aria-selected default varies by selection-follows-focus context) or
19
- * where sources disagree — those are intentionally left out of
20
- * "required" (they remain valid/supported, just not enforced as
21
- * required) rather than risk flagging compliant markup.
22
- * 2) ALLOWED_ROLES_BY_ELEMENT (ARIA-in-HTML permitted-roles table) covers
23
- * the most common/impactful HTML elements first, not the full HTML
24
- * element inventory. Elements not present in this table are treated as
25
- * "no constraint asserted" (aria-allowed-role stays silent) rather than
26
- * guessed at.
27
- *
28
- * Both scope-limitations are intentional per this engine's "coverage-
29
- * driven growth, not vibes" principle (see surea11y-engine.design.md).
30
- * Expanding either table is safe to do incrementally; narrowing them
31
- * (turning a supported-but-not-required property into "required", or
32
- * adding an element to ALLOWED_ROLES_BY_ELEMENT) should be cross-checked
33
- * against the normative WAI-ARIA / ARIA-in-HTML specs first, since a wrong
34
- * "required" or "not allowed" entry directly causes false-positive fails.
35
- */
36
-
37
- function createAriaHelpers(opts, shared) {
38
- const trim = (shared && shared.trim) || ((v) => (v == null ? '' : String(v)).trim());
39
- const lower = (v) => trim(v).toLowerCase();
40
- const ariaDocument = opts && opts.document;
41
- // Same normalization as createDomHelpers's `roots` (src/core/dom-helpers.js)
42
- // -- opts.root accepts a single element or an array (multi-region
43
- // contextSelector support). Used to bound hasLandmarkScopingAncestor's
44
- // ancestor walk to the scanned scope; see that function below.
45
- const ariaRoots = (() => {
46
- const r = opts && opts.root;
47
- if (Array.isArray(r)) return r.filter((x) => x && typeof x === 'object');
48
- if (r && typeof r === 'object') return [r];
49
- return [];
50
- })();
51
-
52
- // Existence check for a single ID token — never throws, returns false
53
- // (not "unknown") when the document isn't available so callers degrade
54
- // to their pre-existing format-only behavior rather than guessing.
55
- function idExists(id) {
56
- if (!ariaDocument || typeof ariaDocument.getElementById !== 'function') return true;
57
- try {
58
- return !!ariaDocument.getElementById(id);
59
- } catch {
60
- return true;
61
- }
62
- }
63
-
64
- // Presence-only accessible-name check (aria-label / aria-labelledby),
65
- // for the small set of role-permission decisions that are themselves
66
- // conditioned on "does this element currently have a name" (e.g.
67
- // <section>'s permitted-roles set — see ALLOWED_ROLES_BY_ELEMENT below).
68
- // Deliberately mirrors landmark-unique-manual.js's own
69
- // getAccessibleLandmarkName (aria-label then aria-labelledby, not
70
- // title) rather than a full accname computation, for the same reason
71
- // that rule gives: consistency with what actually makes an element a
72
- // named landmark per the W3C ARIA-in-HTML spec.
73
- function hasAccessibleNameHint(el) {
74
- const al = trim(getAttr(el, 'aria-label'));
75
- if (al) return true;
76
- const alb = trim(getAttr(el, 'aria-labelledby'));
77
- if (!alb || !ariaDocument || typeof ariaDocument.getElementById !== 'function') return false;
78
- for (const refId of alb.split(/\s+/).filter(Boolean)) {
79
- try {
80
- const ref = ariaDocument.getElementById(refId);
81
- if (ref && trim(ref.textContent)) return true;
82
- } catch {}
83
- }
84
- return false;
85
- }
86
-
87
- // Shared "does this element have a landmark-scoping ancestor" primitive
88
- // — <header>'s "banner", <footer>'s "contentinfo", and <aside>'s
89
- // "complementary" implicit roles are all conditioned on the same W3C
90
- // ARIA-in-HTML exclusion: suppressed when nested inside sectioning
91
- // content (article/aside/nav/section), and for header/footer only,
92
- // also suppressed when nested inside <main> (pass includeMain: true).
93
- // <aside> itself omits <main> from its own exclusion list — see the
94
- // `aside` case in getElementRoleKey below — so callers must pass the
95
- // right includeMain for the role they're computing.
96
- //
97
- // ROLE-AWARE, not tag-only: an ancestor's bare TAG only counts when it
98
- // has NO role attribute at all; once ANY role attribute is present,
99
- // only that attribute's own (first-token) value decides membership —
100
- // matching a widely-used reference engine's real
101
- // getSectioningContentSelector/getSectioningContentPlusMainSelector,
102
- // verified 2026-07-30 by reading that engine's source directly:
103
- // `${tag}:not([role])` for the tag-based branch, OR'd with a wholly
104
- // separate ` [role=article], [role=complementary], [role=navigation],
105
- // [role=region]` branch (plus `, main:not([role]), [role=main]` for
106
- // the plus-main variant) — never a tag-AND-role intersection.
107
- //
108
- // This replaced a tag-name-only ancestor walk (used only for <header>,
109
- // and separately re-duplicated with the same tag-only bug across 6
110
- // manual landmark-check files — see each file's own delegation to
111
- // helpers.hasLandmarkScopingAncestor now) that missed a real page:
112
- // handsontable.com's docs-assistant side panel is an
113
- // <aside role="dialog"> containing its own <header>. role="dialog" is
114
- // not one of the four scoping roles, so per spec the nested <header>
115
- // DOES keep its implicit "banner" role (confirmed against that
116
- // reference engine's real output via a minimal repro) — but the old
117
- // tag-only check unconditionally suppressed it purely because the
118
- // ancestor TAG was <aside>, regardless of its role override. A real
119
- // false negative in landmark-no-duplicate-banner/landmark-unique,
120
- // found via the cross-engine comparisons project 2026-07-30.
121
- const LANDMARK_SCOPING_TAGS = new Set(['article', 'aside', 'nav', 'section']);
122
- const LANDMARK_SCOPING_ROLE_TOKENS = new Set([
123
- 'article',
124
- 'complementary',
125
- 'navigation',
126
- 'region'
127
- ]);
128
-
129
- function isLandmarkScopingAncestorElement(el, includeMain) {
130
- const tag = lower(el.tagName || '');
131
- const roleAttr = getAttr(el, 'role');
132
- if (roleAttr == null) {
133
- // No role attribute at all: falls back to the plain HTML tag.
134
- if (LANDMARK_SCOPING_TAGS.has(tag)) return true;
135
- return includeMain && tag === 'main';
136
- }
137
- // A role attribute is present (even empty/invalid) — the element's
138
- // bare TAG no longer counts; only an explicit, scoping-relevant
139
- // role value does.
140
- const token = trim(roleAttr).split(/\s+/)[0].toLowerCase();
141
- if (LANDMARK_SCOPING_ROLE_TOKENS.has(token)) return true;
142
- return includeMain && token === 'main';
143
- }
144
-
145
- function hasLandmarkScopingAncestor(el, opts) {
146
- if (!isElement(el)) return false;
147
- const includeMain = !!(opts && opts.includeMain);
148
- let cur = el.parentElement;
149
- let guard = 0;
150
- while (cur && guard++ < 200) {
151
- if (isLandmarkScopingAncestorElement(cur, includeMain)) return true;
152
- // Don't climb past the scanned scope -- a contextSelector-scoped
153
- // (or fragment) scan should never let ancestry OUTSIDE the
154
- // analyzed subtree affect a role computed WITHIN it.
155
- if (ariaRoots.includes(cur)) break;
156
- cur = cur.parentElement;
157
- }
158
- return false;
159
- }
160
-
161
- // -------------------------------------------------------------------
162
- // A) Abstract roles — MUST NOT be used directly in a role="" attribute.
163
- // -------------------------------------------------------------------
164
- const ABSTRACT_ROLES = new Set([
165
- 'command',
166
- 'composite',
167
- 'input',
168
- 'landmark',
169
- 'range',
170
- 'roletype',
171
- 'section',
172
- 'sectionhead',
173
- 'select',
174
- 'structure',
175
- 'widget',
176
- 'window'
177
- ]);
178
-
179
- // -------------------------------------------------------------------
180
- // B) Valid, concrete (non-abstract) roles that authors must never
181
- // explicitly declare — either because WAI-ARIA has deprecated them
182
- // (a direct replacement exists) or because they are reserved for
183
- // user-agent-internal use only (not a spec deprecation, but the
184
- // same "valid token, prohibited for authors" shape). Flagged by
185
- // aria-deprecated-role, not aria-roles-valid (which only checks
186
- // existence/abstractness) — see DEPRECATED_ROLE_GUIDANCE below for
187
- // per-role, reason-accurate messaging.
188
- // -------------------------------------------------------------------
189
- const DEPRECATED_ROLES = new Set([
190
- 'directory', // superseded by role="list"
191
- // WAI-ARIA 1.2: "intended for use as the implicit role of generic
192
- // elements in host languages for use by user agents only; not for
193
- // use by developers." MDN, verbatim: "It should not be used by web
194
- // authors." Verified 2026-07-20 against both sources — this is not
195
- // a guess.
196
- 'generic'
197
- ]);
198
-
199
- const DEPRECATED_ROLE_GUIDANCE = {
200
- directory: 'Replace it with role="list" (its recommended replacement).',
201
- generic:
202
- 'Remove it — this role is reserved for user-agent-internal use, not authors. Use role="presentation"/"none" to strip semantics, a semantic role like "group" to convey grouping, or simply a plain element (which already carries the implicit generic role) instead.'
203
- };
204
-
205
- function getDeprecatedRoleGuidance(role) {
206
- const key = lower(role);
207
- return Object.prototype.hasOwnProperty.call(DEPRECATED_ROLE_GUIDANCE, key)
208
- ? DEPRECATED_ROLE_GUIDANCE[key]
209
- : 'Replace the deprecated role with its recommended replacement.';
210
- }
211
-
212
- // -------------------------------------------------------------------
213
- // C) Complete set of concrete (non-abstract) WAI-ARIA 1.2 role tokens.
214
- // -------------------------------------------------------------------
215
- const CONCRETE_ROLES = new Set([
216
- // Live region / window roles
217
- 'alert',
218
- 'alertdialog',
219
- 'dialog',
220
- 'log',
221
- 'marquee',
222
- 'status',
223
- 'timer',
224
- // Landmark roles
225
- 'banner',
226
- 'complementary',
227
- 'contentinfo',
228
- 'form',
229
- 'main',
230
- 'navigation',
231
- 'region',
232
- 'search',
233
- // Widget roles (leaf)
234
- 'button',
235
- 'checkbox',
236
- 'gridcell',
237
- 'link',
238
- 'menuitem',
239
- 'menuitemcheckbox',
240
- 'menuitemradio',
241
- 'option',
242
- 'progressbar',
243
- 'radio',
244
- 'scrollbar',
245
- 'searchbox',
246
- 'separator',
247
- 'slider',
248
- 'spinbutton',
249
- 'switch',
250
- 'tab',
251
- 'tabpanel',
252
- 'textbox',
253
- 'treeitem',
254
- 'tooltip',
255
- // Composite widget roles
256
- 'combobox',
257
- 'grid',
258
- 'listbox',
259
- 'menu',
260
- 'menubar',
261
- 'radiogroup',
262
- 'tablist',
263
- 'tree',
264
- 'treegrid',
265
- // Document structure roles
266
- 'application',
267
- 'article',
268
- 'blockquote',
269
- 'caption',
270
- 'cell',
271
- 'code',
272
- 'columnheader',
273
- 'comment',
274
- 'definition',
275
- 'deletion',
276
- 'directory',
277
- 'document',
278
- 'emphasis',
279
- 'feed',
280
- 'figure',
281
- 'generic',
282
- 'group',
283
- 'heading',
284
- 'img',
285
- 'insertion',
286
- 'list',
287
- 'listitem',
288
- 'mark',
289
- 'math',
290
- 'meter',
291
- 'none',
292
- 'note',
293
- 'paragraph',
294
- 'presentation',
295
- 'row',
296
- 'rowgroup',
297
- 'rowheader',
298
- 'strong',
299
- 'subscript',
300
- 'suggestion',
301
- 'superscript',
302
- 'table',
303
- 'term',
304
- 'text',
305
- 'time',
306
- 'toolbar'
307
- ]);
308
-
309
- // -------------------------------------------------------------------
310
- // D) ARIA attribute value types.
311
- // 'token' — one value from a fixed enumerated set
312
- // 'token-list' — space-separated values from a fixed enumerated set
313
- // 'boolean' — "true" | "false"
314
- // 'tristate' — "true" | "false" | "mixed"
315
- // 'boolean-undefined' — "true" | "false" | "undefined"
316
- // 'idref' — a single ID token (existence not verified here)
317
- // 'idref-list' — space-separated ID tokens
318
- // 'integer' — a base-10 integer (may be negative where noted)
319
- // 'number' — a real number
320
- // 'string' — free-form text (only non-emptiness may be checked)
321
- // -------------------------------------------------------------------
322
- const ATTR_VALUE_TYPES = {
323
- 'aria-activedescendant': 'idref',
324
- 'aria-atomic': 'boolean',
325
- 'aria-autocomplete': 'token',
326
- 'aria-braillelabel': 'string',
327
- 'aria-brailleroledescription': 'string',
328
- 'aria-busy': 'boolean',
329
- 'aria-checked': 'tristate',
330
- 'aria-colcount': 'integer',
331
- 'aria-colindex': 'integer',
332
- 'aria-colindextext': 'string',
333
- 'aria-colspan': 'integer',
334
- 'aria-controls': 'idref-list',
335
- 'aria-current': 'token', // also allows 'true'/'false', handled in token set
336
- 'aria-describedby': 'idref-list',
337
- 'aria-description': 'string',
338
- 'aria-details': 'idref-list',
339
- 'aria-disabled': 'boolean',
340
- 'aria-dropeffect': 'token-list', // deprecated but still validated if present
341
- 'aria-errormessage': 'idref',
342
- 'aria-expanded': 'boolean-undefined',
343
- 'aria-flowto': 'idref-list',
344
- 'aria-grabbed': 'boolean-undefined', // deprecated but still validated if present
345
- 'aria-haspopup': 'token', // also allows 'true'/'false'
346
- 'aria-hidden': 'boolean-undefined',
347
- 'aria-invalid': 'token', // also allows 'true'/'false'
348
- 'aria-keyshortcuts': 'string',
349
- 'aria-label': 'string',
350
- 'aria-labelledby': 'idref-list',
351
- 'aria-level': 'integer',
352
- 'aria-live': 'token',
353
- 'aria-modal': 'boolean',
354
- 'aria-multiline': 'boolean',
355
- 'aria-multiselectable': 'boolean',
356
- 'aria-orientation': 'token',
357
- 'aria-owns': 'idref-list',
358
- 'aria-placeholder': 'string',
359
- 'aria-posinset': 'integer',
360
- 'aria-pressed': 'tristate',
361
- 'aria-readonly': 'boolean',
362
- 'aria-relevant': 'token-list',
363
- 'aria-required': 'boolean',
364
- 'aria-roledescription': 'string',
365
- 'aria-rowcount': 'integer',
366
- 'aria-rowindex': 'integer',
367
- 'aria-rowindextext': 'string',
368
- 'aria-rowspan': 'integer',
369
- 'aria-selected': 'boolean-undefined',
370
- 'aria-setsize': 'integer',
371
- 'aria-sort': 'token',
372
- 'aria-valuemax': 'number',
373
- 'aria-valuemin': 'number',
374
- 'aria-valuenow': 'number',
375
- 'aria-valuetext': 'string'
376
- };
377
-
378
- // Enumerated token sets for 'token'/'token-list' attributes.
379
- const ATTR_TOKEN_SETS = {
380
- 'aria-autocomplete': new Set(['inline', 'list', 'both', 'none']),
381
- 'aria-current': new Set(['page', 'step', 'location', 'date', 'time', 'true', 'false']),
382
- 'aria-dropeffect': new Set(['copy', 'execute', 'link', 'move', 'none', 'popup']),
383
- 'aria-haspopup': new Set(['false', 'true', 'menu', 'listbox', 'tree', 'grid', 'dialog']),
384
- 'aria-invalid': new Set(['grammar', 'false', 'spelling', 'true']),
385
- 'aria-live': new Set(['off', 'polite', 'assertive']),
386
- 'aria-orientation': new Set(['horizontal', 'vertical', 'undefined']),
387
- 'aria-relevant': new Set(['additions', 'removals', 'text', 'all']),
388
- 'aria-sort': new Set(['ascending', 'descending', 'none', 'other'])
389
- };
390
-
391
- // -------------------------------------------------------------------
392
- // E) Required states/properties per role (see file header — deliberately
393
- // conservative; only unambiguous, context-independent cases).
394
- // -------------------------------------------------------------------
395
- const REQUIRED_PROPS_BY_ROLE = {
396
- checkbox: ['aria-checked'],
397
- combobox: ['aria-expanded'],
398
- heading: ['aria-level'],
399
- menuitemcheckbox: ['aria-checked'],
400
- menuitemradio: ['aria-checked'],
401
- // Verified 2026-07-21 against a widely-used reference engine's own
402
- // requiredAttrs table AND cross-checked for the context-dependence
403
- // this table's own policy cares about: unlike progressbar
404
- // (deliberately excluded here and by that engine — an indeterminate
405
- // progressbar legitimately omits aria-valuenow) or separator
406
- // (required only when focusable/acting as a widget, not for the
407
- // common plain-divider usage — a genuine conditional case,
408
- // deliberately NOT added here for that reason), meter has no
409
- // "indeterminate" concept and no focusable/non-
410
- // focusable split: it always represents a concrete measurement, so
411
- // aria-valuenow is unconditionally required. Also investigated
412
- // combobox's aria-controls (in that engine's table too) and deliberately
413
- // did NOT add it — confirmed via MDN's own combobox role page that
414
- // it's only required once the popup is actually displayed
415
- // (aria-expanded="true"), a real context-dependent case this
416
- // table's own stated policy excludes.
417
- meter: ['aria-valuenow'],
418
- radio: ['aria-checked'],
419
- scrollbar: ['aria-valuenow'],
420
- slider: ['aria-valuenow'],
421
- switch: ['aria-checked']
422
- };
423
-
424
- // -------------------------------------------------------------------
425
- // F) Required owned (child) roles for composite/container roles.
426
- // Value is an array of alternative acceptable child roles (any one
427
- // satisfies the requirement). aria-owns references also count as
428
- // "owning" — checked by the rule, not this table.
429
- // -------------------------------------------------------------------
430
- const REQUIRED_OWNED_ROLES = {
431
- list: ['listitem'],
432
- listbox: ['option', 'group'],
433
- menu: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'group'],
434
- menubar: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'group'],
435
- radiogroup: ['radio'],
436
- rowgroup: ['row'],
437
- table: ['row', 'rowgroup'],
438
- grid: ['row', 'rowgroup'],
439
- treegrid: ['row', 'rowgroup'],
440
- tablist: ['tab'],
441
- tree: ['treeitem', 'group'],
442
- row: ['cell', 'gridcell', 'columnheader', 'rowheader']
443
- };
444
-
445
- // -------------------------------------------------------------------
446
- // G) Required context (parent) role for roles that must be owned by a
447
- // specific ancestor role. Value is an array of acceptable ancestor
448
- // roles (any one satisfies the requirement); ownership may be via
449
- // DOM containment OR aria-owns (checked by the rule).
450
- // -------------------------------------------------------------------
451
- const REQUIRED_CONTEXT_ROLE = {
452
- listitem: ['list'],
453
- option: ['listbox', 'group'],
454
- menuitem: ['menu', 'menubar', 'group'],
455
- menuitemcheckbox: ['menu', 'menubar', 'group'],
456
- menuitemradio: ['menu', 'menubar', 'group'],
457
- tab: ['tablist'],
458
- tabpanel: [], // no single required container in ARIA 1.2; left unconstrained
459
- treeitem: ['tree', 'group'],
460
- row: ['rowgroup', 'grid', 'table', 'treegrid'],
461
- cell: ['row'],
462
- gridcell: ['row'],
463
- columnheader: ['row'],
464
- rowheader: ['row'],
465
- rowgroup: ['grid', 'table', 'treegrid']
466
- };
467
-
468
- // -------------------------------------------------------------------
469
- // H) ARIA-in-HTML permitted roles per element (deliberately scoped to
470
- // the most common elements first — see file header). `null` values
471
- // are used for elements that permit "any role" in typical states.
472
- // Element keys may include a simple attribute condition using the
473
- // form 'tag[attr]' or 'tag[attr=value]' for the small number of
474
- // elements whose permitted roles depend on an attribute.
475
- // -------------------------------------------------------------------
476
- const ALLOWED_ROLES_BY_ELEMENT = {
477
- // Verified against a widely-used reference engine's own allowedRoles table for the
478
- // 'href' variant of 'a' (2026-07-20 — found via a real page: Blick
479
- // Art Materials' homepage carousel uses <a href="..." role="group">
480
- // for its slides, which is not a permitted override; a plain
481
- // <a href> is NOT unconstrained the way a hrefless <a> is — that was
482
- // the actual bug, this key had been set to null/"any role" by
483
- // mistake). Restating the native 'link' role is still always
484
- // permitted via the native-role fallback below regardless of this
485
- // list.
486
- 'a[href]': [
487
- 'button',
488
- 'checkbox',
489
- 'menuitem',
490
- 'menuitemcheckbox',
491
- 'menuitemradio',
492
- 'option',
493
- 'radio',
494
- 'switch',
495
- 'tab',
496
- 'treeitem',
497
- 'doc-backlink',
498
- 'doc-biblioref',
499
- 'doc-glossref',
500
- 'doc-noteref'
501
- ],
502
- // Verified against a widely-used reference engine's own allowedRoles table for
503
- // 'article' (2026-07-21 — found via a real page: Udacity's
504
- // homepage carousel, a Swiper.js slider whose 15 slides are each
505
- // <article role="group">, not a permitted override). Restating
506
- // the native 'article' role remains permitted via the native-role
507
- // fallback below regardless of this list.
508
- article: ['feed', 'presentation', 'none', 'document', 'application', 'main', 'region'],
509
- // Verified against a widely-used reference engine's own allowedRoles table for the
510
- // 'href' variant of 'area' (2026-07-20, found while re-checking the
511
- // sibling <a href> bug above): that engine sets this to `false`, i.e. NO
512
- // override role is permitted at all on an <area href> — only its
513
- // native 'link' role, via the native-role fallback below. An empty
514
- // array (not null) is the correct encoding, same convention already
515
- // used for 'label[associated]' below.
516
- 'area[href]': [],
517
- // Verified against a widely-used reference engine and the W3C ARIA-in-HTML spec
518
- // (2026-07-20): <area> without href permits only these two roles
519
- // ('generic' is also technically allowed but SHOULD NOT be used per
520
- // spec, and that engine itself excludes it from its allowed-roles list, so
521
- // it's left out here too rather than asserted as permitted).
522
- area: ['button', 'link'],
523
- // Verified against a widely-used reference engine's own element-spec table for 'html'
524
- // (2026-07-21, found via a real page: news24.com's South Africa
525
- // homepage uses <html role="document">): that engine sets this to
526
- // `allowedRoles: false` — no explicit role is ever permitted on
527
- // <html>, and there's no native role to restate either (no entry in
528
- // NATIVE_ROLE_BY_ELEMENT_KEY below), so an empty array is correct
529
- // here, not "unconstrained" (no prior entry meant this element was
530
- // silently unchecked).
531
- html: [],
532
- // Verified against a widely-used reference engine's own element-spec table for 'picture'
533
- // (2026-07-23, found via a real page: TradingView's homepage,
534
- // <picture role="presentation"> used twice for hero illustrations):
535
- // that engine sets this to `allowedRoles: false` -- no override role is ever
536
- // permitted on <picture> (it has no implicit ARIA role either, so
537
- // there's no native-role-restatement exception -- an empty array is
538
- // correct, same convention as 'html'/'area[href]' above).
539
- picture: [],
540
- button: [
541
- 'checkbox',
542
- 'combobox',
543
- 'link',
544
- 'menuitem',
545
- 'menuitemcheckbox',
546
- 'menuitemradio',
547
- 'option',
548
- 'radio',
549
- 'switch',
550
- 'tab'
551
- ],
552
- h1: ['tab', 'presentation', 'none'],
553
- h2: ['tab', 'presentation', 'none'],
554
- h3: ['tab', 'presentation', 'none'],
555
- h4: ['tab', 'presentation', 'none'],
556
- h5: ['tab', 'presentation', 'none'],
557
- h6: ['tab', 'presentation', 'none'],
558
- hr: ['none', 'presentation'],
559
- // Verified against a widely-used reference engine and the W3C ARIA-in-HTML spec
560
- // (2026-07-20 — found via a real page: Stack Overflow's search
561
- // filter panel uses <form role="region">, which surea11y was
562
- // silently not checking at all — no entry meant "unconstrained").
563
- // 'complementary' is <aside>'s own native role — allowed via the
564
- // native-role fallback below even though it's not in this list
565
- // (spec: "also allowed, but NOT RECOMMENDED", same shape as <nav>).
566
- aside: ['feed', 'none', 'note', 'presentation', 'region', 'search'],
567
- form: ['form', 'search', 'none', 'presentation'],
568
- // Verified against a widely-used reference engine's own allowedRoles table for
569
- // 'header' (2026-07-20): the old code had no entry at all for
570
- // <header>, meaning "no role constraint" — silently not checking it.
571
- // Found via a real site: Vimeo's global nav uses
572
- // <header role="navigation">, invalid regardless of nesting since
573
- // "navigation" isn't in that engine's allowed array either way. The array
574
- // itself is identical for both keys — what differs by nesting is
575
- // only the native-role match (see 'header[toplevel]' below and
576
- // getElementRoleKey's header branch): a top-level <header
577
- // role="banner"> restates its own implicit "banner" role (a no-op,
578
- // always permitted) even though 'banner' isn't in this array —
579
- // found via a real site, Navy Federal's top-level <header
580
- // role="banner">, which was a false-positive fail before this split
581
- // existed (same shape as <section>'s named/unnamed split).
582
- 'header[toplevel]': ['group', 'none', 'presentation', 'doc-footnote'],
583
- header: ['group', 'none', 'presentation', 'doc-footnote'],
584
- // Verified against a widely-used reference engine and the W3C ARIA-in-HTML spec
585
- // (2026-07-20): a <label> associated with a labelable control
586
- // permits no explicit role at all (see getElementRoleKey's
587
- // label[associated] split above).
588
- 'label[associated]': [],
589
- // Verified against a widely-used reference engine and the W3C ARIA-in-HTML spec
590
- // (2026-07-20): permitted roles depend on whether the img has a
591
- // non-empty alt (see getElementRoleKey's img[alt]/img split above).
592
- 'img[alt]': [
593
- 'button',
594
- 'checkbox',
595
- 'link',
596
- 'math',
597
- 'menuitem',
598
- 'menuitemcheckbox',
599
- 'menuitemradio',
600
- 'meter',
601
- 'option',
602
- 'progressbar',
603
- 'radio',
604
- 'scrollbar',
605
- 'separator',
606
- 'slider',
607
- 'switch',
608
- 'tab',
609
- 'treeitem'
610
- ],
611
- img: ['presentation', 'none'],
612
- li: [
613
- 'menuitem',
614
- 'menuitemcheckbox',
615
- 'menuitemradio',
616
- 'option',
617
- 'radio',
618
- 'separator',
619
- 'tab',
620
- 'treeitem',
621
- 'listitem',
622
- 'presentation',
623
- 'none'
624
- ],
625
- // Verified against a widely-used reference engine's own allowedRoles table for
626
- // 'nav' (2026-07-20, found via a real site — Vimeo's global nav
627
- // uses <nav role="menu">/<nav aria-label="Menu" role="menu"> for
628
- // its dropdown panels): the old entry only had presentation/none.
629
- nav: [
630
- 'doc-index',
631
- 'doc-pagelist',
632
- 'doc-toc',
633
- 'menu',
634
- 'menubar',
635
- 'none',
636
- 'presentation',
637
- 'tablist'
638
- ],
639
- // Verified against a widely-used reference engine (2026-07-20): every other role
640
- // tried (presentation/none/img/document/group/figure/region/banner/
641
- // button/link/main/article/tab/list/table) is disallowed on <video>.
642
- video: ['application'],
643
- // Verified against a widely-used reference engine (2026-07-20): same probe as
644
- // <video>, but img/document are also permitted (an <object> can
645
- // stand in for an image or a full document, unlike <video>).
646
- object: ['application', 'img', 'document'],
647
- // Verified against a widely-used reference engine and the W3C ARIA-in-HTML spec
648
- // (2026-07-20 — see getElementRoleKey's section[named]/section
649
- // split above): 'region' is only permitted when the section has an
650
- // accessible name (it's <section>'s own conditional native role in
651
- // that case); every other role here is permitted regardless of
652
- // naming.
653
- 'section[named]': [
654
- 'alert',
655
- 'alertdialog',
656
- 'application',
657
- 'banner',
658
- 'complementary',
659
- 'contentinfo',
660
- 'dialog',
661
- 'document',
662
- 'feed',
663
- 'group',
664
- 'log',
665
- 'main',
666
- 'marquee',
667
- 'navigation',
668
- 'none',
669
- 'note',
670
- 'presentation',
671
- 'region',
672
- 'search',
673
- 'status',
674
- 'tabpanel'
675
- ],
676
- section: [
677
- 'alert',
678
- 'alertdialog',
679
- 'application',
680
- 'banner',
681
- 'complementary',
682
- 'contentinfo',
683
- 'dialog',
684
- 'document',
685
- 'feed',
686
- 'group',
687
- 'log',
688
- 'main',
689
- 'marquee',
690
- 'navigation',
691
- 'none',
692
- 'note',
693
- 'presentation',
694
- 'search',
695
- 'status',
696
- 'tabpanel'
697
- ],
698
- ol: [
699
- 'group',
700
- 'listbox',
701
- 'menu',
702
- 'menubar',
703
- 'radiogroup',
704
- 'tablist',
705
- 'toolbar',
706
- 'tree',
707
- 'presentation',
708
- 'none'
709
- ],
710
- ul: [
711
- 'group',
712
- 'listbox',
713
- 'menu',
714
- 'menubar',
715
- 'radiogroup',
716
- 'tablist',
717
- 'toolbar',
718
- 'tree',
719
- 'presentation',
720
- 'none'
721
- ],
722
- // role="button" is only permitted when paired with aria-pressed
723
- // (verified against a widely-used reference engine and the W3C ARIA-in-HTML spec,
724
- // 2026-07-20 — see getElementRoleKey's checkbox[aria-pressed] split
725
- // above).
726
- 'input[type=checkbox][aria-pressed]': ['button', 'menuitemcheckbox', 'option', 'switch'],
727
- 'input[type=checkbox]': ['menuitemcheckbox', 'option', 'switch'],
728
- 'input[type=radio]': ['menuitemradio'],
729
- 'input[type=image]': [
730
- 'button',
731
- 'link',
732
- 'menuitem',
733
- 'menuitemcheckbox',
734
- 'menuitemradio',
735
- 'radio',
736
- 'switch'
737
- ],
738
- 'input[type=text]': ['combobox', 'searchbox', 'spinbutton'],
739
- 'input[type=search]': ['combobox', 'spinbutton'],
740
- 'input[type=tel]': ['combobox', 'spinbutton'],
741
- 'input[type=url]': ['combobox', 'spinbutton'],
742
- 'input[type=email]': ['combobox', 'spinbutton'],
743
- select: ['menu'],
744
- // <select multiple> or <select size> 1>: native role is listbox, not
745
- // combobox (see NATIVE_ROLE_BY_ELEMENT_KEY below) — no override role
746
- // is permitted per ARIA-in-HTML, but restating the native listbox
747
- // role itself is always allowed via isRoleAllowedOnElement's
748
- // native-role fallback, same as every other element in this table.
749
- 'select[multiple]': [],
750
- // Verified against a widely-used reference engine and the W3C ARIA-in-HTML spec
751
- // (2026-07-20 — found via a real page: Wikipedia's sidebar uses
752
- // <table role="navigation">, which surea11y was incorrectly
753
- // failing): <table> permits any role. <td>/<th>/<tr> are spec'd as
754
- // context-dependent (restricted only when the ancestor <table> is
755
- // itself exposed with role=table/grid/treegrid) — a widely-used reference engine's own
756
- // table doesn't implement that conditional either (always
757
- // unconstrained for these three), so this follows the same
758
- // deliberate simplification rather than guessing at ancestor-role
759
- // resolution.
760
- table: null,
761
- td: null,
762
- th: null,
763
- tr: null
764
- };
765
-
766
- // -------------------------------------------------------------------
767
- // H2) Native/implicit role per ALLOWED_ROLES_BY_ELEMENT key. Keeping an
768
- // element's own native role (e.g. role="list" on <ul>, role="table"
769
- // on <table>) is never a spec violation — the ARIA-in-HTML "allowed
770
- // roles" tables enumerate roles you may override *to*, not the
771
- // native default, which remains implicitly valid whether or not it
772
- // is redundantly re-declared. isRoleAllowedOnElement always accepts
773
- // this role in addition to whatever ALLOWED_ROLES_BY_ELEMENT lists.
774
- // -------------------------------------------------------------------
775
- const NATIVE_ROLE_BY_ELEMENT_KEY = {
776
- 'a[href]': 'link',
777
- 'area[href]': 'link',
778
- article: 'article',
779
- aside: 'complementary',
780
- button: 'button',
781
- form: 'form',
782
- // No entry for plain 'header' — a header nested in sectioning
783
- // content/<main> has no implicit role to restate (matches that engine's
784
- // own implicit-role function returning null in that case).
785
- 'header[toplevel]': 'banner',
786
- h1: 'heading',
787
- h2: 'heading',
788
- h3: 'heading',
789
- h4: 'heading',
790
- h5: 'heading',
791
- h6: 'heading',
792
- hr: 'separator',
793
- 'img[alt]': 'img',
794
- img: 'img',
795
- li: 'listitem',
796
- nav: 'navigation',
797
- ol: 'list',
798
- ul: 'list',
799
- 'input[type=checkbox][aria-pressed]': 'checkbox',
800
- 'input[type=checkbox]': 'checkbox',
801
- 'input[type=radio]': 'radio',
802
- 'input[type=image]': 'button',
803
- 'input[type=text]': 'textbox',
804
- 'input[type=search]': 'searchbox',
805
- 'input[type=tel]': 'textbox',
806
- 'input[type=url]': 'textbox',
807
- 'input[type=email]': 'textbox',
808
- select: 'combobox',
809
- 'select[multiple]': 'listbox',
810
- table: 'table',
811
- td: 'cell',
812
- th: 'columnheader',
813
- tr: 'row'
814
- };
815
-
816
- // -------------------------------------------------------------------
817
- // I) Native HTML tag -> implicit "containment role" mapping, used only
818
- // for aria-required-children / aria-required-parent ownership
819
- // matching (getContainmentRole). Deliberately small and scoped to
820
- // exactly the roles referenced by REQUIRED_OWNED_ROLES /
821
- // REQUIRED_CONTEXT_ROLE above, so that adding an explicit container
822
- // role (e.g. role="list" on a <ul>, a common CSS-reset workaround)
823
- // does not produce a false positive against plain native children
824
- // (e.g. <li> with no role attribute) — same scope-limiting rationale
825
- // as ALLOWED_ROLES_BY_ELEMENT (see file header).
826
- // -------------------------------------------------------------------
827
- const NATIVE_CONTAINMENT_ROLE_BY_ELEMENT = {
828
- li: 'listitem',
829
- option: 'option',
830
- tr: 'row',
831
- td: 'cell',
832
- th: 'columnheader',
833
- thead: 'rowgroup',
834
- tbody: 'rowgroup',
835
- tfoot: 'rowgroup',
836
- ul: 'list',
837
- ol: 'list',
838
- table: 'table',
839
- select: 'listbox',
840
- 'input[type=radio]': 'radio'
841
- };
842
-
843
- function isElement(el) {
844
- return !!(el && el.nodeType === 1);
845
- }
846
-
847
- function getAttr(el, name) {
848
- try {
849
- return el && el.getAttribute ? el.getAttribute(name) : null;
850
- } catch {
851
- return null;
852
- }
853
- }
854
-
855
- // -------------------------------------------------------------------
856
- // Public API
857
- // -------------------------------------------------------------------
858
-
859
- function getExplicitRole(el) {
860
- if (!isElement(el)) return '';
861
- const raw = trim(getAttr(el, 'role'));
862
- if (!raw) return '';
863
- // role attribute may be a space-separated fallback list; the first
864
- // token is the "primary" role used by the accessibility tree.
865
- const tokens = raw.split(/\s+/).filter(Boolean);
866
- return tokens.length ? lower(tokens[0]) : '';
867
- }
868
-
869
- function getAllRoleTokens(el) {
870
- if (!isElement(el)) return [];
871
- const raw = trim(getAttr(el, 'role'));
872
- if (!raw) return [];
873
- return raw.split(/\s+/).filter(Boolean).map(lower);
874
- }
875
-
876
- function isAbstractRole(role) {
877
- return ABSTRACT_ROLES.has(lower(role));
878
- }
879
-
880
- function isDeprecatedRole(role) {
881
- return DEPRECATED_ROLES.has(lower(role));
882
- }
883
-
884
- function isKnownRole(role) {
885
- const r = lower(role);
886
- return ABSTRACT_ROLES.has(r) || CONCRETE_ROLES.has(r);
887
- }
888
-
889
- function isValidConcreteRole(role) {
890
- return CONCRETE_ROLES.has(lower(role));
891
- }
892
-
893
- function isValidAriaAttrName(name) {
894
- const n = lower(name);
895
- if (!n || n.slice(0, 5) !== 'aria-') return false;
896
- return Object.prototype.hasOwnProperty.call(ATTR_VALUE_TYPES, n);
897
- }
898
-
899
- function getAttrValueType(name) {
900
- return ATTR_VALUE_TYPES[lower(name)] || null;
901
- }
902
-
903
- // Validates a single attribute's raw string value against its declared
904
- // value type. Returns { valid, reason } — reason is a short machine
905
- // code, not a user-facing string (rules localize their own messages).
906
- function validateAttrValue(name, rawValue) {
907
- const type = getAttrValueType(name);
908
- if (!type) return { valid: true, reason: 'unknown-attr-skip' };
909
-
910
- const v = trim(rawValue);
911
-
912
- switch (type) {
913
- case 'boolean': {
914
- const ok = v === 'true' || v === 'false';
915
- return { valid: ok, reason: ok ? '' : 'expected-true-false' };
916
- }
917
- case 'boolean-undefined': {
918
- const ok = v === 'true' || v === 'false' || v === 'undefined' || v === '';
919
- return { valid: ok, reason: ok ? '' : 'expected-true-false-undefined' };
920
- }
921
- case 'tristate': {
922
- const ok = v === 'true' || v === 'false' || v === 'mixed';
923
- return { valid: ok, reason: ok ? '' : 'expected-true-false-mixed' };
924
- }
925
- case 'integer': {
926
- const ok = /^-?\d+$/.test(v);
927
- return { valid: ok, reason: ok ? '' : 'expected-integer' };
928
- }
929
- case 'number': {
930
- const ok = v !== '' && Number.isFinite(Number(v));
931
- return { valid: ok, reason: ok ? '' : 'expected-number' };
932
- }
933
- case 'token': {
934
- const set = ATTR_TOKEN_SETS[lower(name)];
935
- if (!set) return { valid: true, reason: 'no-token-set-defined' };
936
- const ok = set.has(lower(v));
937
- return { valid: ok, reason: ok ? '' : 'invalid-token' };
938
- }
939
- case 'token-list': {
940
- const set = ATTR_TOKEN_SETS[lower(name)];
941
- if (!set) return { valid: true, reason: 'no-token-set-defined' };
942
- const parts = v.split(/\s+/).filter(Boolean).map(lower);
943
- if (!parts.length) return { valid: false, reason: 'empty-token-list' };
944
- const ok = parts.every((p) => set.has(p));
945
- return { valid: ok, reason: ok ? '' : 'invalid-token' };
946
- }
947
- case 'idref': {
948
- const formatOk = v.length > 0 && !/\s/.test(v);
949
- if (!formatOk) return { valid: false, reason: 'expected-single-idref' };
950
- if (!idExists(v)) return { valid: false, reason: 'idref-not-found' };
951
- return { valid: true, reason: '' };
952
- }
953
- case 'idref-list': {
954
- const parts = v.split(/\s+/).filter(Boolean);
955
- if (!parts.length) return { valid: false, reason: 'empty-idref-list' };
956
- // Only flag when NONE of the referenced ids resolve — a
957
- // partially-dangling list (some ids exist, some don't) is
958
- // left unflagged. Verified 2026-07-21 directly against
959
- // a widely-used reference engine's own validateAttrValue (its
960
- // 'idrefs' case): that engine's real check is
961
- // `idrefs(vNode, attr).some(node => !!node)` — i.e. it
962
- // itself only treats an idref-list as invalid when EVERY
963
- // token fails to resolve, identical to this behavior. Not a
964
- // conservative guess; confirmed to match the reference
965
- // implementation exactly, not just "left as-is."
966
- if (!parts.some((p) => idExists(p)))
967
- return { valid: false, reason: 'idref-list-none-found' };
968
- return { valid: true, reason: '' };
969
- }
970
- case 'string':
971
- default:
972
- return { valid: true, reason: '' };
973
- }
974
- }
975
-
976
- function getRequiredAttrsForRole(role) {
977
- return REQUIRED_PROPS_BY_ROLE[lower(role)] ? REQUIRED_PROPS_BY_ROLE[lower(role)].slice(0) : [];
978
- }
979
-
980
- function getRequiredOwnedRoles(role) {
981
- return REQUIRED_OWNED_ROLES[lower(role)] ? REQUIRED_OWNED_ROLES[lower(role)].slice(0) : null;
982
- }
983
-
984
- function getRequiredContextRoles(role) {
985
- return Object.prototype.hasOwnProperty.call(REQUIRED_CONTEXT_ROLE, lower(role))
986
- ? REQUIRED_CONTEXT_ROLE[lower(role)].slice(0)
987
- : null;
988
- }
989
-
990
- // Resolves the ALLOWED_ROLES_BY_ELEMENT / NATIVE_ROLE_BY_ELEMENT_KEY
991
- // lookup key for an element, accounting for the small set of
992
- // attribute-conditioned entries. Returns '' when no key applies.
993
- function getElementRoleKey(el) {
994
- if (!isElement(el)) return '';
995
- const tag = lower(el.tagName || '');
996
-
997
- if (tag === 'a' || tag === 'area') {
998
- const href = getAttr(el, 'href');
999
- if (href != null && trim(href) !== '') return tag + '[href]';
1000
- // <area> without href has its own permitted-roles entry (verified
1001
- // against ARIA-in-HTML — see ALLOWED_ROLES_BY_ELEMENT above);
1002
- // hrefless <a> is left unconstrained pending the same
1003
- // verification (WHATWG/HTML-AAM sources disagree on how
1004
- // restrictive it is, so it's not asserted here).
1005
- return tag === 'area' ? 'area' : '';
1006
- }
1007
-
1008
- if (tag === 'section') {
1009
- // <section>'s own implicit role is conditional: "region" when it
1010
- // has an accessible name, "generic" when it doesn't (W3C
1011
- // ARIA-in-HTML). role="region" restated is only a no-op
1012
- // restatement of the native role — and therefore permitted —
1013
- // when a name is actually present; on an unnamed <section> it's
1014
- // a real violation (verified 2026-07-20 against a widely-used
1015
- // reference engine's roleIsAllowed, which checks
1016
- // explicit-role-equals-implicit-role before its allowedRoles
1017
- // array — <section>'s array itself doesn't include 'region' at
1018
- // all — and directly via that engine's own runtime). Found via a real page: ESPN's unnamed
1019
- // <section role="region" id="global-scoreboard">.
1020
- return hasAccessibleNameHint(el) ? 'section[named]' : 'section';
1021
- }
1022
-
1023
- if (tag === 'header') {
1024
- // <header>'s own implicit role is conditional: "banner" when
1025
- // top-level (not nested inside sectioning content/<main>),
1026
- // generic/null when nested — see hasLandmarkScopingAncestor
1027
- // above (includeMain: true, matching <header>'s real exclusion
1028
- // list, which does include <main>).
1029
- // role="banner" restated is only a no-op restatement of the
1030
- // native role (and therefore permitted) at the top level; a
1031
- // widely-used reference engine's own allowedRoles array for
1032
- // <header> doesn't include 'banner'
1033
- // at all (reached only via the native-role-match branch), same
1034
- // shape as <section>'s 'region'.
1035
- return hasLandmarkScopingAncestor(el, { includeMain: true }) ? 'header' : 'header[toplevel]';
1036
- }
1037
-
1038
- if (tag === 'label') {
1039
- // A <label> permits no explicit role at all when associated with
1040
- // a labelable form control (via `for` or wrapping); otherwise
1041
- // any role is permitted (verified against the W3C ARIA-in-HTML
1042
- // spec and a widely-used reference engine's own table, 2026-07-20 — found via a real
1043
- // page: basecamp.com's nav toggle uses
1044
- // <label for="..." role="button">, which that reference engine correctly flags).
1045
- // Uses the native `.control` API (resolves both `for` and
1046
- // wrapping association) instead of reimplementing that lookup.
1047
- let associated = false;
1048
- try {
1049
- associated = !!el.control;
1050
- } catch {}
1051
- return associated ? 'label[associated]' : '';
1052
- }
1053
-
1054
- if (tag === 'img') {
1055
- // Permitted roles depend on whether the img has a non-empty alt
1056
- // (verified against ARIA-in-HTML — see ALLOWED_ROLES_BY_ELEMENT
1057
- // above): with alt text it may take a small set of widget roles;
1058
- // without it, only presentation/none (plus its own native img
1059
- // role, always allowed via the native-role fallback below).
1060
- const alt = getAttr(el, 'alt');
1061
- return alt != null && trim(alt) !== '' ? 'img[alt]' : 'img';
1062
- }
1063
-
1064
- if (tag === 'input') {
1065
- const type = lower(getAttr(el, 'type') || 'text');
1066
- if (type === 'checkbox') {
1067
- // role="button" is only permitted on a checkbox when paired
1068
- // with aria-pressed (verified 2026-07-20 against a widely-used
1069
- // reference engine and the W3C ARIA-in-HTML spec — found via a real
1070
- // page: Wikipedia's dropdown toggles use
1071
- // <input type="checkbox" role="button" aria-haspopup="true">
1072
- // with no aria-pressed, which is not a permitted
1073
- // combination — this is a real markup issue on their side,
1074
- // not a genuine engine disagreement).
1075
- let hasAriaPressed = false;
1076
- try {
1077
- hasAriaPressed = !!(el.hasAttribute && el.hasAttribute('aria-pressed'));
1078
- } catch {}
1079
- return hasAriaPressed ? 'input[type=checkbox][aria-pressed]' : 'input[type=checkbox]';
1080
- }
1081
- return 'input[type=' + type + ']';
1082
- }
1083
-
1084
- if (tag === 'select') {
1085
- // <select multiple> or <select size> 1>: native role becomes
1086
- // listbox instead of combobox (WHATWG HTML-AAM), a distinct
1087
- // permitted-roles entry — see ALLOWED_ROLES_BY_ELEMENT/
1088
- // NATIVE_ROLE_BY_ELEMENT_KEY above.
1089
- let isMultiSelect;
1090
- try {
1091
- isMultiSelect = !!(el.hasAttribute && el.hasAttribute('multiple'));
1092
- if (!isMultiSelect) {
1093
- const sizeAttr = getAttr(el, 'size');
1094
- const size = sizeAttr != null ? parseInt(sizeAttr, 10) : NaN;
1095
- isMultiSelect = Number.isFinite(size) && size > 1;
1096
- }
1097
- } catch {
1098
- isMultiSelect = false;
1099
- }
1100
- return isMultiSelect ? 'select[multiple]' : 'select';
1101
- }
1102
-
1103
- return tag;
1104
- }
1105
-
1106
- function getAllowedRolesForElement(el) {
1107
- const key = getElementRoleKey(el);
1108
- if (!key) return undefined;
1109
- return Object.prototype.hasOwnProperty.call(ALLOWED_ROLES_BY_ELEMENT, key)
1110
- ? ALLOWED_ROLES_BY_ELEMENT[key]
1111
- : undefined;
1112
- }
1113
-
1114
- function getNativeRoleForElement(el) {
1115
- const key = getElementRoleKey(el);
1116
- if (!key) return '';
1117
- return Object.prototype.hasOwnProperty.call(NATIVE_ROLE_BY_ELEMENT_KEY, key)
1118
- ? NATIVE_ROLE_BY_ELEMENT_KEY[key]
1119
- : '';
1120
- }
1121
-
1122
- // Returns { constrained, allowed } — constrained=false means this
1123
- // element/role combination has no asserted constraint (rule should
1124
- // not flag it), matching the deliberately-scoped table above. An
1125
- // element's own native/implicit role (see NATIVE_ROLE_BY_ELEMENT_KEY)
1126
- // is always allowed, even when not separately listed.
1127
- function isRoleAllowedOnElement(el, role) {
1128
- const allowed = getAllowedRolesForElement(el);
1129
- if (allowed === undefined) return { constrained: false, allowed: true };
1130
- if (allowed === null) return { constrained: true, allowed: true };
1131
- const r = lower(role);
1132
- if (r && r === getNativeRoleForElement(el)) return { constrained: true, allowed: true };
1133
- return { constrained: true, allowed: allowed.indexOf(r) !== -1 };
1134
- }
1135
-
1136
- // Effective role for ownership/context matching only (aria-required-
1137
- // children / aria-required-parent): explicit role wins when present;
1138
- // otherwise falls back to the small NATIVE_CONTAINMENT_ROLE_BY_ELEMENT
1139
- // map above. Not a general-purpose implicit-role resolver — scoped
1140
- // deliberately narrow, see the table's header comment.
1141
- //
1142
- // The explicit role must be a real, valid concrete ARIA role to count:
1143
- // an invalid/unrecognized role="" token (e.g. a library's own
1144
- // non-standard "columngroup") is ignored by real browsers/AT (they fall
1145
- // back to the implicit role, same as any other unrecognized enumerated
1146
- // attribute value) — a widely-used reference engine's own explicit-role
1147
- // resolution validates the same way. Without this, a bogus role token
1148
- // wrongly "blocks" the ancestor/descendant containment-role search
1149
- // instead of being transparent to it. Found on tabulator.info's
1150
- // column-grouping example: role="columnheader" cells sit inside a
1151
- // role="columngroup" wrapper div (not a real ARIA role) that itself
1152
- // sits inside the actual role="row" ancestor — the reference engine
1153
- // correctly skips the fake role and finds "row"; this helper previously
1154
- // stopped at "columngroup" and reported a false required-context
1155
- // failure.
1156
- function getContainmentRole(el) {
1157
- const explicit = getExplicitRole(el);
1158
- if (explicit && isValidConcreteRole(explicit)) return explicit;
1159
-
1160
- if (!isElement(el)) return '';
1161
- const tag = lower(el.tagName || '');
1162
-
1163
- if (tag === 'input') {
1164
- const type = lower(getAttr(el, 'type') || 'text');
1165
- const key = 'input[type=' + type + ']';
1166
- return Object.prototype.hasOwnProperty.call(NATIVE_CONTAINMENT_ROLE_BY_ELEMENT, key)
1167
- ? NATIVE_CONTAINMENT_ROLE_BY_ELEMENT[key]
1168
- : '';
1169
- }
1170
-
1171
- return Object.prototype.hasOwnProperty.call(NATIVE_CONTAINMENT_ROLE_BY_ELEMENT, tag)
1172
- ? NATIVE_CONTAINMENT_ROLE_BY_ELEMENT[tag]
1173
- : '';
1174
- }
1175
-
1176
- return {
1177
- isValidAriaAttrName,
1178
- getAttrValueType,
1179
- validateAttrValue,
1180
- getExplicitRole,
1181
- getAllRoleTokens,
1182
- isAbstractRole,
1183
- isDeprecatedRole,
1184
- getDeprecatedRoleGuidance,
1185
- isKnownRole,
1186
- isValidConcreteRole,
1187
- getRequiredAttrsForRole,
1188
- getRequiredOwnedRoles,
1189
- getRequiredContextRoles,
1190
- isRoleAllowedOnElement,
1191
- getContainmentRole,
1192
-
1193
- // An element's own native/implicit ARIA-in-HTML role (see
1194
- // NATIVE_ROLE_BY_ELEMENT_KEY above) — previously internal-only
1195
- // (used by isRoleAllowedOnElement), now also re-exported for
1196
- // aria-prohibited-attr's roleless-element branch, which needs to
1197
- // tell "no role at all" (e.g. a bare <span>/<div>) apart from "has
1198
- // a real implicit role" (e.g. <button>, <a href>) without
1199
- // over-flagging the latter.
1200
- getNativeRoleForElement,
1201
-
1202
- // Shared "does this element have a landmark-scoping ancestor"
1203
- // primitive — see its own header comment above. Re-exported at
1204
- // helpers' top level too (src/core/dom-helpers.js), matching
1205
- // getLandmarkNameInfo's precedent, for the manual landmark-check
1206
- // files that used to each carry their own (buggy, tag-only) copy.
1207
- hasLandmarkScopingAncestor
1208
- };
1209
- }
1210
-
1211
- module.exports = { createAriaHelpers };