pour-engine 1.32.0 → 1.34.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/COVERAGE.md +5 -5
- package/engine/index.js +11 -4
- package/engine/lib/accessible-name.js +15 -4
- package/engine/lib/contrast.js +316 -11
- package/engine/lib/roles.js +30 -6
- package/engine/rules/best-practice/button-type.js +23 -2
- package/engine/rules/best-practice/fieldset-legend.js +7 -2
- package/engine/rules/best-practice/heading-order.js +2 -1
- package/engine/rules/best-practice/landmark-top-level.js +10 -0
- package/engine/rules/best-practice/landmark-unique.js +9 -1
- package/engine/rules/best-practice/new-window-link.js +34 -1
- package/engine/rules/best-practice/no-autofocus.js +15 -3
- package/engine/rules/best-practice/page-heading-one.js +15 -1
- package/engine/rules/best-practice/redundant-alt-phrase.js +9 -1
- package/engine/rules/best-practice/redundant-aria-label.js +24 -0
- package/engine/rules/best-practice/redundant-role.js +71 -20
- package/engine/rules/best-practice/region.js +6 -2
- package/engine/rules/index.js +13 -0
- package/engine/rules/wcag/1.1.1-input-image-alt.js +4 -1
- package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
- package/engine/rules/wcag/1.2.1-audio-transcript.js +47 -0
- package/engine/rules/wcag/1.2.2-media-captions.js +16 -1
- package/engine/rules/wcag/1.3.1-aria-required-children.js +30 -3
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +25 -9
- package/engine/rules/wcag/1.3.1-definition-list.js +15 -3
- package/engine/rules/wcag/1.3.1-dlitem-parent.js +14 -1
- package/engine/rules/wcag/1.3.1-form-label.js +39 -1
- package/engine/rules/wcag/1.3.1-listitem-parent.js +23 -7
- package/engine/rules/wcag/1.3.1-table-headers.js +34 -3
- package/engine/rules/wcag/1.3.2-reading-order-divergence.js +69 -0
- package/engine/rules/wcag/1.3.4-orientation.js +21 -7
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +87 -13
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +254 -13
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +27 -3
- package/engine/rules/wcag/1.4.2-audio-control.js +45 -14
- package/engine/rules/wcag/1.4.3-color-contrast.js +337 -47
- package/engine/rules/wcag/1.4.3-control-contrast.js +26 -5
- package/engine/rules/wcag/2.2.1-meta-refresh.js +25 -1
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +26 -3
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +29 -5
- package/engine/rules/wcag/2.4.2-document-title.js +8 -2
- package/engine/rules/wcag/2.4.3-visual-order-divergence.js +6 -4
- package/engine/rules/wcag/2.4.4-link-name.js +11 -0
- package/engine/rules/wcag/2.4.4-link-text-generic.js +22 -1
- package/engine/rules/wcag/2.4.7-focus-visible.js +47 -10
- package/engine/rules/wcag/2.4.9-link-text-generic-only.js +36 -5
- package/engine/rules/wcag/2.5.3-label-in-name.js +14 -5
- package/engine/rules/wcag/2.5.5-target-size-enhanced.js +3 -1
- package/engine/rules/wcag/2.5.7-drag-alternative.js +38 -0
- package/engine/rules/wcag/2.5.8-target-size.js +141 -7
- package/engine/rules/wcag/3.1.1-html-lang.js +12 -1
- package/engine/rules/wcag/3.2.2-on-input-navigation.js +18 -8
- package/engine/rules/wcag/3.3.7-redundant-entry.js +55 -0
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +50 -20
- package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +1 -1
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +49 -13
- package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +6 -1
- package/engine/rules/wcag/4.1.2-aria-label-misuse.js +1 -1
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +44 -73
- package/engine/rules/wcag/4.1.2-button-name.js +11 -0
- package/engine/rules/wcag/4.1.2-composite-widget-name.js +12 -6
- package/engine/rules/wcag/4.1.2-label-for-valid.js +41 -8
- package/engine/rules/wcag/4.1.2-nested-interactive.js +18 -1
- package/engine/rules/wcag/4.1.2-valid-role.js +16 -0
- package/engine/wcag22.js +4 -4
- package/package.json +1 -1
|
@@ -2,14 +2,25 @@
|
|
|
2
2
|
// Child roles that only make sense inside a specific container role.
|
|
3
3
|
// Explicit roles only — native elements (li outside ul) are covered by
|
|
4
4
|
// listitem-parent/dlitem-parent.
|
|
5
|
+
//
|
|
6
|
+
// ARIA 1.2 Required Context Role, verbatim: group is a real context for
|
|
7
|
+
// option, the menuitem family and treeitem (listbox > group > option,
|
|
8
|
+
// menu > group > menuitem, tree > treeitem > group > treeitem is the APG
|
|
9
|
+
// Tree View pattern itself). Treating group as transparent for those roles
|
|
10
|
+
// walked past the group onto the parent treeitem, which is neither a
|
|
11
|
+
// container nor transparent, and asserted every multi-level tree built to
|
|
12
|
+
// the pattern (2026-08-25 overnight audit; Chromium exposes tree > treeitem
|
|
13
|
+
// > group > treeitem exactly as authored). For the roles whose context does
|
|
14
|
+
// not list group (listitem, tab, row, cell), group stays transparent as
|
|
15
|
+
// before.
|
|
5
16
|
const REQUIRED_PARENT = {
|
|
6
17
|
listitem: ['list'],
|
|
7
|
-
option: ['listbox'],
|
|
8
|
-
menuitem: ['menu', 'menubar'],
|
|
9
|
-
menuitemcheckbox: ['menu', 'menubar'],
|
|
10
|
-
menuitemradio: ['menu', 'menubar'],
|
|
18
|
+
option: ['listbox', 'group'],
|
|
19
|
+
menuitem: ['menu', 'menubar', 'group'],
|
|
20
|
+
menuitemcheckbox: ['menu', 'menubar', 'group'],
|
|
21
|
+
menuitemradio: ['menu', 'menubar', 'group'],
|
|
11
22
|
tab: ['tablist'],
|
|
12
|
-
treeitem: ['tree'],
|
|
23
|
+
treeitem: ['tree', 'group'],
|
|
13
24
|
row: ['table', 'grid', 'treegrid', 'rowgroup'],
|
|
14
25
|
rowgroup: ['table', 'grid', 'treegrid'],
|
|
15
26
|
cell: ['row'],
|
|
@@ -29,6 +40,9 @@ export default {
|
|
|
29
40
|
evaluate(element) {
|
|
30
41
|
const role = element.getAttribute('role').trim().split(/\s+/)[0].toLowerCase();
|
|
31
42
|
const containers = REQUIRED_PARENT[role];
|
|
43
|
+
// The container named in the message and fix: the structure's root, not
|
|
44
|
+
// the group that may sit between it and the item.
|
|
45
|
+
const named = containers.filter((container) => container !== 'group');
|
|
32
46
|
// aria-owns makes the relationship without containment: the owner can sit
|
|
33
47
|
// anywhere in the document and the browser still builds the structure.
|
|
34
48
|
// Judging by DOM ancestry alone failed every remotely-owned child.
|
|
@@ -55,18 +69,20 @@ export default {
|
|
|
55
69
|
const parentRole =
|
|
56
70
|
parent.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase() ??
|
|
57
71
|
IMPLICIT_CONTAINER[parent.tagName.toLowerCase()];
|
|
58
|
-
|
|
72
|
+
// A permitted context (the structure's root, or a group for the roles
|
|
73
|
+
// whose ARIA context lists it) satisfies the requirement outright.
|
|
74
|
+
if (containers.includes(parentRole)) return { status: 'pass' };
|
|
75
|
+
if (parentRole === 'group') continue; // group is transparent in the remaining structures
|
|
59
76
|
// presentation/none removes the wrapper from the tree but leaves its
|
|
60
77
|
// children owned by the next real ancestor — li[role=presentation]
|
|
61
78
|
// between a tablist and its tabs is the canonical pattern.
|
|
62
79
|
if (parentRole === 'presentation' || parentRole === 'none') continue;
|
|
63
|
-
if (containers.includes(parentRole)) return { status: 'pass' };
|
|
64
80
|
if (parentRole) break; // a different role interrupts the required structure
|
|
65
81
|
}
|
|
66
82
|
return {
|
|
67
83
|
status: 'fail',
|
|
68
|
-
message: `role="${role}" is not inside a ${
|
|
69
|
-
fix: `Wrap it in an element with role="${
|
|
84
|
+
message: `role="${role}" is not inside a ${named.join('/')} — assistive technology loses the structure entirely.`,
|
|
85
|
+
fix: `Wrap it in an element with role="${named[0]}", or fix the intervening roles.`,
|
|
70
86
|
};
|
|
71
87
|
},
|
|
72
88
|
};
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
// WCAG SC 1.3.1 Info and Relationships (Level A)
|
|
2
|
-
const ALLOWED = new Set(['DT', 'DD', 'DIV'
|
|
2
|
+
const ALLOWED = new Set(['DT', 'DD', 'DIV']);
|
|
3
|
+
// Nothing here reaches the accessibility tree, so none of it can corrupt
|
|
4
|
+
// the term/description pairing: the same set list-structure keeps, for the
|
|
5
|
+
// same reason (CSS-in-JS injects <style> right beside the markup). HTML's
|
|
6
|
+
// content model does exclude style from dl, but validity is not the
|
|
7
|
+
// criterion; 1.3.1 is about relationships assistive technology can
|
|
8
|
+
// determine, and every dt/dd pair stays intact around an element that has
|
|
9
|
+
// no tree presence (2026-08-25 overnight audit).
|
|
10
|
+
const NEVER_RENDERED = new Set(['SCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
|
|
3
11
|
|
|
4
12
|
export default {
|
|
5
13
|
id: 'definition-list',
|
|
@@ -9,8 +17,12 @@ export default {
|
|
|
9
17
|
help: '<dl> must be structured as term/description pairs',
|
|
10
18
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
|
|
11
19
|
selector: 'dl:not([role])', // a role attribute replaces the dl semantics
|
|
12
|
-
evaluate(element) {
|
|
13
|
-
|
|
20
|
+
evaluate(element, { isRendered }) {
|
|
21
|
+
// A child the browser never renders (display:none, hidden) is not in
|
|
22
|
+
// the accessibility tree either, so it cannot break the pairing a
|
|
23
|
+
// reader hears; list-structure applies the same gate.
|
|
24
|
+
const invalid = [...element.children].filter((child) =>
|
|
25
|
+
!ALLOWED.has(child.tagName) && !NEVER_RENDERED.has(child.tagName) && (!isRendered || isRendered(child)));
|
|
14
26
|
if (invalid.length) {
|
|
15
27
|
const tags = [...new Set(invalid.map((child) => `<${child.tagName.toLowerCase()}>`))].join(', ');
|
|
16
28
|
return {
|
|
@@ -8,7 +8,20 @@ export default {
|
|
|
8
8
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
|
|
9
9
|
selector: 'dt:not([role]), dd:not([role])', // role overrides the dt/dd semantics
|
|
10
10
|
evaluate(element) {
|
|
11
|
-
|
|
11
|
+
// Judge the FLAT-tree ancestry, what assistive technology actually
|
|
12
|
+
// sees: a <dt>/<dd> slotted into a host whose shadow root holds
|
|
13
|
+
// <dl><slot></slot></dl> belongs to that <dl> (Chromium exposes
|
|
14
|
+
// DescriptionList > term > definition for it), and an item at the top
|
|
15
|
+
// of a shadow tree continues through its host. A light-DOM closest('dl')
|
|
16
|
+
// asserted every slotted pair as orphaned (2026-08-25 overnight audit;
|
|
17
|
+
// same walk as listitem-parent).
|
|
18
|
+
const flatParent = (node) => node.assignedSlot?.parentElement
|
|
19
|
+
?? node.parentElement
|
|
20
|
+
?? node.getRootNode()?.host
|
|
21
|
+
?? null;
|
|
22
|
+
for (let parent = flatParent(element); parent; parent = flatParent(parent)) {
|
|
23
|
+
if (parent.tagName === 'DL') return { status: 'pass' };
|
|
24
|
+
}
|
|
12
25
|
return {
|
|
13
26
|
status: 'fail',
|
|
14
27
|
message: `<${element.tagName.toLowerCase()}> outside a <dl> has no term/description semantics.`,
|
|
@@ -28,10 +28,21 @@ export default {
|
|
|
28
28
|
// aria-hidden styled twin names NOTHING — the browser's tree computes an
|
|
29
29
|
// empty name), while aria-label on the label itself or on things inside
|
|
30
30
|
// it (icon-only labels) and image alts DO feed the name per accname.
|
|
31
|
+
//
|
|
32
|
+
// An UNRENDERED descendant is excluded the same way: accname step 2A
|
|
33
|
+
// returns the empty string for a hidden node that is not the root of
|
|
34
|
+
// the label traversal, and hidden covers display:none and
|
|
35
|
+
// visibility:hidden (the hidden attribute is display:none). A label
|
|
36
|
+
// whose only text sits in a display:none span names nothing (Chromium
|
|
37
|
+
// computes ""), the mirror of the aria-hidden twin above (2026-08-25
|
|
38
|
+
// overnight audit). Computed style rather than checkVisibility: an SVG
|
|
39
|
+
// <title> has no box yet still names its graphic.
|
|
31
40
|
const accessibleText = (node) => {
|
|
32
41
|
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
|
|
33
42
|
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
|
34
43
|
if (node.getAttribute('aria-hidden') === 'true') return '';
|
|
44
|
+
const nodeStyle = getComputedStyle(node);
|
|
45
|
+
if (nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden') return '';
|
|
35
46
|
const aria = node.getAttribute('aria-label');
|
|
36
47
|
if (aria?.trim()) return aria;
|
|
37
48
|
const alt = (node.tagName === 'IMG' || node.tagName === 'AREA') ? node.getAttribute('alt') : null;
|
|
@@ -66,9 +77,25 @@ export default {
|
|
|
66
77
|
};
|
|
67
78
|
}
|
|
68
79
|
if (element.labels?.length && !visibleLabels.length) {
|
|
80
|
+
// aria-hidden has no visual effect: a label hidden ONLY that way is
|
|
81
|
+
// still painted for sighted users and lost only to assistive
|
|
82
|
+
// technology (Chromium computes an empty name). Telling the author
|
|
83
|
+
// to "make the label visible" sent them to fix the wrong thing; the
|
|
84
|
+
// fix is to remove aria-hidden (2026-08-25 overnight audit).
|
|
85
|
+
const onlyAriaHidden = [...element.labels].some((label) => {
|
|
86
|
+
const style = getComputedStyle(label);
|
|
87
|
+
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
88
|
+
});
|
|
89
|
+
if (onlyAriaHidden) {
|
|
90
|
+
return {
|
|
91
|
+
status: 'fail',
|
|
92
|
+
message: 'This field\'s only <label> is inside aria-hidden="true": sighted users still see it, but it is removed from the accessibility tree, so the field has no accessible name.',
|
|
93
|
+
fix: 'Remove aria-hidden from the label (or from the ancestor that hides it). If the label must stay hidden from assistive technology, name the field another way with aria-label or aria-labelledby.',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
69
96
|
return {
|
|
70
97
|
status: 'fail',
|
|
71
|
-
message: 'This field\'s only <label> is hidden (display:none or
|
|
98
|
+
message: 'This field\'s only <label> is hidden (display:none or visibility:hidden), so no one can see or rely on it.',
|
|
72
99
|
fix: 'Make the label visible, or use a visually-hidden-but-rendered technique (clip/sr-only) if it must not show.',
|
|
73
100
|
};
|
|
74
101
|
}
|
|
@@ -77,6 +104,17 @@ export default {
|
|
|
77
104
|
// sitting inside an aria-hidden subtree (the styled-twin custom
|
|
78
105
|
// control pattern) is excluded from the name computation.
|
|
79
106
|
const looksLabelled = visibleLabels.some((l) => l.textContent.trim());
|
|
107
|
+
const ariaHiddenText = visibleLabels.some((l) =>
|
|
108
|
+
[...l.querySelectorAll('[aria-hidden="true"]')].some((twin) => twin.textContent.trim()));
|
|
109
|
+
if (looksLabelled && !ariaHiddenText) {
|
|
110
|
+
// The text is there in the markup but nothing renders it (defect
|
|
111
|
+
// 8's shape): the sighted user sees an empty label too.
|
|
112
|
+
return {
|
|
113
|
+
status: 'fail',
|
|
114
|
+
message: 'This field\'s <label> holds text, but all of it is inside unrendered content (display:none or visibility:hidden), so the accessible name computes to nothing and nobody sees a label.',
|
|
115
|
+
fix: 'Render the text inside the label, move it out of the hidden element, or add aria-label to the field.',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
80
118
|
return {
|
|
81
119
|
status: 'fail',
|
|
82
120
|
message: looksLabelled
|
|
@@ -11,7 +11,8 @@ export default {
|
|
|
11
11
|
// Judge the FLAT-tree parent — what assistive technology actually sees:
|
|
12
12
|
// a slotted <li> belongs to the <ul> around its slot, and an <li> at the
|
|
13
13
|
// top of a shadow tree belongs to its host element.
|
|
14
|
-
|
|
14
|
+
const flatParent = (el) => el.assignedSlot?.parentElement ?? el.parentElement ?? el.getRootNode()?.host;
|
|
15
|
+
let parent = flatParent(element);
|
|
15
16
|
while (parent?.tagName === 'SLOT') parent = parent.assignedSlot?.parentElement ?? parent.parentElement;
|
|
16
17
|
// List ownership passes through presentation/none wrappers and
|
|
17
18
|
// role-less div/span grouping wrappers (generic nodes) — an <li> in a
|
|
@@ -32,16 +33,31 @@ export default {
|
|
|
32
33
|
if (el.matches('ul, ol, menu')) return !role || ['list', 'presentation', 'none'].includes(role);
|
|
33
34
|
return role === 'list';
|
|
34
35
|
};
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
)) {
|
|
39
|
-
parent = parent.assignedSlot?.parentElement ?? parent.parentElement ?? parent.getRootNode()?.host;
|
|
40
|
-
}
|
|
36
|
+
const transparent = (el) => ['presentation', 'none'].includes(el.getAttribute('role') ?? '')
|
|
37
|
+
|| (!el.hasAttribute('role') && (el.tagName === 'DIV' || el.tagName === 'SPAN'));
|
|
38
|
+
while (parent && !listContainer(parent) && transparent(parent)) parent = flatParent(parent);
|
|
41
39
|
if (parent && listContainer(parent)) return { status: 'pass' };
|
|
42
40
|
const parentLabel = parent
|
|
43
41
|
? `<${parent.tagName.toLowerCase()}${parent.getAttribute('role') ? ` role="${parent.getAttribute('role')}"` : ''}>`
|
|
44
42
|
: 'nothing';
|
|
43
|
+
// A role="group" wrapper BETWEEN a list and its items: the list is
|
|
44
|
+
// there (Chromium exposes list > group > listitem), but ARIA 1.2 gives
|
|
45
|
+
// list one required owned element, listitem, so the group is not
|
|
46
|
+
// permitted in between and the items are no longer the list's own. The
|
|
47
|
+
// verdict stands; the old message blamed a missing list and the fix
|
|
48
|
+
// sent the author to add one that already existed (2026-08-25
|
|
49
|
+
// overnight audit).
|
|
50
|
+
if (parent?.getAttribute('role') === 'group') {
|
|
51
|
+
let above = flatParent(parent);
|
|
52
|
+
while (above && !listContainer(above) && (transparent(above) || above.getAttribute('role') === 'group')) above = flatParent(above);
|
|
53
|
+
if (above && listContainer(above)) {
|
|
54
|
+
return {
|
|
55
|
+
status: 'fail',
|
|
56
|
+
message: `This <li> sits inside ${parentLabel} within its list. A list may only own list items directly, and role="group" is not permitted between a list and its items, so screen readers lose the item's list context.`,
|
|
57
|
+
fix: 'Remove role="group" from the wrapper (a plain <div> keeps the items owned by the list), or move the group label onto the list element with aria-label.',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
45
61
|
return {
|
|
46
62
|
status: 'fail',
|
|
47
63
|
message: `This <li> sits inside ${parentLabel} — ${parent?.matches('ul, ol, menu') && parent.getAttribute('role')
|
|
@@ -7,6 +7,17 @@
|
|
|
7
7
|
// Layout tables are skipped via a conservative heuristic (role=presentation,
|
|
8
8
|
// or too small/text-sparse to be a data grid) — a missed layout table is
|
|
9
9
|
// better than false positives on real pages.
|
|
10
|
+
//
|
|
11
|
+
// Block-level content inside cells (headings, paragraphs, lists, forms,
|
|
12
|
+
// navigation) is the other layout signature: tabular data is inline text,
|
|
13
|
+
// and a page scaffold built as a table (site name / nav / sidebar / article
|
|
14
|
+
// / footer) meets the 2 x 2 x 4-text-cells heuristic while conveying no
|
|
15
|
+
// header relationship at all. H51 and F91 are scoped to "tabular
|
|
16
|
+
// information"; the DOM cannot prove such a table is data, so it asks
|
|
17
|
+
// rather than asserts (2026-08-25 overnight audit).
|
|
18
|
+
const BLOCK_CONTENT = 'h1, h2, h3, h4, h5, h6, p, ul, ol, dl, menu, form, nav, main, header, footer, '
|
|
19
|
+
+ 'section, article, aside, blockquote, figure, fieldset, pre, hr, table';
|
|
20
|
+
|
|
10
21
|
export default {
|
|
11
22
|
id: 'table-headers',
|
|
12
23
|
name: 'Table header associations',
|
|
@@ -18,18 +29,32 @@ export default {
|
|
|
18
29
|
evaluate(element) {
|
|
19
30
|
const role = element.getAttribute('role');
|
|
20
31
|
if (role && !['table', 'grid'].includes(role)) return { status: 'pass' }; // presentation/none/other widget
|
|
32
|
+
// The table's OWN rows and cells (table.rows / row.cells stop at the
|
|
33
|
+
// table's boundary): a header cell inside a nested table belongs to that
|
|
34
|
+
// table, so it neither satisfies the outer table nor lifts it out of the
|
|
35
|
+
// nested-layout demotion (2026-08-25 overnight audit; a deep
|
|
36
|
+
// querySelector('th') did both).
|
|
21
37
|
const rows = [...element.rows];
|
|
22
38
|
const cells = rows.flatMap((row) => [...row.cells]);
|
|
23
39
|
// Data-grid heuristic: at least a 2×2 grid with mostly-text cells.
|
|
24
40
|
const isGrid = rows.length >= 2 && Math.max(0, ...rows.map((row) => row.cells.length)) >= 2;
|
|
25
41
|
const textCells = cells.filter((cell) => cell.textContent.trim()).length;
|
|
26
42
|
if (!isGrid || textCells < 4) return { status: 'pass' }; // likely layout or trivial
|
|
43
|
+
// A header cell is a <th>, or a <td> given the columnheader/rowheader
|
|
44
|
+
// role: ARIA in HTML permits any role on a td of a role-less table, and
|
|
45
|
+
// the header roles are the header semantics ARIA defines (Chromium
|
|
46
|
+
// exposes td[role=columnheader] as columnheader). 1.3.1 asks that the
|
|
47
|
+
// relationship be programmatically determinable, which the role
|
|
48
|
+
// satisfies (2026-08-25 overnight audit).
|
|
49
|
+
const cellRole = (cell) => cell.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase();
|
|
50
|
+
const isHeaderCell = (cell) => cell.tagName === 'TH' || ['columnheader', 'rowheader'].includes(cellRole(cell));
|
|
51
|
+
const hasHeaderCell = cells.some(isHeaderCell);
|
|
27
52
|
// Nested tables are the signature of legacy LAYOUT markup, not data —
|
|
28
53
|
// asserting "missing headers" there flags email-style scaffolding. A
|
|
29
54
|
// human can tell in a glance; the machine can't. The signature reads in
|
|
30
55
|
// both directions: a table INSIDE another table (Hacker News's story
|
|
31
56
|
// list inside its page-scaffold table) is the same legacy layout.
|
|
32
|
-
if (!
|
|
57
|
+
if (!hasHeaderCell && (element.querySelector('table') || element.parentElement?.closest('table'))) {
|
|
33
58
|
return {
|
|
34
59
|
status: 'incomplete',
|
|
35
60
|
message: 'This table has no header cells and is part of a nested-table structure — a layout-table signature. If it really presents data, mark its header cells with <th>; if it is layout scaffolding, add role="presentation".',
|
|
@@ -40,7 +65,7 @@ export default {
|
|
|
40
65
|
const headers = cell.getAttribute('headers');
|
|
41
66
|
return headers && headers.trim().split(/\s+/).filter(Boolean).some((id) => {
|
|
42
67
|
const target = element.getRootNode().getElementById?.(id);
|
|
43
|
-
return !target || !
|
|
68
|
+
return !target || !cells.includes(target) || !(/^T[HD]$/.test(target.tagName) || isHeaderCell(target));
|
|
44
69
|
});
|
|
45
70
|
});
|
|
46
71
|
if (badRefs.length) {
|
|
@@ -51,8 +76,14 @@ export default {
|
|
|
51
76
|
};
|
|
52
77
|
}
|
|
53
78
|
|
|
54
|
-
const hasHeaders =
|
|
79
|
+
const hasHeaders = hasHeaderCell || cells.some((cell) => cell.hasAttribute('scope') || cell.hasAttribute('headers'));
|
|
55
80
|
if (!hasHeaders) {
|
|
81
|
+
if (cells.some((cell) => cell.querySelector(BLOCK_CONTENT))) {
|
|
82
|
+
return {
|
|
83
|
+
status: 'incomplete',
|
|
84
|
+
message: 'This table has no header cells and its cells hold block content (headings, paragraphs, lists or forms), which is a layout-table signature. If it really presents data, mark its header cells with <th>; if it is layout scaffolding, add role="presentation".',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
56
87
|
return {
|
|
57
88
|
status: 'fail',
|
|
58
89
|
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.',
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// WCAG SC 1.3.2 Meaningful Sequence (Level A) — the CSS-reordering slice
|
|
2
|
+
//
|
|
3
|
+
// Screen readers read the DOM in source order. When CSS `order` or a
|
|
4
|
+
// reversed flex direction arranges sibling content differently on screen,
|
|
5
|
+
// sighted users and screen reader users receive the page in two different
|
|
6
|
+
// sequences — C27's substance ("make the DOM order match the visual
|
|
7
|
+
// order"), with the divergence measurable. Whether that divergence
|
|
8
|
+
// CHANGES MEANING is the criterion's own test and a human call, so this
|
|
9
|
+
// reviews, never asserts.
|
|
10
|
+
//
|
|
11
|
+
// Same geometry and the same deliberate narrowness as 2.4.3's
|
|
12
|
+
// visual-order-divergence (same-container siblings only; `order` or
|
|
13
|
+
// `*-reverse` as the only accepted evidence; wrapped-one-level-deep and
|
|
14
|
+
// grid-native placement are disclosed gaps there and equally here), with
|
|
15
|
+
// two scope cuts of its own:
|
|
16
|
+
// - at least two of the reordered siblings must carry rendered text:
|
|
17
|
+
// a reversed row of purely decorative icons has no reading sequence
|
|
18
|
+
// to preserve;
|
|
19
|
+
// - groups where EVERY reordered sibling is tabbable are left to 2.4.3,
|
|
20
|
+
// which already asks about that container from the focus side —
|
|
21
|
+
// asking the same question twice about the same box helps nobody.
|
|
22
|
+
import { FLEXGRID, tabbable, visualSequence } from './2.4.3-visual-order-divergence.js';
|
|
23
|
+
|
|
24
|
+
export default {
|
|
25
|
+
id: 'reading-order-divergence',
|
|
26
|
+
name: 'Reading order vs visual order',
|
|
27
|
+
impact: 'moderate',
|
|
28
|
+
tags: ['wcag2a', 'wcag132'],
|
|
29
|
+
help: 'CSS reordering should not make reading order diverge from the visual order',
|
|
30
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/meaningful-sequence.html',
|
|
31
|
+
selector: '*',
|
|
32
|
+
evaluateAll(elements) {
|
|
33
|
+
const outcomes = new Map();
|
|
34
|
+
const groups = new Map();
|
|
35
|
+
for (const element of elements) {
|
|
36
|
+
const parent = element.parentElement;
|
|
37
|
+
if (!parent) continue;
|
|
38
|
+
if (!groups.has(parent)) groups.set(parent, []);
|
|
39
|
+
groups.get(parent).push(element);
|
|
40
|
+
}
|
|
41
|
+
for (const [parent, children] of groups) {
|
|
42
|
+
if (children.length < 2) continue;
|
|
43
|
+
const parentStyle = getComputedStyle(parent);
|
|
44
|
+
if (!FLEXGRID.test(parentStyle.display)) continue;
|
|
45
|
+
const reversed = /-reverse$/.test(parentStyle.flexDirection);
|
|
46
|
+
const entries = children
|
|
47
|
+
.map((element) => ({ element, style: getComputedStyle(element) }))
|
|
48
|
+
.filter(({ style }) => style.position !== 'absolute' && style.position !== 'fixed')
|
|
49
|
+
.map((entry) => ({ ...entry, rect: entry.element.getBoundingClientRect() }))
|
|
50
|
+
.filter(({ rect }) => rect.width > 0 && rect.height > 0);
|
|
51
|
+
if (entries.length < 2) continue;
|
|
52
|
+
const usesOrder = entries.some(({ style }) => style.order !== '0');
|
|
53
|
+
if (!reversed && !usesOrder) continue;
|
|
54
|
+
const withText = entries.filter(({ element }) => (element.textContent || '').trim().length > 0);
|
|
55
|
+
if (withText.length < 2) continue;
|
|
56
|
+
if (entries.every(({ element }) => element.matches('a[href], button, input, select, textarea, summary, [tabindex]') && tabbable(element))) continue;
|
|
57
|
+
const visual = visualSequence(entries, parentStyle.direction === 'rtl');
|
|
58
|
+
const diverges = visual.some((entry, i) => entry !== entries[i]);
|
|
59
|
+
if (!diverges) continue;
|
|
60
|
+
const cause = reversed ? `flex-direction: ${parentStyle.flexDirection}` : 'CSS order';
|
|
61
|
+
outcomes.set(entries[0].element, {
|
|
62
|
+
status: 'incomplete',
|
|
63
|
+
message: `A screen reader reads these ${entries.length} blocks in DOM order, but ${cause} arranges them differently on screen — sighted and screen reader users get two different sequences. Check the source order still tells the same story as the layout.`,
|
|
64
|
+
fix: 'Reorder the source to match the visual order instead of reordering with CSS, or confirm the sequence difference does not change meaning here.',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return elements.map((element) => outcomes.get(element) ?? { status: 'pass' });
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -16,22 +16,36 @@
|
|
|
16
16
|
// Script-driven locks (screen.orientation.lock()) and cross-origin
|
|
17
17
|
// stylesheets (unreadable under CORS) are invisible to a static pass; the
|
|
18
18
|
// rule asserts only what it can see and stays silent otherwise.
|
|
19
|
-
|
|
19
|
+
// The root ELEMENT only, optionally qualified by pseudo-classes. A pseudo-
|
|
20
|
+
// element (body::before, html::backdrop) is one generated box, and hiding
|
|
21
|
+
// it in portrait is the region restyling this rule deliberately leaves
|
|
22
|
+
// alone; matching it asserted "hides the page" over a decorative band
|
|
23
|
+
// (2026-08-25 overnight audit). Pseudo-classes are kept, but only assert
|
|
24
|
+
// when the live root actually matches them (see rootSelector).
|
|
25
|
+
const ROOT_PART = /^(html|body|:root)((?::[a-z-]+(?:\([^()]*\))?)*)$/i;
|
|
20
26
|
const LOCK_ROTATE = /rotate(?:3d\([^)]*,\s*)?\(?\s*(?:-?(?:90|270)deg|0\.25turn|-0\.25turn|100grad|-100grad)/i;
|
|
21
27
|
|
|
22
|
-
function rootSelector(selectorText) {
|
|
23
|
-
return (selectorText ?? '').split(',').some((part) =>
|
|
28
|
+
function rootSelector(selectorText, doc) {
|
|
29
|
+
return (selectorText ?? '').split(',').some((part) => {
|
|
30
|
+
const match = ROOT_PART.exec(part.trim());
|
|
31
|
+
if (!match) return false;
|
|
32
|
+
if (!match[2]) return true; // bare root: always in force
|
|
33
|
+
// body:not(.ready) { visibility: hidden } locks nothing once the class
|
|
34
|
+
// is on; the selector must match the root as it stands to be a lock.
|
|
35
|
+
const root = /^body$/i.test(match[1]) ? doc.body : doc.documentElement;
|
|
36
|
+
try { return !!root && root.matches(part.trim()); } catch { return false; }
|
|
37
|
+
});
|
|
24
38
|
}
|
|
25
39
|
|
|
26
|
-
function scanRules(rules, insideOrientation, findings) {
|
|
40
|
+
function scanRules(rules, insideOrientation, findings, doc) {
|
|
27
41
|
for (const rule of rules ?? []) {
|
|
28
42
|
const condition = rule.conditionText ?? rule.media?.mediaText;
|
|
29
43
|
if (condition !== undefined && rule.cssRules) {
|
|
30
44
|
const orientation = /orientation\s*:\s*(portrait|landscape)/i.exec(condition)?.[1];
|
|
31
|
-
scanRules(rule.cssRules, orientation ? { condition, orientation } : insideOrientation, findings);
|
|
45
|
+
scanRules(rule.cssRules, orientation ? { condition, orientation } : insideOrientation, findings, doc);
|
|
32
46
|
continue;
|
|
33
47
|
}
|
|
34
|
-
if (insideOrientation && rule.style && rootSelector(rule.selectorText)) {
|
|
48
|
+
if (insideOrientation && rule.style && rootSelector(rule.selectorText, doc)) {
|
|
35
49
|
const { display, visibility, transform, rotate } = rule.style;
|
|
36
50
|
if (display === 'none' || visibility === 'hidden') {
|
|
37
51
|
findings.push({ kind: 'hidden', selector: rule.selectorText, ...insideOrientation });
|
|
@@ -61,7 +75,7 @@ export default {
|
|
|
61
75
|
for (const sheet of element.ownerDocument.styleSheets) {
|
|
62
76
|
let rules;
|
|
63
77
|
try { rules = sheet.cssRules; } catch { continue; } // cross-origin: unreadable, skip
|
|
64
|
-
scanRules(rules, null, findings);
|
|
78
|
+
scanRules(rules, null, findings, element.ownerDocument);
|
|
65
79
|
}
|
|
66
80
|
if (!findings.length) return { status: 'pass' };
|
|
67
81
|
const f = findings[0];
|
|
@@ -2,19 +2,77 @@
|
|
|
2
2
|
// WCAG 1.3.5: autocomplete tokens from the HTML spec's input-purposes list.
|
|
3
3
|
// Membership set, so order is free: kept a-z rather than mirroring the spec
|
|
4
4
|
// table's own sequence.
|
|
5
|
-
|
|
5
|
+
//
|
|
6
|
+
// The field names: the 53 purposes WCAG 2.2 section 7 lists, plus
|
|
7
|
+
// one-time-code (HTML's list is a superset of WCAG's).
|
|
8
|
+
const FIELD_NAMES = new Set([
|
|
6
9
|
'additional-name', 'address-level1', 'address-level2', 'address-level3', 'address-level4',
|
|
7
10
|
'address-line1', 'address-line2', 'address-line3', 'bday', 'bday-day', 'bday-month',
|
|
8
|
-
'bday-year', '
|
|
11
|
+
'bday-year', 'cc-additional-name', 'cc-csc', 'cc-exp', 'cc-exp-month',
|
|
9
12
|
'cc-exp-year', 'cc-family-name', 'cc-given-name', 'cc-name', 'cc-number', 'cc-type',
|
|
10
|
-
'country', 'country-name', 'current-password', 'email', 'family-name', '
|
|
11
|
-
'
|
|
12
|
-
'new-password', 'nickname', '
|
|
13
|
-
'organization-title', '
|
|
13
|
+
'country', 'country-name', 'current-password', 'email', 'family-name', 'given-name',
|
|
14
|
+
'honorific-prefix', 'honorific-suffix', 'impp', 'language', 'name',
|
|
15
|
+
'new-password', 'nickname', 'one-time-code', 'organization',
|
|
16
|
+
'organization-title', 'photo', 'postal-code', 'sex', 'street-address',
|
|
14
17
|
'tel', 'tel-area-code', 'tel-country-code', 'tel-extension', 'tel-local', 'tel-local-prefix',
|
|
15
18
|
'tel-local-suffix', 'tel-national', 'transaction-amount', 'transaction-currency', 'url',
|
|
16
|
-
'username',
|
|
19
|
+
'username',
|
|
17
20
|
]);
|
|
21
|
+
// HTML's contact types, and the field names they may qualify.
|
|
22
|
+
const CONTACT_TYPES = new Set(['home', 'work', 'mobile', 'fax', 'pager']);
|
|
23
|
+
const CONTACT_FIELDS = new Set([
|
|
24
|
+
'tel', 'tel-country-code', 'tel-national', 'tel-area-code', 'tel-local',
|
|
25
|
+
'tel-local-prefix', 'tel-local-suffix', 'tel-extension', 'email', 'impp',
|
|
26
|
+
]);
|
|
27
|
+
const MODIFIERS = new Set(['shipping', 'billing', 'webauthn', 'on', 'off']);
|
|
28
|
+
const isSection = (token) => token.startsWith('section-');
|
|
29
|
+
const isKnown = (token) => FIELD_NAMES.has(token) || CONTACT_TYPES.has(token) || MODIFIERS.has(token) || isSection(token);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* HTML's autofill grammar, parsed positionally: the value is either "on" or
|
|
33
|
+
* "off" alone, or autofill detail tokens in this order: optionally one
|
|
34
|
+
* section-* token, then optionally shipping or billing, then EITHER one
|
|
35
|
+
* field name OR a contact type (home, work, mobile, fax, pager) immediately
|
|
36
|
+
* followed by a tel, tel-*, email or impp field name, then optionally
|
|
37
|
+
* webauthn. A browser that cannot parse the value discards all of it, so a
|
|
38
|
+
* grammatically broken value exposes no purpose at all: F107 territory even
|
|
39
|
+
* when every token is individually real. Membership alone passed "shipping"
|
|
40
|
+
* (no field), "tel email" (two fields), "home name" (contact type before a
|
|
41
|
+
* non-contact field), "billing section-x tel" (section not first), "off
|
|
42
|
+
* email" and "webauthn" alone (2026-08-25 overnight audit).
|
|
43
|
+
* Returns null when the value is well formed, otherwise the reason it is not.
|
|
44
|
+
*/
|
|
45
|
+
function grammarFault(tokens) {
|
|
46
|
+
if (tokens.length === 1 && (tokens[0] === 'on' || tokens[0] === 'off')) return null;
|
|
47
|
+
if (tokens.includes('on') || tokens.includes('off')) return '"on" and "off" must stand alone, with no other tokens';
|
|
48
|
+
let i = 0;
|
|
49
|
+
if (isSection(tokens[i])) i += 1;
|
|
50
|
+
if (tokens[i] === 'shipping' || tokens[i] === 'billing') i += 1;
|
|
51
|
+
if (CONTACT_TYPES.has(tokens[i])) {
|
|
52
|
+
if (!CONTACT_FIELDS.has(tokens[i + 1])) {
|
|
53
|
+
return `"${tokens[i]}" must come directly before a tel, tel-*, email or impp field name`;
|
|
54
|
+
}
|
|
55
|
+
i += 2;
|
|
56
|
+
} else if (FIELD_NAMES.has(tokens[i])) {
|
|
57
|
+
i += 1;
|
|
58
|
+
} else {
|
|
59
|
+
const found = tokens[i];
|
|
60
|
+
if (found && isSection(found)) return `"${found}" must be the first token`;
|
|
61
|
+
if (found === 'webauthn') return '"webauthn" must come last, after a field name';
|
|
62
|
+
if (found === 'shipping' || found === 'billing') return `"${found}" may appear once, directly before the field name`;
|
|
63
|
+
return 'the value names no field (exactly one field name such as email, tel or given-name is required)';
|
|
64
|
+
}
|
|
65
|
+
if (tokens[i] === 'webauthn') i += 1;
|
|
66
|
+
if (i < tokens.length) {
|
|
67
|
+
const extra = tokens[i];
|
|
68
|
+
if (isSection(extra)) return `"${extra}" must be the first token`;
|
|
69
|
+
if (extra === 'shipping' || extra === 'billing') return `"${extra}" must come before the field name`;
|
|
70
|
+
if (extra === 'webauthn') return '"webauthn" must be the last token';
|
|
71
|
+
if (FIELD_NAMES.has(extra) || CONTACT_TYPES.has(extra)) return `only one field name is allowed, but "${tokens[i - 1]}" is followed by "${extra}"`;
|
|
72
|
+
return `"${extra}" cannot follow the field name`;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
18
76
|
|
|
19
77
|
export default {
|
|
20
78
|
id: 'autocomplete-valid',
|
|
@@ -23,19 +81,35 @@ export default {
|
|
|
23
81
|
tags: ['wcag21aa', 'wcag135'],
|
|
24
82
|
help: 'autocomplete attributes must use valid tokens',
|
|
25
83
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/identify-input-purpose.html',
|
|
26
|
-
|
|
84
|
+
// HTML gives the autocomplete attribute no effect on checkbox, radio, file,
|
|
85
|
+
// submit, image, reset and button inputs, and a disabled control collects
|
|
86
|
+
// nothing, so neither can misidentify a purpose (2026-08-25 overnight
|
|
87
|
+
// audit; the same applicability ACT rule 73f2c2 states).
|
|
88
|
+
selector: 'input[autocomplete]:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="submit"])'
|
|
89
|
+
+ ':not([type="image"]):not([type="reset"]):not([type="button"]):not(:disabled),'
|
|
90
|
+
+ ' select[autocomplete]:not(:disabled), textarea[autocomplete]:not(:disabled)',
|
|
27
91
|
evaluate(element) {
|
|
28
|
-
|
|
92
|
+
if (element.getAttribute('aria-disabled') === 'true') return { status: 'pass' };
|
|
93
|
+
const tokens = element
|
|
29
94
|
.getAttribute('autocomplete')
|
|
30
95
|
.trim()
|
|
31
96
|
.toLowerCase()
|
|
32
97
|
.split(/\s+/)
|
|
33
|
-
.filter(
|
|
34
|
-
|
|
98
|
+
.filter(Boolean);
|
|
99
|
+
const invalid = tokens.filter((token) => !isKnown(token));
|
|
100
|
+
if (invalid.length) {
|
|
101
|
+
return {
|
|
102
|
+
status: 'fail',
|
|
103
|
+
message: `autocomplete contains unknown token(s): ${invalid.join(', ')} — browsers and assistive tools can't identify this field's purpose.`,
|
|
104
|
+
fix: 'Use tokens from the HTML input-purposes list, e.g. autocomplete="email" or autocomplete="given-name".',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const fault = tokens.length ? grammarFault(tokens) : null;
|
|
108
|
+
if (!fault) return { status: 'pass' };
|
|
35
109
|
return {
|
|
36
110
|
status: 'fail',
|
|
37
|
-
message: `autocomplete
|
|
38
|
-
fix: 'Use
|
|
111
|
+
message: `autocomplete="${element.getAttribute('autocomplete').trim()}" is not a valid autofill value: ${fault}. Browsers discard the whole value, so no purpose is exposed.`,
|
|
112
|
+
fix: 'Use one field name from the HTML autofill list, optionally preceded by section-*, then shipping or billing, and (for tel, email and impp only) a contact type, e.g. autocomplete="shipping tel" or autocomplete="home email".',
|
|
39
113
|
};
|
|
40
114
|
},
|
|
41
115
|
};
|