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