@surea11y/core 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -2
- package/README.md +109 -35
- package/bin/surea11y-core.js +20 -0
- package/docs/API_STABILITY.md +26 -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/package.json +50 -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 +15 -51
- 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 +4 -3
- 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 +2 -0
- 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 +2 -0
- package/src/checks/automatic/avoid-inline-spacing.js +3 -2
- package/src/checks/automatic/binary-control-name-present.js +2 -0
- package/src/checks/automatic/button-name-present.js +7 -7
- package/src/checks/automatic/bypass-blocks-present.js +9 -7
- package/src/checks/automatic/canvas-text-alternative-present.js +2 -0
- package/src/checks/automatic/combobox-name-present.js +2 -0
- 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 +10 -10
- 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 +2 -0
- package/src/checks/automatic/form-control-single-label.js +6 -7
- 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 +7 -9
- package/src/checks/automatic/input-image-alt-present.js +4 -6
- package/src/checks/automatic/label-in-name.js +15 -19
- package/src/checks/automatic/language-page-present.js +2 -0
- package/src/checks/automatic/link-in-text-block.js +2 -0
- package/src/checks/automatic/link-name-present.js +2 -0
- package/src/checks/automatic/list-children-valid.js +14 -24
- package/src/checks/automatic/listbox-name-present.js +2 -0
- package/src/checks/automatic/listitem-parent-valid.js +30 -7
- package/src/checks/automatic/menuitem-name-present.js +2 -0
- package/src/checks/automatic/meta-refresh-no-exceptions.js +4 -4
- package/src/checks/automatic/meta-refresh-timing-absent.js +2 -0
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +2 -0
- package/src/checks/automatic/meter-name-present.js +4 -3
- package/src/checks/automatic/nested-interactive-controls-absent.js +27 -5
- package/src/checks/automatic/object-text-alternative-present.js +2 -0
- package/src/checks/automatic/option-name-present.js +2 -0
- package/src/checks/automatic/page-title-present.js +2 -0
- package/src/checks/automatic/progressbar-name-present.js +8 -10
- package/src/checks/automatic/role-img-alt-present.js +4 -4
- package/src/checks/automatic/searchbox-name-present.js +2 -0
- package/src/checks/automatic/server-side-image-map-absent.js +4 -3
- package/src/checks/automatic/slider-name-present.js +2 -0
- package/src/checks/automatic/spinbutton-name-present.js +2 -0
- package/src/checks/automatic/summary-name-present.js +2 -0
- 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 +2 -0
- package/src/checks/automatic/table-headers-attr-valid.js +3 -2
- package/src/checks/automatic/table-th-has-data-cells.js +2 -0
- 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 +2 -0
- package/src/checks/automatic/tooltip-name-present.js +2 -0
- package/src/checks/automatic/treeitem-name-present.js +2 -0
- package/src/checks/automatic/valid-lang.js +2 -0
- 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/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 +2 -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 +54 -47
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +42 -26
- 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 +6772 -2158
- package/src/index.js +2 -0
- package/src/report.js +51 -9
- package/src/sarif.js +18 -3
- package/surea11y.browser.js +2731 -999
- package/bin/core.js +0 -473
- package/docs/CLI.md +0 -128
- package/src/catalogs/composites.wcag.js +0 -454
- 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-no-duplicate-contentinfo
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary A page must not have more than one contentinfo landmark
|
|
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 the page contains at least one contentinfo landmark
|
|
10
12
|
* (explicit role="contentinfo", or an implicit, non-nested <footer>).
|
|
@@ -16,11 +18,8 @@
|
|
|
16
18
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
17
19
|
* header comment for the shared rationale/precedent.
|
|
18
20
|
* - Only landmarks actually exposed to assistive technology can collide —
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* _isVisibleToScreenReaders(elm))`) — same fix applied to the sibling
|
|
22
|
-
* banner/main rules after finding real hidden-duplicate false positives
|
|
23
|
-
* on Trello and Zoom.
|
|
21
|
+
* same as the sibling banner/main rules, avoiding hidden-duplicate false
|
|
22
|
+
* positives.
|
|
24
23
|
*/
|
|
25
24
|
|
|
26
25
|
const id = 'landmark-no-duplicate-contentinfo';
|
|
@@ -55,9 +54,8 @@ function runInPage(ctx) {
|
|
|
55
54
|
|
|
56
55
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
57
56
|
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
58
|
-
// local copy -- see that function's header comment in src/core/dom-helpers.js
|
|
59
|
-
//
|
|
60
|
-
// copy of this logic.
|
|
57
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
|
|
58
|
+
// the title-attribute fallback consistent across the landmark rules.
|
|
61
59
|
function getAccessibleLandmarkName(el) {
|
|
62
60
|
try {
|
|
63
61
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -80,10 +78,8 @@ function runInPage(ctx) {
|
|
|
80
78
|
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
81
79
|
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
82
80
|
// rather than a local tag-only copy. See that function's header comment
|
|
83
|
-
// in src/core/aria-helpers.js for the full algorithm
|
|
84
|
-
//
|
|
85
|
-
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
86
|
-
// rule's own former tag-only copy as a false negative.
|
|
81
|
+
// in src/core/aria-helpers.js for the full algorithm. Example: an
|
|
82
|
+
// <aside role="dialog"> containing its own <header>.
|
|
87
83
|
function hasSectioningAncestor(el, includeMain) {
|
|
88
84
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
89
85
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -97,11 +93,9 @@ function runInPage(ctx) {
|
|
|
97
93
|
if (tag === 'main') return 'main';
|
|
98
94
|
if (tag === 'nav') return 'navigation';
|
|
99
95
|
if (tag === 'aside') {
|
|
100
|
-
// A named <aside> is never suppressed, even when nested —
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
// "complementary" when the element has an accessible name, even
|
|
104
|
-
// inside sectioning content); propagated here for consistency.
|
|
96
|
+
// A named <aside> is never suppressed, even when nested — it keeps
|
|
97
|
+
// "complementary" when it has an accessible name, even inside
|
|
98
|
+
// sectioning content. Matches landmark-unique's precedent.
|
|
105
99
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
106
100
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
107
101
|
}
|
|
@@ -129,9 +123,8 @@ function runInPage(ctx) {
|
|
|
129
123
|
}
|
|
130
124
|
|
|
131
125
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
132
|
-
// landmark-unique-manual.js's header comment
|
|
133
|
-
//
|
|
134
|
-
// invisible to a light-DOM-only query.
|
|
126
|
+
// landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
|
|
127
|
+
// widget's own landmark is invisible to a light-DOM-only query.
|
|
135
128
|
let nodes;
|
|
136
129
|
try {
|
|
137
130
|
nodes =
|
|
@@ -1,29 +1,27 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check landmark-no-duplicate-main
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary A page must not have more than one main landmark
|
|
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 the page contains at least one main landmark
|
|
10
12
|
* (explicit role="main", or an implicit <main>).
|
|
11
13
|
* @expectation
|
|
12
14
|
* At most one main landmark exists on the page. Distinct, atomic
|
|
13
15
|
* decision from landmark-one-main (that rule flags zero
|
|
14
|
-
* mains too; this one only flags more than one)
|
|
15
|
-
* reference engine shipping both as separate rules with some overlap by design.
|
|
16
|
+
* mains too; this one only flags more than one).
|
|
16
17
|
* @implementation-notes
|
|
17
18
|
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
18
19
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
19
20
|
* header comment for the shared rationale/precedent.
|
|
20
|
-
* - Only landmarks actually exposed to assistive technology can collide
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* rendering both a visible and a CSS-hidden duplicate `<main>` (found on
|
|
25
|
-
* a real site — Zoom's homepage) was wrongly flagged as a duplicate
|
|
26
|
-
* landmark; the hidden copy is never actually reachable by AT.
|
|
21
|
+
* - Only landmarks actually exposed to assistive technology can collide.
|
|
22
|
+
* Without this, a responsive layout rendering both a visible and a
|
|
23
|
+
* CSS-hidden duplicate `<main>` is flagged as a duplicate landmark even
|
|
24
|
+
* though the hidden copy is never reachable by AT.
|
|
27
25
|
*/
|
|
28
26
|
|
|
29
27
|
const id = 'landmark-no-duplicate-main';
|
|
@@ -70,9 +68,8 @@ function runInPage(ctx) {
|
|
|
70
68
|
}
|
|
71
69
|
|
|
72
70
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
73
|
-
// landmark-unique-manual.js's header comment
|
|
74
|
-
//
|
|
75
|
-
// invisible to a light-DOM-only query.
|
|
71
|
+
// landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
|
|
72
|
+
// widget's own landmark is invisible to a light-DOM-only query.
|
|
76
73
|
let nodes;
|
|
77
74
|
try {
|
|
78
75
|
nodes =
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check landmark-one-main
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary The page should have a main landmark
|
|
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 a main landmark" is a whole-page concern,
|
|
@@ -18,27 +20,15 @@
|
|
|
18
20
|
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
19
21
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
20
22
|
* header comment for the shared rationale/precedent.
|
|
21
|
-
* - Presence-only
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* ships that as a fully separate rule, `landmark-no-duplicate-main` /
|
|
28
|
-
* `page-no-duplicate-main`, already correctly implemented here as
|
|
29
|
-
* `landmark-no-duplicate-main`) and missing that sibling rule's
|
|
30
|
-
* accessibility-tree visibility filter, so it double-flagged cases the
|
|
31
|
-
* sibling rule already handles correctly — found via a real page
|
|
32
|
-
* (2026-07-22, live-DOM corpus): Resy's and DuckDuckGo's homepages each
|
|
33
|
-
* genuinely have two visible `<main>` elements, which that reference
|
|
34
|
-
* engine's `landmark-one-main` doesn't flag at all (out of its scope) but
|
|
35
|
-
* this rule wrongly did, disagreeing with it for a
|
|
36
|
-
* reason that wasn't a real coverage gap on either side — just a
|
|
37
|
-
* redundant, incorrectly-scoped extra branch here.
|
|
23
|
+
* - Presence-only: a plain descendant-exists test. It does NOT flag more
|
|
24
|
+
* than one main. "More than one main" is a separate rule,
|
|
25
|
+
* `landmark-no-duplicate-main`, already implemented — deliberately not
|
|
26
|
+
* duplicated here, since a page can genuinely have two visible `<main>`
|
|
27
|
+
* elements, which is out of scope for "does a main landmark exist," not
|
|
28
|
+
* a violation this rule should report.
|
|
38
29
|
* - Filters candidates through `isAccTreeEligible` (hidden/aria-hidden/
|
|
39
30
|
* display:none/inert elements don't count as "a main landmark exists"),
|
|
40
|
-
* matching `landmark-no-duplicate-main`'s own precedent
|
|
41
|
-
* that reference engine's own accessibility-tree-scoped matching.
|
|
31
|
+
* matching `landmark-no-duplicate-main`'s own precedent.
|
|
42
32
|
*/
|
|
43
33
|
|
|
44
34
|
const id = 'landmark-one-main';
|
|
@@ -106,9 +96,8 @@ function runInPage(ctx) {
|
|
|
106
96
|
}
|
|
107
97
|
|
|
108
98
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
109
|
-
// landmark-unique-manual.js's header comment
|
|
110
|
-
//
|
|
111
|
-
// invisible to a light-DOM-only query.
|
|
99
|
+
// landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
|
|
100
|
+
// widget's own landmark is invisible to a light-DOM-only query.
|
|
112
101
|
let nodes;
|
|
113
102
|
try {
|
|
114
103
|
nodes =
|
|
@@ -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 {
|