@surea11y/core 1.3.0 → 1.4.1
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 +87 -2
- package/README.md +109 -35
- package/bin/surea11y-core.js +20 -0
- package/docs/API_STABILITY.md +26 -0
- package/docs/ARIA_DEPRECATION.md +95 -0
- package/docs/CI_INTEGRATIONS.md +7 -7
- package/docs/ENGINE_OPTIONS.md +1 -1
- package/docs/I18N.md +12 -9
- package/docs/INTEGRATION.md +1 -1
- package/docs/LIMITATIONS.md +1 -1
- package/docs/REPORT.md +1 -1
- package/docs/RULE_CATALOG.md +6 -6
- package/package.json +52 -16
- package/src/baseline.js +0 -0
- package/src/checks/automatic/area-alt-present.js +4 -6
- package/src/checks/automatic/aria-allowed-attr.js +663 -134
- package/src/checks/automatic/aria-allowed-role.js +2 -0
- package/src/checks/automatic/aria-braille-equivalent.js +2 -0
- package/src/checks/automatic/aria-conditional-attr.js +8 -7
- package/src/checks/automatic/aria-deprecated-role.js +107 -38
- package/src/checks/automatic/aria-hidden-body.js +6 -4
- package/src/checks/automatic/aria-hidden-focus.js +12 -13
- package/src/checks/automatic/aria-prohibited-attr.js +98 -105
- package/src/checks/automatic/aria-prohibited-children.js +56 -87
- package/src/checks/automatic/aria-required-attr.js +6 -7
- package/src/checks/automatic/aria-required-children.js +7 -10
- package/src/checks/automatic/aria-required-parent.js +20 -25
- package/src/checks/automatic/aria-role-name-present.js +2 -0
- package/src/checks/automatic/aria-roles-valid.js +33 -6
- package/src/checks/automatic/aria-valid-attr-value.js +18 -15
- package/src/checks/automatic/aria-valid-attr.js +2 -0
- package/src/checks/automatic/autocomplete-valid.js +39 -1
- package/src/checks/automatic/avoid-inline-spacing.js +181 -20
- package/src/checks/automatic/binary-control-name-present.js +13 -3
- package/src/checks/automatic/button-name-present.js +54 -21
- package/src/checks/automatic/canvas-text-alternative-present.js +17 -8
- package/src/checks/automatic/combobox-name-present.js +10 -1
- package/src/checks/automatic/contrast-computable.js +2 -0
- package/src/checks/automatic/contrast-enhanced.js +2 -0
- package/src/checks/automatic/contrast-minimum.js +2 -0
- package/src/checks/automatic/css-orientation-lock.js +21 -27
- package/src/checks/automatic/definition-list-children-valid.js +6 -6
- package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
- package/src/checks/automatic/dialog-name-present.js +18 -11
- package/src/checks/automatic/dlitem-parent-valid.js +2 -0
- package/src/checks/automatic/duplicate-id-aria.js +4 -3
- package/src/checks/automatic/embed-text-alternative-present.js +2 -0
- package/src/checks/automatic/form-control-programmatic-label-present.js +50 -4
- package/src/checks/automatic/form-control-single-label.js +110 -43
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
- package/src/checks/automatic/iframe-focusable-content.js +246 -18
- package/src/checks/automatic/iframe-name-present.js +2 -0
- package/src/checks/automatic/iframe-title-unique.js +3 -1
- package/src/checks/automatic/img-alt-present.js +23 -18
- package/src/checks/automatic/input-image-alt-present.js +101 -52
- package/src/checks/automatic/label-in-name.js +97 -27
- package/src/checks/automatic/language-page-present.js +7 -1
- package/src/checks/automatic/link-in-text-block.js +2 -0
- package/src/checks/automatic/link-name-present.js +52 -17
- package/src/checks/automatic/list-children-valid.js +14 -24
- package/src/checks/automatic/listbox-name-present.js +10 -1
- package/src/checks/automatic/listitem-parent-valid.js +30 -7
- package/src/checks/automatic/menuitem-name-present.js +10 -1
- package/src/checks/automatic/meta-refresh-no-exceptions.js +33 -4
- package/src/checks/automatic/meta-refresh-timing-absent.js +32 -4
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +39 -15
- package/src/checks/automatic/meter-name-present.js +12 -4
- package/src/checks/automatic/nested-interactive-controls-absent.js +177 -25
- package/src/checks/automatic/object-text-alternative-present.js +16 -7
- package/src/checks/automatic/option-name-present.js +10 -1
- package/src/checks/automatic/page-title-present.js +2 -0
- package/src/checks/automatic/progressbar-name-present.js +16 -11
- package/src/checks/automatic/role-img-alt-present.js +4 -4
- package/src/checks/automatic/searchbox-name-present.js +10 -1
- package/src/checks/automatic/server-side-image-map-absent.js +4 -3
- package/src/checks/automatic/slider-name-present.js +13 -2
- package/src/checks/automatic/spinbutton-name-present.js +10 -1
- package/src/checks/automatic/summary-name-present.js +10 -1
- package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
- package/src/checks/automatic/svg-text-alternative-present.js +17 -5
- package/src/checks/automatic/tab-name-present.js +10 -1
- package/src/checks/automatic/table-headers-attr-valid.js +3 -2
- package/src/checks/automatic/table-th-has-data-cells.js +69 -6
- package/src/checks/automatic/target-size-minimum.js +5 -0
- package/src/checks/automatic/td-has-header.js +24 -1
- package/src/checks/automatic/textbox-name-present.js +10 -1
- package/src/checks/automatic/tooltip-name-present.js +10 -1
- package/src/checks/automatic/treeitem-name-present.js +10 -1
- package/src/checks/automatic/valid-lang.js +18 -3
- package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
- package/src/checks/manual/accesskeys-manual.js +3 -1
- package/src/checks/manual/area-alt-decorative-manual.js +2 -0
- package/src/checks/manual/area-alt-quality-manual.js +2 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
- package/src/checks/manual/aria-text-manual.js +6 -5
- package/src/checks/manual/bypass-blocks-present-manual.js +279 -0
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
- package/src/checks/manual/css-hidden-focus.js +184 -9
- package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
- package/src/checks/manual/empty-heading-manual.js +17 -17
- package/src/checks/manual/empty-table-header-manual.js +52 -25
- package/src/checks/manual/focus-order-semantics-manual.js +16 -4
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
- package/src/checks/manual/heading-order-manual.js +28 -1
- package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
- package/src/checks/manual/image-redundant-alt-manual.js +21 -1
- package/src/checks/manual/img-alt-decorative-manual.js +2 -0
- package/src/checks/manual/img-alt-quality-manual.js +2 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +26 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
- package/src/checks/manual/label-title-only-manual.js +29 -22
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +51 -55
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +44 -31
- package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
- package/src/checks/manual/landmark-one-main-manual.js +12 -23
- package/src/checks/manual/landmark-unique-manual.js +37 -52
- package/src/checks/manual/link-name-quality-manual.js +2 -0
- package/src/checks/manual/media-transcript-present-manual.js +2 -0
- package/src/checks/manual/meta-viewport-large-manual.js +3 -1
- package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
- package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
- package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
- package/src/checks/manual/p-as-heading-manual.js +2 -0
- package/src/checks/manual/page-has-heading-one-manual.js +12 -11
- package/src/checks/manual/page-title-patterns-manual.js +2 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
- package/src/checks/manual/region-manual.js +27 -36
- package/src/checks/manual/scope-attr-valid-manual.js +3 -1
- package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
- package/src/checks/manual/skip-link-manual.js +7 -6
- package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
- package/src/checks/manual/tabindex-manual.js +3 -1
- package/src/checks/manual/table-duplicate-name-manual.js +5 -4
- package/src/checks/manual/table-fake-caption-manual.js +24 -3
- package/src/checks/manual/video-caption-manual.js +2 -0
- package/src/checks/manual-review.js +2 -0
- package/src/core.js +11820 -3317
- package/src/index.js +2 -0
- package/src/report.js +51 -9
- package/src/sarif.js +20 -5
- package/surea11y.browser.js +4943 -1388
- package/bin/core.js +0 -473
- package/docs/CLI.md +0 -128
- package/src/catalogs/composites.wcag.js +0 -454
- package/src/checks/automatic/bypass-blocks-present.js +0 -215
- package/src/checks/rules-and-tags.full.csv +0 -19
- package/src/checks/rules-and-tags.full.json +0 -259
- package/src/core/aria-helpers.js +0 -1211
- package/src/core/contrast-helpers.js +0 -1302
- package/src/core/dom-helpers.js +0 -4493
- package/src/core/dom-runner.js +0 -787
- package/src/core/frame-messaging.js +0 -261
- package/src/core/frame-scan.js +0 -190
- package/src/core/rollup-composites.js +0 -127
- package/src/core/rule-meta.js +0 -176
- package/src/coverage/wcag-facets.js +0 -1079
- package/src/coverage/wcag-version-map.js +0 -84
- package/src/i18n/en.js +0 -1228
- package/src/i18n/fr.js +0 -1185
- package/src/policy/contracts.js +0 -18
- package/src/policy/resolvePolicy.js +0 -59
- package/src/policy/schemas/engine-options.schema.json +0 -103
- package/src/policy/schemas/policy-contract.schema.json +0 -40
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check landmark-unique
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary Landmarks sharing the same role must have unique accessible names
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Applies whenever two or more landmark regions on the page share the
|
|
10
12
|
* same landmark role (banner, contentinfo, main, navigation,
|
|
@@ -59,9 +61,7 @@ function runInPage(ctx) {
|
|
|
59
61
|
|
|
60
62
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
61
63
|
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
62
|
-
// local copy -- see that function's header comment in src/core/dom-helpers.js
|
|
63
|
-
// (missing title fallback) this replaced across all 7 landmark rule files that had their own
|
|
64
|
-
// copy of this logic.
|
|
64
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js.
|
|
65
65
|
function getAccessibleLandmarkName(el) {
|
|
66
66
|
try {
|
|
67
67
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -81,27 +81,21 @@ function runInPage(ctx) {
|
|
|
81
81
|
// Delegates to the shared helpers.hasLandmarkScopingAncestor (role-aware:
|
|
82
82
|
// an ancestor's bare TAG only counts when it carries no role attribute at
|
|
83
83
|
// all; an explicit role="dialog"-style override no longer suppresses —
|
|
84
|
-
// see that function's header comment in src/core/aria-helpers.js)
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
// "
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
// 2026-07-30 via the cross-engine comparisons project, on
|
|
100
|
-
// handsontable.com's docs-assistant side panel: an <aside role="dialog">
|
|
101
|
-
// containing its own <header> — role="dialog" isn't one of the four
|
|
102
|
-
// scoping roles, so the nested <header> keeps "banner" per spec, but a
|
|
103
|
-
// tag-only check unconditionally suppressed it just because the ancestor
|
|
104
|
-
// TAG was <aside>.
|
|
84
|
+
// see that function's header comment in src/core/aria-helpers.js), using
|
|
85
|
+
// two distinct ancestor scopes rather than one shared list: <header>/
|
|
86
|
+
// <footer> use "sectioning content PLUS <main>" (includeMain: true) to
|
|
87
|
+
// decide banner/contentinfo suppression, but <aside> uses PLAIN
|
|
88
|
+
// sectioning content only — NOT main (includeMain: false) — to decide
|
|
89
|
+
// complementary suppression. A single shared sectioning-ancestors set
|
|
90
|
+
// that includes 'main' is correct for header/footer but wrong for aside:
|
|
91
|
+
// e.g. two unnamed <aside> elements that are direct children of <main>
|
|
92
|
+
// would have their implicit "complementary" role incorrectly suppressed,
|
|
93
|
+
// hiding a real duplicate-landmark violation. The role-aware half matters
|
|
94
|
+
// too: e.g. an <aside role="dialog"> containing its own <header> —
|
|
95
|
+
// role="dialog" isn't one of the four scoping roles, so the nested
|
|
96
|
+
// <header> keeps "banner" per spec, but a tag-only (non-role-aware)
|
|
97
|
+
// check would unconditionally suppress it just because the ancestor TAG
|
|
98
|
+
// was <aside>.
|
|
105
99
|
function hasSectioningAncestor(el, includeMain) {
|
|
106
100
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
107
101
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -115,10 +109,9 @@ function runInPage(ctx) {
|
|
|
115
109
|
if (tag === 'main') return 'main';
|
|
116
110
|
if (tag === 'nav') return 'navigation';
|
|
117
111
|
if (tag === 'aside') {
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
// nested.
|
|
112
|
+
// An <aside> is suppressed by a sectioning-content ancestor ONLY
|
|
113
|
+
// when it also has no accessible name — a named <aside> is never
|
|
114
|
+
// suppressed, even when nested.
|
|
122
115
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
123
116
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
124
117
|
}
|
|
@@ -146,15 +139,12 @@ function runInPage(ctx) {
|
|
|
146
139
|
// <form>/<section> only count as landmarks when they have an
|
|
147
140
|
// accessible name — a property of the ELEMENT, not of how the role
|
|
148
141
|
// got there. This applies whether the role is implicit (already
|
|
149
|
-
// handled in getImplicitLandmarkRole below) or explicit
|
|
150
|
-
// explicit role bypassed the check entirely before this fix. Verified
|
|
151
|
-
// against a widely-used reference engine's isLandmarkVirtual (checks
|
|
152
|
-
// nodeName === 'section' || 'form' unconditionally, regardless of role source) and the W3C
|
|
142
|
+
// handled in getImplicitLandmarkRole below) or explicit. Per the W3C
|
|
153
143
|
// ARIA-in-HTML spec ("a form is not exposed as a landmark region
|
|
154
|
-
// unless it has been provided an accessible name").
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
144
|
+
// unless it has been provided an accessible name"). Otherwise an
|
|
145
|
+
// unnamed <form role="search"> nested inside an unnamed
|
|
146
|
+
// <div role="search"> gets wrongly counted as a second distinct
|
|
147
|
+
// "search" landmark.
|
|
158
148
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
159
149
|
if (tag === 'form' || tag === 'section') {
|
|
160
150
|
return getAccessibleLandmarkName(el) ? explicit : '';
|
|
@@ -179,12 +169,10 @@ function runInPage(ctx) {
|
|
|
179
169
|
}
|
|
180
170
|
|
|
181
171
|
// queryAllSmart (shadow-DOM-aware, includeShadowDom defaults true) instead of a plain
|
|
182
|
-
// document.querySelectorAll -- a
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
// with the page's own unnamed header <nav>/page <footer> -- a real, confirmed surea11y
|
|
187
|
-
// false-negative miss, invisible to plain querySelectorAll's light-DOM-only reach.
|
|
172
|
+
// document.querySelectorAll -- a third-party widget rendering its own
|
|
173
|
+
// unnamed <nav>/<footer> inside a shadow root collides with the page's
|
|
174
|
+
// own unnamed header <nav>/page <footer>, but is invisible to plain
|
|
175
|
+
// querySelectorAll's light-DOM-only reach.
|
|
188
176
|
let nodes;
|
|
189
177
|
try {
|
|
190
178
|
nodes =
|
|
@@ -195,15 +183,12 @@ function runInPage(ctx) {
|
|
|
195
183
|
nodes = [];
|
|
196
184
|
}
|
|
197
185
|
|
|
198
|
-
// Only landmarks actually exposed to assistive technology can collide
|
|
199
|
-
//
|
|
200
|
-
// (
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
// weather.com) were wrongly flagged as duplicate landmarks, since the
|
|
205
|
-
// hidden copy is never actually reachable by AT and can't really
|
|
206
|
-
// collide with the visible one.
|
|
186
|
+
// Only landmarks actually exposed to assistive technology can collide.
|
|
187
|
+
// Without this, responsive layouts that render both a desktop and a
|
|
188
|
+
// mobile copy of the same named nav (one hidden via CSS at any given
|
|
189
|
+
// viewport) are wrongly flagged as duplicate landmarks, since the hidden
|
|
190
|
+
// copy is never reachable by AT and can't really collide with the
|
|
191
|
+
// visible one.
|
|
207
192
|
const byRole = new Map(); // role -> [{el, name}]
|
|
208
193
|
const seen = new Set();
|
|
209
194
|
for (const el of nodes) {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check meta-viewport-large
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary Viewport meta tag should allow zooming up to 500% (AAA-level)
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Applies to <meta name="viewport"> elements that carry a non-empty
|
|
10
12
|
* content attribute.
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check page-has-heading-one
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary The page should have at least one level-one heading
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Always applicable to any HTML document with a <body> element —
|
|
10
12
|
* "does the page have an h1" is a whole-page concern, matching
|
|
@@ -21,16 +23,15 @@
|
|
|
21
23
|
* header comment for the shared rationale/precedent.
|
|
22
24
|
* - Filters candidates through `isAccTreeEligible` (hidden/aria-hidden/
|
|
23
25
|
* display:none/inert elements don't count as "the page has a heading
|
|
24
|
-
* one"), matching `landmark-one-main`'s own precedent
|
|
25
|
-
*
|
|
26
|
-
* `display:none` ancestor
|
|
27
|
-
* reader users alike
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* ones merely clipped from the visual viewport.
|
|
26
|
+
* one"), matching `landmark-one-main`'s own precedent — a raw
|
|
27
|
+
* `document.querySelectorAll` would wrongly credit an `<h1>` that sits
|
|
28
|
+
* inside a `display:none` ancestor, genuinely unreachable by sighted and
|
|
29
|
+
* screen reader users alike, as satisfying this check. This does NOT
|
|
30
|
+
* regress purely-visually-clipped-but-AT-exposed headings (e.g. an `<h1>`
|
|
31
|
+
* hidden via clip-path/off-screen positioning, `visibility:visible`, no
|
|
32
|
+
* `aria-hidden`) — `isAccTreeEligible` only excludes elements actually
|
|
33
|
+
* removed from the accessibility tree, not ones merely clipped from the
|
|
34
|
+
* visual viewport.
|
|
34
35
|
*/
|
|
35
36
|
|
|
36
37
|
const id = 'page-has-heading-one';
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check presentation-role-conflict
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary role="presentation"/"none" must not be combined with a global ARIA naming attribute or focusability
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Applies to elements with an explicit role="presentation" or
|
|
10
12
|
* role="none", OR an <img alt=""> (empty alt gives an <img> an implicit
|
|
11
13
|
* presentation role per HTML-AAM, even with no explicit role attribute
|
|
12
|
-
* at all —
|
|
13
|
-
* for this exact check, `img[alt=''], [role="none"], [role="presentation"]`).
|
|
14
|
+
* at all — `img[alt=''], [role="none"], [role="presentation"]`).
|
|
14
15
|
* @expectation
|
|
15
16
|
* The element does not also carry a WAI-ARIA *global* state/property
|
|
16
17
|
* (aria-label, aria-hidden, aria-describedby, aria-live, aria-current,
|
|
@@ -25,32 +26,27 @@
|
|
|
25
26
|
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
26
27
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
27
28
|
* header comment for the shared rationale/precedent.
|
|
28
|
-
* -
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* broad has surfaced in any corpus round to date.
|
|
50
|
-
* - The native-implicit-role table needed to replicate that gate (if this
|
|
51
|
-
* scope decision is ever revisited) has since been produced — see
|
|
52
|
-
* ROADMAP.md §7 item 9 (2026-07-31) for the full table and the open
|
|
53
|
-
* maintainer decision; not implemented here pending that call.
|
|
29
|
+
* - `aria-hidden="true"` (the exact valid truthy value) on the
|
|
30
|
+
* presentational element itself is deliberately EXCLUDED as a trigger,
|
|
31
|
+
* even though it's a global ARIA attribute: it removes the element from
|
|
32
|
+
* the accessibility tree unconditionally, so the "role restoration"
|
|
33
|
+
* this rule warns about never actually reaches assistive tech, making a
|
|
34
|
+
* flag misleading. Any OTHER conflicting attribute present alongside
|
|
35
|
+
* `aria-hidden="true"` is equally inert for the same reason and is not
|
|
36
|
+
* flagged either. An `aria-hidden=""` (empty/invalid value, does NOT
|
|
37
|
+
* hide) still triggers normally. Focusability is unaffected by this
|
|
38
|
+
* exemption (see the code comment at the check site).
|
|
39
|
+
* - The conflicting-attribute set is the full list of ARIA attributes
|
|
40
|
+
* marked `global: true`, not a narrower naming-only list.
|
|
41
|
+
* - Deliberately NOT applying an implicit-role applicability gate that
|
|
42
|
+
* would make the check inapplicable to role="presentation" on elements
|
|
43
|
+
* with no native implicit role to suppress (e.g. `<div
|
|
44
|
+
* role="presentation" aria-hidden="true">` — a <div> has no native role,
|
|
45
|
+
* so there's nothing for the presentational role to "conflict" with).
|
|
46
|
+
* surea11y stays broader/more cautious here rather than narrower, which
|
|
47
|
+
* is the safer direction to diverge in. The native-implicit-role table
|
|
48
|
+
* needed to add that gate, if this scope decision is ever revisited, is
|
|
49
|
+
* in ROADMAP.md §7 item 9.
|
|
54
50
|
* - Focusability is computed via helpers.getFocusableInfo (native +
|
|
55
51
|
* tabindex), same helper aria-hidden-focus already relies on — a
|
|
56
52
|
* `:disabled` or otherwise non-focusable element is not flagged.
|
|
@@ -81,9 +77,8 @@ function runInPage(ctx) {
|
|
|
81
77
|
const { helpers, rule } = ctx;
|
|
82
78
|
|
|
83
79
|
// The full set of ARIA attributes marked `global: true` per the WAI-ARIA
|
|
84
|
-
// spec
|
|
85
|
-
//
|
|
86
|
-
// element restores its implicit role, not just the naming ones.
|
|
80
|
+
// spec — any of these present on a presentational element restores its
|
|
81
|
+
// implicit role, not just the naming ones.
|
|
87
82
|
const CONFLICTING_ATTRS = [
|
|
88
83
|
'aria-atomic',
|
|
89
84
|
'aria-braillelabel',
|
|
@@ -128,14 +123,33 @@ function runInPage(ctx) {
|
|
|
128
123
|
|
|
129
124
|
// Presence, not value truthiness: the WAI-ARIA role-conflict-resolution
|
|
130
125
|
// rule triggers on a global ARIA attribute being SPECIFIED at all, even
|
|
131
|
-
// with an empty value —
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
const present = CONFLICTING_ATTRS.filter((attr) =>
|
|
126
|
+
// with an empty value — e.g. <img alt="" aria-hidden="">, where
|
|
127
|
+
// aria-hidden="" (empty string) is still a specified attribute. A
|
|
128
|
+
// truthy-value check would miss this.
|
|
129
|
+
let present = CONFLICTING_ATTRS.filter((attr) =>
|
|
136
130
|
el.hasAttribute ? el.hasAttribute(attr) : el.getAttribute(attr) != null
|
|
137
131
|
);
|
|
138
132
|
|
|
133
|
+
// aria-hidden="true" (the exact, valid truthy value — not the
|
|
134
|
+
// empty-string case above, which never actually hides anything) is a
|
|
135
|
+
// special case: it removes the element and its subtree from the
|
|
136
|
+
// accessibility tree unconditionally, independent of role. That makes
|
|
137
|
+
// the "role restoration" this rule warns about ("...which restores its
|
|
138
|
+
// implicit role and cancels the presentational intent") factually
|
|
139
|
+
// inert — no AT will ever expose the restored role OR any of the other
|
|
140
|
+
// conflicting attributes (aria-label, aria-describedby, ...) present
|
|
141
|
+
// alongside it, since the whole element stays out of the tree
|
|
142
|
+
// regardless. This pattern is extremely common (e.g. <svg
|
|
143
|
+
// role="presentation" aria-hidden="true"> decorative icons — a
|
|
144
|
+
// defensive belt-and-suspenders double-hide, not an authoring mistake).
|
|
145
|
+
// Focusability is NOT covered by this exemption — a keyboard user can
|
|
146
|
+
// still tab onto an aria-hidden="true" focusable element (the
|
|
147
|
+
// aria-hidden-focus anti-pattern), a real, independent hazard
|
|
148
|
+
// aria-hidden does nothing to prevent.
|
|
149
|
+
if (el.getAttribute('aria-hidden') === 'true') {
|
|
150
|
+
present = [];
|
|
151
|
+
}
|
|
152
|
+
|
|
139
153
|
let isFocusable = false;
|
|
140
154
|
if (getFocusableInfo) {
|
|
141
155
|
try {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check region
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary Page content should be contained within a landmark region
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Applies to any element under <body> that directly carries visible text
|
|
10
12
|
* (or other own content — see @implementation-notes) and is not itself a
|
|
@@ -20,18 +22,15 @@
|
|
|
20
22
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
21
23
|
* header comment for the shared rationale/precedent and the landmark-
|
|
22
24
|
* detection model.
|
|
23
|
-
* - Recursive tree walk, not a direct-<body>-children-only scan
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* it or collapsed the whole page into one undifferentiated report.
|
|
33
|
-
* - Algorithm, using this engine's own (already more spec-correct in
|
|
34
|
-
* places — see hasLandmarkScopingAncestor's header comment) helpers:
|
|
25
|
+
* - Recursive tree walk, not a direct-<body>-children-only scan: a
|
|
26
|
+
* direct-children-only scope is nearly inert on the single most common
|
|
27
|
+
* real-world page shape, a modern framework's single root mount div
|
|
28
|
+
* (`<body><div id="root">...everything...</div></body>`) — that shape
|
|
29
|
+
* gives at most one candidate for the entire page and either misses
|
|
30
|
+
* every real gap inside it or collapses the whole page into one
|
|
31
|
+
* undifferentiated report.
|
|
32
|
+
* - Algorithm, using this engine's own helpers (see
|
|
33
|
+
* hasLandmarkScopingAncestor's header comment):
|
|
35
34
|
* 1. Depth-first walk from <body>'s children.
|
|
36
35
|
* 2. At each node: if it's ineligible for the accessibility tree
|
|
37
36
|
* (helpers.isAccTreeEligible), OR is itself a "stopper" (landmark,
|
|
@@ -52,7 +51,7 @@
|
|
|
52
51
|
* contiguous unplaced content into one occurrence per real gap instead
|
|
53
52
|
* of reporting every individual text-bearing leaf — this is what keeps
|
|
54
53
|
* the walk from being noisy on ordinary pages that mix landmarked and
|
|
55
|
-
* stray content
|
|
54
|
+
* stray content.
|
|
56
55
|
* - "Stopper" exemptions (button, dialog, <svg>, resolvable skip-links)
|
|
57
56
|
* are a deliberate scope choice, not an oversight: these
|
|
58
57
|
* are extremely common real-world patterns (floating action buttons,
|
|
@@ -119,9 +118,7 @@ function runInPage(ctx) {
|
|
|
119
118
|
|
|
120
119
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
121
120
|
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
122
|
-
// local copy -- see that function's header comment in src/core/dom-helpers.js
|
|
123
|
-
// (missing title fallback) this replaced across all 7 landmark rule files that had their own
|
|
124
|
-
// copy of this logic.
|
|
121
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js.
|
|
125
122
|
function getAccessibleLandmarkName(el) {
|
|
126
123
|
try {
|
|
127
124
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -144,10 +141,9 @@ function runInPage(ctx) {
|
|
|
144
141
|
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
145
142
|
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
146
143
|
// rather than a local tag-only copy. See that function's header comment
|
|
147
|
-
// in src/core/aria-helpers.js for the full algorithm
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
// rule's own former tag-only copy as a false negative.
|
|
144
|
+
// in src/core/aria-helpers.js for the full algorithm — e.g. an
|
|
145
|
+
// <aside role="dialog"> containing its own <header>, where the <header>
|
|
146
|
+
// keeps its banner role.
|
|
151
147
|
function hasSectioningAncestor(el, includeMain) {
|
|
152
148
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
153
149
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -161,11 +157,9 @@ function runInPage(ctx) {
|
|
|
161
157
|
if (tag === 'main') return 'main';
|
|
162
158
|
if (tag === 'nav') return 'navigation';
|
|
163
159
|
if (tag === 'aside') {
|
|
164
|
-
// A named <aside> is never suppressed, even when nested
|
|
165
|
-
// landmark-unique's own verified-against-reference-engine precedent
|
|
166
|
-
// (that engine's real `aside` implicit-role function keeps
|
|
160
|
+
// A named <aside> is never suppressed, even when nested: keeps
|
|
167
161
|
// "complementary" when the element has an accessible name, even
|
|
168
|
-
// inside sectioning content
|
|
162
|
+
// inside sectioning content. See landmark-unique-manual.js.
|
|
169
163
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
170
164
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
171
165
|
}
|
|
@@ -196,8 +190,7 @@ function runInPage(ctx) {
|
|
|
196
190
|
|
|
197
191
|
// Roles/attributes that make an element its own self-contained
|
|
198
192
|
// announced area — not literally a WAI-ARIA landmark, but not "content
|
|
199
|
-
// that needs a landmark" either.
|
|
200
|
-
// reference engine's own region rule stops recursion at.
|
|
193
|
+
// that needs a landmark" either.
|
|
201
194
|
const LIVE_REGION_ROLES = new Set(['alert', 'status', 'log', 'marquee', 'timer']);
|
|
202
195
|
|
|
203
196
|
function isAriaLive(el) {
|
|
@@ -257,12 +250,11 @@ function runInPage(ctx) {
|
|
|
257
250
|
const VISUAL_CONTENT_TAGS = new Set(['img', 'video', 'audio', 'canvas', 'object', 'embed']);
|
|
258
251
|
|
|
259
252
|
// Non-recursive "does THIS element, on its own, carry content" check —
|
|
260
|
-
// deliberately mirrors only the direct-content half of getContentNameInfo
|
|
261
|
-
// a
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
// ancestor swallowing everything beneath it into one report.
|
|
253
|
+
// deliberately mirrors only the direct-content half of getContentNameInfo,
|
|
254
|
+
// not a full name-from-content recursion: the whole point is to keep
|
|
255
|
+
// recursing through plain wrapper elements (a framework's root mount
|
|
256
|
+
// <div> included) until reaching the actual content-bearing node, rather
|
|
257
|
+
// than a coarse ancestor swallowing everything beneath it into one report.
|
|
266
258
|
function hasOwnContent(el) {
|
|
267
259
|
const kids = el.childNodes || [];
|
|
268
260
|
for (let i = 0; i < kids.length; i++) {
|
|
@@ -341,9 +333,8 @@ function runInPage(ctx) {
|
|
|
341
333
|
// Collapse each candidate leaf upward through parents that have no OTHER
|
|
342
334
|
// stopper anywhere in their subtree, so contiguous unplaced content
|
|
343
335
|
// merges into ONE occurrence per real gap instead of one per text node --
|
|
344
|
-
// this collapsing
|
|
345
|
-
//
|
|
346
|
-
// from producing noisy, one-per-leaf reports.
|
|
336
|
+
// this collapsing is what keeps ordinary pages (landmarked content mixed
|
|
337
|
+
// with a little stray content) from producing noisy, one-per-leaf reports.
|
|
347
338
|
const collapsed = [];
|
|
348
339
|
const seen = new Set();
|
|
349
340
|
for (const leaf of leaves) {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check scope-attr-valid
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary The scope attribute must have a valid value
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Applies to elements with a non-empty scope attribute.
|
|
10
12
|
* @expectation
|
|
@@ -1,20 +1,21 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check skip-link
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary A "skip" link must resolve to a real, usable target
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Applies to <a href="#fragment"> elements whose accessible name
|
|
10
12
|
* matches a common "skip to ..." / "jump to ..." authoring convention
|
|
11
13
|
* (case-insensitive "skip" or "jump to" in the name) — the recognizable
|
|
12
14
|
* pattern for a skip-navigation link, not every same-page anchor link
|
|
13
|
-
* on the page. "jump to"
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* caught it. Text-pattern matching itself stays deliberate (see
|
|
15
|
+
* on the page. "jump to" is included alongside "skip" since real skip
|
|
16
|
+
* links use both conventions (e.g. a "Jump to section" link, which a
|
|
17
|
+
* purely positional match would catch but a "skip"-only text pattern
|
|
18
|
+
* would miss). Text-pattern matching itself stays deliberate (see
|
|
18
19
|
* implementation-notes) — this only widens the known-convention list.
|
|
19
20
|
* @expectation
|
|
20
21
|
* The link's fragment resolves to a real element in the document
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check tabindex
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary tabindex should not be greater than 0
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Applies to elements with a tabindex attribute whose value parses as
|
|
10
12
|
* a valid integer.
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check table-duplicate-name
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary A table's caption must not duplicate its summary attribute
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Applies to <table> elements that have both a <caption> with text
|
|
10
12
|
* content and a (deprecated but still encountered) summary attribute.
|
|
@@ -17,9 +19,8 @@
|
|
|
17
19
|
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
18
20
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
19
21
|
* header comment for the shared rationale/precedent.
|
|
20
|
-
* - Narrowly scoped to caption-vs-summary duplication specifically
|
|
21
|
-
*
|
|
22
|
-
* name quality" check.
|
|
22
|
+
* - Narrowly scoped to caption-vs-summary duplication specifically, not a
|
|
23
|
+
* general "table name quality" check.
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
26
|
const id = 'table-duplicate-name';
|