pour-engine 1.33.0 → 1.34.1
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 +224 -17
- 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 +122 -3
- 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-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-valid-refs.js +109 -72
- 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
|
@@ -1,43 +1,67 @@
|
|
|
1
1
|
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
2
|
// aria-activedescendant and aria-owns matter most: a dangling
|
|
3
3
|
// activedescendant breaks composite-widget focus reporting entirely.
|
|
4
|
+
import { effectiveRole } from '../../lib/roles.js';
|
|
5
|
+
import { labelledByName } from '../../lib/accessible-name.js';
|
|
6
|
+
|
|
4
7
|
const REF_ATTRIBUTES = [
|
|
5
8
|
'aria-labelledby', 'aria-describedby', 'aria-controls',
|
|
6
9
|
'aria-activedescendant', 'aria-owns', 'aria-errormessage', 'aria-details',
|
|
7
10
|
];
|
|
8
11
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// (
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// before is the right answer.
|
|
20
|
-
const PROBE_BUDGET = 8;
|
|
21
|
-
const PROBE_WAIT_MS = 250;
|
|
12
|
+
// No hover/focus probe (removed 2026-08-26, David). From 1.2.81 to this
|
|
13
|
+
// change the rule dispatched hover and focus events at elements whose
|
|
14
|
+
// aria-describedby pointed nowhere, to catch tooltip libraries that create
|
|
15
|
+
// the target on demand. That mutated the page under audit: focus was taken
|
|
16
|
+
// and not given back, hover-driven menus opened, page scripts ran, mobile
|
|
17
|
+
// keyboards popped, and 300 ms went by per element. Once a dead describedby
|
|
18
|
+
// became a review rather than a fail (same morning) the probe only bought
|
|
19
|
+
// fewer review rows, which is not worth an audit that is no longer
|
|
20
|
+
// read-only. The deferred-tooltip pattern is now named in the review
|
|
21
|
+
// message instead, for the human to confirm.
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
23
|
+
// 4.1.2 is scoped to user interface components ("a part of the content
|
|
24
|
+
// that is perceived by users as a single control for a distinct function").
|
|
25
|
+
// A nameless labelledby is only the 4.1.2 failure proper on one of those: a
|
|
26
|
+
// landmark, list or group whose reference died is left unnamed, which WCAG
|
|
27
|
+
// permits for those roles, so the DOM proves an authoring error, not a
|
|
28
|
+
// component without a name. Same reading label-for-valid took on 2026-08-25.
|
|
29
|
+
// Corpus evidence (parity 2026-08-27): the one assert on 137 sites was an
|
|
30
|
+
// empty <aside tabindex="-1"> whose heading never rendered, while identical
|
|
31
|
+
// dead references on two visible <nav>s and a footer list passed only because the name
|
|
32
|
+
// computation falls through to their link text. The verdict must not turn
|
|
33
|
+
// on whether a landmark happens to contain words.
|
|
34
|
+
const WIDGET_ROLES = new Set([
|
|
35
|
+
// ARIA 1.2 widget roles
|
|
36
|
+
'button', 'checkbox', 'gridcell', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
|
|
37
|
+
'option', 'progressbar', 'radio', 'scrollbar', 'searchbox', 'separator', 'slider',
|
|
38
|
+
'spinbutton', 'switch', 'tab', 'tabpanel', 'textbox', 'treeitem',
|
|
39
|
+
// ARIA 1.2 composite widget roles
|
|
40
|
+
'combobox', 'grid', 'listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid',
|
|
41
|
+
// window roles: a dialog is operated as one thing, and dialog-name asserts on it anyway
|
|
42
|
+
'dialog', 'alertdialog',
|
|
43
|
+
]);
|
|
44
|
+
const NATIVE_CONTROLS = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, audio[controls], video[controls], [contenteditable="true"]';
|
|
45
|
+
|
|
46
|
+
/** accname step 2D, the host-language naming that every role keeps
|
|
47
|
+
* (name-from-content is the only role-gated step): an image's alt, an
|
|
48
|
+
* svg's own <title>, a table's caption, a fieldset's legend, a figure's
|
|
49
|
+
* figcaption. The Tate footer's social icons are <svg aria-labelledby="title">
|
|
50
|
+
* with a <title>Facebook</title> child and no element with that id: named. */
|
|
51
|
+
function nativeName(element) {
|
|
52
|
+
const tag = element.tagName.toLowerCase();
|
|
53
|
+
if (tag === 'img' || tag === 'area') return element.getAttribute('alt')?.trim() ?? '';
|
|
54
|
+
const child = { svg: 'title', table: 'caption', fieldset: 'legend', figure: 'figcaption' }[tag];
|
|
55
|
+
if (!child) return '';
|
|
56
|
+
return [...element.children].find((c) => c.tagName.toLowerCase() === child)?.textContent.trim() ?? '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isUserInterfaceComponent(element) {
|
|
60
|
+
if (element.matches(NATIVE_CONTROLS)) return true;
|
|
61
|
+
if (WIDGET_ROLES.has(effectiveRole(element))) return true;
|
|
62
|
+
// A tabbable element is operated as a control whatever its role;
|
|
63
|
+
// tabindex="-1" (a skip-link or scroll target) is not.
|
|
64
|
+
return element.tabIndex >= 0;
|
|
41
65
|
}
|
|
42
66
|
|
|
43
67
|
function inspect(element) {
|
|
@@ -67,29 +91,61 @@ function inspect(element) {
|
|
|
67
91
|
return { missing, ambiguous };
|
|
68
92
|
}
|
|
69
93
|
|
|
70
|
-
function outcome({ missing, ambiguous },
|
|
94
|
+
function outcome({ missing, ambiguous }, element, accessibleName) {
|
|
71
95
|
if (missing.length) {
|
|
72
96
|
// A dangling ref is skipped by the accname computation, so the HARM
|
|
73
|
-
// depends on what remains.
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
97
|
+
// depends on what remains, and 4.1.2 only cares about name, role and
|
|
98
|
+
// user-settable value (re-decided by David 2026-08-26 against measured
|
|
99
|
+
// verdicts on the corpus; the old "fragile, validator-flagged"
|
|
100
|
+
// argument was 4.1.1's, and 2.2 retired it):
|
|
101
|
+
// - aria-labelledby that dangles while the element still names
|
|
102
|
+
// itself from elsewhere: the name is fully determinable (accname
|
|
103
|
+
// step 2B processes the valid IDREFs), so it is not a finding here;
|
|
104
|
+
// - aria-labelledby that leaves a USER INTERFACE COMPONENT nameless,
|
|
105
|
+
// and aria-activedescendant (a focused composite reports the wrong
|
|
106
|
+
// active item): the 4.1.2 failure proper, asserted. The same dead
|
|
107
|
+
// reference on a landmark, list or group is asked about (2026-08-27,
|
|
108
|
+
// see isUserInterfaceComponent);
|
|
109
|
+
// - aria-describedby that points nowhere at rest: a description is
|
|
110
|
+
// not name, role or a user-set value, and tooltip libraries create
|
|
111
|
+
// the target on hover or focus, so the DOM proves an authoring
|
|
112
|
+
// error at most. Asked, never asserted;
|
|
113
|
+
// - aria-controls, aria-owns, aria-details, aria-errormessage: the
|
|
114
|
+
// standing policy (asserted; the comparator asserts these too).
|
|
115
|
+
const component = isUserInterfaceComponent(element);
|
|
116
|
+
// The resting name for a component is the full computation (a button
|
|
117
|
+
// names itself from its contents). A landmark, list or group is not
|
|
118
|
+
// allowed name-from-content (accname step 2F), so for those only the
|
|
119
|
+
// surviving labelledby ids, aria-label and title count: otherwise a
|
|
120
|
+
// <nav> full of links would read as named and the dead reference vanish.
|
|
121
|
+
const restingName = component
|
|
122
|
+
? (accessibleName?.(element) ?? '').trim()
|
|
123
|
+
: (labelledByName(element) || element.getAttribute('aria-label') || nativeName(element) || element.getAttribute('title') || '').trim();
|
|
124
|
+
const relevant = missing.filter(({ attr }) => !(attr === 'aria-labelledby' && restingName));
|
|
125
|
+
if (!relevant.length) return { status: 'pass' };
|
|
126
|
+
const line = ({ attr, id }) => attr === 'aria-labelledby'
|
|
127
|
+
? `aria-labelledby="${id}" points to nothing and leaves this element without an accessible name`
|
|
128
|
+
: `${attr}="${id}" points to nothing — assistive technology silently ignores it`;
|
|
129
|
+
const asserted = relevant.filter(({ attr }) => attr !== 'aria-describedby' && (attr !== 'aria-labelledby' || component));
|
|
130
|
+
if (asserted.length) {
|
|
131
|
+
return {
|
|
132
|
+
status: 'fail',
|
|
133
|
+
message: `Broken ARIA references: ${relevant.map(line).join('; ')}.`,
|
|
134
|
+
fix: 'Correct or remove the reference, or give the target element that id.',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const unnamed = relevant.filter(({ attr }) => attr === 'aria-labelledby');
|
|
138
|
+
if (unnamed.length) {
|
|
139
|
+
const tag = element.tagName.toLowerCase();
|
|
140
|
+
return {
|
|
141
|
+
status: 'incomplete',
|
|
142
|
+
message: `${unnamed.map(({ id }) => `aria-labelledby="${id}" points to nothing`).join('; ')}, so this <${tag}> has no accessible name. It is not a user interface component, so 4.1.2 does not require one, but the reference was meant to label it: correct it, or remove it if the label was never needed.`,
|
|
143
|
+
fix: 'Correct or remove the reference, or give the target element that id.',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
90
146
|
return {
|
|
91
|
-
status: '
|
|
92
|
-
message:
|
|
147
|
+
status: 'incomplete',
|
|
148
|
+
message: `${relevant.map(line).join('; ')} at rest. The element keeps its name and role, so this is not a proven 4.1.2 failure: if the description is created when the element is hovered or focused (a tooltip library), this works; otherwise correct the reference.`,
|
|
93
149
|
fix: 'Correct or remove the reference, or give the target element that id.',
|
|
94
150
|
};
|
|
95
151
|
}
|
|
@@ -116,26 +172,7 @@ export default {
|
|
|
116
172
|
help: 'ARIA id references must point to elements that exist',
|
|
117
173
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
118
174
|
selector: REF_ATTRIBUTES.map((attr) => `[${attr}]`).join(', '),
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const outcomes = new Array(elements.length);
|
|
122
|
-
for (let i = 0; i < elements.length; i++) {
|
|
123
|
-
const element = elements[i];
|
|
124
|
-
const found = inspect(element);
|
|
125
|
-
const deferredOnly = found.missing.length > 0
|
|
126
|
-
&& found.missing.every(({ attr }) => PROBEABLE.has(attr));
|
|
127
|
-
if (deferredOnly && budget > 0) {
|
|
128
|
-
budget--;
|
|
129
|
-
if (await probeDeferred(element, found.missing.map(({ id }) => id))) {
|
|
130
|
-
// The description materialises exactly when it would be announced.
|
|
131
|
-
outcomes[i] = { status: 'pass' };
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
outcomes[i] = outcome(found, true, element, accessibleName);
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
outcomes[i] = outcome(found, false, element, accessibleName);
|
|
138
|
-
}
|
|
139
|
-
return outcomes;
|
|
175
|
+
evaluate(element, { accessibleName } = {}) {
|
|
176
|
+
return outcome(inspect(element), element, accessibleName);
|
|
140
177
|
},
|
|
141
178
|
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
import { effectiveRole, implicitRole } from '../../lib/roles.js';
|
|
3
|
+
|
|
2
4
|
export default {
|
|
3
5
|
id: 'button-name',
|
|
4
6
|
name: 'Button names',
|
|
@@ -8,6 +10,15 @@ export default {
|
|
|
8
10
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
9
11
|
selector: 'button, [role="button"], input[type="button"], input[type="submit"], input[type="reset"]',
|
|
10
12
|
evaluate(element, { accessibleName }) {
|
|
13
|
+
// Route by EFFECTIVE role (2026-08-25 overnight audit): a <button
|
|
14
|
+
// role="tab"> or <button role="link"> is exposed under that role, so its
|
|
15
|
+
// missing name belongs to the rule that speaks for that role
|
|
16
|
+
// (composite-widget-name, link-name). This rule owns every element whose
|
|
17
|
+
// effective role is button, whatever its tag: <a href role="button">
|
|
18
|
+
// included, which used to draw a finding here AND in link-name. An
|
|
19
|
+
// unknown explicit role falls back to the native role.
|
|
20
|
+
const role = effectiveRole(element) ?? implicitRole(element);
|
|
21
|
+
if (role !== 'button') return { status: 'pass' };
|
|
11
22
|
if (accessibleName(element)) return { status: 'pass' };
|
|
12
23
|
return {
|
|
13
24
|
status: 'fail',
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
// The container roles (tablist, menu, listbox, tree) are name-recommended
|
|
9
9
|
// rather than required and are deliberately not judged here; the items are
|
|
10
10
|
// where the binary, assertable failure lives.
|
|
11
|
+
import { effectiveRole } from '../../lib/roles.js';
|
|
12
|
+
|
|
13
|
+
const ITEM_ROLES = new Set(['tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'treeitem']);
|
|
14
|
+
|
|
11
15
|
export default {
|
|
12
16
|
id: 'composite-widget-name',
|
|
13
17
|
name: 'Widget item names',
|
|
@@ -17,13 +21,15 @@ export default {
|
|
|
17
21
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
18
22
|
selector: '[role="tab"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="treeitem"]',
|
|
19
23
|
evaluate(element, { accessibleName }) {
|
|
20
|
-
//
|
|
21
|
-
// button
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
24
|
+
// This rule owns every element whose EFFECTIVE role is one of the six,
|
|
25
|
+
// <button role="tab"> and <a href role="tab"> included: button-name and
|
|
26
|
+
// link-name defer to the effective role since 2026-08-25 (overnight
|
|
27
|
+
// audit), so the tab wording now reaches the tab, and one nameless
|
|
28
|
+
// control still yields one finding. A host whose role resolves elsewhere
|
|
29
|
+
// (presentation discarded back to a native role, say) is not ours.
|
|
30
|
+
const role = effectiveRole(element);
|
|
31
|
+
if (!ITEM_ROLES.has(role)) return { status: 'pass' };
|
|
25
32
|
if (accessibleName(element)) return { status: 'pass' };
|
|
26
|
-
const role = element.getAttribute('role');
|
|
27
33
|
return {
|
|
28
34
|
status: 'fail',
|
|
29
35
|
message: `This ${role} has no accessible name — a screen reader announces an unnamed ${role}, and users cannot tell what choosing it does.`,
|
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
-
|
|
2
|
+
//
|
|
3
|
+
// Rewritten 2026-08-25 overnight audit, two decisions:
|
|
4
|
+
//
|
|
5
|
+
// 1. The browser decides what is labelable, not a tag list. HTML defines
|
|
6
|
+
// the labeled control as "the first element in tree order whose ID is
|
|
7
|
+
// equal to the value of the for attribute, if it is a labelable element",
|
|
8
|
+
// and exposes exactly that resolution as label.control. The list this
|
|
9
|
+
// rule used to keep missed form-associated custom elements (HTML lists
|
|
10
|
+
// them as labelable) and admitted input type=hidden (HTML does not).
|
|
11
|
+
//
|
|
12
|
+
// 2. A label is not a user interface component. 4.1.2 asks that every
|
|
13
|
+
// component's name and role be determinable; a caption whose for points
|
|
14
|
+
// at nothing, or at a paragraph, harms no component by itself. Where a
|
|
15
|
+
// real control was left nameless, form-label asserts on that control, so
|
|
16
|
+
// asserting here too was one fault reported twice, and where no control
|
|
17
|
+
// was ever meant, it was a fault reported that does not exist. The rule
|
|
18
|
+
// now asks. It still asserts the one shape the DOM proves: a label that
|
|
19
|
+
// WRAPS a labelable control while its for points elsewhere, because
|
|
20
|
+
// HTML's for rule overrides the wrapping and the wrapped control provably
|
|
21
|
+
// loses its label.
|
|
22
|
+
const WRAPPABLE = 'input:not([type="hidden"]), select, textarea, button, meter, output, progress';
|
|
3
23
|
|
|
4
24
|
export default {
|
|
5
25
|
id: 'label-for-valid',
|
|
@@ -11,16 +31,29 @@ export default {
|
|
|
11
31
|
selector: 'label[for]',
|
|
12
32
|
evaluate(element) {
|
|
13
33
|
const id = element.getAttribute('for');
|
|
14
|
-
//
|
|
15
|
-
//
|
|
34
|
+
// `control` is the browser's own for-resolution: null when the id is
|
|
35
|
+
// missing or names something that is not labelable. It resolves within
|
|
36
|
+
// the label's own root, so a label in a shadow tree can only point at
|
|
37
|
+
// controls in that same tree.
|
|
38
|
+
if (element.control) return { status: 'pass' };
|
|
16
39
|
const target = element.getRootNode().getElementById?.(id);
|
|
17
|
-
|
|
40
|
+
// A hidden input is not labelable, but nothing perceivable is harmed by
|
|
41
|
+
// a label that points at one (standing policy: this shape passes).
|
|
42
|
+
if (target && target.tagName === 'INPUT' && target.type === 'hidden') return { status: 'pass' };
|
|
43
|
+
const wrapped = element.querySelector(WRAPPABLE);
|
|
44
|
+
if (wrapped) {
|
|
45
|
+
return {
|
|
46
|
+
status: 'fail',
|
|
47
|
+
message: `This label wraps a <${wrapped.tagName.toLowerCase()}> but its for="${id}" ${target ? `points at a <${target.tagName.toLowerCase()}>, which is not labelable` : 'points at nothing'}. The for attribute overrides the wrapping, so the wrapped control is not labelled by this text.`,
|
|
48
|
+
fix: `Point the for attribute at the wrapped control's id, or remove the for attribute so the wrapping labels it.`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
18
51
|
return {
|
|
19
|
-
status: '
|
|
52
|
+
status: 'incomplete',
|
|
20
53
|
message: target
|
|
21
|
-
? `for="${id}" points at a <${target.tagName.toLowerCase()}>, which
|
|
22
|
-
: `for="${id}" points at nothing
|
|
23
|
-
fix: 'Point the for attribute at the id of the
|
|
54
|
+
? `for="${id}" points at a <${target.tagName.toLowerCase()}>, which is not a labelable element, so the browser ignores the association. Was a form control meant to be here? If so it has no label from this text (form-label reports the control itself); if this text captions something that is not a control, nothing is affected.`
|
|
55
|
+
: `for="${id}" points at nothing, so this label is not associated with any control. Was a form control meant to be here? If so it has lost its label (form-label reports the control itself); if this is leftover markup, no one is affected.`,
|
|
56
|
+
fix: 'Point the for attribute at the id of the control it labels, or remove the attribute.',
|
|
24
57
|
};
|
|
25
58
|
},
|
|
26
59
|
};
|
|
@@ -26,12 +26,29 @@ export default {
|
|
|
26
26
|
// role="button" with no tabindex is not in the tab order — keyboard
|
|
27
27
|
// users cannot reach it, so it cannot be a competing stop).
|
|
28
28
|
const NATIVE = 'a[href], button, input, select, textarea, summary, audio[controls], video[controls]';
|
|
29
|
+
// Any negative tabindex is unreachable by Tab (HTML: "a negative
|
|
30
|
+
// integer"), not only the literal "-1"; hasAttribute guards the default
|
|
31
|
+
// -1 Chromium reports for attribute-less contenteditable elements.
|
|
32
|
+
// 2026-08-25 overnight audit.
|
|
29
33
|
const nested = [...element.querySelectorAll(INTERACTIVE)].find((el) =>
|
|
30
|
-
!el.matches(':disabled') && el.
|
|
34
|
+
!el.matches(':disabled') && !(el.hasAttribute('tabindex') && el.tabIndex < 0)
|
|
31
35
|
&& !(el.tagName === 'INPUT' && el.type === 'hidden') && isRendered(el)
|
|
32
36
|
&& !el.closest('[aria-hidden="true"]')
|
|
33
37
|
&& (el.matches(NATIVE) || el.hasAttribute('tabindex')));
|
|
34
38
|
if (!nested) return { status: 'pass' };
|
|
39
|
+
// <summary> is the one outer control HTML does not forbid this on: its
|
|
40
|
+
// content model is phrasing content with no "no interactive content
|
|
41
|
+
// descendant" clause (a and button both carry one), it maps to no ARIA
|
|
42
|
+
// role that is Children Presentational, and browsers keep a link inside
|
|
43
|
+
// it as a link. Both controls have their name and role, so nothing is
|
|
44
|
+
// announced wrongly; whether Enter on the link versus the disclosure
|
|
45
|
+
// confuses is a question for a person. 2026-08-25 overnight audit.
|
|
46
|
+
if (element.tagName === 'SUMMARY') {
|
|
47
|
+
return {
|
|
48
|
+
status: 'incomplete',
|
|
49
|
+
message: `This summary contains another interactive element (<${nested.tagName.toLowerCase()}>). HTML allows it and both controls keep their name and role, but the disclosure and the control inside it are separate keyboard stops in one label. Check that activating each does what a user expects.`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
35
52
|
return {
|
|
36
53
|
status: 'fail',
|
|
37
54
|
message: `This ${element.tagName.toLowerCase()} contains another interactive element (<${nested.tagName.toLowerCase()}>) — focus order and announcements become unpredictable.`,
|
|
@@ -57,6 +57,22 @@ export default {
|
|
|
57
57
|
const hasAriaProps = [...element.attributes].some(
|
|
58
58
|
(attr) => attr.name.startsWith('aria-') && attr.name !== 'aria-hidden');
|
|
59
59
|
if (genericFallback && !focusable && !hasAriaProps) return { status: 'pass' };
|
|
60
|
+
// The same two triggers decide for EVERY tag (2026-08-25 overnight
|
|
61
|
+
// audit). ARIA 1.2 section 9.1: an unrecognised role token is treated as
|
|
62
|
+
// if no role had been provided, so <h2 role="text"> is exposed exactly
|
|
63
|
+
// as <h2>. Name, role and value are all intact and determinable; what
|
|
64
|
+
// the DOM cannot tell is what the author meant, so when the element is
|
|
65
|
+
// not focusable and carries no ARIA state on the back of the bogus role
|
|
66
|
+
// this is a question, not an assertion. Focusable or state-bearing
|
|
67
|
+
// elements keep failing: a focusable element left generic, or a state
|
|
68
|
+
// riding on a role the browser threw away, is the real 4.1.2 harm.
|
|
69
|
+
if (!focusable && !hasAriaProps) {
|
|
70
|
+
return {
|
|
71
|
+
status: 'incomplete',
|
|
72
|
+
message: `role="${element.getAttribute('role')}" is not a valid ARIA role, so assistive technology ignores it and exposes the element's native <${tag}> semantics. Nothing is announced wrongly, but the author reached for a role that does not exist. Is the native <${tag}> role the right one here? If a different role was intended, that role is missing.`,
|
|
73
|
+
fix: 'Use a valid role from the ARIA specification, or remove the attribute to keep the element’s native role.',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
60
76
|
return {
|
|
61
77
|
status: 'fail',
|
|
62
78
|
message: `role="${element.getAttribute('role')}" is not a valid ARIA role, so assistive technology ignores it${genericFallback ? '' : ` and falls back to the element’s native <${tag}> semantics`}.`,
|
package/engine/wcag22.js
CHANGED
|
@@ -25,7 +25,7 @@ const sc = (num, name, level, automation, since = '2.0') => ({
|
|
|
25
25
|
export default [
|
|
26
26
|
// 1. Perceivable
|
|
27
27
|
sc('1.1.1', 'Non-text Content', 'A', 'partial'),
|
|
28
|
-
sc('1.2.1', 'Audio-only and Video-only (Prerecorded)', 'A', '
|
|
28
|
+
sc('1.2.1', 'Audio-only and Video-only (Prerecorded)', 'A', 'partial'), // partial: the audio-only transcript question is askable; silent-video detection is not
|
|
29
29
|
sc('1.2.2', 'Captions (Prerecorded)', 'A', 'partial'),
|
|
30
30
|
sc('1.2.3', 'Audio Description or Media Alternative (Prerecorded)', 'A', 'manual'),
|
|
31
31
|
sc('1.2.4', 'Captions (Live)', 'AA', 'manual'),
|
|
@@ -35,7 +35,7 @@ export default [
|
|
|
35
35
|
sc('1.2.8', 'Media Alternative (Prerecorded)', 'AAA', 'manual'),
|
|
36
36
|
sc('1.2.9', 'Audio-only (Live)', 'AAA', 'manual'),
|
|
37
37
|
sc('1.3.1', 'Info and Relationships', 'A', 'partial'),
|
|
38
|
-
sc('1.3.2', 'Meaningful Sequence', 'A', '
|
|
38
|
+
sc('1.3.2', 'Meaningful Sequence', 'A', 'partial'), // partial: CSS-reorder divergence is measurable; whether meaning survives is the reader's call
|
|
39
39
|
sc('1.3.3', 'Sensory Characteristics', 'A', 'manual'),
|
|
40
40
|
sc('1.3.4', 'Orientation', 'AA', 'partial', '2.1'), // orientation-lock (2026-08-01) proves CSS root hides/rotations; script locks stay a human check
|
|
41
41
|
sc('1.3.5', 'Identify Input Purpose', 'AA', 'partial', '2.1'), // partial: wrong tokens are provable; MISSING autocomplete on identity fields needs judgment
|
|
@@ -86,7 +86,7 @@ export default [
|
|
|
86
86
|
sc('2.5.4', 'Motion Actuation', 'A', 'manual', '2.1'),
|
|
87
87
|
sc('2.5.5', 'Target Size (Enhanced)', 'AAA', 'partial', '2.1'),
|
|
88
88
|
sc('2.5.6', 'Concurrent Input Mechanisms', 'AAA', 'manual', '2.1'),
|
|
89
|
-
sc('2.5.7', 'Dragging Movements', 'AA', '
|
|
89
|
+
sc('2.5.7', 'Dragging Movements', 'AA', 'partial', '2.2'), // partial: declared draggable UI is visible; pointer-event drag logic is not
|
|
90
90
|
sc('2.5.8', 'Target Size (Minimum)', 'AA', 'partial', '2.2'), // partial: size/spacing are computable; the equivalent-control and essential exceptions need judgment
|
|
91
91
|
// 3. Understandable
|
|
92
92
|
sc('3.1.1', 'Language of Page', 'A', 'partial'), // partial: html-lang proves the tag is present and well-formed; whether it names the language actually written needs a reader
|
|
@@ -107,7 +107,7 @@ export default [
|
|
|
107
107
|
sc('3.3.4', 'Error Prevention (Legal, Financial, Data)', 'AA', 'manual'),
|
|
108
108
|
sc('3.3.5', 'Help', 'AAA', 'manual'),
|
|
109
109
|
sc('3.3.6', 'Error Prevention (All)', 'AAA', 'manual'),
|
|
110
|
-
sc('3.3.7', 'Redundant Entry', 'A', '
|
|
110
|
+
sc('3.3.7', 'Redundant Entry', 'A', 'partial', '2.2'), // partial: duplicate autocomplete purposes in one form are visible; cross-page processes are not
|
|
111
111
|
sc('3.3.8', 'Accessible Authentication (Minimum)', 'AA', 'partial', '2.2'), // partial: auth-field-obstruction catches paste blocking; alternatives need judgment
|
|
112
112
|
sc('3.3.9', 'Accessible Authentication (Enhanced)', 'AAA', 'manual', '2.2'),
|
|
113
113
|
// 4. Robust (4.1.1 Parsing was removed in WCAG 2.2)
|
package/package.json
CHANGED