pour-engine 1.35.5 → 1.37.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/rules/wcag/1.3.1-aria-required-children.js +6 -1
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +8 -2
- package/engine/rules/wcag/1.3.1-form-label.js +17 -0
- package/engine/rules/wcag/1.3.1-table-headers.js +7 -1
- package/engine/rules/wcag/4.1.2-aria-label-misuse.js +14 -3
- package/engine/rules/wcag/4.1.2-role-required-aria.js +83 -23
- package/engine/rules/wcag/4.1.2-valid-role.js +1 -1
- package/package.json +1 -1
package/config/project.config.js
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
// Composite roles are meaningless without their required child roles —
|
|
3
3
|
// role="list" with no listitems announces as an empty list.
|
|
4
4
|
import { implicitRole } from '../../lib/roles.js';
|
|
5
|
-
|
|
5
|
+
// Every row of ARIA 1.2's Required Owned Elements table; verify:aria-tables
|
|
6
|
+
// diffs this map against the spec (2026-09-01, after feed and row were
|
|
7
|
+
// found missing).
|
|
8
|
+
export const REQUIRED_CHILDREN = {
|
|
9
|
+
feed: ['article'],
|
|
10
|
+
row: ['cell', 'columnheader', 'gridcell', 'rowheader'],
|
|
6
11
|
list: ['listitem'],
|
|
7
12
|
listbox: ['option', 'group'],
|
|
8
13
|
menu: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'group'],
|
|
@@ -13,7 +13,13 @@
|
|
|
13
13
|
// > group > treeitem exactly as authored). For the roles whose context does
|
|
14
14
|
// not list group (listitem, tab, row, cell), group stays transparent as
|
|
15
15
|
// before.
|
|
16
|
-
|
|
16
|
+
// Every row of ARIA 1.2's Required Context Role table; verify:aria-tables
|
|
17
|
+
// diffs this map against the spec (2026-09-01, after caption, columnheader
|
|
18
|
+
// and rowheader were found missing).
|
|
19
|
+
export const REQUIRED_PARENT = {
|
|
20
|
+
caption: ['figure', 'grid', 'table', 'treegrid'],
|
|
21
|
+
columnheader: ['row'],
|
|
22
|
+
rowheader: ['row'],
|
|
17
23
|
listitem: ['list'],
|
|
18
24
|
option: ['listbox', 'group'],
|
|
19
25
|
menuitem: ['menu', 'menubar', 'group'],
|
|
@@ -27,7 +33,7 @@ const REQUIRED_PARENT = {
|
|
|
27
33
|
gridcell: ['row'],
|
|
28
34
|
};
|
|
29
35
|
|
|
30
|
-
const IMPLICIT_CONTAINER = { ul: 'list', ol: 'list', menu: 'list', table: 'table', tbody: 'rowgroup', thead: 'rowgroup', tfoot: 'rowgroup', tr: 'row' };
|
|
36
|
+
const IMPLICIT_CONTAINER = { ul: 'list', ol: 'list', menu: 'list', table: 'table', tbody: 'rowgroup', thead: 'rowgroup', tfoot: 'rowgroup', tr: 'row', figure: 'figure' };
|
|
31
37
|
|
|
32
38
|
export default {
|
|
33
39
|
id: 'aria-required-parent',
|
|
@@ -125,6 +125,23 @@ export default {
|
|
|
125
125
|
: 'Put visible text inside the label.',
|
|
126
126
|
};
|
|
127
127
|
}
|
|
128
|
+
// A label points at this id, but the id is shared: for binds to the
|
|
129
|
+
// FIRST element with it, so this later twin has no label and no name
|
|
130
|
+
// even though a label sits beside it (hsbc.com download basket, two
|
|
131
|
+
// rows reusing one document id, 2026-09-01). The fix is a unique id,
|
|
132
|
+
// not another label.
|
|
133
|
+
if (element.id) {
|
|
134
|
+
const root = element.getRootNode();
|
|
135
|
+
const pointed = root.querySelector?.(`label[for="${CSS.escape(element.id)}"]`);
|
|
136
|
+
const first = root.getElementById?.(element.id);
|
|
137
|
+
if (pointed && first && first !== element) {
|
|
138
|
+
return {
|
|
139
|
+
status: 'fail',
|
|
140
|
+
message: `A <label for="${element.id}"> exists, but an earlier element on the page has the same id, so the label belongs to that one and this field has no label at all.`,
|
|
141
|
+
fix: `Give this field a unique id and point its label at that; ids must not repeat on a page.`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
128
145
|
return {
|
|
129
146
|
status: 'fail',
|
|
130
147
|
message: 'This form field has no label — users cannot tell what to enter.',
|
|
@@ -84,10 +84,16 @@ export default {
|
|
|
84
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
85
|
};
|
|
86
86
|
}
|
|
87
|
+
// A two-column table is a list of label-and-value pairs; its first
|
|
88
|
+
// column is the row headers, and that is the whole fix (gov.uk
|
|
89
|
+
// inheritance tax guidance, 2026-09-01: five such tables, all td).
|
|
90
|
+
const twoColumns = Math.max(0, ...rows.map((row) => row.cells.length)) === 2;
|
|
87
91
|
return {
|
|
88
92
|
status: 'fail',
|
|
89
93
|
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.',
|
|
90
|
-
fix:
|
|
94
|
+
fix: twoColumns
|
|
95
|
+
? 'Mark the first cell of each row as <th scope="row">: in a two-column table the first column names what the second holds.'
|
|
96
|
+
: 'Mark header cells with <th> (add scope="col" or scope="row" when the table has both).',
|
|
91
97
|
};
|
|
92
98
|
}
|
|
93
99
|
return { status: 'pass' };
|
|
@@ -40,6 +40,18 @@ const NAME_FROM_CONTENT = 'a[href], button, summary, h1, h2, h3, h4, h5, h6, th,
|
|
|
40
40
|
+ ' [role="checkbox"], [role="radio"], [role="switch"], [role="heading"],'
|
|
41
41
|
+ ' [role="cell"], [role="gridcell"], [role="columnheader"], [role="rowheader"], [role="tooltip"]';
|
|
42
42
|
|
|
43
|
+
// ARIA 1.2's ten roles that prohibit naming, and the HTML elements that
|
|
44
|
+
// carry them implicitly (HTML-AAM); verify:aria-tables diffs the set
|
|
45
|
+
// against the spec. The rule started with div, span and p alone
|
|
46
|
+
// (generic and paragraph); the other eight, explicit or as strong, em,
|
|
47
|
+
// code, del, s, ins, sub and sup, joined 2026-09-01.
|
|
48
|
+
export const NAME_PROHIBITED_ROLES = ['caption', 'code', 'deletion', 'emphasis', 'generic', 'insertion', 'paragraph', 'strong', 'subscript', 'superscript'];
|
|
49
|
+
const IMPLICITLY_PROHIBITED = ['div', 'span', 'p', 'strong', 'em', 'code', 'del', 's', 'ins', 'sub', 'sup'];
|
|
50
|
+
const SELECTOR = ['aria-label', 'aria-labelledby'].flatMap((attr) => [
|
|
51
|
+
...IMPLICITLY_PROHIBITED.map((tag) => `${tag}[${attr}]:not([role]):not([tabindex])`),
|
|
52
|
+
...NAME_PROHIBITED_ROLES.map((role) => `[role="${role}"][${attr}]:not([tabindex])`),
|
|
53
|
+
]).join(', ');
|
|
54
|
+
|
|
43
55
|
export default {
|
|
44
56
|
id: 'aria-label-misuse',
|
|
45
57
|
name: 'aria-label placement',
|
|
@@ -50,8 +62,7 @@ export default {
|
|
|
50
62
|
// §5.2.8.6 prohibits BOTH naming attributes on generic — aria-labelledby
|
|
51
63
|
// pointing at perfectly real text is the same authoring error as an inline
|
|
52
64
|
// aria-label, and the more tempting one (a card labelled by its own title).
|
|
53
|
-
selector:
|
|
54
|
-
+ ' div[aria-labelledby]:not([role]):not([tabindex]), span[aria-labelledby]:not([role]):not([tabindex]), p[aria-labelledby]:not([role]):not([tabindex])',
|
|
65
|
+
selector: SELECTOR,
|
|
55
66
|
evaluate(element) {
|
|
56
67
|
// Inside a name-from-content ancestor the label is NOT ignored: accname
|
|
57
68
|
// applies the whole algorithm to each descendant while building the
|
|
@@ -72,7 +83,7 @@ export default {
|
|
|
72
83
|
})();
|
|
73
84
|
return {
|
|
74
85
|
status: 'incomplete',
|
|
75
|
-
message: `${attempt} on a plain <${element.tagName.toLowerCase()}
|
|
86
|
+
message: `${attempt} on ${element.hasAttribute('role') ? `role="${element.getAttribute('role').trim()}"` : `a plain <${element.tagName.toLowerCase()}>`} is prohibited by ARIA: this element's role does not support naming, so although many browser and screen reader pairings announce the label today, that support is not guaranteed anywhere and can differ between assistive technologies. Where it is announced it replaces the element's visible text; where it is not, users get the visible text alone. Check both presentations read correctly, and that nothing here relies on the label being heard. Prohibited markup is not by itself a WCAG failure: the criterion that policed validity, 4.1.1 Parsing, was removed in WCAG 2.2 (w3.org/WAI/WCAG22/Understanding/parsing.html), so what decides this is what users actually hear.`,
|
|
76
87
|
fix: 'If this element must reliably announce something of its own, give it the role it is playing (a landmark, group, or widget role) so the name is legal and exposure is guaranteed — or move the spoken text into visible or visually-hidden real text. If the label is stray, remove it. Do not add a role solely to legalise the label.',
|
|
77
88
|
};
|
|
78
89
|
},
|
|
@@ -1,13 +1,65 @@
|
|
|
1
1
|
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
-
// Roles that are meaningless without a state
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
// Roles that are meaningless without a state or property alongside them.
|
|
3
|
+
// The table is ARIA 1.2's "Required States and Properties" row for every
|
|
4
|
+
// role that has one, read from the spec on 2026-09-01: checkbox, switch,
|
|
5
|
+
// radio, menuitemcheckbox and menuitemradio need aria-checked; slider,
|
|
6
|
+
// meter and scrollbar need aria-valuenow (scrollbar also aria-controls);
|
|
7
|
+
// a SEPARATOR needs aria-valuenow only when it is focusable, because a
|
|
8
|
+
// focusable separator is a resizing widget and a static one is a divider;
|
|
9
|
+
// heading needs aria-level; combobox needs aria-expanded and aria-controls;
|
|
10
|
+
// option needs aria-selected.
|
|
11
|
+
//
|
|
12
|
+
// Two of those properties are held at review rather than failed. Browsers
|
|
13
|
+
// and screen readers get by without aria-controls (the relationship it
|
|
14
|
+
// names is rarely announced, and a collapsed combobox has nothing to
|
|
15
|
+
// control yet), and without aria-selected on an option (selection is
|
|
16
|
+
// often conveyed by focus alone); the spec lists both, the DOM cannot
|
|
17
|
+
// show a user is blocked, so the finding asks rather than asserts.
|
|
18
|
+
export const REQUIRED = {
|
|
19
|
+
checkbox: ['aria-checked'],
|
|
20
|
+
switch: ['aria-checked'],
|
|
21
|
+
radio: ['aria-checked'],
|
|
22
|
+
menuitemcheckbox: ['aria-checked'],
|
|
23
|
+
menuitemradio: ['aria-checked'],
|
|
24
|
+
slider: ['aria-valuenow'],
|
|
25
|
+
meter: ['aria-valuenow'],
|
|
26
|
+
scrollbar: ['aria-valuenow', 'aria-controls'],
|
|
27
|
+
separator: ['aria-valuenow'],
|
|
28
|
+
heading: ['aria-level'],
|
|
29
|
+
combobox: ['aria-expanded', 'aria-controls'],
|
|
30
|
+
option: ['aria-selected'],
|
|
10
31
|
};
|
|
32
|
+
export const HELD_AT_REVIEW = new Set(['aria-controls', 'aria-selected']);
|
|
33
|
+
|
|
34
|
+
// What each missing property costs the user, in the message.
|
|
35
|
+
const COST = {
|
|
36
|
+
'aria-checked': "screen readers can't announce whether it is on or off",
|
|
37
|
+
'aria-valuenow': "screen readers can't announce its value or position",
|
|
38
|
+
'aria-level': 'browsers assume level 2 for a heading with no level, so the outline a screen reader user navigates by is a guess',
|
|
39
|
+
'aria-expanded': "screen readers can't announce whether its list is open",
|
|
40
|
+
'aria-controls': 'assistive technology has no link from the control to what it operates',
|
|
41
|
+
'aria-selected': 'a screen reader may not announce which option is selected',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Native semantics supply the state regardless of WHICH state-bearing
|
|
45
|
+
// role is claimed: HTML-AAM maps an input's checkedness to aria-checked
|
|
46
|
+
// (input type=checkbox role="switch" — the standard toggle pattern —
|
|
47
|
+
// exposes checked=false/true in the browser's tree with no ARIA at all),
|
|
48
|
+
// a range input's or meter's value to aria-valuenow, a heading element's
|
|
49
|
+
// rank to aria-level, an option's selectedness to aria-selected, and a
|
|
50
|
+
// select's open state to aria-expanded.
|
|
51
|
+
function suppliedNatively(element, attr) {
|
|
52
|
+
const tag = element.tagName;
|
|
53
|
+
const type = tag === 'INPUT' ? element.type : null;
|
|
54
|
+
switch (attr) {
|
|
55
|
+
case 'aria-checked': return type === 'checkbox' || type === 'radio';
|
|
56
|
+
case 'aria-valuenow': return type === 'range' || tag === 'METER' || tag === 'PROGRESS';
|
|
57
|
+
case 'aria-level': return /^H[1-6]$/.test(tag);
|
|
58
|
+
case 'aria-selected': return tag === 'OPTION';
|
|
59
|
+
case 'aria-expanded': return tag === 'SELECT';
|
|
60
|
+
default: return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
11
63
|
|
|
12
64
|
export default {
|
|
13
65
|
id: 'role-required-aria',
|
|
@@ -19,23 +71,31 @@ export default {
|
|
|
19
71
|
selector: Object.keys(REQUIRED).map((role) => `[role="${role}"]`).join(', '),
|
|
20
72
|
evaluate(element) {
|
|
21
73
|
const role = element.getAttribute('role');
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
74
|
+
// A separator that cannot take focus is a static divider (an <hr> by
|
|
75
|
+
// another name) and requires nothing; only the focusable, moveable
|
|
76
|
+
// kind is a widget with a position to announce.
|
|
77
|
+
if (role === 'separator' && !(element.tabIndex >= 0)) return { status: 'pass' };
|
|
78
|
+
const missing = REQUIRED[role].filter((attr) => !element.hasAttribute(attr) && !suppliedNatively(element, attr));
|
|
79
|
+
if (!missing.length) return { status: 'pass' };
|
|
80
|
+
const failing = missing.filter((attr) => !HELD_AT_REVIEW.has(attr));
|
|
81
|
+
if (failing.length) {
|
|
82
|
+
const list = failing.join(' and ');
|
|
83
|
+
const why = role === 'separator'
|
|
84
|
+
? `this separator takes focus, so it is a resizing widget, but without aria-valuenow ${COST['aria-valuenow']}`
|
|
85
|
+
: failing.map((attr) => COST[attr]).join('; ');
|
|
86
|
+
return {
|
|
87
|
+
status: 'fail',
|
|
88
|
+
message: `role="${role}" without ${list} — ${why}.`,
|
|
89
|
+
fix: `Add ${list} and keep it updated from your script, or use the native HTML element instead.`,
|
|
90
|
+
};
|
|
32
91
|
}
|
|
33
|
-
|
|
34
|
-
|
|
92
|
+
// Only review-held properties are missing. A collapsed combobox has no
|
|
93
|
+
// popup to point aria-controls at, so it is asked only while open.
|
|
94
|
+
if (role === 'combobox' && element.getAttribute('aria-expanded') !== 'true') return { status: 'pass' };
|
|
95
|
+
const list = missing.join(' and ');
|
|
35
96
|
return {
|
|
36
|
-
status: '
|
|
37
|
-
message: `role="${role}" without ${
|
|
38
|
-
fix: `Add ${required} and keep it updated from your script, or use the native HTML element instead.`,
|
|
97
|
+
status: 'incomplete',
|
|
98
|
+
message: `role="${role}" without ${list}, which ARIA lists as required for the role — ${missing.map((attr) => COST[attr]).join('; ')}. Check the control is usable with a screen reader; adding ${list} settles it.`,
|
|
39
99
|
};
|
|
40
100
|
},
|
|
41
101
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
2
|
import { attributesOf } from '../../lib/dom.js';
|
|
3
3
|
// Concrete, usable roles — abstract roles are invalid in markup.
|
|
4
|
-
const VALID_ROLES = new Set([
|
|
4
|
+
export const VALID_ROLES = new Set([
|
|
5
5
|
'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button',
|
|
6
6
|
'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', 'complementary',
|
|
7
7
|
'contentinfo', 'definition', 'deletion', 'dialog', 'directory', 'document', 'emphasis',
|
package/package.json
CHANGED