@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,36 +1,30 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check empty-table-header
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary Table header cells must not be empty
|
|
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 <th> elements that don't carry a conflicting explicit role,
|
|
10
12
|
* plus any element (native <th> or not) with role="columnheader" or
|
|
11
|
-
* role="rowheader"
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* role="columnheader"> in a role="grid"/role="table" widget) is caught too
|
|
18
|
-
* — found missing entirely via the cross-engine diff tool 2026-07-23.
|
|
13
|
+
* role="rowheader" (`th:not([role]), [role="columnheader"], [role="rowheader"]`):
|
|
14
|
+
* a <th> that explicitly restates role="columnheader"/"rowheader" is still
|
|
15
|
+
* covered via the second clause, but a <th role="presentation"> (no longer
|
|
16
|
+
* meaningfully a header) is correctly excluded, and an ARIA-role-only header
|
|
17
|
+
* (e.g. a <div role="columnheader"> in a role="grid"/role="table" widget) is
|
|
18
|
+
* caught too.
|
|
19
19
|
* @expectation
|
|
20
20
|
* The header cell has visible text content. A <th> named only via
|
|
21
21
|
* aria-label/aria-labelledby (no visible text) is ALSO flagged, not
|
|
22
|
-
* treated as equivalent —
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* (only visible text is announced), JAWS+Chrome/IE11 also only announce
|
|
29
|
-
* visible text in the header cell itself. Visible text is the one
|
|
30
|
-
* mechanism confirmed to work across every tested combination. A widely-used
|
|
31
|
-
* reference engine's own equivalent check (`has-visible-text` only, no aria-label/
|
|
32
|
-
* aria-labelledby alternative — confirmed directly against its rule
|
|
33
|
-
* descriptor) reaches the same conclusion.
|
|
22
|
+
* treated as equivalent — aria-label support on <th> is genuinely
|
|
23
|
+
* inconsistent in practice: NVDA+Firefox and iOS VoiceOver+Safari ignore
|
|
24
|
+
* it entirely (only visible text is announced), JAWS+Chrome/IE11 also only
|
|
25
|
+
* announce visible text in the header cell itself. Visible text is the one
|
|
26
|
+
* mechanism confirmed to work across every tested combination. See
|
|
27
|
+
* https://html5accessibility.com/stuff/2024/05/22/not-so-short-note-on-aria-label-usage-big-table-edition/.
|
|
34
28
|
* @implementation-notes
|
|
35
29
|
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
36
30
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
@@ -72,8 +66,36 @@ function runInPage(ctx) {
|
|
|
72
66
|
.trim();
|
|
73
67
|
}
|
|
74
68
|
|
|
69
|
+
const isAccTreeEligible =
|
|
70
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
71
|
+
|
|
72
|
+
function isEligible(node) {
|
|
73
|
+
if (!isAccTreeEligible) return true;
|
|
74
|
+
try {
|
|
75
|
+
const r = isAccTreeEligible(node, ctx);
|
|
76
|
+
return !!(r && r.eligible);
|
|
77
|
+
} catch {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Plain el.textContent includes text from aria-hidden descendants, which
|
|
83
|
+
// a real screen reader never announces. A <th> whose only text comes from
|
|
84
|
+
// an aria-hidden descendant (e.g. <th><span aria-hidden="true">Name</span
|
|
85
|
+
// ></th>) would otherwise be treated as having visible text, even though
|
|
86
|
+
// AT announces nothing for it.
|
|
75
87
|
function getVisibleText(el) {
|
|
76
|
-
|
|
88
|
+
function walk(node) {
|
|
89
|
+
if (node.nodeType === 3) return node.nodeValue || '';
|
|
90
|
+
if (node.nodeType !== 1) return '';
|
|
91
|
+
if (!isEligible(node)) return '';
|
|
92
|
+
let text = '';
|
|
93
|
+
for (const child of node.childNodes || []) text += walk(child);
|
|
94
|
+
return text;
|
|
95
|
+
}
|
|
96
|
+
let text = '';
|
|
97
|
+
for (const child of el.childNodes || []) text += walk(child);
|
|
98
|
+
return normalizeWs(text);
|
|
77
99
|
}
|
|
78
100
|
|
|
79
101
|
function getAriaOnlyName(el) {
|
|
@@ -97,9 +119,8 @@ function runInPage(ctx) {
|
|
|
97
119
|
return '';
|
|
98
120
|
}
|
|
99
121
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// columnheader/rowheader role (native or not).
|
|
122
|
+
// A <th> with no conflicting explicit role, plus any element carrying an
|
|
123
|
+
// explicit columnheader/rowheader role (native or not).
|
|
103
124
|
const selector = 'th:not([role]), [role="columnheader"], [role="rowheader"]';
|
|
104
125
|
const nodes = helpers.queryAllSmart
|
|
105
126
|
? helpers.queryAllSmart(selector)
|
|
@@ -110,6 +131,12 @@ function runInPage(ctx) {
|
|
|
110
131
|
|
|
111
132
|
for (const el of nodes) {
|
|
112
133
|
if (!el) continue;
|
|
134
|
+
|
|
135
|
+
// A fully aria-hidden header cell isn't part of the AT-perceived
|
|
136
|
+
// table structure at all -- queryAllSmart's default hidden-content
|
|
137
|
+
// policy only excludes "hard" CSS-based hiding, not aria-hidden.
|
|
138
|
+
if (!isEligible(el)) continue;
|
|
139
|
+
|
|
113
140
|
applicableCount += 1;
|
|
114
141
|
|
|
115
142
|
if (getVisibleText(el)) continue;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check focus-order-semantics
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary Elements added to the tab order should have interactive semantics
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Elements with an explicit `tabindex` of `0` or greater (in the tab
|
|
10
12
|
* order) AND an explicit `role` attribute that is one of a curated set
|
|
@@ -16,8 +18,8 @@
|
|
|
16
18
|
* announce, which is confusing for keyboard users who land on it and
|
|
17
19
|
* get no indication of what activating it (if anything) would do.
|
|
18
20
|
* @implementation-notes
|
|
19
|
-
* - Not WCAG-normative
|
|
20
|
-
*
|
|
21
|
+
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
22
|
+
* `type: 'manual'` rule.
|
|
21
23
|
* - The non-interactive role list is deliberately curated and
|
|
22
24
|
* conservative (structural/document roles only) — legitimate custom
|
|
23
25
|
* widget patterns using `tabindex` with a genuinely interactive role
|
|
@@ -25,6 +27,17 @@
|
|
|
25
27
|
* `tabindex` and NO role at all are also not flagged: native semantics
|
|
26
28
|
* or an intentionally generic custom-interactive pattern cannot be
|
|
27
29
|
* distinguished from markup alone with the same confidence.
|
|
30
|
+
* - `region` is deliberately NOT in the non-interactive role list: a
|
|
31
|
+
* tabbable `role="region"` is a real, WCAG 2.1.1/2.1.3-grounded pattern
|
|
32
|
+
* this engine's own `scrollable-region-focusable` check exists to
|
|
33
|
+
* RECOMMEND (a scrollable landmark with no other focusable content
|
|
34
|
+
* needs `tabindex="0"` to be keyboard-reachable at all) — flagging it
|
|
35
|
+
* here would be internally inconsistent with that sibling check. A
|
|
36
|
+
* `role="region"` is also commonly made tabbable on its own merits
|
|
37
|
+
* (e.g. a cookie-consent banner or notification/toast region a keyboard
|
|
38
|
+
* user should be able to reach directly, as in
|
|
39
|
+
* `<div role="region" tabindex="0">`). Scoped to `region` only;
|
|
40
|
+
* navigation/status/tabpanel remain flagged pending their own evidence.
|
|
28
41
|
*/
|
|
29
42
|
|
|
30
43
|
const id = 'focus-order-semantics';
|
|
@@ -59,7 +72,6 @@ function runInPage(ctx) {
|
|
|
59
72
|
'none',
|
|
60
73
|
'img',
|
|
61
74
|
'heading',
|
|
62
|
-
'region',
|
|
63
75
|
'article',
|
|
64
76
|
'banner',
|
|
65
77
|
'contentinfo',
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check heading-order
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary Heading levels must not skip a level going deeper
|
|
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 two or more heading elements
|
|
10
12
|
* (native <h1>-<h6>, or explicit role="heading" with aria-level —
|
|
@@ -83,6 +85,31 @@ function runInPage(ctx) {
|
|
|
83
85
|
for (const el of nodes) {
|
|
84
86
|
if (!el || seen.has(el)) continue;
|
|
85
87
|
seen.add(el);
|
|
88
|
+
|
|
89
|
+
// queryAllSmart's default hidden-content policy only excludes "hard"
|
|
90
|
+
// CSS-based hiding (display:none, visibility:hidden, etc.) -- it does
|
|
91
|
+
// NOT exclude aria-hidden, a softer/semantic removal from the
|
|
92
|
+
// accessibility tree that still leaves an element visually rendered.
|
|
93
|
+
// A heading order rule whose whole premise is "the document outline
|
|
94
|
+
// assistive technology users rely on" (see this rule's own header
|
|
95
|
+
// comment) must not let an aria-hidden heading participate in that
|
|
96
|
+
// outline at all: it's invisible to exactly the users this rule
|
|
97
|
+
// exists to protect. Without this check an aria-hidden heading is both
|
|
98
|
+
// flagged itself (though it isn't part of the AT-perceived outline)
|
|
99
|
+
// and can mask a real skip immediately after it, by advancing the
|
|
100
|
+
// "highest level reached so far" tracker on a level no AT user
|
|
101
|
+
// actually encountered.
|
|
102
|
+
if (helpers.isAccTreeEligible) {
|
|
103
|
+
const elig = (() => {
|
|
104
|
+
try {
|
|
105
|
+
return helpers.isAccTreeEligible(el, ctx);
|
|
106
|
+
} catch {
|
|
107
|
+
return { eligible: true, reasons: [] };
|
|
108
|
+
}
|
|
109
|
+
})();
|
|
110
|
+
if (elig && elig.eligible === false) continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
86
113
|
const level = getHeadingLevel(el);
|
|
87
114
|
if (level > 0) headings.push({ el, level });
|
|
88
115
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check image-redundant-alt
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary An image's alt text must not duplicate adjacent visible text
|
|
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 <img> elements with non-empty alt text whose immediate
|
|
10
12
|
* parent element also has other visible text content (i.e. text nodes
|
|
@@ -57,12 +59,30 @@ function runInPage(ctx) {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
function getOwnTextExcludingImg(parent, imgEl) {
|
|
62
|
+
const isAccTreeEligible =
|
|
63
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
64
|
+
|
|
60
65
|
let text = '';
|
|
61
66
|
for (const child of parent.childNodes || []) {
|
|
62
67
|
if (child === imgEl) continue;
|
|
63
68
|
if (child.nodeType === 3) {
|
|
64
69
|
text += ' ' + (child.nodeValue || '');
|
|
65
70
|
} else if (child.nodeType === 1 && child !== imgEl) {
|
|
71
|
+
// An aria-hidden sibling is never actually announced to assistive
|
|
72
|
+
// technology, so its text can't cause the "same words twice"
|
|
73
|
+
// double-announcement this rule exists to catch -- counting it
|
|
74
|
+
// anyway flags a redundancy that doesn't exist in what AT users
|
|
75
|
+
// actually hear.
|
|
76
|
+
if (isAccTreeEligible) {
|
|
77
|
+
const elig = (() => {
|
|
78
|
+
try {
|
|
79
|
+
return isAccTreeEligible(child, ctx);
|
|
80
|
+
} catch {
|
|
81
|
+
return { eligible: true, reasons: [] };
|
|
82
|
+
}
|
|
83
|
+
})();
|
|
84
|
+
if (elig && elig.eligible === false) continue;
|
|
85
|
+
}
|
|
66
86
|
text += ' ' + (child.textContent || '');
|
|
67
87
|
}
|
|
68
88
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -69,6 +71,9 @@ function runInPage(ctx) {
|
|
|
69
71
|
const isAccTreeEligible =
|
|
70
72
|
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
71
73
|
|
|
74
|
+
const getAriaNameInfo =
|
|
75
|
+
helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
76
|
+
|
|
72
77
|
const getFocusableInfo =
|
|
73
78
|
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
74
79
|
|
|
@@ -105,6 +110,26 @@ function runInPage(ctx) {
|
|
|
105
110
|
return !focusable;
|
|
106
111
|
}
|
|
107
112
|
|
|
113
|
+
// alt="" plus a name from aria-label/aria-labelledby/title is the judgement
|
|
114
|
+
// call this rule reviews. alt="" with no other source leaves the control
|
|
115
|
+
// unnamed, which input-image-alt-present fails outright.
|
|
116
|
+
function hasNameFromOtherSource(el) {
|
|
117
|
+
if (getAriaNameInfo) {
|
|
118
|
+
try {
|
|
119
|
+
const aria = getAriaNameInfo(el, ctx);
|
|
120
|
+
if (aria && aria.present && String(aria.value || '').trim()) return true;
|
|
121
|
+
} catch {
|
|
122
|
+
// fall through to title
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const title = el.getAttribute('title');
|
|
127
|
+
return title != null && String(title).trim() !== '';
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
108
133
|
const els = (() => {
|
|
109
134
|
try {
|
|
110
135
|
return Array.from(
|
|
@@ -141,6 +166,7 @@ function runInPage(ctx) {
|
|
|
141
166
|
|
|
142
167
|
// Rule-specific applicability (only elements that already have a text alternative mechanism)
|
|
143
168
|
if (!(el.getAttribute('alt') != null && String(el.getAttribute('alt')).trim() === '')) continue;
|
|
169
|
+
if (!hasNameFromOtherSource(el)) continue;
|
|
144
170
|
|
|
145
171
|
applicableCount += 1;
|
|
146
172
|
|
|
@@ -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,14 @@
|
|
|
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
|
-
* check itself (`getImplicitLandmarkRole`'s `hasSectioningAncestor`
|
|
37
|
-
* gate) — so the moment a `<header>` was nested inside another
|
|
38
|
-
* landmark, that same nesting made it stop counting as a banner
|
|
39
|
-
* candidate in the first place, and the rule could never flag the one
|
|
40
|
-
* case it exists to catch. A widely-used reference engine's own
|
|
41
|
-
* `landmark-banner-is-top-level` avoids this: its selector
|
|
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`) requires the element to really
|
|
32
|
+
* carry the banner role, via the suppression-aware `getLandmarkRole`. The
|
|
33
|
+
* two ancestor sets differ, so this does not make the rule vacuous: the
|
|
34
|
+
* suppression set is the sectioning tags plus `<main>`, while the blocking
|
|
35
|
+
* set is any landmark role, so a `<header>` inside `role="region"`,
|
|
36
|
+
* `<form>` or `<footer>` is still caught, and an explicit `role="banner"`
|
|
37
|
+
* is a candidate wherever it sits.
|
|
52
38
|
*/
|
|
53
39
|
|
|
54
40
|
const id = 'landmark-banner-is-top-level';
|
|
@@ -85,9 +71,8 @@ function runInPage(ctx) {
|
|
|
85
71
|
|
|
86
72
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
87
73
|
// 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.
|
|
74
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
|
|
75
|
+
// the title-attribute fallback consistent across the landmark rules.
|
|
91
76
|
function getAccessibleLandmarkName(el) {
|
|
92
77
|
try {
|
|
93
78
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -110,10 +95,8 @@ function runInPage(ctx) {
|
|
|
110
95
|
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
111
96
|
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
112
97
|
// 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.
|
|
98
|
+
// in src/core/aria-helpers.js for the full algorithm. Example: an
|
|
99
|
+
// <aside role="dialog"> containing its own <header>.
|
|
117
100
|
function hasSectioningAncestor(el, includeMain) {
|
|
118
101
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
119
102
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -127,11 +110,9 @@ function runInPage(ctx) {
|
|
|
127
110
|
if (tag === 'main') return 'main';
|
|
128
111
|
if (tag === 'nav') return 'navigation';
|
|
129
112
|
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.
|
|
113
|
+
// A named <aside> is never suppressed, even when nested — it keeps
|
|
114
|
+
// "complementary" when it has an accessible name, even inside
|
|
115
|
+
// sectioning content. Matches landmark-unique's precedent.
|
|
135
116
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
136
117
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
137
118
|
}
|
|
@@ -158,16 +139,11 @@ function runInPage(ctx) {
|
|
|
158
139
|
return getImplicitLandmarkRole(el);
|
|
159
140
|
}
|
|
160
141
|
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
// of whether sectioning-ancestor nesting would currently suppress its
|
|
165
|
-
// implicit role; an explicit role="banner" is always a candidate too.
|
|
142
|
+
// A candidate must actually have the banner role. Per HTML-AAM a <header>
|
|
143
|
+
// descended from article/aside/main/nav/section is not a banner at all, so
|
|
144
|
+
// flagging it as a nested banner reports a landmark that does not exist.
|
|
166
145
|
function isBannerCandidate(el) {
|
|
167
|
-
|
|
168
|
-
const explicit = getExplicitRoleToken(el);
|
|
169
|
-
if (explicit) return explicit === 'banner';
|
|
170
|
-
return !!(el.tagName && el.tagName.toLowerCase() === 'header');
|
|
146
|
+
return getLandmarkRole(el) === 'banner';
|
|
171
147
|
}
|
|
172
148
|
|
|
173
149
|
function hasLandmarkAncestor(el) {
|
|
@@ -184,9 +160,8 @@ function runInPage(ctx) {
|
|
|
184
160
|
}
|
|
185
161
|
|
|
186
162
|
// 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.
|
|
163
|
+
// landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
|
|
164
|
+
// widget's own landmark is invisible to a light-DOM-only query.
|
|
190
165
|
let nodes;
|
|
191
166
|
try {
|
|
192
167
|
nodes =
|
|
@@ -202,7 +177,28 @@ function runInPage(ctx) {
|
|
|
202
177
|
for (const el of nodes) {
|
|
203
178
|
if (!el || seen.has(el)) continue;
|
|
204
179
|
seen.add(el);
|
|
205
|
-
if (isBannerCandidate(el))
|
|
180
|
+
if (!isBannerCandidate(el)) continue;
|
|
181
|
+
|
|
182
|
+
// An aria-hidden banner candidate is removed from the accessibility
|
|
183
|
+
// tree entirely -- it isn't part of the landmark structure assistive
|
|
184
|
+
// technology users navigate at all, so it shouldn't be flagged as
|
|
185
|
+
// "nested inside another landmark" (there's no real landmark there to
|
|
186
|
+
// begin with, from AT's perspective). queryAllSmart's default hidden-
|
|
187
|
+
// content policy only excludes "hard" CSS-based hiding (display:none,
|
|
188
|
+
// etc.), not the softer aria-hidden exclusion, so this needs its own
|
|
189
|
+
// check.
|
|
190
|
+
if (helpers && typeof helpers.isAccTreeEligible === 'function') {
|
|
191
|
+
const elig = (() => {
|
|
192
|
+
try {
|
|
193
|
+
return helpers.isAccTreeEligible(el, ctx);
|
|
194
|
+
} catch {
|
|
195
|
+
return { eligible: true, reasons: [] };
|
|
196
|
+
}
|
|
197
|
+
})();
|
|
198
|
+
if (elig && elig.eligible === false) continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
banners.push(el);
|
|
206
202
|
}
|
|
207
203
|
|
|
208
204
|
if (banners.length === 0) {
|