@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,906 @@
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
+
42
+ // Existence check for a single ID token — never throws, returns false
43
+ // (not "unknown") when the document isn't available so callers degrade
44
+ // to their pre-existing format-only behavior rather than guessing.
45
+ function idExists(id) {
46
+ if (!ariaDocument || typeof ariaDocument.getElementById !== 'function') return true;
47
+ try {
48
+ return !!ariaDocument.getElementById(id);
49
+ } catch {
50
+ return true;
51
+ }
52
+ }
53
+
54
+ // Presence-only accessible-name check (aria-label / aria-labelledby),
55
+ // for the small set of role-permission decisions that are themselves
56
+ // conditioned on "does this element currently have a name" (e.g.
57
+ // <section>'s permitted-roles set — see ALLOWED_ROLES_BY_ELEMENT below).
58
+ // Deliberately mirrors landmark-unique-manual.js's own
59
+ // getAccessibleLandmarkName (aria-label then aria-labelledby, not
60
+ // title) rather than a full accname computation, for the same reason
61
+ // that rule gives: consistency with what actually makes an element a
62
+ // named landmark per the W3C ARIA-in-HTML spec.
63
+ function hasAccessibleNameHint(el) {
64
+ const al = trim(getAttr(el, 'aria-label'));
65
+ if (al) return true;
66
+ const alb = trim(getAttr(el, 'aria-labelledby'));
67
+ if (!alb || !ariaDocument || typeof ariaDocument.getElementById !== 'function') return false;
68
+ for (const refId of alb.split(/\s+/).filter(Boolean)) {
69
+ try {
70
+ const ref = ariaDocument.getElementById(refId);
71
+ if (ref && trim(ref.textContent)) return true;
72
+ } catch {
73
+ }
74
+ }
75
+ return false;
76
+ }
77
+
78
+ // Used for <header>/<footer>'s own conditional implicit role: per the
79
+ // W3C ARIA-in-HTML spec (matching a widely-used reference engine's own
80
+ // element-spec table for "header"/"footer" implicit-role functions, which check
81
+ // getSectioningContentPlusMainSelector — a fixed HTML content-type list,
82
+ // not a deep accname-style computation), a <header>/<footer> is only a
83
+ // "banner"/"contentinfo" landmark when it is NOT nested inside sectioning
84
+ // content (article/aside/nav/section) or <main>; nested, its implicit
85
+ // role is generic/null instead. Simplified to a tag-name ancestor walk
86
+ // (skipping that engine's additional role=article/complementary/navigation/
87
+ // region/main equivalences) — deliberately pragmatic, matching this
88
+ // table's existing precedent (hasAccessibleNameHint above takes the same
89
+ // "close enough, tag-based" approach rather than a full spec re-implementation).
90
+ function hasSectioningAncestor(el) {
91
+ if (!isElement(el)) return false;
92
+ let cur = el.parentElement;
93
+ let guard = 0;
94
+ while (cur && guard++ < 200) {
95
+ const t = lower(cur.tagName || '');
96
+ if (t === 'article' || t === 'aside' || t === 'main' || t === 'nav' || t === 'section') return true;
97
+ cur = cur.parentElement;
98
+ }
99
+ return false;
100
+ }
101
+
102
+ // -------------------------------------------------------------------
103
+ // A) Abstract roles — MUST NOT be used directly in a role="" attribute.
104
+ // -------------------------------------------------------------------
105
+ const ABSTRACT_ROLES = new Set([
106
+ 'command', 'composite', 'input', 'landmark', 'range', 'roletype',
107
+ 'section', 'sectionhead', 'select', 'structure', 'widget', 'window'
108
+ ]);
109
+
110
+ // -------------------------------------------------------------------
111
+ // B) Valid, concrete (non-abstract) roles that authors must never
112
+ // explicitly declare — either because WAI-ARIA has deprecated them
113
+ // (a direct replacement exists) or because they are reserved for
114
+ // user-agent-internal use only (not a spec deprecation, but the
115
+ // same "valid token, prohibited for authors" shape). Flagged by
116
+ // aria-deprecated-role, not aria-roles-valid (which only checks
117
+ // existence/abstractness) — see DEPRECATED_ROLE_GUIDANCE below for
118
+ // per-role, reason-accurate messaging.
119
+ // -------------------------------------------------------------------
120
+ const DEPRECATED_ROLES = new Set([
121
+ 'directory', // superseded by role="list"
122
+ // WAI-ARIA 1.2: "intended for use as the implicit role of generic
123
+ // elements in host languages for use by user agents only; not for
124
+ // use by developers." MDN, verbatim: "It should not be used by web
125
+ // authors." Verified 2026-07-20 against both sources — this is not
126
+ // a guess.
127
+ 'generic'
128
+ ]);
129
+
130
+ const DEPRECATED_ROLE_GUIDANCE = {
131
+ directory: 'Replace it with role="list" (its recommended replacement).',
132
+ generic: '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.'
133
+ };
134
+
135
+ function getDeprecatedRoleGuidance(role) {
136
+ const key = lower(role);
137
+ return Object.prototype.hasOwnProperty.call(DEPRECATED_ROLE_GUIDANCE, key)
138
+ ? DEPRECATED_ROLE_GUIDANCE[key]
139
+ : 'Replace the deprecated role with its recommended replacement.';
140
+ }
141
+
142
+ // -------------------------------------------------------------------
143
+ // C) Complete set of concrete (non-abstract) WAI-ARIA 1.2 role tokens.
144
+ // -------------------------------------------------------------------
145
+ const CONCRETE_ROLES = new Set([
146
+ // Live region / window roles
147
+ 'alert', 'alertdialog', 'dialog', 'log', 'marquee', 'status', 'timer',
148
+ // Landmark roles
149
+ 'banner', 'complementary', 'contentinfo', 'form', 'main', 'navigation', 'region', 'search',
150
+ // Widget roles (leaf)
151
+ 'button', 'checkbox', 'gridcell', 'link', 'menuitem', 'menuitemcheckbox',
152
+ 'menuitemradio', 'option', 'progressbar', 'radio', 'scrollbar', 'searchbox',
153
+ 'separator', 'slider', 'spinbutton', 'switch', 'tab', 'tabpanel', 'textbox',
154
+ 'treeitem', 'tooltip',
155
+ // Composite widget roles
156
+ 'combobox', 'grid', 'listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid',
157
+ // Document structure roles
158
+ 'application', 'article', 'blockquote', 'caption', 'cell', 'code', 'columnheader',
159
+ 'comment', 'definition', 'deletion', 'directory', 'document', 'emphasis', 'feed',
160
+ 'figure', 'generic', 'group', 'heading', 'img', 'insertion', 'list', 'listitem',
161
+ 'mark', 'math', 'meter', 'none', 'note', 'paragraph', 'presentation', 'row',
162
+ 'rowgroup', 'rowheader', 'strong', 'subscript', 'suggestion', 'superscript',
163
+ 'table', 'term', 'text', 'time', 'toolbar'
164
+ ]);
165
+
166
+ // -------------------------------------------------------------------
167
+ // D) ARIA attribute value types.
168
+ // 'token' — one value from a fixed enumerated set
169
+ // 'token-list' — space-separated values from a fixed enumerated set
170
+ // 'boolean' — "true" | "false"
171
+ // 'tristate' — "true" | "false" | "mixed"
172
+ // 'boolean-undefined' — "true" | "false" | "undefined"
173
+ // 'idref' — a single ID token (existence not verified here)
174
+ // 'idref-list' — space-separated ID tokens
175
+ // 'integer' — a base-10 integer (may be negative where noted)
176
+ // 'number' — a real number
177
+ // 'string' — free-form text (only non-emptiness may be checked)
178
+ // -------------------------------------------------------------------
179
+ const ATTR_VALUE_TYPES = {
180
+ 'aria-activedescendant': 'idref',
181
+ 'aria-atomic': 'boolean',
182
+ 'aria-autocomplete': 'token',
183
+ 'aria-braillelabel': 'string',
184
+ 'aria-brailleroledescription': 'string',
185
+ 'aria-busy': 'boolean',
186
+ 'aria-checked': 'tristate',
187
+ 'aria-colcount': 'integer',
188
+ 'aria-colindex': 'integer',
189
+ 'aria-colindextext': 'string',
190
+ 'aria-colspan': 'integer',
191
+ 'aria-controls': 'idref-list',
192
+ 'aria-current': 'token', // also allows 'true'/'false', handled in token set
193
+ 'aria-describedby': 'idref-list',
194
+ 'aria-description': 'string',
195
+ 'aria-details': 'idref-list',
196
+ 'aria-disabled': 'boolean',
197
+ 'aria-dropeffect': 'token-list', // deprecated but still validated if present
198
+ 'aria-errormessage': 'idref',
199
+ 'aria-expanded': 'boolean-undefined',
200
+ 'aria-flowto': 'idref-list',
201
+ 'aria-grabbed': 'boolean-undefined', // deprecated but still validated if present
202
+ 'aria-haspopup': 'token', // also allows 'true'/'false'
203
+ 'aria-hidden': 'boolean-undefined',
204
+ 'aria-invalid': 'token', // also allows 'true'/'false'
205
+ 'aria-keyshortcuts': 'string',
206
+ 'aria-label': 'string',
207
+ 'aria-labelledby': 'idref-list',
208
+ 'aria-level': 'integer',
209
+ 'aria-live': 'token',
210
+ 'aria-modal': 'boolean',
211
+ 'aria-multiline': 'boolean',
212
+ 'aria-multiselectable': 'boolean',
213
+ 'aria-orientation': 'token',
214
+ 'aria-owns': 'idref-list',
215
+ 'aria-placeholder': 'string',
216
+ 'aria-posinset': 'integer',
217
+ 'aria-pressed': 'tristate',
218
+ 'aria-readonly': 'boolean',
219
+ 'aria-relevant': 'token-list',
220
+ 'aria-required': 'boolean',
221
+ 'aria-roledescription': 'string',
222
+ 'aria-rowcount': 'integer',
223
+ 'aria-rowindex': 'integer',
224
+ 'aria-rowindextext': 'string',
225
+ 'aria-rowspan': 'integer',
226
+ 'aria-selected': 'boolean-undefined',
227
+ 'aria-setsize': 'integer',
228
+ 'aria-sort': 'token',
229
+ 'aria-valuemax': 'number',
230
+ 'aria-valuemin': 'number',
231
+ 'aria-valuenow': 'number',
232
+ 'aria-valuetext': 'string'
233
+ };
234
+
235
+ // Enumerated token sets for 'token'/'token-list' attributes.
236
+ const ATTR_TOKEN_SETS = {
237
+ 'aria-autocomplete': new Set(['inline', 'list', 'both', 'none']),
238
+ 'aria-current': new Set(['page', 'step', 'location', 'date', 'time', 'true', 'false']),
239
+ 'aria-dropeffect': new Set(['copy', 'execute', 'link', 'move', 'none', 'popup']),
240
+ 'aria-haspopup': new Set(['false', 'true', 'menu', 'listbox', 'tree', 'grid', 'dialog']),
241
+ 'aria-invalid': new Set(['grammar', 'false', 'spelling', 'true']),
242
+ 'aria-live': new Set(['off', 'polite', 'assertive']),
243
+ 'aria-orientation': new Set(['horizontal', 'vertical', 'undefined']),
244
+ 'aria-relevant': new Set(['additions', 'removals', 'text', 'all']),
245
+ 'aria-sort': new Set(['ascending', 'descending', 'none', 'other'])
246
+ };
247
+
248
+ // -------------------------------------------------------------------
249
+ // E) Required states/properties per role (see file header — deliberately
250
+ // conservative; only unambiguous, context-independent cases).
251
+ // -------------------------------------------------------------------
252
+ const REQUIRED_PROPS_BY_ROLE = {
253
+ checkbox: ['aria-checked'],
254
+ combobox: ['aria-expanded'],
255
+ heading: ['aria-level'],
256
+ menuitemcheckbox: ['aria-checked'],
257
+ menuitemradio: ['aria-checked'],
258
+ // Verified 2026-07-21 against a widely-used reference engine's own
259
+ // requiredAttrs table AND cross-checked for the context-dependence
260
+ // this table's own policy cares about: unlike progressbar
261
+ // (deliberately excluded here and by that engine — an indeterminate
262
+ // progressbar legitimately omits aria-valuenow) or separator
263
+ // (required only when focusable/acting as a widget, not for the
264
+ // common plain-divider usage — a genuine conditional case,
265
+ // deliberately NOT added here for that reason), meter has no
266
+ // "indeterminate" concept and no focusable/non-
267
+ // focusable split: it always represents a concrete measurement, so
268
+ // aria-valuenow is unconditionally required. Also investigated
269
+ // combobox's aria-controls (in that engine's table too) and deliberately
270
+ // did NOT add it — confirmed via MDN's own combobox role page that
271
+ // it's only required once the popup is actually displayed
272
+ // (aria-expanded="true"), a real context-dependent case this
273
+ // table's own stated policy excludes.
274
+ meter: ['aria-valuenow'],
275
+ radio: ['aria-checked'],
276
+ scrollbar: ['aria-valuenow'],
277
+ slider: ['aria-valuenow'],
278
+ switch: ['aria-checked']
279
+ };
280
+
281
+ // -------------------------------------------------------------------
282
+ // F) Required owned (child) roles for composite/container roles.
283
+ // Value is an array of alternative acceptable child roles (any one
284
+ // satisfies the requirement). aria-owns references also count as
285
+ // "owning" — checked by the rule, not this table.
286
+ // -------------------------------------------------------------------
287
+ const REQUIRED_OWNED_ROLES = {
288
+ list: ['listitem'],
289
+ listbox: ['option', 'group'],
290
+ menu: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'group'],
291
+ menubar: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'group'],
292
+ radiogroup: ['radio'],
293
+ rowgroup: ['row'],
294
+ table: ['row', 'rowgroup'],
295
+ grid: ['row', 'rowgroup'],
296
+ treegrid: ['row', 'rowgroup'],
297
+ tablist: ['tab'],
298
+ tree: ['treeitem', 'group'],
299
+ row: ['cell', 'gridcell', 'columnheader', 'rowheader']
300
+ };
301
+
302
+ // -------------------------------------------------------------------
303
+ // G) Required context (parent) role for roles that must be owned by a
304
+ // specific ancestor role. Value is an array of acceptable ancestor
305
+ // roles (any one satisfies the requirement); ownership may be via
306
+ // DOM containment OR aria-owns (checked by the rule).
307
+ // -------------------------------------------------------------------
308
+ const REQUIRED_CONTEXT_ROLE = {
309
+ listitem: ['list'],
310
+ option: ['listbox', 'group'],
311
+ menuitem: ['menu', 'menubar', 'group'],
312
+ menuitemcheckbox: ['menu', 'menubar', 'group'],
313
+ menuitemradio: ['menu', 'menubar', 'group'],
314
+ tab: ['tablist'],
315
+ tabpanel: [], // no single required container in ARIA 1.2; left unconstrained
316
+ treeitem: ['tree', 'group'],
317
+ row: ['rowgroup', 'grid', 'table', 'treegrid'],
318
+ cell: ['row'],
319
+ gridcell: ['row'],
320
+ columnheader: ['row'],
321
+ rowheader: ['row'],
322
+ rowgroup: ['grid', 'table', 'treegrid']
323
+ };
324
+
325
+ // -------------------------------------------------------------------
326
+ // H) ARIA-in-HTML permitted roles per element (deliberately scoped to
327
+ // the most common elements first — see file header). `null` values
328
+ // are used for elements that permit "any role" in typical states.
329
+ // Element keys may include a simple attribute condition using the
330
+ // form 'tag[attr]' or 'tag[attr=value]' for the small number of
331
+ // elements whose permitted roles depend on an attribute.
332
+ // -------------------------------------------------------------------
333
+ const ALLOWED_ROLES_BY_ELEMENT = {
334
+ // Verified against a widely-used reference engine's own allowedRoles table for the
335
+ // 'href' variant of 'a' (2026-07-20 — found via a real page: Blick
336
+ // Art Materials' homepage carousel uses <a href="..." role="group">
337
+ // for its slides, which is not a permitted override; a plain
338
+ // <a href> is NOT unconstrained the way a hrefless <a> is — that was
339
+ // the actual bug, this key had been set to null/"any role" by
340
+ // mistake). Restating the native 'link' role is still always
341
+ // permitted via the native-role fallback below regardless of this
342
+ // list.
343
+ 'a[href]': ['button', 'checkbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
344
+ 'option', 'radio', 'switch', 'tab', 'treeitem', 'doc-backlink', 'doc-biblioref',
345
+ 'doc-glossref', 'doc-noteref'],
346
+ // Verified against a widely-used reference engine's own allowedRoles table for
347
+ // 'article' (2026-07-21 — found via a real page: Udacity's
348
+ // homepage carousel, a Swiper.js slider whose 15 slides are each
349
+ // <article role="group">, not a permitted override). Restating
350
+ // the native 'article' role remains permitted via the native-role
351
+ // fallback below regardless of this list.
352
+ article: ['feed', 'presentation', 'none', 'document', 'application', 'main', 'region'],
353
+ // Verified against a widely-used reference engine's own allowedRoles table for the
354
+ // 'href' variant of 'area' (2026-07-20, found while re-checking the
355
+ // sibling <a href> bug above): that engine sets this to `false`, i.e. NO
356
+ // override role is permitted at all on an <area href> — only its
357
+ // native 'link' role, via the native-role fallback below. An empty
358
+ // array (not null) is the correct encoding, same convention already
359
+ // used for 'label[associated]' below.
360
+ 'area[href]': [],
361
+ // Verified against a widely-used reference engine and the W3C ARIA-in-HTML spec
362
+ // (2026-07-20): <area> without href permits only these two roles
363
+ // ('generic' is also technically allowed but SHOULD NOT be used per
364
+ // spec, and that engine itself excludes it from its allowed-roles list, so
365
+ // it's left out here too rather than asserted as permitted).
366
+ area: ['button', 'link'],
367
+ // Verified against a widely-used reference engine's own element-spec table for 'html'
368
+ // (2026-07-21, found via a real page: news24.com's South Africa
369
+ // homepage uses <html role="document">): that engine sets this to
370
+ // `allowedRoles: false` — no explicit role is ever permitted on
371
+ // <html>, and there's no native role to restate either (no entry in
372
+ // NATIVE_ROLE_BY_ELEMENT_KEY below), so an empty array is correct
373
+ // here, not "unconstrained" (no prior entry meant this element was
374
+ // silently unchecked).
375
+ html: [],
376
+ // Verified against a widely-used reference engine's own element-spec table for 'picture'
377
+ // (2026-07-23, found via a real page: TradingView's homepage,
378
+ // <picture role="presentation"> used twice for hero illustrations):
379
+ // that engine sets this to `allowedRoles: false` -- no override role is ever
380
+ // permitted on <picture> (it has no implicit ARIA role either, so
381
+ // there's no native-role-restatement exception -- an empty array is
382
+ // correct, same convention as 'html'/'area[href]' above).
383
+ picture: [],
384
+ button: ['checkbox', 'combobox', 'link', 'menuitem', 'menuitemcheckbox',
385
+ 'menuitemradio', 'option', 'radio', 'switch', 'tab'],
386
+ h1: ['tab', 'presentation', 'none'],
387
+ h2: ['tab', 'presentation', 'none'],
388
+ h3: ['tab', 'presentation', 'none'],
389
+ h4: ['tab', 'presentation', 'none'],
390
+ h5: ['tab', 'presentation', 'none'],
391
+ h6: ['tab', 'presentation', 'none'],
392
+ hr: ['none', 'presentation'],
393
+ // Verified against a widely-used reference engine and the W3C ARIA-in-HTML spec
394
+ // (2026-07-20 — found via a real page: Stack Overflow's search
395
+ // filter panel uses <form role="region">, which surea11y was
396
+ // silently not checking at all — no entry meant "unconstrained").
397
+ // 'complementary' is <aside>'s own native role — allowed via the
398
+ // native-role fallback below even though it's not in this list
399
+ // (spec: "also allowed, but NOT RECOMMENDED", same shape as <nav>).
400
+ aside: ['feed', 'none', 'note', 'presentation', 'region', 'search'],
401
+ form: ['form', 'search', 'none', 'presentation'],
402
+ // Verified against a widely-used reference engine's own allowedRoles table for
403
+ // 'header' (2026-07-20): the old code had no entry at all for
404
+ // <header>, meaning "no role constraint" — silently not checking it.
405
+ // Found via a real site: Vimeo's global nav uses
406
+ // <header role="navigation">, invalid regardless of nesting since
407
+ // "navigation" isn't in that engine's allowed array either way. The array
408
+ // itself is identical for both keys — what differs by nesting is
409
+ // only the native-role match (see 'header[toplevel]' below and
410
+ // getElementRoleKey's header branch): a top-level <header
411
+ // role="banner"> restates its own implicit "banner" role (a no-op,
412
+ // always permitted) even though 'banner' isn't in this array —
413
+ // found via a real site, Navy Federal's top-level <header
414
+ // role="banner">, which was a false-positive fail before this split
415
+ // existed (same shape as <section>'s named/unnamed split).
416
+ 'header[toplevel]': ['group', 'none', 'presentation', 'doc-footnote'],
417
+ header: ['group', 'none', 'presentation', 'doc-footnote'],
418
+ // Verified against a widely-used reference engine and the W3C ARIA-in-HTML spec
419
+ // (2026-07-20): a <label> associated with a labelable control
420
+ // permits no explicit role at all (see getElementRoleKey's
421
+ // label[associated] split above).
422
+ 'label[associated]': [],
423
+ // Verified against a widely-used reference engine and the W3C ARIA-in-HTML spec
424
+ // (2026-07-20): permitted roles depend on whether the img has a
425
+ // non-empty alt (see getElementRoleKey's img[alt]/img split above).
426
+ 'img[alt]': ['button', 'checkbox', 'link', 'math', 'menuitem', 'menuitemcheckbox',
427
+ 'menuitemradio', 'meter', 'option', 'progressbar', 'radio', 'scrollbar',
428
+ 'separator', 'slider', 'switch', 'tab', 'treeitem'],
429
+ img: ['presentation', 'none'],
430
+ li: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio',
431
+ 'separator', 'tab', 'treeitem', 'listitem', 'presentation', 'none'],
432
+ // Verified against a widely-used reference engine's own allowedRoles table for
433
+ // 'nav' (2026-07-20, found via a real site — Vimeo's global nav
434
+ // uses <nav role="menu">/<nav aria-label="Menu" role="menu"> for
435
+ // its dropdown panels): the old entry only had presentation/none.
436
+ nav: ['doc-index', 'doc-pagelist', 'doc-toc', 'menu', 'menubar', 'none', 'presentation', 'tablist'],
437
+ // Verified against a widely-used reference engine (2026-07-20): every other role
438
+ // tried (presentation/none/img/document/group/figure/region/banner/
439
+ // button/link/main/article/tab/list/table) is disallowed on <video>.
440
+ video: ['application'],
441
+ // Verified against a widely-used reference engine (2026-07-20): same probe as
442
+ // <video>, but img/document are also permitted (an <object> can
443
+ // stand in for an image or a full document, unlike <video>).
444
+ object: ['application', 'img', 'document'],
445
+ // Verified against a widely-used reference engine and the W3C ARIA-in-HTML spec
446
+ // (2026-07-20 — see getElementRoleKey's section[named]/section
447
+ // split above): 'region' is only permitted when the section has an
448
+ // accessible name (it's <section>'s own conditional native role in
449
+ // that case); every other role here is permitted regardless of
450
+ // naming.
451
+ 'section[named]': ['alert', 'alertdialog', 'application', 'banner', 'complementary',
452
+ 'contentinfo', 'dialog', 'document', 'feed', 'group', 'log', 'main', 'marquee',
453
+ 'navigation', 'none', 'note', 'presentation', 'region', 'search', 'status', 'tabpanel'],
454
+ section: ['alert', 'alertdialog', 'application', 'banner', 'complementary',
455
+ 'contentinfo', 'dialog', 'document', 'feed', 'group', 'log', 'main', 'marquee',
456
+ 'navigation', 'none', 'note', 'presentation', 'search', 'status', 'tabpanel'],
457
+ ol: ['group', 'listbox', 'menu', 'menubar', 'radiogroup', 'tablist',
458
+ 'toolbar', 'tree', 'presentation', 'none'],
459
+ ul: ['group', 'listbox', 'menu', 'menubar', 'radiogroup', 'tablist',
460
+ 'toolbar', 'tree', 'presentation', 'none'],
461
+ // role="button" is only permitted when paired with aria-pressed
462
+ // (verified against a widely-used reference engine and the W3C ARIA-in-HTML spec,
463
+ // 2026-07-20 — see getElementRoleKey's checkbox[aria-pressed] split
464
+ // above).
465
+ 'input[type=checkbox][aria-pressed]': ['button', 'menuitemcheckbox', 'option', 'switch'],
466
+ 'input[type=checkbox]': ['menuitemcheckbox', 'option', 'switch'],
467
+ 'input[type=radio]': ['menuitemradio'],
468
+ 'input[type=image]': ['button', 'link', 'menuitem', 'menuitemcheckbox',
469
+ 'menuitemradio', 'radio', 'switch'],
470
+ 'input[type=text]': ['combobox', 'searchbox', 'spinbutton'],
471
+ 'input[type=search]': ['combobox', 'spinbutton'],
472
+ 'input[type=tel]': ['combobox', 'spinbutton'],
473
+ 'input[type=url]': ['combobox', 'spinbutton'],
474
+ 'input[type=email]': ['combobox', 'spinbutton'],
475
+ select: ['menu'],
476
+ // <select multiple> or <select size> 1>: native role is listbox, not
477
+ // combobox (see NATIVE_ROLE_BY_ELEMENT_KEY below) — no override role
478
+ // is permitted per ARIA-in-HTML, but restating the native listbox
479
+ // role itself is always allowed via isRoleAllowedOnElement's
480
+ // native-role fallback, same as every other element in this table.
481
+ 'select[multiple]': [],
482
+ // Verified against a widely-used reference engine and the W3C ARIA-in-HTML spec
483
+ // (2026-07-20 — found via a real page: Wikipedia's sidebar uses
484
+ // <table role="navigation">, which surea11y was incorrectly
485
+ // failing): <table> permits any role. <td>/<th>/<tr> are spec'd as
486
+ // context-dependent (restricted only when the ancestor <table> is
487
+ // itself exposed with role=table/grid/treegrid) — a widely-used reference engine's own
488
+ // table doesn't implement that conditional either (always
489
+ // unconstrained for these three), so this follows the same
490
+ // deliberate simplification rather than guessing at ancestor-role
491
+ // resolution.
492
+ table: null,
493
+ td: null,
494
+ th: null,
495
+ tr: null
496
+ };
497
+
498
+ // -------------------------------------------------------------------
499
+ // H2) Native/implicit role per ALLOWED_ROLES_BY_ELEMENT key. Keeping an
500
+ // element's own native role (e.g. role="list" on <ul>, role="table"
501
+ // on <table>) is never a spec violation — the ARIA-in-HTML "allowed
502
+ // roles" tables enumerate roles you may override *to*, not the
503
+ // native default, which remains implicitly valid whether or not it
504
+ // is redundantly re-declared. isRoleAllowedOnElement always accepts
505
+ // this role in addition to whatever ALLOWED_ROLES_BY_ELEMENT lists.
506
+ // -------------------------------------------------------------------
507
+ const NATIVE_ROLE_BY_ELEMENT_KEY = {
508
+ 'a[href]': 'link',
509
+ 'area[href]': 'link',
510
+ article: 'article',
511
+ aside: 'complementary',
512
+ button: 'button',
513
+ form: 'form',
514
+ // No entry for plain 'header' — a header nested in sectioning
515
+ // content/<main> has no implicit role to restate (matches that engine's
516
+ // own implicit-role function returning null in that case).
517
+ 'header[toplevel]': 'banner',
518
+ h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading',
519
+ hr: 'separator',
520
+ 'img[alt]': 'img',
521
+ img: 'img',
522
+ li: 'listitem',
523
+ nav: 'navigation',
524
+ ol: 'list',
525
+ ul: 'list',
526
+ 'input[type=checkbox][aria-pressed]': 'checkbox',
527
+ 'input[type=checkbox]': 'checkbox',
528
+ 'input[type=radio]': 'radio',
529
+ 'input[type=image]': 'button',
530
+ 'input[type=text]': 'textbox',
531
+ 'input[type=search]': 'searchbox',
532
+ 'input[type=tel]': 'textbox',
533
+ 'input[type=url]': 'textbox',
534
+ 'input[type=email]': 'textbox',
535
+ select: 'combobox',
536
+ 'select[multiple]': 'listbox',
537
+ table: 'table',
538
+ td: 'cell',
539
+ th: 'columnheader',
540
+ tr: 'row'
541
+ };
542
+
543
+ // -------------------------------------------------------------------
544
+ // I) Native HTML tag -> implicit "containment role" mapping, used only
545
+ // for aria-required-children / aria-required-parent ownership
546
+ // matching (getContainmentRole). Deliberately small and scoped to
547
+ // exactly the roles referenced by REQUIRED_OWNED_ROLES /
548
+ // REQUIRED_CONTEXT_ROLE above, so that adding an explicit container
549
+ // role (e.g. role="list" on a <ul>, a common CSS-reset workaround)
550
+ // does not produce a false positive against plain native children
551
+ // (e.g. <li> with no role attribute) — same scope-limiting rationale
552
+ // as ALLOWED_ROLES_BY_ELEMENT (see file header).
553
+ // -------------------------------------------------------------------
554
+ const NATIVE_CONTAINMENT_ROLE_BY_ELEMENT = {
555
+ li: 'listitem',
556
+ option: 'option',
557
+ tr: 'row',
558
+ td: 'cell',
559
+ th: 'columnheader',
560
+ thead: 'rowgroup',
561
+ tbody: 'rowgroup',
562
+ tfoot: 'rowgroup',
563
+ ul: 'list',
564
+ ol: 'list',
565
+ table: 'table',
566
+ select: 'listbox',
567
+ 'input[type=radio]': 'radio'
568
+ };
569
+
570
+ function isElement(el) {
571
+ return !!(el && el.nodeType === 1);
572
+ }
573
+
574
+ function getAttr(el, name) {
575
+ try {
576
+ return el && el.getAttribute ? el.getAttribute(name) : null;
577
+ } catch {
578
+ return null;
579
+ }
580
+ }
581
+
582
+ // -------------------------------------------------------------------
583
+ // Public API
584
+ // -------------------------------------------------------------------
585
+
586
+ function getExplicitRole(el) {
587
+ if (!isElement(el)) return '';
588
+ const raw = trim(getAttr(el, 'role'));
589
+ if (!raw) return '';
590
+ // role attribute may be a space-separated fallback list; the first
591
+ // token is the "primary" role used by the accessibility tree.
592
+ const tokens = raw.split(/\s+/).filter(Boolean);
593
+ return tokens.length ? lower(tokens[0]) : '';
594
+ }
595
+
596
+ function getAllRoleTokens(el) {
597
+ if (!isElement(el)) return [];
598
+ const raw = trim(getAttr(el, 'role'));
599
+ if (!raw) return [];
600
+ return raw.split(/\s+/).filter(Boolean).map(lower);
601
+ }
602
+
603
+ function isAbstractRole(role) {
604
+ return ABSTRACT_ROLES.has(lower(role));
605
+ }
606
+
607
+ function isDeprecatedRole(role) {
608
+ return DEPRECATED_ROLES.has(lower(role));
609
+ }
610
+
611
+ function isKnownRole(role) {
612
+ const r = lower(role);
613
+ return ABSTRACT_ROLES.has(r) || CONCRETE_ROLES.has(r);
614
+ }
615
+
616
+ function isValidConcreteRole(role) {
617
+ return CONCRETE_ROLES.has(lower(role));
618
+ }
619
+
620
+ function isValidAriaAttrName(name) {
621
+ const n = lower(name);
622
+ if (!n || n.slice(0, 5) !== 'aria-') return false;
623
+ return Object.prototype.hasOwnProperty.call(ATTR_VALUE_TYPES, n);
624
+ }
625
+
626
+ function getAttrValueType(name) {
627
+ return ATTR_VALUE_TYPES[lower(name)] || null;
628
+ }
629
+
630
+ // Validates a single attribute's raw string value against its declared
631
+ // value type. Returns { valid, reason } — reason is a short machine
632
+ // code, not a user-facing string (rules localize their own messages).
633
+ function validateAttrValue(name, rawValue) {
634
+ const type = getAttrValueType(name);
635
+ if (!type) return { valid: true, reason: 'unknown-attr-skip' };
636
+
637
+ const v = trim(rawValue);
638
+
639
+ switch (type) {
640
+ case 'boolean': {
641
+ const ok = v === 'true' || v === 'false';
642
+ return { valid: ok, reason: ok ? '' : 'expected-true-false' };
643
+ }
644
+ case 'boolean-undefined': {
645
+ const ok = v === 'true' || v === 'false' || v === 'undefined' || v === '';
646
+ return { valid: ok, reason: ok ? '' : 'expected-true-false-undefined' };
647
+ }
648
+ case 'tristate': {
649
+ const ok = v === 'true' || v === 'false' || v === 'mixed';
650
+ return { valid: ok, reason: ok ? '' : 'expected-true-false-mixed' };
651
+ }
652
+ case 'integer': {
653
+ const ok = /^-?\d+$/.test(v);
654
+ return { valid: ok, reason: ok ? '' : 'expected-integer' };
655
+ }
656
+ case 'number': {
657
+ const ok = v !== '' && Number.isFinite(Number(v));
658
+ return { valid: ok, reason: ok ? '' : 'expected-number' };
659
+ }
660
+ case 'token': {
661
+ const set = ATTR_TOKEN_SETS[lower(name)];
662
+ if (!set) return { valid: true, reason: 'no-token-set-defined' };
663
+ const ok = set.has(lower(v));
664
+ return { valid: ok, reason: ok ? '' : 'invalid-token' };
665
+ }
666
+ case 'token-list': {
667
+ const set = ATTR_TOKEN_SETS[lower(name)];
668
+ if (!set) return { valid: true, reason: 'no-token-set-defined' };
669
+ const parts = v.split(/\s+/).filter(Boolean).map(lower);
670
+ if (!parts.length) return { valid: false, reason: 'empty-token-list' };
671
+ const ok = parts.every((p) => set.has(p));
672
+ return { valid: ok, reason: ok ? '' : 'invalid-token' };
673
+ }
674
+ case 'idref': {
675
+ const formatOk = v.length > 0 && !/\s/.test(v);
676
+ if (!formatOk) return { valid: false, reason: 'expected-single-idref' };
677
+ if (!idExists(v)) return { valid: false, reason: 'idref-not-found' };
678
+ return { valid: true, reason: '' };
679
+ }
680
+ case 'idref-list': {
681
+ const parts = v.split(/\s+/).filter(Boolean);
682
+ if (!parts.length) return { valid: false, reason: 'empty-idref-list' };
683
+ // Only flag when NONE of the referenced ids resolve — a
684
+ // partially-dangling list (some ids exist, some don't) is
685
+ // left unflagged. Verified 2026-07-21 directly against
686
+ // a widely-used reference engine's own validateAttrValue (its
687
+ // 'idrefs' case): that engine's real check is
688
+ // `idrefs(vNode, attr).some(node => !!node)` — i.e. it
689
+ // itself only treats an idref-list as invalid when EVERY
690
+ // token fails to resolve, identical to this behavior. Not a
691
+ // conservative guess; confirmed to match the reference
692
+ // implementation exactly, not just "left as-is."
693
+ if (!parts.some((p) => idExists(p))) return { valid: false, reason: 'idref-list-none-found' };
694
+ return { valid: true, reason: '' };
695
+ }
696
+ case 'string':
697
+ default:
698
+ return { valid: true, reason: '' };
699
+ }
700
+ }
701
+
702
+ function getRequiredAttrsForRole(role) {
703
+ return REQUIRED_PROPS_BY_ROLE[lower(role)] ? REQUIRED_PROPS_BY_ROLE[lower(role)].slice(0) : [];
704
+ }
705
+
706
+ function getRequiredOwnedRoles(role) {
707
+ return REQUIRED_OWNED_ROLES[lower(role)] ? REQUIRED_OWNED_ROLES[lower(role)].slice(0) : null;
708
+ }
709
+
710
+ function getRequiredContextRoles(role) {
711
+ return Object.prototype.hasOwnProperty.call(REQUIRED_CONTEXT_ROLE, lower(role))
712
+ ? REQUIRED_CONTEXT_ROLE[lower(role)].slice(0)
713
+ : null;
714
+ }
715
+
716
+ // Resolves the ALLOWED_ROLES_BY_ELEMENT / NATIVE_ROLE_BY_ELEMENT_KEY
717
+ // lookup key for an element, accounting for the small set of
718
+ // attribute-conditioned entries. Returns '' when no key applies.
719
+ function getElementRoleKey(el) {
720
+ if (!isElement(el)) return '';
721
+ const tag = lower(el.tagName || '');
722
+
723
+ if (tag === 'a' || tag === 'area') {
724
+ const href = getAttr(el, 'href');
725
+ if (href != null && trim(href) !== '') return tag + '[href]';
726
+ // <area> without href has its own permitted-roles entry (verified
727
+ // against ARIA-in-HTML — see ALLOWED_ROLES_BY_ELEMENT above);
728
+ // hrefless <a> is left unconstrained pending the same
729
+ // verification (WHATWG/HTML-AAM sources disagree on how
730
+ // restrictive it is, so it's not asserted here).
731
+ return tag === 'area' ? 'area' : '';
732
+ }
733
+
734
+ if (tag === 'section') {
735
+ // <section>'s own implicit role is conditional: "region" when it
736
+ // has an accessible name, "generic" when it doesn't (W3C
737
+ // ARIA-in-HTML). role="region" restated is only a no-op
738
+ // restatement of the native role — and therefore permitted —
739
+ // when a name is actually present; on an unnamed <section> it's
740
+ // a real violation (verified 2026-07-20 against a widely-used
741
+ // reference engine's roleIsAllowed, which checks
742
+ // explicit-role-equals-implicit-role before its allowedRoles
743
+ // array — <section>'s array itself doesn't include 'region' at
744
+ // all — and directly via that engine's own runtime). Found via a real page: ESPN's unnamed
745
+ // <section role="region" id="global-scoreboard">.
746
+ return hasAccessibleNameHint(el) ? 'section[named]' : 'section';
747
+ }
748
+
749
+ if (tag === 'header') {
750
+ // <header>'s own implicit role is conditional: "banner" when
751
+ // top-level (not nested inside sectioning content/<main>),
752
+ // generic/null when nested — see hasSectioningAncestor above.
753
+ // role="banner" restated is only a no-op restatement of the
754
+ // native role (and therefore permitted) at the top level; a
755
+ // widely-used reference engine's own allowedRoles array for
756
+ // <header> doesn't include 'banner'
757
+ // at all (reached only via the native-role-match branch), same
758
+ // shape as <section>'s 'region'.
759
+ return hasSectioningAncestor(el) ? 'header' : 'header[toplevel]';
760
+ }
761
+
762
+ if (tag === 'label') {
763
+ // A <label> permits no explicit role at all when associated with
764
+ // a labelable form control (via `for` or wrapping); otherwise
765
+ // any role is permitted (verified against the W3C ARIA-in-HTML
766
+ // spec and a widely-used reference engine's own table, 2026-07-20 — found via a real
767
+ // page: basecamp.com's nav toggle uses
768
+ // <label for="..." role="button">, which that reference engine correctly flags).
769
+ // Uses the native `.control` API (resolves both `for` and
770
+ // wrapping association) instead of reimplementing that lookup.
771
+ let associated = false;
772
+ try {
773
+ associated = !!(el.control);
774
+ } catch {
775
+ }
776
+ return associated ? 'label[associated]' : '';
777
+ }
778
+
779
+ if (tag === 'img') {
780
+ // Permitted roles depend on whether the img has a non-empty alt
781
+ // (verified against ARIA-in-HTML — see ALLOWED_ROLES_BY_ELEMENT
782
+ // above): with alt text it may take a small set of widget roles;
783
+ // without it, only presentation/none (plus its own native img
784
+ // role, always allowed via the native-role fallback below).
785
+ const alt = getAttr(el, 'alt');
786
+ return (alt != null && trim(alt) !== '') ? 'img[alt]' : 'img';
787
+ }
788
+
789
+ if (tag === 'input') {
790
+ const type = lower(getAttr(el, 'type') || 'text');
791
+ if (type === 'checkbox') {
792
+ // role="button" is only permitted on a checkbox when paired
793
+ // with aria-pressed (verified 2026-07-20 against a widely-used
794
+ // reference engine and the W3C ARIA-in-HTML spec — found via a real
795
+ // page: Wikipedia's dropdown toggles use
796
+ // <input type="checkbox" role="button" aria-haspopup="true">
797
+ // with no aria-pressed, which is not a permitted
798
+ // combination — this is a real markup issue on their side,
799
+ // not a genuine engine disagreement).
800
+ let hasAriaPressed = false;
801
+ try {
802
+ hasAriaPressed = !!(el.hasAttribute && el.hasAttribute('aria-pressed'));
803
+ } catch {
804
+ }
805
+ return hasAriaPressed ? 'input[type=checkbox][aria-pressed]' : 'input[type=checkbox]';
806
+ }
807
+ return 'input[type=' + type + ']';
808
+ }
809
+
810
+ if (tag === 'select') {
811
+ // <select multiple> or <select size> 1>: native role becomes
812
+ // listbox instead of combobox (WHATWG HTML-AAM), a distinct
813
+ // permitted-roles entry — see ALLOWED_ROLES_BY_ELEMENT/
814
+ // NATIVE_ROLE_BY_ELEMENT_KEY above.
815
+ let isMultiSelect = false;
816
+ try {
817
+ isMultiSelect = !!(el.hasAttribute && el.hasAttribute('multiple'));
818
+ if (!isMultiSelect) {
819
+ const sizeAttr = getAttr(el, 'size');
820
+ const size = sizeAttr != null ? parseInt(sizeAttr, 10) : NaN;
821
+ isMultiSelect = Number.isFinite(size) && size > 1;
822
+ }
823
+ } catch {
824
+ isMultiSelect = false;
825
+ }
826
+ return isMultiSelect ? 'select[multiple]' : 'select';
827
+ }
828
+
829
+ return tag;
830
+ }
831
+
832
+ function getAllowedRolesForElement(el) {
833
+ const key = getElementRoleKey(el);
834
+ if (!key) return undefined;
835
+ return Object.prototype.hasOwnProperty.call(ALLOWED_ROLES_BY_ELEMENT, key)
836
+ ? ALLOWED_ROLES_BY_ELEMENT[key]
837
+ : undefined;
838
+ }
839
+
840
+ function getNativeRoleForElement(el) {
841
+ const key = getElementRoleKey(el);
842
+ if (!key) return '';
843
+ return Object.prototype.hasOwnProperty.call(NATIVE_ROLE_BY_ELEMENT_KEY, key)
844
+ ? NATIVE_ROLE_BY_ELEMENT_KEY[key]
845
+ : '';
846
+ }
847
+
848
+ // Returns { constrained, allowed } — constrained=false means this
849
+ // element/role combination has no asserted constraint (rule should
850
+ // not flag it), matching the deliberately-scoped table above. An
851
+ // element's own native/implicit role (see NATIVE_ROLE_BY_ELEMENT_KEY)
852
+ // is always allowed, even when not separately listed.
853
+ function isRoleAllowedOnElement(el, role) {
854
+ const allowed = getAllowedRolesForElement(el);
855
+ if (allowed === undefined) return { constrained: false, allowed: true };
856
+ if (allowed === null) return { constrained: true, allowed: true };
857
+ const r = lower(role);
858
+ if (r && r === getNativeRoleForElement(el)) return { constrained: true, allowed: true };
859
+ return { constrained: true, allowed: allowed.indexOf(r) !== -1 };
860
+ }
861
+
862
+ // Effective role for ownership/context matching only (aria-required-
863
+ // children / aria-required-parent): explicit role wins when present;
864
+ // otherwise falls back to the small NATIVE_CONTAINMENT_ROLE_BY_ELEMENT
865
+ // map above. Not a general-purpose implicit-role resolver — scoped
866
+ // deliberately narrow, see the table's header comment.
867
+ function getContainmentRole(el) {
868
+ const explicit = getExplicitRole(el);
869
+ if (explicit) return explicit;
870
+
871
+ if (!isElement(el)) return '';
872
+ const tag = lower(el.tagName || '');
873
+
874
+ if (tag === 'input') {
875
+ const type = lower(getAttr(el, 'type') || 'text');
876
+ const key = 'input[type=' + type + ']';
877
+ return Object.prototype.hasOwnProperty.call(NATIVE_CONTAINMENT_ROLE_BY_ELEMENT, key)
878
+ ? NATIVE_CONTAINMENT_ROLE_BY_ELEMENT[key]
879
+ : '';
880
+ }
881
+
882
+ return Object.prototype.hasOwnProperty.call(NATIVE_CONTAINMENT_ROLE_BY_ELEMENT, tag)
883
+ ? NATIVE_CONTAINMENT_ROLE_BY_ELEMENT[tag]
884
+ : '';
885
+ }
886
+
887
+ return {
888
+ isValidAriaAttrName,
889
+ getAttrValueType,
890
+ validateAttrValue,
891
+ getExplicitRole,
892
+ getAllRoleTokens,
893
+ isAbstractRole,
894
+ isDeprecatedRole,
895
+ getDeprecatedRoleGuidance,
896
+ isKnownRole,
897
+ isValidConcreteRole,
898
+ getRequiredAttrsForRole,
899
+ getRequiredOwnedRoles,
900
+ getRequiredContextRoles,
901
+ isRoleAllowedOnElement,
902
+ getContainmentRole
903
+ };
904
+ }
905
+
906
+ module.exports = { createAriaHelpers };