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
|
@@ -6,39 +6,16 @@ const REF_ATTRIBUTES = [
|
|
|
6
6
|
'aria-activedescendant', 'aria-owns', 'aria-errormessage', 'aria-details',
|
|
7
7
|
];
|
|
8
8
|
|
|
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;
|
|
22
|
-
|
|
23
|
-
/** Fire the events a tooltip library listens for, wait for it to react,
|
|
24
|
-
* check whether the ids materialised, then put the page back. */
|
|
25
|
-
async function probeDeferred(element, ids) {
|
|
26
|
-
const over = ['pointerover', 'mouseover', 'mouseenter'];
|
|
27
|
-
const out = ['pointerout', 'mouseout', 'mouseleave'];
|
|
28
|
-
for (const type of over) element.dispatchEvent(new MouseEvent(type, { bubbles: type !== 'mouseenter' }));
|
|
29
|
-
element.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
|
30
|
-
try { element.focus?.({ preventScroll: true }); } catch { /* non-focusable */ }
|
|
31
|
-
await new Promise((resolve) => setTimeout(resolve, PROBE_WAIT_MS));
|
|
32
|
-
const root = element.getRootNode();
|
|
33
|
-
const appeared = ids.every((id) => root.getElementById?.(id));
|
|
34
|
-
// Restore: the audit must leave the page as it found it, and later rules
|
|
35
|
-
// must not meet a tooltip that only exists because we hovered.
|
|
36
|
-
for (const type of out) element.dispatchEvent(new MouseEvent(type, { bubbles: type !== 'mouseleave' }));
|
|
37
|
-
element.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
|
38
|
-
try { element.blur?.(); } catch { /* ignore */ }
|
|
39
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
40
|
-
return appeared;
|
|
41
|
-
}
|
|
9
|
+
// No hover/focus probe (removed 2026-08-26, David). From 1.2.81 to this
|
|
10
|
+
// change the rule dispatched hover and focus events at elements whose
|
|
11
|
+
// aria-describedby pointed nowhere, to catch tooltip libraries that create
|
|
12
|
+
// the target on demand. That mutated the page under audit: focus was taken
|
|
13
|
+
// and not given back, hover-driven menus opened, page scripts ran, mobile
|
|
14
|
+
// keyboards popped, and 300 ms went by per element. Once a dead describedby
|
|
15
|
+
// became a review rather than a fail (same morning) the probe only bought
|
|
16
|
+
// fewer review rows, which is not worth an audit that is no longer
|
|
17
|
+
// read-only. The deferred-tooltip pattern is now named in the review
|
|
18
|
+
// message instead, for the human to confirm.
|
|
42
19
|
|
|
43
20
|
function inspect(element) {
|
|
44
21
|
// ARIA id references cannot cross shadow boundaries: they resolve only
|
|
@@ -67,29 +44,42 @@ function inspect(element) {
|
|
|
67
44
|
return { missing, ambiguous };
|
|
68
45
|
}
|
|
69
46
|
|
|
70
|
-
function outcome({ missing, ambiguous },
|
|
47
|
+
function outcome({ missing, ambiguous }, element, accessibleName) {
|
|
71
48
|
if (missing.length) {
|
|
72
49
|
// A dangling ref is skipped by the accname computation, so the HARM
|
|
73
|
-
// depends on what remains.
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
50
|
+
// depends on what remains, and 4.1.2 only cares about name, role and
|
|
51
|
+
// user-settable value (re-decided by David 2026-08-26 with axe-core's
|
|
52
|
+
// measured verdicts as the second opinion; the old "fragile,
|
|
53
|
+
// validator-flagged" argument was 4.1.1's, and 2.2 retired it):
|
|
54
|
+
// - aria-labelledby that dangles while the element still names
|
|
55
|
+
// itself from elsewhere: the name is fully determinable (accname
|
|
56
|
+
// step 2B processes the valid IDREFs), so it is not a finding here;
|
|
57
|
+
// - aria-labelledby that leaves the element nameless, and
|
|
58
|
+
// aria-activedescendant (a focused composite reports the wrong
|
|
59
|
+
// active item): the 4.1.2 failure proper, asserted;
|
|
60
|
+
// - aria-describedby that points nowhere at rest: a description is
|
|
61
|
+
// not name, role or a user-set value, and tooltip libraries create
|
|
62
|
+
// the target on hover or focus, so the DOM proves an authoring
|
|
63
|
+
// error at most. Asked, never asserted;
|
|
64
|
+
// - aria-controls, aria-owns, aria-details, aria-errormessage: the
|
|
65
|
+
// standing policy (asserted; the comparator asserts these too).
|
|
79
66
|
const restingName = (accessibleName?.(element) ?? '').trim();
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
67
|
+
const relevant = missing.filter(({ attr }) => !(attr === 'aria-labelledby' && restingName));
|
|
68
|
+
if (!relevant.length) return { status: 'pass' };
|
|
69
|
+
const line = ({ attr, id }) => attr === 'aria-labelledby'
|
|
70
|
+
? `aria-labelledby="${id}" points to nothing and leaves this element without an accessible name`
|
|
71
|
+
: `${attr}="${id}" points to nothing — assistive technology silently ignores it`;
|
|
72
|
+
const asserted = relevant.filter(({ attr }) => attr !== 'aria-describedby');
|
|
73
|
+
if (asserted.length) {
|
|
74
|
+
return {
|
|
75
|
+
status: 'fail',
|
|
76
|
+
message: `Broken ARIA references: ${relevant.map(line).join('; ')}.`,
|
|
77
|
+
fix: 'Correct or remove the reference, or give the target element that id.',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
90
80
|
return {
|
|
91
|
-
status: '
|
|
92
|
-
message:
|
|
81
|
+
status: 'incomplete',
|
|
82
|
+
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
83
|
fix: 'Correct or remove the reference, or give the target element that id.',
|
|
94
84
|
};
|
|
95
85
|
}
|
|
@@ -116,26 +106,7 @@ export default {
|
|
|
116
106
|
help: 'ARIA id references must point to elements that exist',
|
|
117
107
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
118
108
|
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;
|
|
109
|
+
evaluate(element, { accessibleName } = {}) {
|
|
110
|
+
return outcome(inspect(element), element, accessibleName);
|
|
140
111
|
},
|
|
141
112
|
};
|
|
@@ -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