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