pour-engine 1.1.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/LICENSE +21 -0
- package/README.md +99 -0
- package/config/project.config.js +11 -0
- package/engine/COVERAGE.md +115 -0
- package/engine/README.md +115 -0
- package/engine/index.js +258 -0
- package/engine/lib/accessible-name.js +126 -0
- package/engine/lib/contrast.js +628 -0
- package/engine/lib/dom.js +116 -0
- package/engine/lib/roles.js +125 -0
- package/engine/rules/best-practice/accesskeys.js +29 -0
- package/engine/rules/best-practice/aria-label-misuse.js +22 -0
- package/engine/rules/best-practice/button-type.js +15 -0
- package/engine/rules/best-practice/empty-heading.js +19 -0
- package/engine/rules/best-practice/fieldset-legend.js +35 -0
- package/engine/rules/best-practice/heading-order.js +30 -0
- package/engine/rules/best-practice/landmark-one-main.js +22 -0
- package/engine/rules/best-practice/landmark-top-level.js +46 -0
- package/engine/rules/best-practice/landmark-unique.js +84 -0
- package/engine/rules/best-practice/multiple-labels.js +23 -0
- package/engine/rules/best-practice/new-window-link.js +21 -0
- package/engine/rules/best-practice/no-autofocus.js +16 -0
- package/engine/rules/best-practice/page-heading-one.js +17 -0
- package/engine/rules/best-practice/positive-tabindex.js +17 -0
- package/engine/rules/best-practice/redundant-alt-phrase.js +22 -0
- package/engine/rules/best-practice/redundant-aria-label.js +18 -0
- package/engine/rules/best-practice/redundant-aria.js +32 -0
- package/engine/rules/best-practice/redundant-image-alt.js +20 -0
- package/engine/rules/best-practice/redundant-role.js +62 -0
- package/engine/rules/best-practice/redundant-tabindex.js +15 -0
- package/engine/rules/best-practice/region.js +73 -0
- package/engine/rules/index.js +179 -0
- package/engine/rules/wcag/1.1.1-area-alt.js +18 -0
- package/engine/rules/wcag/1.1.1-image-alt.js +48 -0
- package/engine/rules/wcag/1.1.1-input-image-alt.js +17 -0
- package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
- package/engine/rules/wcag/1.1.1-svg-img-alt.js +34 -0
- package/engine/rules/wcag/1.2.2-media-captions.js +19 -0
- package/engine/rules/wcag/1.3.1-aria-required-children.js +66 -0
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +49 -0
- package/engine/rules/wcag/1.3.1-definition-list.js +21 -0
- package/engine/rules/wcag/1.3.1-dlitem-parent.js +17 -0
- package/engine/rules/wcag/1.3.1-form-label.js +81 -0
- package/engine/rules/wcag/1.3.1-list-structure.js +59 -0
- package/engine/rules/wcag/1.3.1-listitem-parent.js +33 -0
- package/engine/rules/wcag/1.3.1-p-as-heading.js +25 -0
- package/engine/rules/wcag/1.3.1-table-headers.js +61 -0
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +38 -0
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +74 -0
- package/engine/rules/wcag/1.4.10-reflow.js +25 -0
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +109 -0
- package/engine/rules/wcag/1.4.12-text-spacing.js +71 -0
- package/engine/rules/wcag/1.4.2-audio-control.js +40 -0
- package/engine/rules/wcag/1.4.3-color-contrast.js +462 -0
- package/engine/rules/wcag/1.4.3-control-contrast.js +72 -0
- package/engine/rules/wcag/1.4.4-meta-viewport.js +22 -0
- package/engine/rules/wcag/1.4.6-contrast-enhanced.js +11 -0
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +48 -0
- package/engine/rules/wcag/2.2.1-meta-refresh.js +20 -0
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +25 -0
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +36 -0
- package/engine/rules/wcag/2.4.11-focus-not-obscured.js +80 -0
- package/engine/rules/wcag/2.4.2-document-title.js +18 -0
- package/engine/rules/wcag/2.4.4-link-name.js +19 -0
- package/engine/rules/wcag/2.4.7-focus-visible.js +46 -0
- package/engine/rules/wcag/2.5.3-label-in-name.js +88 -0
- package/engine/rules/wcag/2.5.5-target-size-enhanced.js +14 -0
- package/engine/rules/wcag/2.5.8-target-size.js +159 -0
- package/engine/rules/wcag/3.1.1-html-lang.js +31 -0
- package/engine/rules/wcag/3.1.2-valid-lang-parts.js +20 -0
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +37 -0
- package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +44 -0
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +50 -0
- package/engine/rules/wcag/4.1.2-aria-field-name.js +54 -0
- package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +48 -0
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +61 -0
- package/engine/rules/wcag/4.1.2-button-name.js +17 -0
- package/engine/rules/wcag/4.1.2-dialog-name.js +35 -0
- package/engine/rules/wcag/4.1.2-frame-title.js +17 -0
- package/engine/rules/wcag/4.1.2-label-for-valid.js +25 -0
- package/engine/rules/wcag/4.1.2-nested-interactive.js +30 -0
- package/engine/rules/wcag/4.1.2-role-required-aria.js +36 -0
- package/engine/rules/wcag/4.1.2-valid-role.js +52 -0
- package/engine/wcag22.js +116 -0
- package/engine/wcag30-draft.js +149 -0
- package/package.json +34 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// WCAG SC 1.3.1 Info and Relationships (Level A)
|
|
2
|
+
// Composite roles are meaningless without their required child roles —
|
|
3
|
+
// role="list" with no listitems announces as an empty list.
|
|
4
|
+
const REQUIRED_CHILDREN = {
|
|
5
|
+
list: ['listitem'],
|
|
6
|
+
listbox: ['option', 'group'],
|
|
7
|
+
menu: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'group'],
|
|
8
|
+
menubar: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'group'],
|
|
9
|
+
radiogroup: ['radio'],
|
|
10
|
+
tablist: ['tab'],
|
|
11
|
+
tree: ['treeitem', 'group'],
|
|
12
|
+
table: ['row', 'rowgroup'],
|
|
13
|
+
grid: ['row', 'rowgroup'],
|
|
14
|
+
treegrid: ['row', 'rowgroup'],
|
|
15
|
+
rowgroup: ['row'],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const IMPLICIT = { li: 'listitem', option: 'option', tr: 'row', tbody: 'rowgroup', thead: 'rowgroup', tfoot: 'rowgroup' };
|
|
19
|
+
|
|
20
|
+
const roleOf = (element) =>
|
|
21
|
+
element.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase() ??
|
|
22
|
+
IMPLICIT[element.tagName.toLowerCase()];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The children a composite role OWNS in the accessibility tree — not the
|
|
26
|
+
* DOM children. role="presentation"/"none" removes an element but leaves
|
|
27
|
+
* its children in place, and role-less div/span wrappers are generic nodes
|
|
28
|
+
* the required-ownership relation passes straight through (tabs inside a
|
|
29
|
+
* styling wrapper inside a tablist are still the tablist's tabs).
|
|
30
|
+
*/
|
|
31
|
+
function ownedChildren(element) {
|
|
32
|
+
const owned = [];
|
|
33
|
+
for (const child of element.children) {
|
|
34
|
+
if (child.matches('script, style, template')) continue;
|
|
35
|
+
const role = child.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase();
|
|
36
|
+
const passThrough = role === 'presentation' || role === 'none'
|
|
37
|
+
|| (!role && (child.tagName === 'DIV' || child.tagName === 'SPAN'));
|
|
38
|
+
if (passThrough) owned.push(...ownedChildren(child));
|
|
39
|
+
else owned.push(child);
|
|
40
|
+
}
|
|
41
|
+
return owned;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default {
|
|
45
|
+
id: 'aria-required-children',
|
|
46
|
+
impact: 'critical',
|
|
47
|
+
tags: ['wcag2a', 'wcag131'],
|
|
48
|
+
help: 'Composite ARIA roles must contain their required children',
|
|
49
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
|
|
50
|
+
selector: Object.keys(REQUIRED_CHILDREN).map((role) => `[role="${role}"]`).join(', '),
|
|
51
|
+
evaluate(element) {
|
|
52
|
+
if (element.getAttribute('aria-busy') === 'true') return { status: 'pass' }; // still loading
|
|
53
|
+
const role = element.getAttribute('role').trim().split(/\s+/)[0].toLowerCase();
|
|
54
|
+
const required = REQUIRED_CHILDREN[role];
|
|
55
|
+
const children = [...element.children].filter((c) => !c.matches('script, style, template'));
|
|
56
|
+
// A completely empty container is a lazy-load placeholder more often
|
|
57
|
+
// than a defect — it announces as an empty list, which is accurate.
|
|
58
|
+
if (!children.length) return { status: 'pass' };
|
|
59
|
+
if (ownedChildren(element).some((child) => required.includes(roleOf(child)))) return { status: 'pass' };
|
|
60
|
+
return {
|
|
61
|
+
status: 'fail',
|
|
62
|
+
message: `role="${role}" contains no ${required.join('/')} children (not even through wrapper elements) — screen readers announce a broken, empty ${role}.`,
|
|
63
|
+
fix: `Give the item elements role="${required[0]}", or remove role="${role}" from the container.`,
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// WCAG SC 1.3.1 Info and Relationships (Level A)
|
|
2
|
+
// Child roles that only make sense inside a specific container role.
|
|
3
|
+
// Explicit roles only — native elements (li outside ul) are covered by
|
|
4
|
+
// listitem-parent/dlitem-parent.
|
|
5
|
+
const REQUIRED_PARENT = {
|
|
6
|
+
listitem: ['list'],
|
|
7
|
+
option: ['listbox'],
|
|
8
|
+
menuitem: ['menu', 'menubar'],
|
|
9
|
+
menuitemcheckbox: ['menu', 'menubar'],
|
|
10
|
+
menuitemradio: ['menu', 'menubar'],
|
|
11
|
+
tab: ['tablist'],
|
|
12
|
+
treeitem: ['tree'],
|
|
13
|
+
row: ['table', 'grid', 'treegrid', 'rowgroup'],
|
|
14
|
+
rowgroup: ['table', 'grid', 'treegrid'],
|
|
15
|
+
cell: ['row'],
|
|
16
|
+
gridcell: ['row'],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const IMPLICIT_CONTAINER = { ul: 'list', ol: 'list', menu: 'list', table: 'table', tbody: 'rowgroup', thead: 'rowgroup', tfoot: 'rowgroup', tr: 'row' };
|
|
20
|
+
|
|
21
|
+
export default {
|
|
22
|
+
id: 'aria-required-parent',
|
|
23
|
+
impact: 'critical',
|
|
24
|
+
tags: ['wcag2a', 'wcag131'],
|
|
25
|
+
help: 'ARIA child roles must be inside their required container role',
|
|
26
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
|
|
27
|
+
selector: Object.keys(REQUIRED_PARENT).map((role) => `[role="${role}"]`).join(', '),
|
|
28
|
+
evaluate(element) {
|
|
29
|
+
const role = element.getAttribute('role').trim().split(/\s+/)[0].toLowerCase();
|
|
30
|
+
const containers = REQUIRED_PARENT[role];
|
|
31
|
+
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
|
32
|
+
const parentRole =
|
|
33
|
+
parent.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase() ??
|
|
34
|
+
IMPLICIT_CONTAINER[parent.tagName.toLowerCase()];
|
|
35
|
+
if (parentRole === 'group') continue; // group is transparent in these structures
|
|
36
|
+
// presentation/none removes the wrapper from the tree but leaves its
|
|
37
|
+
// children owned by the next real ancestor — li[role=presentation]
|
|
38
|
+
// between a tablist and its tabs is the canonical pattern.
|
|
39
|
+
if (parentRole === 'presentation' || parentRole === 'none') continue;
|
|
40
|
+
if (containers.includes(parentRole)) return { status: 'pass' };
|
|
41
|
+
if (parentRole) break; // a different role interrupts the required structure
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
status: 'fail',
|
|
45
|
+
message: `role="${role}" is not inside a ${containers.join('/')} — assistive technology loses the structure entirely.`,
|
|
46
|
+
fix: `Wrap it in an element with role="${containers[0]}", or fix the intervening roles.`,
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// WCAG SC 1.3.1 Info and Relationships (Level A)
|
|
2
|
+
const ALLOWED = new Set(['DT', 'DD', 'DIV', 'SCRIPT', 'TEMPLATE']);
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
id: 'definition-list',
|
|
6
|
+
impact: 'serious',
|
|
7
|
+
tags: ['wcag2a', 'wcag131'],
|
|
8
|
+
help: '<dl> must be structured as term/description pairs',
|
|
9
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
|
|
10
|
+
selector: 'dl:not([role])', // a role attribute replaces the dl semantics
|
|
11
|
+
evaluate(element) {
|
|
12
|
+
const invalid = [...element.children].filter((child) => !ALLOWED.has(child.tagName));
|
|
13
|
+
if (!invalid.length) return { status: 'pass' };
|
|
14
|
+
const tags = [...new Set(invalid.map((child) => `<${child.tagName.toLowerCase()}>`))].join(', ');
|
|
15
|
+
return {
|
|
16
|
+
status: 'fail',
|
|
17
|
+
message: `This <dl> contains ${tags} directly — only <dt>, <dd> (optionally grouped in <div>) are allowed, otherwise the term/description pairing breaks.`,
|
|
18
|
+
fix: 'Restructure the list into <dt>/<dd> pairs, or use a different element.',
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// WCAG SC 1.3.1 Info and Relationships (Level A)
|
|
2
|
+
export default {
|
|
3
|
+
id: 'dlitem-parent',
|
|
4
|
+
impact: 'serious',
|
|
5
|
+
tags: ['wcag2a', 'wcag131'],
|
|
6
|
+
help: '<dt> and <dd> must be inside a <dl>',
|
|
7
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
|
|
8
|
+
selector: 'dt:not([role]), dd:not([role])', // role overrides the dt/dd semantics
|
|
9
|
+
evaluate(element) {
|
|
10
|
+
if (element.closest('dl')) return { status: 'pass' };
|
|
11
|
+
return {
|
|
12
|
+
status: 'fail',
|
|
13
|
+
message: `<${element.tagName.toLowerCase()}> outside a <dl> has no term/description semantics.`,
|
|
14
|
+
fix: 'Wrap the terms and descriptions in a <dl>, or use different elements.',
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// WCAG SC 1.3.1 Info and Relationships (Level A) · SC 3.3.2 Labels or
|
|
2
|
+
// Instructions (Level A) · SC 4.1.2 Name, Role, Value (Level A)
|
|
3
|
+
// 3.3.2 mapping: a field with no label at all, or only a vanishing
|
|
4
|
+
// placeholder, is failure F82 — the classic 3.3.2 pattern.
|
|
5
|
+
export default {
|
|
6
|
+
id: 'form-label',
|
|
7
|
+
impact: 'critical',
|
|
8
|
+
tags: ['wcag2a', 'wcag131', 'wcag332', 'wcag412'],
|
|
9
|
+
help: 'Every form field needs a visible label',
|
|
10
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
|
|
11
|
+
selector:
|
|
12
|
+
'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="image"]), select, textarea',
|
|
13
|
+
evaluate(element) {
|
|
14
|
+
// Only perceivable labels count: a display:none / aria-hidden label
|
|
15
|
+
// technically feeds the accessible name, but nobody can see it and
|
|
16
|
+
// that defeats the point of a label.
|
|
17
|
+
const visibleLabels = [...(element.labels ?? [])].filter((label) => {
|
|
18
|
+
if (label.closest('[aria-hidden="true"]')) return false;
|
|
19
|
+
const style = getComputedStyle(label);
|
|
20
|
+
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
21
|
+
});
|
|
22
|
+
// Label content includes aria-labels on things inside it (icon-only
|
|
23
|
+
// labels like <label><svg aria-label="Dark mode"/></label> still name)
|
|
24
|
+
// and image alts (<label><img alt="…"></label> is a visible, named
|
|
25
|
+
// label — alt text feeds name-from-contents per accname).
|
|
26
|
+
const labelsText = visibleLabels
|
|
27
|
+
.map((l) =>
|
|
28
|
+
`${l.textContent} ${[...l.querySelectorAll('[aria-label], img[alt], area[alt]')]
|
|
29
|
+
.map((e) => e.getAttribute('aria-label') ?? e.getAttribute('alt')).join(' ')}`)
|
|
30
|
+
.join(' ')
|
|
31
|
+
.trim();
|
|
32
|
+
const ariaLabel = element.getAttribute('aria-label')?.trim();
|
|
33
|
+
const labelledby = (element.getAttribute('aria-labelledby') ?? '')
|
|
34
|
+
.split(/\s+/)
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.map((id) => element.getRootNode().getElementById?.(id)?.textContent ?? '')
|
|
37
|
+
.join(' ')
|
|
38
|
+
.trim();
|
|
39
|
+
if (labelsText || ariaLabel || labelledby) return { status: 'pass' };
|
|
40
|
+
|
|
41
|
+
const id = element.id ? ` (it already has id="${element.id}")` : ' (give it an id first)';
|
|
42
|
+
|
|
43
|
+
// A title-only name is published sufficient technique H65 — conformant,
|
|
44
|
+
// but invisible until hover and ignored by some tools, so it's flagged
|
|
45
|
+
// for eyes rather than failed.
|
|
46
|
+
if (element.getAttribute('title') && !element.getAttribute('placeholder')) {
|
|
47
|
+
return {
|
|
48
|
+
status: 'incomplete',
|
|
49
|
+
message: 'This field is named only by its title attribute (technique H65) — conformant, but the label is invisible until hover and some tools skip it. Check a visible label isn’t needed here.',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// A placeholder-only "label" vanishes as soon as the user types (F82
|
|
53
|
+
// territory) — that one stays a failure.
|
|
54
|
+
if (element.getAttribute('placeholder')) {
|
|
55
|
+
return {
|
|
56
|
+
status: 'fail',
|
|
57
|
+
message: 'This field relies on its placeholder as the only label — the name vanishes as soon as the user types.',
|
|
58
|
+
fix: `Add <label for="…">Field name</label> pointing at this field${id}; keep the placeholder as an example value only.`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (element.labels?.length && !visibleLabels.length) {
|
|
62
|
+
return {
|
|
63
|
+
status: 'fail',
|
|
64
|
+
message: 'This field\'s only <label> is hidden (display:none or aria-hidden) — no one can see or rely on it.',
|
|
65
|
+
fix: 'Make the label visible, or use a visually-hidden-but-rendered technique (clip/sr-only) if it must not show.',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (element.labels?.length) {
|
|
69
|
+
return {
|
|
70
|
+
status: 'fail',
|
|
71
|
+
message: 'This field has a <label>, but the label is empty — it announces nothing.',
|
|
72
|
+
fix: 'Put visible text inside the label.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
status: 'fail',
|
|
77
|
+
message: 'This form field has no label — users cannot tell what to enter.',
|
|
78
|
+
fix: `Add <label for="…">Field name</label> pointing at this field${id}.`,
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// WCAG SC 1.3.1 Info and Relationships (Level A)
|
|
2
|
+
const NEVER_RENDERED = new Set(['SCRIPT', 'TEMPLATE']);
|
|
3
|
+
const ITEM_ROLES = ['listitem', 'presentation', 'none'];
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
id: 'list-structure',
|
|
7
|
+
impact: 'serious',
|
|
8
|
+
tags: ['wcag2a', 'wcag131'],
|
|
9
|
+
help: 'Lists must only contain list items',
|
|
10
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
|
|
11
|
+
selector: 'ul, ol',
|
|
12
|
+
evaluate(element) {
|
|
13
|
+
// A role attribute replaces the HTML list semantics — a <ul role="tablist">
|
|
14
|
+
// is judged by ARIA structure rules, not HTML list rules.
|
|
15
|
+
if (element.hasAttribute('role') && element.getAttribute('role') !== 'list') return { status: 'pass' };
|
|
16
|
+
// Flat-tree children: a <slot> renders as its assigned elements (or its
|
|
17
|
+
// fallback content), so a shadow <ul><slot></slot></ul> receiving <li>s
|
|
18
|
+
// is a perfectly valid list.
|
|
19
|
+
const effectiveChildren = (parent) => [...parent.children].flatMap((child) =>
|
|
20
|
+
child.tagName === 'SLOT'
|
|
21
|
+
? (child.assignedElements?.().length ? child.assignedElements() : [...child.children])
|
|
22
|
+
: [child]);
|
|
23
|
+
// Role-less div/span wrappers are generic nodes the accessibility tree
|
|
24
|
+
// passes list ownership through — a <ul><div><li>…</li></div></ul>
|
|
25
|
+
// (framework grouping wrappers) still announces size and structure
|
|
26
|
+
// correctly, so it's judged by what the wrapper holds, recursively.
|
|
27
|
+
// Only PURE wrappers qualify: direct text of its own means the element
|
|
28
|
+
// is content, not plumbing — that text sits outside any list item and
|
|
29
|
+
// genuinely corrupts the list.
|
|
30
|
+
const isValidChild = (child) => {
|
|
31
|
+
if (NEVER_RENDERED.has(child.tagName)) return true;
|
|
32
|
+
// An explicit role REPLACES an <li>'s listitem semantics: a carousel
|
|
33
|
+
// built as <ul> with <li role="group"> slides no longer owns any
|
|
34
|
+
// listitems, so the list announces broken/empty. Only a role-less
|
|
35
|
+
// <li> (or an explicit listitem/presentation/none anywhere) keeps
|
|
36
|
+
// the structure intact.
|
|
37
|
+
const role = child.getAttribute('role') ?? '';
|
|
38
|
+
if (child.tagName === 'LI') return !role || ITEM_ROLES.includes(role);
|
|
39
|
+
if (ITEM_ROLES.includes(role)) return true;
|
|
40
|
+
const genericWrapper = !child.hasAttribute('role')
|
|
41
|
+
&& (child.tagName === 'DIV' || child.tagName === 'SPAN');
|
|
42
|
+
if (!genericWrapper) return false;
|
|
43
|
+
const hasStrayText = [...child.childNodes].some(
|
|
44
|
+
(node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim());
|
|
45
|
+
return !hasStrayText && effectiveChildren(child).every(isValidChild);
|
|
46
|
+
};
|
|
47
|
+
const invalid = effectiveChildren(element).filter((child) => !isValidChild(child));
|
|
48
|
+
if (!invalid.length) return { status: 'pass' };
|
|
49
|
+
const tags = [...new Set(invalid.map((child) => {
|
|
50
|
+
const role = child.getAttribute('role');
|
|
51
|
+
return `<${child.tagName.toLowerCase()}${role ? ` role="${role}"` : ''}>`;
|
|
52
|
+
}))].join(', ');
|
|
53
|
+
return {
|
|
54
|
+
status: 'fail',
|
|
55
|
+
message: `This list has ${invalid.length} direct child(ren) without listitem semantics (${tags}) — screen readers misreport the list's size and structure.`,
|
|
56
|
+
fix: 'Use plain <li> children (an explicit role like role="group" replaces the listitem role), or move non-item elements outside the list.',
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// WCAG SC 1.3.1 Info and Relationships (Level A)
|
|
2
|
+
export default {
|
|
3
|
+
id: 'listitem-parent',
|
|
4
|
+
impact: 'serious',
|
|
5
|
+
tags: ['wcag2a', 'wcag131'],
|
|
6
|
+
help: '<li> must be inside a list',
|
|
7
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
|
|
8
|
+
selector: 'li:not([role])', // a role attribute replaces the listitem semantics
|
|
9
|
+
evaluate(element) {
|
|
10
|
+
// Judge the FLAT-tree parent — what assistive technology actually sees:
|
|
11
|
+
// a slotted <li> belongs to the <ul> around its slot, and an <li> at the
|
|
12
|
+
// top of a shadow tree belongs to its host element.
|
|
13
|
+
let parent = element.assignedSlot?.parentElement ?? element.parentElement ?? element.getRootNode()?.host;
|
|
14
|
+
while (parent?.tagName === 'SLOT') parent = parent.assignedSlot?.parentElement ?? parent.parentElement;
|
|
15
|
+
// List ownership passes through presentation/none wrappers and
|
|
16
|
+
// role-less div/span grouping wrappers (generic nodes) — an <li> in a
|
|
17
|
+
// framework's wrapping div inside a <ul> is still that list's item.
|
|
18
|
+
while (parent && (
|
|
19
|
+
['presentation', 'none'].includes(parent.getAttribute('role') ?? '')
|
|
20
|
+
|| (!parent.hasAttribute('role') && (parent.tagName === 'DIV' || parent.tagName === 'SPAN'))
|
|
21
|
+
)) {
|
|
22
|
+
parent = parent.assignedSlot?.parentElement ?? parent.parentElement ?? parent.getRootNode()?.host;
|
|
23
|
+
}
|
|
24
|
+
if (parent && (parent.matches('ul, ol, menu') || parent.getAttribute('role') === 'list')) {
|
|
25
|
+
return { status: 'pass' };
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
status: 'fail',
|
|
29
|
+
message: `This <li> sits inside <${parent?.tagName.toLowerCase() ?? 'nothing'}> — outside a list, screen readers lose the item's list context entirely.`,
|
|
30
|
+
fix: 'Wrap it in a <ul> or <ol>, or change it to a <div>/<p> if it isn’t really a list item.',
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// WCAG SC 1.3.1 Info and Relationships (Level A)
|
|
2
|
+
export default {
|
|
3
|
+
id: 'p-as-heading',
|
|
4
|
+
impact: 'serious',
|
|
5
|
+
tags: ['wcag2a', 'wcag131'],
|
|
6
|
+
help: 'Bold paragraphs should not stand in for headings',
|
|
7
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
|
|
8
|
+
selector: 'p',
|
|
9
|
+
evaluate(element) {
|
|
10
|
+
const text = element.textContent.trim();
|
|
11
|
+
const only = element.children.length === 1 && element.children[0];
|
|
12
|
+
const looksLikeHeading =
|
|
13
|
+
text && text.length < 60 &&
|
|
14
|
+
only && only.matches('b, strong') && only.textContent.trim() === text &&
|
|
15
|
+
element.nextElementSibling?.matches('p');
|
|
16
|
+
if (!looksLikeHeading) return { status: 'pass' };
|
|
17
|
+
// A heuristic, not proof — bold lead-ins are a legitimate style, so this
|
|
18
|
+
// asks for review instead of asserting a violation.
|
|
19
|
+
return {
|
|
20
|
+
status: 'incomplete',
|
|
21
|
+
message: `"${text}" is a short bold paragraph followed by text — if it's acting as a heading, it's invisible to heading navigation. Verify.`,
|
|
22
|
+
fix: 'If it is a heading, use a real heading element (<h2>…</h2>) at the appropriate level.',
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// WCAG SC 1.3.1 Info and Relationships (Level A)
|
|
2
|
+
// WCAG 1.3.1: data tables must expose their header/data relationships in
|
|
3
|
+
// markup. Two failure modes, reported per TABLE (one fix point), not per
|
|
4
|
+
// cell:
|
|
5
|
+
// - a clear data table with no header cells at all (no th, scope, headers)
|
|
6
|
+
// - headers="" attributes referencing ids that aren't cells of this table
|
|
7
|
+
// Layout tables are skipped via a conservative heuristic (role=presentation,
|
|
8
|
+
// or too small/text-sparse to be a data grid) — a missed layout table is
|
|
9
|
+
// better than false positives on real pages.
|
|
10
|
+
export default {
|
|
11
|
+
id: 'table-headers',
|
|
12
|
+
impact: 'serious',
|
|
13
|
+
tags: ['wcag2a', 'wcag131'],
|
|
14
|
+
help: 'Data tables must have properly associated header cells',
|
|
15
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
|
|
16
|
+
selector: 'table',
|
|
17
|
+
evaluate(element) {
|
|
18
|
+
const role = element.getAttribute('role');
|
|
19
|
+
if (role && !['table', 'grid'].includes(role)) return { status: 'pass' }; // presentation/none/other widget
|
|
20
|
+
const rows = [...element.rows];
|
|
21
|
+
const cells = rows.flatMap((row) => [...row.cells]);
|
|
22
|
+
// Data-grid heuristic: at least a 2×2 grid with mostly-text cells.
|
|
23
|
+
const isGrid = rows.length >= 2 && Math.max(0, ...rows.map((row) => row.cells.length)) >= 2;
|
|
24
|
+
const textCells = cells.filter((cell) => cell.textContent.trim()).length;
|
|
25
|
+
if (!isGrid || textCells < 4) return { status: 'pass' }; // likely layout or trivial
|
|
26
|
+
// Nested tables are the signature of legacy LAYOUT markup, not data —
|
|
27
|
+
// asserting "missing headers" there flags email-style scaffolding. A
|
|
28
|
+
// human can tell in a glance; the machine can't.
|
|
29
|
+
if (!element.querySelector('th') && element.querySelector('table')) {
|
|
30
|
+
return {
|
|
31
|
+
status: 'incomplete',
|
|
32
|
+
message: 'This table has no header cells but contains nested tables — a layout-table signature. If it really presents data, mark its header cells with <th>; if it is layout scaffolding, add role="presentation".',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const badRefs = cells.filter((cell) => {
|
|
37
|
+
const headers = cell.getAttribute('headers');
|
|
38
|
+
return headers && headers.trim().split(/\s+/).filter(Boolean).some((id) => {
|
|
39
|
+
const target = element.getRootNode().getElementById?.(id);
|
|
40
|
+
return !target || !element.contains(target) || !/^T[HD]$/.test(target.tagName);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
if (badRefs.length) {
|
|
44
|
+
return {
|
|
45
|
+
status: 'fail',
|
|
46
|
+
message: `${badRefs.length} cell(s) have headers="…" referencing ids that are not header cells of this table — screen readers cannot relate the data to its headers.`,
|
|
47
|
+
fix: 'Make each headers attribute list the id(s) of <th> cells in the same table.',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const hasHeaders = element.querySelector('th, [scope], td[headers]');
|
|
52
|
+
if (!hasHeaders) {
|
|
53
|
+
return {
|
|
54
|
+
status: 'fail',
|
|
55
|
+
message: 'This looks like a data table but has no header cells — screen reader users get the data with no way to tell what each row/column means.',
|
|
56
|
+
fix: 'Mark header cells with <th> (add scope="col" or scope="row" when the table has both).',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return { status: 'pass' };
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// WCAG SC 1.3.5 Identify Input Purpose (Level AA)
|
|
2
|
+
// WCAG 1.3.5: autocomplete tokens from the HTML spec's input-purposes list.
|
|
3
|
+
const TOKENS = new Set([
|
|
4
|
+
'on', 'off', 'name', 'honorific-prefix', 'given-name', 'additional-name', 'family-name',
|
|
5
|
+
'honorific-suffix', 'nickname', 'organization-title', 'username', 'new-password',
|
|
6
|
+
'current-password', 'one-time-code', 'organization', 'street-address', 'address-line1',
|
|
7
|
+
'address-line2', 'address-line3', 'address-level1', 'address-level2', 'address-level3',
|
|
8
|
+
'address-level4', 'country', 'country-name', 'postal-code', 'cc-name', 'cc-given-name',
|
|
9
|
+
'cc-additional-name', 'cc-family-name', 'cc-number', 'cc-exp', 'cc-exp-month', 'cc-exp-year',
|
|
10
|
+
'cc-csc', 'cc-type', 'transaction-currency', 'transaction-amount', 'language', 'bday',
|
|
11
|
+
'bday-day', 'bday-month', 'bday-year', 'sex', 'url', 'photo', 'tel', 'tel-country-code',
|
|
12
|
+
'tel-national', 'tel-area-code', 'tel-local', 'tel-local-prefix', 'tel-local-suffix',
|
|
13
|
+
'tel-extension', 'email', 'impp', 'webauthn', 'shipping', 'billing', 'home', 'work',
|
|
14
|
+
'mobile', 'fax', 'pager',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export default {
|
|
18
|
+
id: 'autocomplete-valid',
|
|
19
|
+
impact: 'serious',
|
|
20
|
+
tags: ['wcag21aa', 'wcag135'],
|
|
21
|
+
help: 'autocomplete attributes must use valid tokens',
|
|
22
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/identify-input-purpose.html',
|
|
23
|
+
selector: 'input[autocomplete], select[autocomplete], textarea[autocomplete]',
|
|
24
|
+
evaluate(element) {
|
|
25
|
+
const invalid = element
|
|
26
|
+
.getAttribute('autocomplete')
|
|
27
|
+
.trim()
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.split(/\s+/)
|
|
30
|
+
.filter((token) => token && !TOKENS.has(token) && !token.startsWith('section-'));
|
|
31
|
+
if (!invalid.length) return { status: 'pass' };
|
|
32
|
+
return {
|
|
33
|
+
status: 'fail',
|
|
34
|
+
message: `autocomplete contains unknown token(s): ${invalid.join(', ')} — browsers and assistive tools can't identify this field's purpose.`,
|
|
35
|
+
fix: 'Use tokens from the HTML input-purposes list, e.g. autocomplete="email" or autocomplete="given-name".',
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// WCAG SC 1.4.1 Use of Color (Level A)
|
|
2
|
+
import { parseColor, contrastRatio } from '../../lib/contrast.js';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
id: 'link-in-text-block',
|
|
6
|
+
impact: 'serious',
|
|
7
|
+
tags: ['wcag2a', 'wcag141'],
|
|
8
|
+
help: 'Links inside text must be distinguishable by more than colour',
|
|
9
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html',
|
|
10
|
+
// Only running prose counts as a "text block" — lists of links (navs,
|
|
11
|
+
// blogrolls) are identified by their structure, not by colour.
|
|
12
|
+
selector: 'p a[href], dd a[href], blockquote a[href], td a[href]',
|
|
13
|
+
visibility: 'visual', // colour distinction is a purely visual concern
|
|
14
|
+
evaluate(element, { ownText }) {
|
|
15
|
+
const parent = element.closest('p, dd, blockquote, td');
|
|
16
|
+
if (!element.textContent.trim() || !parent) return { status: 'pass' };
|
|
17
|
+
// Needs real surrounding text to blend into (1.4.1 is about links
|
|
18
|
+
// embedded in prose, not a link that IS the content).
|
|
19
|
+
if (ownText(parent).replace(/\s+/g, '').length < 10) return { status: 'pass' };
|
|
20
|
+
|
|
21
|
+
const style = getComputedStyle(element);
|
|
22
|
+
if ((style.textDecorationLine ?? style.textDecoration ?? '').includes('underline')) return { status: 'pass' };
|
|
23
|
+
// Underline substitutes count as non-colour cues too: border-bottom
|
|
24
|
+
// "underlines", box-shadow underlines, and background chips/pills all
|
|
25
|
+
// mark the link by shape, not colour alone.
|
|
26
|
+
if (parseFloat(style.borderBottomWidth) > 0 && style.borderBottomStyle !== 'none') return { status: 'pass' };
|
|
27
|
+
if (style.boxShadow && style.boxShadow !== 'none') return { status: 'pass' };
|
|
28
|
+
const ownBackground = parseColor(style.backgroundColor);
|
|
29
|
+
if (ownBackground && ownBackground.a > 0) return { status: 'pass' };
|
|
30
|
+
if (style.backgroundImage !== 'none') return { status: 'pass' }; // gradient/image underline technique
|
|
31
|
+
|
|
32
|
+
// A clear weight difference is a non-colour distinction too.
|
|
33
|
+
const parentStyle = getComputedStyle(parent);
|
|
34
|
+
const weight = (s) => parseInt(s.fontWeight, 10) || 400;
|
|
35
|
+
if (Math.abs(weight(style) - weight(parentStyle)) >= 300) return { status: 'pass' };
|
|
36
|
+
|
|
37
|
+
const linkColor = parseColor(style.color);
|
|
38
|
+
const textColor = parseColor(parentStyle.color);
|
|
39
|
+
if (!linkColor || !textColor) return { status: 'pass' };
|
|
40
|
+
|
|
41
|
+
const ratio = contrastRatio(linkColor, textColor);
|
|
42
|
+
// WCAG technique G183: 3:1 against the surrounding text is a sufficient
|
|
43
|
+
// colour difference for identifying a link.
|
|
44
|
+
if (ratio >= 3) return { status: 'pass' };
|
|
45
|
+
|
|
46
|
+
// LAST, because it's the expensive check (tree walk + line geometry)
|
|
47
|
+
// and only failing candidates should ever pay for it: a link occupying
|
|
48
|
+
// its own line (<br>-separated address blocks, one-link-per-line
|
|
49
|
+
// footers) has no surrounding words to blend into — nothing is
|
|
50
|
+
// conveyed by colour.
|
|
51
|
+
const rect = element.getBoundingClientRect();
|
|
52
|
+
if (rect.height) {
|
|
53
|
+
const mid = rect.top + rect.height / 2;
|
|
54
|
+
const walker = element.ownerDocument.createTreeWalker(parent, NodeFilter.SHOW_TEXT);
|
|
55
|
+
const range = element.ownerDocument.createRange();
|
|
56
|
+
let sharesLine = false;
|
|
57
|
+
for (let node = walker.nextNode(); node && !sharesLine; node = walker.nextNode()) {
|
|
58
|
+
if (!node.textContent.trim() || element.contains(node)) continue;
|
|
59
|
+
range.selectNodeContents(node);
|
|
60
|
+
for (const r of range.getClientRects()) {
|
|
61
|
+
if (r.top <= mid && r.bottom >= mid && r.width > 0) { sharesLine = true; break; }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!sharesLine) return { status: 'pass' };
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
status: 'fail',
|
|
68
|
+
message: ratio < 1.01
|
|
69
|
+
? 'This link has no underline and the same colour as the surrounding text — sighted users can’t tell it’s a link.'
|
|
70
|
+
: `This link has no underline and only ${ratio.toFixed(2)}:1 colour difference from the surrounding text — colour-blind users can’t spot it.`,
|
|
71
|
+
fix: 'Underline links inside text (text-decoration: underline), or add a non-colour indicator.',
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// WCAG SC 1.4.10 Reflow (Level AA)
|
|
2
|
+
// The full test renders at 320 CSS px; an audit runs at whatever viewport
|
|
3
|
+
// the page is in. What IS knowable here: content that already forces
|
|
4
|
+
// two-dimensional scrolling at the current width will only be worse at
|
|
5
|
+
// 320px. Abstain-over-assert: this flags, it never fails — tables, maps,
|
|
6
|
+
// and images are exempt content the geometry can't distinguish.
|
|
7
|
+
export default {
|
|
8
|
+
id: 'reflow',
|
|
9
|
+
impact: 'moderate',
|
|
10
|
+
tags: ['wcag21aa', 'wcag1410'],
|
|
11
|
+
help: 'Content should reflow to one-dimensional scrolling',
|
|
12
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/reflow.html',
|
|
13
|
+
selector: 'html',
|
|
14
|
+
visibleOnly: false,
|
|
15
|
+
evaluate(element) {
|
|
16
|
+
const doc = element.ownerDocument;
|
|
17
|
+
const scroller = doc.scrollingElement ?? doc.documentElement;
|
|
18
|
+
const overflow = scroller.scrollWidth - scroller.clientWidth;
|
|
19
|
+
if (overflow <= 1) return { status: 'pass' };
|
|
20
|
+
return {
|
|
21
|
+
status: 'incomplete',
|
|
22
|
+
message: `The page already scrolls horizontally by ${Math.round(overflow)}px at the current viewport — at the 320px reflow breakpoint it will be worse. Verify content reflows at 320 CSS px width (data tables, maps and images are exempt).`,
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
};
|