@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 label-title-only
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary Form controls should not rely on the title attribute as their only label
|
|
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 labelable form controls (input, excluding
|
|
10
12
|
* hidden/submit/reset/button/image; select; textarea) that have a
|
|
@@ -46,7 +48,7 @@ const meta = {
|
|
|
46
48
|
};
|
|
47
49
|
|
|
48
50
|
function runInPage(ctx) {
|
|
49
|
-
const {
|
|
51
|
+
const { helpers, rule } = ctx;
|
|
50
52
|
|
|
51
53
|
const selector =
|
|
52
54
|
'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
|
|
@@ -54,37 +56,42 @@ function runInPage(ctx) {
|
|
|
54
56
|
? helpers.queryAllSmart(selector)
|
|
55
57
|
: helpers.queryAll(selector);
|
|
56
58
|
|
|
57
|
-
const labelsByFor = new Map();
|
|
58
|
-
const allLabels = document.getElementsByTagName ? document.getElementsByTagName('label') : [];
|
|
59
|
-
for (const lab of allLabels) {
|
|
60
|
-
if (!lab || !lab.getAttribute) continue;
|
|
61
|
-
const forValue = String(lab.getAttribute('for') || '').trim();
|
|
62
|
-
if (!forValue) continue;
|
|
63
|
-
if (!labelsByFor.has(forValue)) labelsByFor.set(forValue, []);
|
|
64
|
-
labelsByFor.get(forValue).push(lab);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
59
|
const occurrences = [];
|
|
68
60
|
let applicableCount = 0;
|
|
69
61
|
|
|
70
62
|
for (const el of nodes) {
|
|
71
63
|
if (!el || !el.getAttribute) continue;
|
|
72
64
|
|
|
65
|
+
if (helpers.isAccTreeEligible) {
|
|
66
|
+
const elig = (() => {
|
|
67
|
+
try {
|
|
68
|
+
return helpers.isAccTreeEligible(el, ctx);
|
|
69
|
+
} catch {
|
|
70
|
+
return { eligible: true, reasons: [] };
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
if (elig && elig.eligible === false) continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
73
76
|
const title = String(el.getAttribute('title') || '').trim();
|
|
74
77
|
if (!title) continue;
|
|
75
78
|
|
|
76
79
|
applicableCount += 1;
|
|
77
80
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
81
|
+
// Delegates to the shared helpers.getAccessibleNameInfo (aria ->
|
|
82
|
+
// native <label> -> title, the same precedence every other
|
|
83
|
+
// name-dependent rule in this engine uses) rather than a local,
|
|
84
|
+
// hand-rolled "does a <label for>/wrapping <label> exist" check. A
|
|
85
|
+
// structural-association-only check (for="..."/wrapping) never verifies
|
|
86
|
+
// the label actually contributes a name -- an empty <label for="x">
|
|
87
|
+
// </label> or empty wrapping <label> would exempt the control even
|
|
88
|
+
// though title is functionally its only real label (see
|
|
89
|
+
// dom-helpers.js's hasLabelAssociation/labelContributesAccessibleName).
|
|
90
|
+
// If the resolved mechanism isn't 'title', some higher-priority
|
|
91
|
+
// mechanism (aria-label/aria-labelledby/a real contributing label)
|
|
92
|
+
// already won and this control isn't title-only.
|
|
93
|
+
const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
|
|
94
|
+
if (!nameInfo || nameInfo.mechanism !== 'title') continue;
|
|
88
95
|
|
|
89
96
|
const tag = el.tagName.toLowerCase();
|
|
90
97
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
@@ -1,23 +1,25 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check landmark-banner-is-top-level
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary The banner landmark must not be nested inside another 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 banner candidate:
|
|
10
|
-
* explicit role="banner", OR a <header> with NO role attribute at all
|
|
11
|
-
*
|
|
12
|
+
* explicit role="banner", OR a <header> with NO role attribute at all,
|
|
13
|
+
* regardless of nesting (see implementation notes on why candidate
|
|
14
|
+
* selection is deliberately unconditional).
|
|
12
15
|
* @expectation
|
|
13
16
|
* No banner candidate has an ancestor that is itself any landmark
|
|
14
17
|
* region. A banner nested inside another landmark is not a top-level,
|
|
15
18
|
* whole-page banner and confuses landmark-based navigation for
|
|
16
19
|
* assistive technology users.
|
|
17
20
|
* @implementation-notes
|
|
18
|
-
* - Not WCAG-normative
|
|
19
|
-
*
|
|
20
|
-
* rule per ROADMAP.md Tier 1b and the design doc's policy model
|
|
21
|
+
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
22
|
+
* `type: 'manual'` rule per ROADMAP.md Tier 1b and the design doc's policy model
|
|
21
23
|
* ("Advisory / best-practice rules may exist, but must not produce
|
|
22
24
|
* `fail`"). Matches the existing `page-title-patterns-manual.js`
|
|
23
25
|
* precedent: deterministic DOM analysis, no human required, but
|
|
@@ -25,30 +27,20 @@
|
|
|
25
27
|
* - Landmark detection here models WAI-ARIA APG landmark roles and the
|
|
26
28
|
* HTML-AAM implicit-role mapping (header→banner, footer→contentinfo,
|
|
27
29
|
* main→main, nav→navigation, aside→complementary, section/form→
|
|
28
|
-
* region/form only when accessibly named)
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* (`header:not([role]), [role=banner]`) is unconditional — it doesn't
|
|
43
|
-
* care whether the header *currently* carries the banner role, only
|
|
44
|
-
* whether it's a `<header>`/`role="banner"` with a landmark ancestor
|
|
45
|
-
* above it (verified by reading that engine's real
|
|
46
|
-
* `landmark-is-top-level-evaluate` source, not guessed). Candidate
|
|
47
|
-
* selection (`isBannerCandidate` below) now matches that unconditional
|
|
48
|
-
* selector shape; the ancestor walk (`hasLandmarkAncestor`) still uses
|
|
49
|
-
* the full suppression-aware `getLandmarkRole` for each ancestor,
|
|
50
|
-
* which is correct and unchanged — an ancestor genuinely needs its own
|
|
51
|
-
* real role to count as blocking.
|
|
30
|
+
* region/form only when accessibly named).
|
|
31
|
+
* - Candidate selection (`isBannerCandidate` below) is deliberately
|
|
32
|
+
* unconditional — a `<header>`/`role="banner"` counts as a candidate
|
|
33
|
+
* regardless of nesting — rather than reusing the same HTML-AAM
|
|
34
|
+
* sectioning-ancestor suppression (`getImplicitLandmarkRole`'s
|
|
35
|
+
* `hasSectioningAncestor` gate) that the violation check itself relies
|
|
36
|
+
* on. Gating candidate selection on that suppression would be
|
|
37
|
+
* self-defeating: the moment a `<header>` is nested inside another
|
|
38
|
+
* landmark, that same nesting would make it stop counting as a banner
|
|
39
|
+
* candidate in the first place, so the rule could never flag the one
|
|
40
|
+
* case it exists to catch. The ancestor walk (`hasLandmarkAncestor`) is intentionally
|
|
41
|
+
* asymmetric: it still uses the full suppression-aware
|
|
42
|
+
* `getLandmarkRole` for each ancestor, since an ancestor genuinely
|
|
43
|
+
* needs its own real role to count as blocking.
|
|
52
44
|
*/
|
|
53
45
|
|
|
54
46
|
const id = 'landmark-banner-is-top-level';
|
|
@@ -85,9 +77,8 @@ function runInPage(ctx) {
|
|
|
85
77
|
|
|
86
78
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
87
79
|
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
88
|
-
// local copy -- see that function's header comment in src/core/dom-helpers.js
|
|
89
|
-
//
|
|
90
|
-
// copy of this logic.
|
|
80
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
|
|
81
|
+
// the title-attribute fallback consistent across the landmark rules.
|
|
91
82
|
function getAccessibleLandmarkName(el) {
|
|
92
83
|
try {
|
|
93
84
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -110,10 +101,8 @@ function runInPage(ctx) {
|
|
|
110
101
|
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
111
102
|
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
112
103
|
// rather than a local tag-only copy. See that function's header comment
|
|
113
|
-
// in src/core/aria-helpers.js for the full algorithm
|
|
114
|
-
//
|
|
115
|
-
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
116
|
-
// rule's own former tag-only copy as a false negative.
|
|
104
|
+
// in src/core/aria-helpers.js for the full algorithm. Example: an
|
|
105
|
+
// <aside role="dialog"> containing its own <header>.
|
|
117
106
|
function hasSectioningAncestor(el, includeMain) {
|
|
118
107
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
119
108
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -127,11 +116,9 @@ function runInPage(ctx) {
|
|
|
127
116
|
if (tag === 'main') return 'main';
|
|
128
117
|
if (tag === 'nav') return 'navigation';
|
|
129
118
|
if (tag === 'aside') {
|
|
130
|
-
// A named <aside> is never suppressed, even when nested —
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// "complementary" when the element has an accessible name, even
|
|
134
|
-
// inside sectioning content); propagated here for consistency.
|
|
119
|
+
// A named <aside> is never suppressed, even when nested — it keeps
|
|
120
|
+
// "complementary" when it has an accessible name, even inside
|
|
121
|
+
// sectioning content. Matches landmark-unique's precedent.
|
|
135
122
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
136
123
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
137
124
|
}
|
|
@@ -159,7 +146,7 @@ function runInPage(ctx) {
|
|
|
159
146
|
}
|
|
160
147
|
|
|
161
148
|
// Candidate selection is deliberately NOT the same as getLandmarkRole()
|
|
162
|
-
// === 'banner' — see the
|
|
149
|
+
// === 'banner' — see the fix note above. A <header> is a
|
|
163
150
|
// candidate purely by tag + absence of any role attribute, independent
|
|
164
151
|
// of whether sectioning-ancestor nesting would currently suppress its
|
|
165
152
|
// implicit role; an explicit role="banner" is always a candidate too.
|
|
@@ -184,9 +171,8 @@ function runInPage(ctx) {
|
|
|
184
171
|
}
|
|
185
172
|
|
|
186
173
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
187
|
-
// landmark-unique-manual.js's header comment
|
|
188
|
-
//
|
|
189
|
-
// invisible to a light-DOM-only query.
|
|
174
|
+
// landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
|
|
175
|
+
// widget's own landmark is invisible to a light-DOM-only query.
|
|
190
176
|
let nodes;
|
|
191
177
|
try {
|
|
192
178
|
nodes =
|
|
@@ -202,7 +188,28 @@ function runInPage(ctx) {
|
|
|
202
188
|
for (const el of nodes) {
|
|
203
189
|
if (!el || seen.has(el)) continue;
|
|
204
190
|
seen.add(el);
|
|
205
|
-
if (isBannerCandidate(el))
|
|
191
|
+
if (!isBannerCandidate(el)) continue;
|
|
192
|
+
|
|
193
|
+
// An aria-hidden banner candidate is removed from the accessibility
|
|
194
|
+
// tree entirely -- it isn't part of the landmark structure assistive
|
|
195
|
+
// technology users navigate at all, so it shouldn't be flagged as
|
|
196
|
+
// "nested inside another landmark" (there's no real landmark there to
|
|
197
|
+
// begin with, from AT's perspective). queryAllSmart's default hidden-
|
|
198
|
+
// content policy only excludes "hard" CSS-based hiding (display:none,
|
|
199
|
+
// etc.), not the softer aria-hidden exclusion, so this needs its own
|
|
200
|
+
// check.
|
|
201
|
+
if (helpers && typeof helpers.isAccTreeEligible === 'function') {
|
|
202
|
+
const elig = (() => {
|
|
203
|
+
try {
|
|
204
|
+
return helpers.isAccTreeEligible(el, ctx);
|
|
205
|
+
} catch {
|
|
206
|
+
return { eligible: true, reasons: [] };
|
|
207
|
+
}
|
|
208
|
+
})();
|
|
209
|
+
if (elig && elig.eligible === false) continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
banners.push(el);
|
|
206
213
|
}
|
|
207
214
|
|
|
208
215
|
if (banners.length === 0) {
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check landmark-contentinfo-is-top-level
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary The contentinfo landmark must not be nested inside another 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
|
|
10
12
|
* candidate: explicit role="contentinfo", OR a <footer> with NO role
|
|
11
|
-
* attribute at all
|
|
12
|
-
*
|
|
13
|
+
* attribute at all, regardless of nesting (see implementation notes on
|
|
14
|
+
* why candidate selection is deliberately unconditional).
|
|
13
15
|
* @expectation
|
|
14
16
|
* No contentinfo candidate has an ancestor that is itself any landmark
|
|
15
17
|
* region. A contentinfo nested inside another landmark is not a
|
|
@@ -20,13 +22,12 @@
|
|
|
20
22
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
21
23
|
* header comment for the shared rationale/precedent (this rule mirrors
|
|
22
24
|
* its structure with contentinfo/footer in place of banner/header).
|
|
23
|
-
* -
|
|
25
|
+
* - Candidate selection (`isContentinfoCandidate` below) is deliberately
|
|
26
|
+
* unconditional, instead of reusing the sectioning-ancestor suppression
|
|
27
|
+
* that the violation check itself depends on — same
|
|
28
|
+
* self-defeating-candidate-selection reasoning as
|
|
24
29
|
* landmark-banner-is-top-level (see that file's header comment for the
|
|
25
|
-
* full root cause
|
|
26
|
-
* (`isContentinfoCandidate` below) now matches a widely-used reference
|
|
27
|
-
* engine's unconditional `footer:not([role]), [role=contentinfo]`
|
|
28
|
-
* selector shape instead of reusing the sectioning-ancestor
|
|
29
|
-
* suppression that the violation check itself depends on.
|
|
30
|
+
* full root cause).
|
|
30
31
|
*/
|
|
31
32
|
|
|
32
33
|
const id = 'landmark-contentinfo-is-top-level';
|
|
@@ -61,9 +62,8 @@ function runInPage(ctx) {
|
|
|
61
62
|
|
|
62
63
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
63
64
|
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
64
|
-
// local copy -- see that function's header comment in src/core/dom-helpers.js
|
|
65
|
-
//
|
|
66
|
-
// copy of this logic.
|
|
65
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
|
|
66
|
+
// the title-attribute fallback consistent across the landmark rules.
|
|
67
67
|
function getAccessibleLandmarkName(el) {
|
|
68
68
|
try {
|
|
69
69
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -86,10 +86,8 @@ function runInPage(ctx) {
|
|
|
86
86
|
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
87
87
|
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
88
88
|
// rather than a local tag-only copy. See that function's header comment
|
|
89
|
-
// in src/core/aria-helpers.js for the full algorithm
|
|
90
|
-
//
|
|
91
|
-
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
92
|
-
// rule's own former tag-only copy as a false negative.
|
|
89
|
+
// in src/core/aria-helpers.js for the full algorithm. Example: an
|
|
90
|
+
// <aside role="dialog"> containing its own <header>.
|
|
93
91
|
function hasSectioningAncestor(el, includeMain) {
|
|
94
92
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
95
93
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -103,11 +101,9 @@ function runInPage(ctx) {
|
|
|
103
101
|
if (tag === 'main') return 'main';
|
|
104
102
|
if (tag === 'nav') return 'navigation';
|
|
105
103
|
if (tag === 'aside') {
|
|
106
|
-
// A named <aside> is never suppressed, even when nested —
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
// "complementary" when the element has an accessible name, even
|
|
110
|
-
// inside sectioning content); propagated here for consistency.
|
|
104
|
+
// A named <aside> is never suppressed, even when nested — it keeps
|
|
105
|
+
// "complementary" when it has an accessible name, even inside
|
|
106
|
+
// sectioning content. Matches landmark-unique's precedent.
|
|
111
107
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
112
108
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
113
109
|
}
|
|
@@ -135,7 +131,7 @@ function runInPage(ctx) {
|
|
|
135
131
|
}
|
|
136
132
|
|
|
137
133
|
// Candidate selection is deliberately NOT the same as getLandmarkRole()
|
|
138
|
-
// === 'contentinfo' — see the
|
|
134
|
+
// === 'contentinfo' — see the header comment above. A <footer> is
|
|
139
135
|
// a candidate purely by tag + absence of any role attribute, independent
|
|
140
136
|
// of whether sectioning-ancestor nesting would currently suppress its
|
|
141
137
|
// implicit role; an explicit role="contentinfo" is always a candidate too.
|
|
@@ -160,9 +156,8 @@ function runInPage(ctx) {
|
|
|
160
156
|
}
|
|
161
157
|
|
|
162
158
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
163
|
-
// landmark-unique-manual.js's header comment
|
|
164
|
-
//
|
|
165
|
-
// invisible to a light-DOM-only query.
|
|
159
|
+
// landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
|
|
160
|
+
// widget's own landmark is invisible to a light-DOM-only query.
|
|
166
161
|
let nodes;
|
|
167
162
|
try {
|
|
168
163
|
nodes =
|
|
@@ -178,7 +173,28 @@ function runInPage(ctx) {
|
|
|
178
173
|
for (const el of nodes) {
|
|
179
174
|
if (!el || seen.has(el)) continue;
|
|
180
175
|
seen.add(el);
|
|
181
|
-
if (isContentinfoCandidate(el))
|
|
176
|
+
if (!isContentinfoCandidate(el)) continue;
|
|
177
|
+
|
|
178
|
+
// An aria-hidden contentinfo candidate is removed from the
|
|
179
|
+
// accessibility tree entirely -- it isn't part of the landmark
|
|
180
|
+
// structure assistive technology users navigate at all, so it
|
|
181
|
+
// shouldn't be flagged as "nested inside another landmark" (there's
|
|
182
|
+
// no real landmark there to begin with, from AT's perspective).
|
|
183
|
+
// queryAllSmart's default hidden-content policy only excludes "hard"
|
|
184
|
+
// CSS-based hiding (display:none, etc.), not the softer aria-hidden
|
|
185
|
+
// exclusion, so this needs its own check.
|
|
186
|
+
if (helpers && typeof helpers.isAccTreeEligible === 'function') {
|
|
187
|
+
const elig = (() => {
|
|
188
|
+
try {
|
|
189
|
+
return helpers.isAccTreeEligible(el, ctx);
|
|
190
|
+
} catch {
|
|
191
|
+
return { eligible: true, reasons: [] };
|
|
192
|
+
}
|
|
193
|
+
})();
|
|
194
|
+
if (elig && elig.eligible === false) continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
contentinfos.push(el);
|
|
182
198
|
}
|
|
183
199
|
|
|
184
200
|
if (contentinfos.length === 0) {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check landmark-main-is-top-level
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary The main landmark must not be nested inside another 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> element).
|
|
@@ -18,13 +20,13 @@
|
|
|
18
20
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
19
21
|
* header comment for the shared rationale/precedent (this rule mirrors
|
|
20
22
|
* its structure with main in place of banner/header).
|
|
21
|
-
* -
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
* - Unlike landmark-banner-is-top-level/landmark-contentinfo-is-top-level
|
|
24
|
+
* (see that file's header comment), candidate selection here doesn't
|
|
25
|
+
* need to be unconditional: `<main>`'s implicit role is unconditional
|
|
26
|
+
* per HTML-AAM — unlike `<header>`/`<footer>`, nesting never suppresses
|
|
27
|
+
* it — so `getImplicitLandmarkRole`'s `main` branch is never subject to
|
|
28
|
+
* the self-defeating candidate-selection problem those two rules guard
|
|
29
|
+
* against.
|
|
28
30
|
*/
|
|
29
31
|
|
|
30
32
|
const id = 'landmark-main-is-top-level';
|
|
@@ -59,9 +61,8 @@ 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
|
-
//
|
|
64
|
-
// copy of this logic.
|
|
64
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
|
|
65
|
+
// the title-attribute fallback consistent across the landmark rules.
|
|
65
66
|
function getAccessibleLandmarkName(el) {
|
|
66
67
|
try {
|
|
67
68
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -84,10 +85,8 @@ function runInPage(ctx) {
|
|
|
84
85
|
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
85
86
|
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
86
87
|
// rather than a local tag-only copy. See that function's header comment
|
|
87
|
-
// in src/core/aria-helpers.js for the full algorithm
|
|
88
|
-
//
|
|
89
|
-
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
90
|
-
// rule's own former tag-only copy as a false negative.
|
|
88
|
+
// in src/core/aria-helpers.js for the full algorithm. Example: an
|
|
89
|
+
// <aside role="dialog"> containing its own <header>.
|
|
91
90
|
function hasSectioningAncestor(el, includeMain) {
|
|
92
91
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
93
92
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -101,11 +100,9 @@ function runInPage(ctx) {
|
|
|
101
100
|
if (tag === 'main') return 'main';
|
|
102
101
|
if (tag === 'nav') return 'navigation';
|
|
103
102
|
if (tag === 'aside') {
|
|
104
|
-
// A named <aside> is never suppressed, even when nested —
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// "complementary" when the element has an accessible name, even
|
|
108
|
-
// inside sectioning content); propagated here for consistency.
|
|
103
|
+
// A named <aside> is never suppressed, even when nested — it keeps
|
|
104
|
+
// "complementary" when it has an accessible name, even inside
|
|
105
|
+
// sectioning content. Matches landmark-unique's precedent.
|
|
109
106
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
110
107
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
111
108
|
}
|
|
@@ -146,9 +143,8 @@ function runInPage(ctx) {
|
|
|
146
143
|
}
|
|
147
144
|
|
|
148
145
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
149
|
-
// landmark-unique-manual.js's header comment
|
|
150
|
-
//
|
|
151
|
-
// invisible to a light-DOM-only query.
|
|
146
|
+
// landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
|
|
147
|
+
// widget's own landmark is invisible to a light-DOM-only query.
|
|
152
148
|
let nodes;
|
|
153
149
|
try {
|
|
154
150
|
nodes =
|
|
@@ -164,7 +160,28 @@ function runInPage(ctx) {
|
|
|
164
160
|
for (const el of nodes) {
|
|
165
161
|
if (!el || seen.has(el)) continue;
|
|
166
162
|
seen.add(el);
|
|
167
|
-
if (getLandmarkRole(el)
|
|
163
|
+
if (getLandmarkRole(el) !== 'main') continue;
|
|
164
|
+
|
|
165
|
+
// An aria-hidden main candidate is removed from the accessibility
|
|
166
|
+
// tree entirely -- it isn't part of the landmark structure assistive
|
|
167
|
+
// technology users navigate at all, so it shouldn't be flagged as
|
|
168
|
+
// "nested inside another landmark" (there's no real landmark there to
|
|
169
|
+
// begin with, from AT's perspective). queryAllSmart's default hidden-
|
|
170
|
+
// content policy only excludes "hard" CSS-based hiding (display:none,
|
|
171
|
+
// etc.), not the softer aria-hidden exclusion, so this needs its own
|
|
172
|
+
// check.
|
|
173
|
+
if (helpers && typeof helpers.isAccTreeEligible === 'function') {
|
|
174
|
+
const elig = (() => {
|
|
175
|
+
try {
|
|
176
|
+
return helpers.isAccTreeEligible(el, ctx);
|
|
177
|
+
} catch {
|
|
178
|
+
return { eligible: true, reasons: [] };
|
|
179
|
+
}
|
|
180
|
+
})();
|
|
181
|
+
if (elig && elig.eligible === false) continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
mains.push(el);
|
|
168
185
|
}
|
|
169
186
|
|
|
170
187
|
if (mains.length === 0) {
|
|
@@ -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-banner
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary A page must not have more than one banner 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 banner landmark
|
|
10
12
|
* (explicit role="banner", or an implicit, non-nested <header> — see
|
|
@@ -21,14 +23,11 @@
|
|
|
21
23
|
* header comment for the shared rationale/precedent.
|
|
22
24
|
* - Flags every banner instance (not just the "extra" ones) when more
|
|
23
25
|
* than one exists, since which instance is "correct" is ambiguous.
|
|
24
|
-
* - Only landmarks actually exposed to assistive technology can collide
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* on a real site — Trello's homepage, a desktop/mobile header pair) was
|
|
30
|
-
* wrongly flagged as a duplicate landmark; the hidden copy is never
|
|
31
|
-
* actually reachable by AT.
|
|
26
|
+
* - Only landmarks actually exposed to assistive technology can collide.
|
|
27
|
+
* Without this, a responsive layout rendering both a visible and a
|
|
28
|
+
* CSS-hidden duplicate `<header>` (a desktop/mobile header pair) is
|
|
29
|
+
* flagged as a duplicate landmark even though the hidden copy is never
|
|
30
|
+
* reachable by AT.
|
|
32
31
|
*/
|
|
33
32
|
|
|
34
33
|
const id = 'landmark-no-duplicate-banner';
|
|
@@ -63,9 +62,7 @@ function runInPage(ctx) {
|
|
|
63
62
|
|
|
64
63
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
65
64
|
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
66
|
-
// local copy -- see that function's header comment in src/core/dom-helpers.js
|
|
67
|
-
// (missing title fallback) this replaced across all 7 landmark rule files that had their own
|
|
68
|
-
// copy of this logic.
|
|
65
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js.
|
|
69
66
|
function getAccessibleLandmarkName(el) {
|
|
70
67
|
try {
|
|
71
68
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -88,10 +85,9 @@ function runInPage(ctx) {
|
|
|
88
85
|
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
89
86
|
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
90
87
|
// rather than a local tag-only copy. See that function's header comment
|
|
91
|
-
// in src/core/aria-helpers.js for the full algorithm
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
// rule's own former tag-only copy as a false negative.
|
|
88
|
+
// in src/core/aria-helpers.js for the full algorithm — e.g. an
|
|
89
|
+
// <aside role="dialog"> containing its own <header>, where the <header>
|
|
90
|
+
// keeps its banner role.
|
|
95
91
|
function hasSectioningAncestor(el, includeMain) {
|
|
96
92
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
97
93
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -105,11 +101,9 @@ function runInPage(ctx) {
|
|
|
105
101
|
if (tag === 'main') return 'main';
|
|
106
102
|
if (tag === 'nav') return 'navigation';
|
|
107
103
|
if (tag === 'aside') {
|
|
108
|
-
// A named <aside> is never suppressed, even when nested
|
|
109
|
-
// landmark-unique's own verified-against-reference-engine precedent
|
|
110
|
-
// (that engine's real `aside` implicit-role function keeps
|
|
104
|
+
// A named <aside> is never suppressed, even when nested: keeps
|
|
111
105
|
// "complementary" when the element has an accessible name, even
|
|
112
|
-
// inside sectioning content
|
|
106
|
+
// inside sectioning content. See landmark-unique-manual.js.
|
|
113
107
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
114
108
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
115
109
|
}
|
|
@@ -137,9 +131,8 @@ function runInPage(ctx) {
|
|
|
137
131
|
}
|
|
138
132
|
|
|
139
133
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
140
|
-
// landmark-unique-manual.js's header comment
|
|
141
|
-
//
|
|
142
|
-
// invisible to a light-DOM-only query.
|
|
134
|
+
// landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
|
|
135
|
+
// widget's own landmark is invisible to a light-DOM-only query.
|
|
143
136
|
let nodes;
|
|
144
137
|
try {
|
|
145
138
|
nodes =
|