pour-engine 1.29.0 → 1.31.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/README.md +1 -6
- package/config/project.config.js +1 -1
- package/engine/index.js +15 -16
- package/engine/rules/best-practice/accesskeys.js +1 -0
- package/engine/rules/best-practice/button-type.js +1 -0
- package/engine/rules/best-practice/empty-heading.js +1 -0
- package/engine/rules/best-practice/fieldset-legend.js +1 -0
- package/engine/rules/best-practice/heading-order.js +15 -0
- package/engine/rules/best-practice/landmark-one-main.js +11 -2
- package/engine/rules/best-practice/landmark-top-level.js +20 -5
- package/engine/rules/best-practice/landmark-unique.js +1 -0
- package/engine/rules/best-practice/multiple-labels.js +1 -0
- package/engine/rules/best-practice/new-window-link.js +1 -0
- package/engine/rules/best-practice/no-autofocus.js +1 -0
- package/engine/rules/best-practice/page-heading-one.js +1 -0
- package/engine/rules/best-practice/positive-tabindex.js +1 -0
- package/engine/rules/best-practice/redundant-alt-phrase.js +1 -0
- package/engine/rules/best-practice/redundant-aria-label.js +1 -0
- package/engine/rules/best-practice/redundant-aria.js +1 -0
- package/engine/rules/best-practice/redundant-image-alt.js +51 -7
- package/engine/rules/best-practice/redundant-role.js +1 -0
- package/engine/rules/best-practice/redundant-tabindex.js +1 -0
- package/engine/rules/best-practice/region.js +17 -3
- package/engine/rules/wcag/1.1.1-area-alt.js +1 -0
- package/engine/rules/wcag/1.1.1-canvas-alt.js +1 -0
- package/engine/rules/wcag/1.1.1-embed-alt.js +1 -0
- package/engine/rules/wcag/1.1.1-image-alt.js +1 -0
- package/engine/rules/wcag/1.1.1-input-image-alt.js +1 -0
- package/engine/rules/wcag/1.1.1-object-alt.js +1 -0
- package/engine/rules/wcag/1.1.1-svg-img-alt.js +1 -0
- package/engine/rules/wcag/1.2.2-media-captions.js +12 -5
- package/engine/rules/wcag/1.3.1-aria-required-children.js +1 -0
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +1 -0
- package/engine/rules/wcag/1.3.1-definition-list.js +1 -0
- package/engine/rules/wcag/1.3.1-dlitem-parent.js +1 -0
- package/engine/rules/wcag/1.3.1-form-label.js +1 -0
- package/engine/rules/wcag/1.3.1-list-structure.js +1 -0
- package/engine/rules/wcag/1.3.1-listitem-parent.js +1 -0
- package/engine/rules/wcag/1.3.1-p-as-heading.js +1 -0
- package/engine/rules/wcag/1.3.1-table-headers.js +1 -0
- package/engine/rules/wcag/1.3.4-orientation.js +1 -0
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +1 -0
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +1 -0
- package/engine/rules/wcag/1.4.10-reflow.js +1 -0
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +1 -0
- package/engine/rules/wcag/1.4.12-text-spacing.js +1 -0
- package/engine/rules/wcag/1.4.2-audio-control.js +1 -0
- package/engine/rules/wcag/1.4.3-color-contrast.js +1 -0
- package/engine/rules/wcag/1.4.3-control-contrast.js +1 -0
- package/engine/rules/wcag/1.4.4-meta-viewport.js +1 -0
- package/engine/rules/wcag/1.4.6-contrast-enhanced.js +1 -0
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +1 -0
- package/engine/rules/wcag/2.2.1-meta-refresh.js +1 -0
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +1 -0
- package/engine/rules/wcag/2.2.2-video-loop-motion.js +1 -0
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +1 -0
- package/engine/rules/wcag/2.4.11-focus-not-obscured.js +1 -0
- package/engine/rules/wcag/2.4.2-document-title.js +1 -0
- package/engine/rules/wcag/2.4.3-visual-order-divergence.js +1 -0
- package/engine/rules/wcag/2.4.4-link-name.js +1 -0
- package/engine/rules/wcag/2.4.4-link-text-generic.js +1 -0
- package/engine/rules/wcag/2.4.7-focus-visible.js +1 -0
- package/engine/rules/wcag/2.4.9-link-text-generic-only.js +1 -0
- package/engine/rules/wcag/2.5.3-label-in-name.js +1 -0
- package/engine/rules/wcag/2.5.5-target-size-enhanced.js +1 -0
- package/engine/rules/wcag/2.5.8-target-size.js +1 -0
- package/engine/rules/wcag/3.1.1-html-lang.js +1 -0
- package/engine/rules/wcag/3.1.2-valid-lang-parts.js +1 -0
- package/engine/rules/wcag/3.2.2-on-input-navigation.js +1 -0
- package/engine/rules/wcag/3.3.1-error-message-linkage.js +1 -0
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +1 -0
- package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +1 -0
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +1 -0
- package/engine/rules/wcag/4.1.2-aria-field-name.js +1 -0
- package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +1 -0
- package/engine/rules/wcag/4.1.2-aria-label-misuse.js +19 -3
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +1 -0
- package/engine/rules/wcag/4.1.2-button-name.js +1 -0
- package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -0
- package/engine/rules/wcag/4.1.2-dialog-name.js +1 -0
- package/engine/rules/wcag/4.1.2-frame-title.js +1 -0
- package/engine/rules/wcag/4.1.2-label-for-valid.js +1 -0
- package/engine/rules/wcag/4.1.2-nested-interactive.js +1 -0
- package/engine/rules/wcag/4.1.2-role-required-aria.js +1 -0
- package/engine/rules/wcag/4.1.2-summary-name.js +1 -0
- package/engine/rules/wcag/4.1.2-valid-role.js +1 -0
- package/package.json +1 -1
- package/engine/wcag30-draft.js +0 -149
package/README.md
CHANGED
|
@@ -21,10 +21,6 @@ can verify are returned as a `manualReview` checklist instead of being
|
|
|
21
21
|
silently skipped, and findings the engine can't judge conclusively land in
|
|
22
22
|
`incomplete` rather than being guessed at.
|
|
23
23
|
|
|
24
|
-
An experimental WCAG 3.0 draft mode (`engine/wcag30-draft.js`) reframes
|
|
25
|
-
results against the draft's guidelines — clearly stamped as a draft, because
|
|
26
|
-
no tool can honestly claim conformance to an unfinished standard.
|
|
27
|
-
|
|
28
24
|
## Getting started
|
|
29
25
|
|
|
30
26
|
```sh
|
|
@@ -39,7 +35,6 @@ const results = await run(document, {
|
|
|
39
35
|
// optional: filter rules by tag. Tags are cumulative — WCAG 2.2 AA means
|
|
40
36
|
// every A/AA rule from 2.0 through 2.2. Omit to run everything.
|
|
41
37
|
tags: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa'],
|
|
42
|
-
// standard: 'wcag30-draft', // optional: experimental WCAG 3.0 draft mode
|
|
43
38
|
});
|
|
44
39
|
|
|
45
40
|
console.log(results.violations, results.passes, results.incomplete);
|
|
@@ -89,7 +84,7 @@ static HTML strings. For Node-based testing, drive it through a real browser
|
|
|
89
84
|
(`1.1.1-image-alt.js`), plus the catalog index.
|
|
90
85
|
- `engine/lib/` — shared helpers: DOM traversal, accessible-name
|
|
91
86
|
computation, contrast math, role model.
|
|
92
|
-
- `engine/wcag22.js
|
|
87
|
+
- `engine/wcag22.js` — the WCAG catalog.
|
|
93
88
|
- `config/project.config.js` — package-local stand-in for the pour
|
|
94
89
|
monorepo's project config (engine name + version only).
|
|
95
90
|
|
package/config/project.config.js
CHANGED
package/engine/index.js
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
import config from '../config/project.config.js';
|
|
5
5
|
import rules from './rules/index.js';
|
|
6
6
|
import wcagCatalog from './wcag22.js';
|
|
7
|
-
import wcag30Draft from './wcag30-draft.js';
|
|
8
7
|
import { isVisible, isRendered, cssPath, htmlSnippet, ownText, collectRoots } from './lib/dom.js';
|
|
9
8
|
import { accessibleName } from './lib/accessible-name.js';
|
|
10
9
|
import { resetAuditCaches } from './lib/contrast.js';
|
|
@@ -79,16 +78,11 @@ function toResultNode(element, outcome) {
|
|
|
79
78
|
/**
|
|
80
79
|
* Run the audit.
|
|
81
80
|
* @param {Document|Element} context - what to scan
|
|
82
|
-
* @param {{ tags?: string[], exclude?: string,
|
|
81
|
+
* @param {{ tags?: string[], exclude?: string, signal?: AbortSignal }} options -
|
|
83
82
|
* rule selection by tag (wcag2a…wcag22aa, best-practice), empty selects
|
|
84
83
|
* every rule; exclude is a CSS selector — elements matching it, or inside
|
|
85
84
|
* a match (including across shadow boundaries), are left out of every
|
|
86
|
-
* rule;
|
|
87
|
-
* Working Draft (results.standard is stamped draft, the manual checklist
|
|
88
|
-
* becomes the draft guidelines, and each rule result carries the draft
|
|
89
|
-
* guideline it maps to) — the rules themselves stay WCAG 2.x, which is
|
|
90
|
-
* the only honest claim a tool can make about a draft;
|
|
91
|
-
* signal aborts the run between rules (throws AbortError)
|
|
85
|
+
* rule; signal aborts the run between rules (throws AbortError)
|
|
92
86
|
* @param {(progress: object) => void} [onProgress] - called before each rule
|
|
93
87
|
* starts ({rule, running}), during a rule at each renderer yield with a
|
|
94
88
|
* fractional done (completed rules + this rule's evaluated-element share,
|
|
@@ -97,17 +91,15 @@ function toResultNode(element, outcome) {
|
|
|
97
91
|
*/
|
|
98
92
|
export async function run(context = document, options = {}, onProgress) {
|
|
99
93
|
const auditStarted = performance.now();
|
|
100
|
-
const draft30 = options.standard === 'wcag30-draft';
|
|
101
94
|
const results = {
|
|
102
95
|
testEngine: { name, version },
|
|
103
96
|
timestamp: new Date().toISOString(),
|
|
104
97
|
url: context.location?.href ?? context.ownerDocument?.location?.href ?? '',
|
|
105
|
-
standard: draft30 ? wcag30Draft.draftInfo : null,
|
|
106
98
|
violations: [],
|
|
107
99
|
passes: [],
|
|
108
100
|
incomplete: [],
|
|
109
101
|
inapplicable: [],
|
|
110
|
-
manualReview:
|
|
102
|
+
manualReview: manualReviewCriteria(options.tags),
|
|
111
103
|
ruleTimings: [],
|
|
112
104
|
};
|
|
113
105
|
|
|
@@ -223,17 +215,15 @@ export async function run(context = document, options = {}, onProgress) {
|
|
|
223
215
|
|
|
224
216
|
const ruleResult = {
|
|
225
217
|
id: rule.id,
|
|
218
|
+
// Human display name ("Form field labels"); the id stays the
|
|
219
|
+
// machine handle everywhere (selectors, exports, docs anchors).
|
|
220
|
+
name: rule.name ?? rule.id,
|
|
226
221
|
impact: rule.impact,
|
|
227
222
|
tags: rule.tags,
|
|
228
223
|
help: rule.help,
|
|
229
224
|
description: rule.help,
|
|
230
225
|
helpUrl: rule.helpUrl,
|
|
231
226
|
};
|
|
232
|
-
if (draft30) {
|
|
233
|
-
// Which draft guideline this 2.x rule speaks to (null for rules with
|
|
234
|
-
// no draft counterpart, e.g. best-practice extras).
|
|
235
|
-
ruleResult.wcag30 = wcag30Draft.guidelineForTags(rule.tags);
|
|
236
|
-
}
|
|
237
227
|
if (!elements.length) results.inapplicable.push({ ...ruleResult, nodes: [] });
|
|
238
228
|
if (buckets.fail.length) results.violations.push({ ...ruleResult, nodes: buckets.fail });
|
|
239
229
|
if (buckets.incomplete.length) results.incomplete.push({ ...ruleResult, nodes: buckets.incomplete });
|
|
@@ -252,6 +242,15 @@ export async function run(context = document, options = {}, onProgress) {
|
|
|
252
242
|
ms,
|
|
253
243
|
counts: { ...liveCounts },
|
|
254
244
|
typeCounts: { ...liveTypeCounts },
|
|
245
|
+
// The completed rule's actual findings, so a UI can BUILD its list
|
|
246
|
+
// as rules finish rather than delivering everything at the end.
|
|
247
|
+
// Present only when the rule found something.
|
|
248
|
+
...(buckets.fail.length || buckets.incomplete.length ? {
|
|
249
|
+
findings: {
|
|
250
|
+
...(buckets.fail.length ? { violation: { ...ruleResult, nodes: buckets.fail } } : {}),
|
|
251
|
+
...(buckets.incomplete.length ? { incomplete: { ...ruleResult, nodes: buckets.incomplete } } : {}),
|
|
252
|
+
},
|
|
253
|
+
} : {}),
|
|
255
254
|
});
|
|
256
255
|
}
|
|
257
256
|
|
|
@@ -1,10 +1,24 @@
|
|
|
1
|
+
import { effectiveRole } from '../../lib/roles.js';
|
|
2
|
+
|
|
1
3
|
const level = (el) =>
|
|
2
4
|
el.hasAttribute('aria-level')
|
|
3
5
|
? parseInt(el.getAttribute('aria-level'), 10)
|
|
4
6
|
: parseInt(el.tagName[1], 10) || 2; // role="heading" without aria-level defaults to 2
|
|
5
7
|
|
|
8
|
+
// An honoured presentational role (or any other role override, e.g. role="tab")
|
|
9
|
+
// removes the element from the page's heading outline: Chromium exposes no
|
|
10
|
+
// heading node for <h3 role="none">, so it can neither skip a level nor set
|
|
11
|
+
// the level the next heading is judged against. A null effective role means an
|
|
12
|
+
// unknown explicit role, which browsers ignore in favour of the implicit one —
|
|
13
|
+
// still a heading for everything this selector matches.
|
|
14
|
+
const inOutline = (el) => {
|
|
15
|
+
const role = effectiveRole(el);
|
|
16
|
+
return role === 'heading' || role === null;
|
|
17
|
+
};
|
|
18
|
+
|
|
6
19
|
export default {
|
|
7
20
|
id: 'heading-order',
|
|
21
|
+
name: 'Heading order',
|
|
8
22
|
impact: 'moderate',
|
|
9
23
|
tags: ['best-practice'],
|
|
10
24
|
help: 'Headings should step down one level at a time',
|
|
@@ -14,6 +28,7 @@ export default {
|
|
|
14
28
|
evaluateAll(elements) {
|
|
15
29
|
let previous = 0;
|
|
16
30
|
return elements.map((element) => {
|
|
31
|
+
if (!inOutline(element)) return { status: 'pass' };
|
|
17
32
|
const current = level(element);
|
|
18
33
|
const skipped = previous > 0 && current > previous + 1;
|
|
19
34
|
const outcome = skipped
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { collectRoots } from '../../lib/dom.js';
|
|
2
|
+
|
|
1
3
|
export default {
|
|
2
4
|
id: 'landmark-one-main',
|
|
5
|
+
name: 'One main landmark',
|
|
3
6
|
impact: 'moderate',
|
|
4
7
|
tags: ['best-practice'],
|
|
5
8
|
help: 'The page should have exactly one main landmark',
|
|
@@ -8,8 +11,14 @@ export default {
|
|
|
8
11
|
visibleOnly: false,
|
|
9
12
|
evaluate(element, { isVisible }) {
|
|
10
13
|
// Hidden mains (responsive/SPA double-renders kept display:none) are
|
|
11
|
-
// not exposed and must not count as duplicates.
|
|
12
|
-
|
|
14
|
+
// not exposed and must not count as duplicates. The count spans every
|
|
15
|
+
// open shadow root: a web-component SPA whose only <main> renders
|
|
16
|
+
// inside its app shell's shadow tree HAS a main (measured on a live
|
|
17
|
+
// archive home, where the light-DOM-only count reported a false
|
|
18
|
+
// "no main" on a page whose shadow main is plainly exposed).
|
|
19
|
+
const count = collectRoots(element.ownerDocument)
|
|
20
|
+
.flatMap((root) => [...root.querySelectorAll('main, [role="main"]')])
|
|
21
|
+
.filter(isVisible).length;
|
|
13
22
|
if (count === 1) return { status: 'pass' };
|
|
14
23
|
return {
|
|
15
24
|
status: 'fail',
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
// ARIA APG landmark pattern: banner, main,
|
|
2
|
-
// regions — nesting them inside other landmarks breaks the
|
|
1
|
+
// ARIA APG landmark pattern: banner, main, contentinfo and complementary
|
|
2
|
+
// are page-level regions — nesting them inside other landmarks breaks the
|
|
3
|
+
// page outline. Complementary joins via the aside demotion semantics in
|
|
4
|
+
// lib/roles.js: only an aside that actually reaches the accessibility tree
|
|
5
|
+
// as complementary owes top-levelness.
|
|
3
6
|
import { implicitRole } from '../../lib/roles.js';
|
|
4
7
|
|
|
5
8
|
const LANDMARK_ROLES = new Set([
|
|
@@ -22,11 +25,12 @@ function landmarkAncestor(element) {
|
|
|
22
25
|
|
|
23
26
|
export default {
|
|
24
27
|
id: 'landmark-top-level',
|
|
28
|
+
name: 'Top-level landmarks',
|
|
25
29
|
impact: 'moderate',
|
|
26
30
|
tags: ['best-practice'],
|
|
27
|
-
help: 'banner, main and
|
|
31
|
+
help: 'banner, main, contentinfo and complementary landmarks must be top level',
|
|
28
32
|
helpUrl: 'https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/',
|
|
29
|
-
selector: 'header, footer, main, [role="banner"], [role="contentinfo"], [role="main"]',
|
|
33
|
+
selector: 'header, footer, main, aside, [role="banner"], [role="contentinfo"], [role="main"], [role="complementary"]',
|
|
30
34
|
evaluate(element) {
|
|
31
35
|
const tag = element.tagName.toLowerCase();
|
|
32
36
|
// <header>/<footer> inside sectioning content are not landmarks at all.
|
|
@@ -34,9 +38,20 @@ export default {
|
|
|
34
38
|
element.closest('article, aside, main, nav, section')) {
|
|
35
39
|
return { status: 'pass' };
|
|
36
40
|
}
|
|
41
|
+
// An <aside> is complementary only when implicitRole says so: unnamed
|
|
42
|
+
// inside sectioning content it is generic (Chromium exposes NO
|
|
43
|
+
// complementary node for a news home full of related-content asides,
|
|
44
|
+
// measured live) — there is no landmark to be top level. An aside
|
|
45
|
+
// directly inside <main> is NOT demoted (main is not sectioning
|
|
46
|
+
// content), so an ad-slot aside nested in main is a real finding.
|
|
47
|
+
if (tag === 'aside' && !element.hasAttribute('role') &&
|
|
48
|
+
implicitRole(element) !== 'complementary') {
|
|
49
|
+
return { status: 'pass' };
|
|
50
|
+
}
|
|
37
51
|
const container = landmarkAncestor(element);
|
|
38
52
|
if (!container) return { status: 'pass' };
|
|
39
|
-
const role = element.getAttribute('role') ??
|
|
53
|
+
const role = element.getAttribute('role') ??
|
|
54
|
+
{ header: 'banner', footer: 'contentinfo', main: 'main', aside: 'complementary' }[tag];
|
|
40
55
|
return {
|
|
41
56
|
status: 'fail',
|
|
42
57
|
message: `This ${role} landmark is nested inside a ${container.role} landmark (<${container.element.tagName.toLowerCase()}>) — page-level regions lose their meaning when nested.`,
|
|
@@ -11,6 +11,7 @@ const NAME_FROM_CONTENT = new Set(['button', 'link', 'menuitem', 'menuitemcheckb
|
|
|
11
11
|
|
|
12
12
|
export default {
|
|
13
13
|
id: 'redundant-aria-label',
|
|
14
|
+
name: 'Redundant aria-label',
|
|
14
15
|
impact: 'minor',
|
|
15
16
|
tags: ['best-practice'],
|
|
16
17
|
help: 'aria-label should not repeat the visible text',
|
|
@@ -1,20 +1,64 @@
|
|
|
1
|
+
// The question this rule answers: does the image's text alternative
|
|
2
|
+
// duplicate text the control ALREADY announces beside it? Both halves of
|
|
3
|
+
// that must be measured in accname terms, not raw DOM text:
|
|
4
|
+
// - The comparison text is the container's AT-exposed text (aria-hidden,
|
|
5
|
+
// hidden, display:none and visibility:hidden subtrees never reach the
|
|
6
|
+
// name — a display:none "Search" span does not make the alt redundant,
|
|
7
|
+
// it makes the alt the button's ONLY name, and advising alt="" there
|
|
8
|
+
// would delete it. Measured on a live banking home + Chromium AX tree.)
|
|
9
|
+
// - The alternative is alt when present, else title: HTML-AAM's fallback
|
|
10
|
+
// chain names an alt-less image from its title, so a card thumbnail
|
|
11
|
+
// whose title repeats the headline doubles the link name just the same
|
|
12
|
+
// (measured on a live news home where every card did exactly this).
|
|
13
|
+
const HIDDEN_TAGS = new Set(['script', 'style', 'noscript', 'template']);
|
|
14
|
+
|
|
15
|
+
/** The container's name-contributing text EXCLUDING image alternatives:
|
|
16
|
+
* mirrors the accname content walk's hidden-content rules. */
|
|
17
|
+
function exposedText(root) {
|
|
18
|
+
let text = '';
|
|
19
|
+
for (const node of root.childNodes) {
|
|
20
|
+
if (node.nodeType === Node.TEXT_NODE) { text += node.textContent; continue; }
|
|
21
|
+
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
|
22
|
+
const tag = node.tagName.toLowerCase();
|
|
23
|
+
if (HIDDEN_TAGS.has(tag) || tag === 'img' || tag === 'area' || tag === 'svg') continue;
|
|
24
|
+
if (node.getAttribute('aria-hidden') === 'true' || node.hasAttribute('hidden')) continue;
|
|
25
|
+
const style = getComputedStyle(node);
|
|
26
|
+
if (style.display === 'none' || style.visibility === 'hidden') continue;
|
|
27
|
+
text += exposedText(node);
|
|
28
|
+
}
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const norm = (s) => (s ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
33
|
+
|
|
1
34
|
export default {
|
|
2
35
|
id: 'redundant-image-alt',
|
|
36
|
+
name: 'Redundant image alt',
|
|
3
37
|
impact: 'minor',
|
|
4
38
|
tags: ['best-practice'],
|
|
5
39
|
help: 'Image alt should not repeat adjacent link or button text',
|
|
6
40
|
helpUrl: 'https://www.w3.org/WAI/tutorials/images/functional/',
|
|
7
|
-
selector: 'a[href] img[alt], button img[alt]',
|
|
41
|
+
selector: 'a[href] img[alt], button img[alt], a[href] img[title]:not([alt]), button img[title]:not([alt])',
|
|
8
42
|
evaluate(element) {
|
|
9
|
-
const alt = element.getAttribute('alt')
|
|
10
|
-
|
|
43
|
+
const alt = element.getAttribute('alt');
|
|
44
|
+
const usesTitle = alt === null;
|
|
45
|
+
const alternative = norm(usesTitle ? element.getAttribute('title') : alt);
|
|
46
|
+
if (!alternative) return { status: 'pass' };
|
|
11
47
|
const container = element.closest('a[href], button');
|
|
12
|
-
const text =
|
|
13
|
-
|
|
48
|
+
const text = norm(exposedText(container));
|
|
49
|
+
// No exposed text: the image's alternative is the control's only name.
|
|
50
|
+
// Nothing is announced twice — and removing it would unname the control.
|
|
51
|
+
if (!text) return { status: 'pass' };
|
|
52
|
+
if (alternative !== text) return { status: 'pass' };
|
|
53
|
+
const kind = container.tagName === 'A' ? 'link' : 'button';
|
|
14
54
|
return {
|
|
15
55
|
status: 'fail',
|
|
16
|
-
message:
|
|
17
|
-
|
|
56
|
+
message: usesTitle
|
|
57
|
+
? `The image title repeats the ${kind} text — the title names the image, so screen readers announce "${element.getAttribute('title')}" twice.`
|
|
58
|
+
: `The image alt repeats the ${kind} text — screen readers announce "${element.getAttribute('alt')}" twice.`,
|
|
59
|
+
fix: usesTitle
|
|
60
|
+
? 'Add alt="" to the image (and drop the title if it is not needed as a tooltip); the visible text already names the control.'
|
|
61
|
+
: 'Use alt="" on the image; the visible text already names the control.',
|
|
18
62
|
};
|
|
19
63
|
},
|
|
20
64
|
};
|
|
@@ -1,11 +1,21 @@
|
|
|
1
|
+
// header/footer are banner/contentinfo ONLY outside sectioning content
|
|
2
|
+
// (HTML-AAM; mirrored from lib/roles.js implicitRole): a footer buried in an
|
|
3
|
+
// <article> is generic, so treating it as a landmark hid its stray content.
|
|
4
|
+
// Divs do NOT demote — a header wrapped in plain divs is still a banner.
|
|
5
|
+
// role="alertdialog" joins dialog: both are modal containers whose content
|
|
6
|
+
// region navigation reaches through the dialog itself, and consent banners
|
|
7
|
+
// (measured on a live news home) are exactly this shape.
|
|
1
8
|
const LANDMARK =
|
|
2
|
-
'main, nav,
|
|
9
|
+
'main, nav, aside, form[aria-label], form[aria-labelledby], ' +
|
|
10
|
+
'header:not(:is(article, aside, main, nav, section) header), ' +
|
|
11
|
+
'footer:not(:is(article, aside, main, nav, section) footer), ' +
|
|
3
12
|
'section[aria-label], section[aria-labelledby], [role="main"], [role="navigation"], ' +
|
|
4
13
|
'[role="banner"], [role="contentinfo"], [role="complementary"], [role="region"], ' +
|
|
5
|
-
'[role="search"], [role="form"], [role="dialog"]';
|
|
14
|
+
'[role="search"], [role="form"], [role="dialog"], [role="alertdialog"]';
|
|
6
15
|
|
|
7
16
|
export default {
|
|
8
17
|
id: 'region',
|
|
18
|
+
name: 'Content outside landmarks',
|
|
9
19
|
impact: 'moderate',
|
|
10
20
|
tags: ['best-practice'],
|
|
11
21
|
help: 'All readable content belongs inside a landmark region',
|
|
@@ -28,8 +38,12 @@ export default {
|
|
|
28
38
|
// Any perceivable content counts, not just text — but only content in
|
|
29
39
|
// the accessibility tree: decorative images (alt="", presentation) and
|
|
30
40
|
// unnamed inline svg are invisible to AT, so they can't be "unreachable".
|
|
41
|
+
// Bare form controls belong here too: an input has no text of its own,
|
|
42
|
+
// yet it is exactly the content a region-navigating user needs to reach
|
|
43
|
+
// (a booking form outside every landmark was invisible to this rule).
|
|
31
44
|
const isMedia = element.matches(
|
|
32
|
-
'img:not([alt=""]):not([role="presentation"]):not([role="none"]), svg[role="img"], video, audio, canvas, iframe'
|
|
45
|
+
'img:not([alt=""]):not([role="presentation"]):not([role="none"]), svg[role="img"], video, audio, canvas, iframe, ' +
|
|
46
|
+
'input:not([type="hidden"]), select, textarea, button',
|
|
33
47
|
);
|
|
34
48
|
if (!ownText(element) && !isMedia) return { status: 'pass' };
|
|
35
49
|
if (element.closest(LANDMARK)) return { status: 'pass' };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// WCAG SC 1.1.1 Non-text Content (Level A) · SC 2.4.4 Link Purpose (In Context) (Level A)
|
|
2
2
|
export default {
|
|
3
3
|
id: 'area-alt',
|
|
4
|
+
name: 'Image map alt text',
|
|
4
5
|
impact: 'critical',
|
|
5
6
|
tags: ['wcag2a', 'wcag111', 'wcag244'],
|
|
6
7
|
help: 'Image map areas must have alternative text',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// WCAG SC 1.1.1 Non-text Content (Level A) · SC 4.1.2 Name, Role, Value (Level A)
|
|
2
2
|
export default {
|
|
3
3
|
id: 'input-image-alt',
|
|
4
|
+
name: 'Image button alt text',
|
|
4
5
|
impact: 'critical',
|
|
5
6
|
tags: ['wcag2a', 'wcag111', 'wcag412'],
|
|
6
7
|
help: 'Image inputs need alt text describing their action',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// WCAG SC 1.2.2 Captions (Prerecorded) (Level A)
|
|
2
2
|
export default {
|
|
3
3
|
id: 'media-captions',
|
|
4
|
+
name: 'Video captions',
|
|
4
5
|
impact: 'critical',
|
|
5
6
|
tags: ['wcag2a', 'wcag122'],
|
|
6
7
|
help: 'Video content must have captions',
|
|
@@ -21,17 +22,23 @@ export default {
|
|
|
21
22
|
if (!element.currentSrc && !element.getAttribute('src') && !element.querySelector('source')) {
|
|
22
23
|
return { status: 'pass' };
|
|
23
24
|
}
|
|
24
|
-
// The decorative no-audio-path signature
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// the file may carry is never presented to anyone; audio that cannot be
|
|
25
|
+
// The decorative no-audio-path signature: muted with no controls. With
|
|
26
|
+
// no controls there is no user path to unmute, so whatever audio the
|
|
27
|
+
// file may carry is never presented to anyone; audio that cannot be
|
|
28
28
|
// presented is not audio content under 1.2.2, and asking a human to
|
|
29
29
|
// verify captions for it is noise (measured on the benchmark corpus:
|
|
30
30
|
// our own site's aria-hidden hero loops, ffprobe-confirmed to carry no
|
|
31
31
|
// audio stream, plus the same family on four news/university homes).
|
|
32
|
+
// HOW playback starts is deliberately not part of the signature: an
|
|
33
|
+
// earlier autoplay condition missed the two other members of the same
|
|
34
|
+
// family, measured on our own site — a crossfade B-roll take that only
|
|
35
|
+
// ever plays via script (no autoplay attribute, so the review fired
|
|
36
|
+
// exactly when the script had wired its src), and heroes whose
|
|
37
|
+
// autoplay attribute a reduced-motion handler removes. Muteness and
|
|
38
|
+
// controls decide whether audio can reach anyone; autoplay never did.
|
|
32
39
|
// Properties, not attributes: script-muted heroes count too. A muted
|
|
33
40
|
// video WITH controls keeps its review — one click unmutes it.
|
|
34
|
-
if (element.muted &&
|
|
41
|
+
if (element.muted && !element.controls) {
|
|
35
42
|
return { status: 'pass' };
|
|
36
43
|
}
|
|
37
44
|
// Captions may be burned in or provided by the player — a human must check.
|
|
@@ -20,6 +20,7 @@ const IMPLICIT_CONTAINER = { ul: 'list', ol: 'list', menu: 'list', table: 'table
|
|
|
20
20
|
|
|
21
21
|
export default {
|
|
22
22
|
id: 'aria-required-parent',
|
|
23
|
+
name: 'Required ARIA parent',
|
|
23
24
|
impact: 'critical',
|
|
24
25
|
tags: ['wcag2a', 'wcag131'],
|
|
25
26
|
help: 'ARIA child roles must be inside their required container role',
|