pour-engine 1.19.0 → 1.21.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/config/project.config.js +1 -1
- package/engine/AUDIT.html +690 -0
- package/engine/COVERAGE.md +8 -8
- package/engine/README.md +5 -1
- package/engine/lib/contrast.js +63 -9
- package/engine/rules/index.js +18 -0
- package/engine/rules/wcag/1.1.1-canvas-alt.js +43 -0
- package/engine/rules/wcag/1.1.1-embed-alt.js +42 -0
- package/engine/rules/wcag/1.4.3-color-contrast.js +104 -3
- package/engine/rules/wcag/1.4.3-control-contrast.js +13 -0
- package/engine/rules/wcag/2.2.2-video-loop-motion.js +40 -0
- package/engine/rules/wcag/2.4.3-visual-order-divergence.js +92 -0
- package/engine/rules/wcag/3.2.2-on-input-navigation.js +47 -0
- package/engine/rules/wcag/3.3.1-error-message-linkage.js +69 -0
- package/engine/rules/wcag/4.1.2-composite-widget-name.js +32 -0
- package/engine/rules/wcag/4.1.2-summary-name.js +31 -0
- package/engine/wcag22.js +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// WCAG SC 3.2.2 On Input (Level A) — plus the inline slice of 3.2.1 On Focus
|
|
2
|
+
// The classic jump menu: <select onchange="location.href=this.value">.
|
|
3
|
+
// Changing a setting must not change the user's context unless they were
|
|
4
|
+
// told beforehand (3.2.2), and merely receiving focus must never do so at
|
|
5
|
+
// all (3.2.1).
|
|
6
|
+
//
|
|
7
|
+
// Only INLINE handlers are visible to a DOM audit — addEventListener
|
|
8
|
+
// registrations can't be enumerated — and only a navigation-shaped handler
|
|
9
|
+
// body is evidence. That makes this a rare, legacy, high-precision
|
|
10
|
+
// signature: pages that never use inline handlers produce zero findings.
|
|
11
|
+
// Both branches ask rather than assert: the handler string proves a
|
|
12
|
+
// navigation call EXISTS in the code path, not that it runs unconditionally
|
|
13
|
+
// (it may sit behind a confirm(), a flag, or a selected-value check), and
|
|
14
|
+
// for 3.2.2 whether the user "has been advised beforehand" is text on the
|
|
15
|
+
// page only a human can weigh.
|
|
16
|
+
// Writes and calls only: reading location (analytics, hash checks) is not
|
|
17
|
+
// navigation, so the pattern demands an assignment to location or one of
|
|
18
|
+
// the navigating members/calls.
|
|
19
|
+
const NAVIGATES = /\blocation\s*(=|\.\s*(href|assign|replace|reload))|\.(submit|requestSubmit)\s*\(|window\.open\s*\(/;
|
|
20
|
+
|
|
21
|
+
export default {
|
|
22
|
+
id: 'on-input-navigation',
|
|
23
|
+
impact: 'serious',
|
|
24
|
+
tags: ['wcag2a', 'wcag322', 'wcag321'],
|
|
25
|
+
help: 'Changing or focusing a control must not unexpectedly change context',
|
|
26
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/on-input.html',
|
|
27
|
+
selector: 'select[onchange], input[onchange], textarea[onchange], select[oninput], input[oninput], textarea[oninput], [onfocus]',
|
|
28
|
+
evaluate(element) {
|
|
29
|
+
const onfocus = element.getAttribute('onfocus');
|
|
30
|
+
if (onfocus && NAVIGATES.test(onfocus)) {
|
|
31
|
+
return {
|
|
32
|
+
status: 'incomplete',
|
|
33
|
+
message: 'Focusing this element appears to navigate or submit (its onfocus handler reaches for location/submit). A change of context on focus alone fails WCAG 3.2.1 — check what the handler actually does when focus lands.',
|
|
34
|
+
fix: 'Trigger navigation from activation (click/Enter), never from focus.',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const onchange = element.getAttribute('onchange') ?? element.getAttribute('oninput');
|
|
38
|
+
if (onchange && NAVIGATES.test(onchange)) {
|
|
39
|
+
return {
|
|
40
|
+
status: 'incomplete',
|
|
41
|
+
message: 'Changing this control appears to navigate or submit (its onchange handler reaches for location/submit). WCAG 3.2.2 allows that only when users are told beforehand — check the page says so before the control.',
|
|
42
|
+
fix: 'Describe the behaviour before the control, or navigate from an explicit Go button instead of the change event.',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return { status: 'pass' };
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// WCAG SC 3.3.1 Error Identification (Level A)
|
|
2
|
+
// "If an input error is automatically detected, the item that is in error
|
|
3
|
+
// is identified and the error is described to the user in text."
|
|
4
|
+
//
|
|
5
|
+
// A snapshot audit only ever sees this criterion in force when the page is
|
|
6
|
+
// captured mid-error — a pristine form has no detected error and nothing to
|
|
7
|
+
// judge. That makes the rule state-dependent and quiet by construction:
|
|
8
|
+
// aria-invalid="true" IS the page saying "an error was detected here", and
|
|
9
|
+
// from that admission two things become checkable.
|
|
10
|
+
//
|
|
11
|
+
// - aria-errormessage must point at an element that exists, is visible,
|
|
12
|
+
// and says something (ARIA 1.2 requires the target to be displayed and
|
|
13
|
+
// pertinent while the field is invalid). A broken or empty target is a
|
|
14
|
+
// real authoring defect — the reference AT follows leads nowhere — but
|
|
15
|
+
// it is NOT a provable 3.3.1 failure: the Understanding document is
|
|
16
|
+
// explicit that programmatic association is not required, so adjacent
|
|
17
|
+
// visible text (which a machine cannot tie to the field) may satisfy
|
|
18
|
+
// the criterion on its own. Both shapes therefore go to review, with
|
|
19
|
+
// the broken reference named when present.
|
|
20
|
+
//
|
|
21
|
+
// aria-errormessage on a field that is NOT in an invalid state is inert:
|
|
22
|
+
// user agents expose it only while the field is invalid, so a dangling
|
|
23
|
+
// reference there breaks nothing a user receives (the engine's standing
|
|
24
|
+
// inertness doctrine says pass, not assert). Invalid means any aria-invalid
|
|
25
|
+
// value except false/undefined — "spelling" and "grammar" count.
|
|
26
|
+
export default {
|
|
27
|
+
id: 'error-message-linkage',
|
|
28
|
+
impact: 'serious',
|
|
29
|
+
tags: ['wcag2a', 'wcag331'],
|
|
30
|
+
help: 'Fields marked invalid need their error described in text',
|
|
31
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html',
|
|
32
|
+
selector: '[aria-invalid="true"], [aria-invalid="spelling"], [aria-invalid="grammar"], [aria-errormessage]',
|
|
33
|
+
evaluate(element, { isVisible, accessibleName }) {
|
|
34
|
+
const root = element.getRootNode();
|
|
35
|
+
const state = element.getAttribute('aria-invalid');
|
|
36
|
+
const invalid = !!state && state !== 'false' && state !== 'undefined';
|
|
37
|
+
const targetsOf = (attribute) => (element.getAttribute(attribute) ?? '')
|
|
38
|
+
.trim().split(/\s+/).filter(Boolean)
|
|
39
|
+
.map((id) => root.getElementById?.(id))
|
|
40
|
+
.filter(Boolean);
|
|
41
|
+
// An image-of-text error message still describes the error; textContent
|
|
42
|
+
// alone would miss it, so fall back to the accessible-name walk.
|
|
43
|
+
const hasText = (targets) => targets.some((target) =>
|
|
44
|
+
isVisible(target) && (target.textContent.trim() || accessibleName(target)));
|
|
45
|
+
|
|
46
|
+
if (element.hasAttribute('aria-errormessage')) {
|
|
47
|
+
if (!invalid) return { status: 'pass' }; // inert until the field is invalid
|
|
48
|
+
const targets = targetsOf('aria-errormessage');
|
|
49
|
+
if (hasText(targets)) return { status: 'pass' };
|
|
50
|
+
return {
|
|
51
|
+
status: 'incomplete',
|
|
52
|
+
message: (targets.length
|
|
53
|
+
? 'This field is marked invalid, but the element its aria-errormessage points at is empty or hidden — screen readers follow the reference and find no text.'
|
|
54
|
+
: 'This field is marked invalid, but its aria-errormessage points at nothing — screen readers follow the reference and find no text.')
|
|
55
|
+
+ ' The error must be described in text: check a visible message exists, and fix the reference so it reaches assistive technology too.',
|
|
56
|
+
fix: 'Point aria-errormessage at the visible element containing the error text, and keep that element populated while the field is invalid.',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// aria-invalid="true" with no aria-errormessage: a described-by message
|
|
60
|
+
// satisfies the criterion; with no linkage at all, the error may still
|
|
61
|
+
// sit in adjacent text only a human can connect to the field.
|
|
62
|
+
if (hasText(targetsOf('aria-describedby'))) return { status: 'pass' };
|
|
63
|
+
return {
|
|
64
|
+
status: 'incomplete',
|
|
65
|
+
message: 'This field is marked invalid, but no error text is programmatically linked to it. The error must be described to the user in text — check a message exists, and associate it with aria-describedby or aria-errormessage so screen readers announce it with the field.',
|
|
66
|
+
fix: 'Add the error text near the field and reference it with aria-describedby (or aria-errormessage).',
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
// The icon-only tab strip. Items of composite widgets — tabs, menu items,
|
|
3
|
+
// options, tree items — take their name from their contents per ARIA 1.2,
|
|
4
|
+
// and every one of these roles requires an accessible name. So an item
|
|
5
|
+
// whose contents render no text and whose author supplied no label is
|
|
6
|
+
// provably announced as an unnamed control: "tab", selected, of what?
|
|
7
|
+
//
|
|
8
|
+
// The container roles (tablist, menu, listbox, tree) are name-recommended
|
|
9
|
+
// rather than required and are deliberately not judged here; the items are
|
|
10
|
+
// where the binary, assertable failure lives.
|
|
11
|
+
export default {
|
|
12
|
+
id: 'composite-widget-name',
|
|
13
|
+
impact: 'serious',
|
|
14
|
+
tags: ['wcag2a', 'wcag412'],
|
|
15
|
+
help: 'Tabs, menu items, options and tree items need an accessible name',
|
|
16
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
17
|
+
selector: '[role="tab"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="treeitem"]',
|
|
18
|
+
evaluate(element, { accessibleName }) {
|
|
19
|
+
// <button role="tab"> and <a href role="tab"> are already selected by
|
|
20
|
+
// button-name/link-name (their selectors match the tag regardless of
|
|
21
|
+
// the role override) — one nameless control must not become two
|
|
22
|
+
// findings, so this rule owns only the div/span/li-style hosts.
|
|
23
|
+
if (element.matches('button, a[href], input, select, textarea, summary')) return { status: 'pass' };
|
|
24
|
+
if (accessibleName(element)) return { status: 'pass' };
|
|
25
|
+
const role = element.getAttribute('role');
|
|
26
|
+
return {
|
|
27
|
+
status: 'fail',
|
|
28
|
+
message: `This ${role} has no accessible name — a screen reader announces an unnamed ${role}, and users cannot tell what choosing it does.`,
|
|
29
|
+
fix: 'Put text inside the element, or add aria-label="…" for icon-only items.',
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
// <summary> is the interactive disclosure control of <details> — focusable,
|
|
3
|
+
// activatable, exposed to AT as a button-like widget — and button-name's
|
|
4
|
+
// selector never reaches it. An icon-only summary (chevron glyph, no text,
|
|
5
|
+
// no label) announces as an unnamed control.
|
|
6
|
+
//
|
|
7
|
+
// Only the FIRST summary child of a <details> is the control: the HTML spec
|
|
8
|
+
// gives any further summary elements, and summaries outside <details>, no
|
|
9
|
+
// special behaviour — they are plain content and owe no name. A <details>
|
|
10
|
+
// with no summary at all also passes: the browser supplies its own default
|
|
11
|
+
// label ("Details").
|
|
12
|
+
export default {
|
|
13
|
+
id: 'summary-name',
|
|
14
|
+
impact: 'serious',
|
|
15
|
+
tags: ['wcag2a', 'wcag412'],
|
|
16
|
+
help: 'Disclosure controls (<summary>) need an accessible name',
|
|
17
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
18
|
+
selector: 'summary',
|
|
19
|
+
evaluate(element, { accessibleName }) {
|
|
20
|
+
const details = element.parentElement;
|
|
21
|
+
const isControl = details?.tagName === 'DETAILS'
|
|
22
|
+
&& details.querySelector(':scope > summary') === element;
|
|
23
|
+
if (!isControl) return { status: 'pass' };
|
|
24
|
+
if (accessibleName(element)) return { status: 'pass' };
|
|
25
|
+
return {
|
|
26
|
+
status: 'fail',
|
|
27
|
+
message: 'This disclosure control has no accessible name — a screen reader announces an unnamed button, and users cannot tell what it expands.',
|
|
28
|
+
fix: 'Put text inside the <summary>, or add aria-label="…" for icon-only disclosures.',
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
};
|
package/engine/wcag22.js
CHANGED
|
@@ -69,7 +69,7 @@ export default [
|
|
|
69
69
|
sc('2.3.3', 'Animation from Interactions', 'AAA', 'manual', '2.1'),
|
|
70
70
|
sc('2.4.1', 'Bypass Blocks', 'A', 'partial'), // partial: "repeated across pages" can't be established from one page
|
|
71
71
|
sc('2.4.2', 'Page Titled', 'A', 'partial'), // partial: document-title proves a title exists and catches known placeholders; whether a real one DESCRIBES the page needs a reader
|
|
72
|
-
sc('2.4.3', 'Focus Order', 'A', '
|
|
72
|
+
sc('2.4.3', 'Focus Order', 'A', 'partial'), // visual-order-divergence flags CSS order/*-reverse against DOM order; whether an order preserves MEANING stays human
|
|
73
73
|
sc('2.4.4', 'Link Purpose (In Context)', 'A', 'partial'),
|
|
74
74
|
sc('2.4.5', 'Multiple Ways', 'AA', 'manual'),
|
|
75
75
|
sc('2.4.6', 'Headings and Labels', 'AA', 'manual'),
|
|
@@ -95,13 +95,13 @@ export default [
|
|
|
95
95
|
sc('3.1.4', 'Abbreviations', 'AAA', 'manual'),
|
|
96
96
|
sc('3.1.5', 'Reading Level', 'AAA', 'manual'),
|
|
97
97
|
sc('3.1.6', 'Pronunciation', 'AAA', 'manual'),
|
|
98
|
-
sc('3.2.1', 'On Focus', 'A', '
|
|
99
|
-
sc('3.2.2', 'On Input', 'A', '
|
|
98
|
+
sc('3.2.1', 'On Focus', 'A', 'partial'), // on-input-navigation flags inline onfocus handlers that navigate; addEventListener handlers are invisible to a DOM audit
|
|
99
|
+
sc('3.2.2', 'On Input', 'A', 'partial'), // on-input-navigation flags the inline jump-menu signature; "advised beforehand" stays a human call
|
|
100
100
|
sc('3.2.3', 'Consistent Navigation', 'AA', 'manual'),
|
|
101
101
|
sc('3.2.4', 'Consistent Identification', 'AA', 'manual'),
|
|
102
102
|
sc('3.2.5', 'Change on Request', 'AAA', 'manual'),
|
|
103
103
|
sc('3.2.6', 'Consistent Help', 'A', 'manual', '2.2'),
|
|
104
|
-
sc('3.3.1', 'Error Identification', 'A', '
|
|
104
|
+
sc('3.3.1', 'Error Identification', 'A', 'partial'), // error-message-linkage proves broken aria-errormessage targets; only in force when the page is captured mid-error
|
|
105
105
|
sc('3.3.2', 'Labels or Instructions', 'A', 'partial'),
|
|
106
106
|
sc('3.3.3', 'Error Suggestion', 'AA', 'manual'),
|
|
107
107
|
sc('3.3.4', 'Error Prevention (Legal, Financial, Data)', 'AA', 'manual'),
|
package/package.json
CHANGED