@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.
- package/CHANGELOG.md +37 -7
- package/LICENSE +373 -21
- package/README.md +67 -1
- package/bin/core.js +144 -19
- package/docs/API_STABILITY.md +1 -1
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/CLI.md +54 -1
- package/docs/ENGINE_OPTIONS.md +2 -0
- package/docs/INTEGRATION.md +18 -0
- package/docs/OUTPUT_SCHEMA.md +1 -1
- package/docs/RULE_CATALOG.md +1 -1
- package/docs/SARIF.md +59 -0
- package/package.json +15 -4
- package/src/baseline.js +0 -0
- package/src/catalogs/composites.wcag.js +414 -450
- package/src/checks/automatic/area-alt-present.js +59 -25
- package/src/checks/automatic/aria-allowed-attr.js +193 -33
- package/src/checks/automatic/aria-allowed-role.js +21 -7
- package/src/checks/automatic/aria-braille-equivalent.js +32 -10
- package/src/checks/automatic/aria-conditional-attr.js +24 -7
- package/src/checks/automatic/aria-deprecated-role.js +22 -8
- package/src/checks/automatic/aria-hidden-body.js +42 -19
- package/src/checks/automatic/aria-hidden-focus.js +408 -53
- package/src/checks/automatic/aria-prohibited-attr.js +296 -22
- package/src/checks/automatic/aria-prohibited-children.js +55 -16
- package/src/checks/automatic/aria-required-attr.js +23 -8
- package/src/checks/automatic/aria-required-children.js +37 -14
- package/src/checks/automatic/aria-required-parent.js +48 -14
- package/src/checks/automatic/aria-role-name-present.js +47 -21
- package/src/checks/automatic/aria-roles-valid.js +22 -12
- package/src/checks/automatic/aria-valid-attr-value.js +28 -7
- package/src/checks/automatic/aria-valid-attr.js +17 -5
- package/src/checks/automatic/autocomplete-valid.js +74 -16
- package/src/checks/automatic/avoid-inline-spacing.js +20 -6
- package/src/checks/automatic/binary-control-name-present.js +60 -50
- package/src/checks/automatic/button-name-present.js +48 -18
- package/src/checks/automatic/bypass-blocks-present.js +42 -25
- package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
- package/src/checks/automatic/combobox-name-present.js +38 -45
- package/src/checks/automatic/contrast-computable.js +361 -341
- package/src/checks/automatic/contrast-enhanced.js +487 -466
- package/src/checks/automatic/contrast-minimum.js +486 -465
- package/src/checks/automatic/css-orientation-lock.js +30 -8
- package/src/checks/automatic/definition-list-children-valid.js +40 -19
- package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
- package/src/checks/automatic/dialog-name-present.js +37 -75
- package/src/checks/automatic/dlitem-parent-valid.js +23 -8
- package/src/checks/automatic/duplicate-id-aria.js +24 -6
- package/src/checks/automatic/embed-text-alternative-present.js +86 -35
- package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
- package/src/checks/automatic/form-control-single-label.js +47 -10
- package/src/checks/automatic/html-xml-lang-mismatch.js +34 -18
- package/src/checks/automatic/iframe-focusable-content.js +26 -11
- package/src/checks/automatic/iframe-name-present.js +31 -9
- package/src/checks/automatic/iframe-title-unique.js +29 -8
- package/src/checks/automatic/img-alt-present.js +47 -43
- package/src/checks/automatic/input-image-alt-present.js +143 -112
- package/src/checks/automatic/label-in-name.js +50 -22
- package/src/checks/automatic/language-page-present.js +109 -109
- package/src/checks/automatic/link-in-text-block.js +59 -19
- package/src/checks/automatic/link-name-present.js +45 -14
- package/src/checks/automatic/list-children-valid.js +26 -9
- package/src/checks/automatic/listbox-name-present.js +39 -19
- package/src/checks/automatic/listitem-parent-valid.js +18 -6
- package/src/checks/automatic/menuitem-name-present.js +39 -61
- package/src/checks/automatic/meta-refresh-no-exceptions.js +28 -7
- package/src/checks/automatic/meta-refresh-timing-absent.js +20 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +24 -7
- package/src/checks/automatic/meter-name-present.js +36 -33
- package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
- package/src/checks/automatic/object-text-alternative-present.js +91 -39
- package/src/checks/automatic/option-name-present.js +38 -21
- package/src/checks/automatic/page-title-present.js +17 -6
- package/src/checks/automatic/progressbar-name-present.js +41 -34
- package/src/checks/automatic/role-img-alt-present.js +209 -157
- package/src/checks/automatic/searchbox-name-present.js +39 -19
- package/src/checks/automatic/server-side-image-map-absent.js +23 -8
- package/src/checks/automatic/slider-name-present.js +40 -47
- package/src/checks/automatic/spinbutton-name-present.js +39 -19
- package/src/checks/automatic/summary-name-present.js +37 -17
- package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
- package/src/checks/automatic/svg-text-alternative-present.js +246 -226
- package/src/checks/automatic/tab-name-present.js +37 -60
- package/src/checks/automatic/table-headers-attr-valid.js +24 -8
- package/src/checks/automatic/table-th-has-data-cells.js +22 -8
- package/src/checks/automatic/target-size-minimum.js +118 -48
- package/src/checks/automatic/td-has-header.js +29 -11
- package/src/checks/automatic/textbox-name-present.js +39 -19
- package/src/checks/automatic/tooltip-name-present.js +37 -18
- package/src/checks/automatic/treeitem-name-present.js +38 -21
- package/src/checks/automatic/valid-lang.js +20 -6
- package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
- package/src/checks/manual/accesskeys-manual.js +14 -5
- package/src/checks/manual/area-alt-decorative-manual.js +192 -193
- package/src/checks/manual/area-alt-quality-manual.js +182 -141
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
- package/src/checks/manual/aria-text-manual.js +14 -6
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
- package/src/checks/manual/css-hidden-focus.js +196 -165
- package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
- package/src/checks/manual/empty-heading-manual.js +24 -7
- package/src/checks/manual/empty-table-header-manual.js +17 -6
- package/src/checks/manual/focus-order-semantics-manual.js +45 -10
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
- package/src/checks/manual/heading-order-manual.js +22 -7
- package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
- package/src/checks/manual/image-redundant-alt-manual.js +17 -7
- package/src/checks/manual/img-alt-decorative-manual.js +131 -96
- package/src/checks/manual/img-alt-quality-manual.js +176 -127
- package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
- package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
- package/src/checks/manual/label-title-only-manual.js +14 -5
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +66 -16
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +53 -16
- package/src/checks/manual/landmark-main-is-top-level-manual.js +35 -10
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +29 -10
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +29 -10
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
- package/src/checks/manual/landmark-one-main-manual.js +26 -20
- package/src/checks/manual/landmark-unique-manual.js +41 -15
- package/src/checks/manual/link-name-quality-manual.js +43 -12
- package/src/checks/manual/media-transcript-present-manual.js +35 -22
- package/src/checks/manual/meta-viewport-large-manual.js +16 -5
- package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
- package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
- package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
- package/src/checks/manual/p-as-heading-manual.js +22 -7
- package/src/checks/manual/page-has-heading-one-manual.js +31 -22
- package/src/checks/manual/page-title-patterns-manual.js +78 -50
- package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
- package/src/checks/manual/region-manual.js +241 -48
- package/src/checks/manual/scope-attr-valid-manual.js +10 -3
- package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
- package/src/checks/manual/skip-link-manual.js +35 -12
- package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
- package/src/checks/manual/tabindex-manual.js +10 -3
- package/src/checks/manual/table-duplicate-name-manual.js +17 -7
- package/src/checks/manual/table-fake-caption-manual.js +24 -7
- package/src/checks/manual/video-caption-manual.js +15 -4
- package/src/checks/manual-review.js +56 -12
- package/src/core/aria-helpers.js +1127 -886
- package/src/core/contrast-helpers.js +1217 -1062
- package/src/core/dom-helpers.js +4175 -3917
- package/src/core/dom-runner.js +720 -604
- package/src/core/frame-messaging.js +189 -138
- package/src/core/frame-scan.js +94 -82
- package/src/core/rollup-composites.js +94 -102
- package/src/core/rule-meta.js +58 -41
- package/src/core.js +36552 -29111
- package/src/i18n/en.js +1194 -889
- package/src/i18n/fr.js +1136 -795
- package/src/policy/contracts.js +13 -13
- package/src/policy/resolvePolicy.js +48 -44
- package/src/report.js +63 -43
- package/src/sarif.js +175 -0
- package/surea11y.browser.js +36042 -0
package/src/core/aria-helpers.js
CHANGED
|
@@ -35,936 +35,1177 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
function createAriaHelpers(opts, shared) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
659
|
-
|
|
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
|
-
|
|
663
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
672
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
682
|
-
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
758
|
-
|
|
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
|
-
|
|
762
|
-
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
820
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
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
|
-
|
|
890
|
-
|
|
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
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
-
|
|
940
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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 };
|