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