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
|
@@ -98,7 +98,22 @@ function reachableRects(element, rect) {
|
|
|
98
98
|
if (x < 0 || y < 0 || x >= win.innerWidth || y >= win.innerHeight) continue;
|
|
99
99
|
const stack = doc.elementsFromPoint(x, y);
|
|
100
100
|
const index = stack.indexOf(element);
|
|
101
|
-
if (index < 0)
|
|
101
|
+
if (index < 0) {
|
|
102
|
+
// The probe ran and the stack came back without the element.
|
|
103
|
+
// elementsFromPoint reports covered elements too, so for a light-DOM
|
|
104
|
+
// element this is not don't-know but a definitive no: nothing at this
|
|
105
|
+
// point accepts a pointer for it. The sr-only WRAPPER pattern makes
|
|
106
|
+
// exactly this shape — the clipped 1×1 box is the wrapper, the skip
|
|
107
|
+
// link inside keeps its full-size rect, and every self-applied hiding
|
|
108
|
+
// check (own rect, own opacity, own clip) passes; that phantom rect
|
|
109
|
+
// crowded frame.work's 160×21 header wordmark out of the spacing
|
|
110
|
+
// exception its layout plainly earns. Shadow-tree elements are
|
|
111
|
+
// invisible to the document's hit-test by design (the stack shows
|
|
112
|
+
// their host), so absence proves nothing there and the fragment keeps
|
|
113
|
+
// the benefit of the doubt.
|
|
114
|
+
if (element.getRootNode() === doc) testable = true;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
102
117
|
testable = true;
|
|
103
118
|
// Anything above it that is part of the same control is not an
|
|
104
119
|
// obstruction: a button's own icon paints over the button, an overlay
|
|
@@ -107,9 +122,9 @@ function reachableRects(element, rect) {
|
|
|
107
122
|
reachable.push(fragment);
|
|
108
123
|
}
|
|
109
124
|
}
|
|
110
|
-
// Nothing could be probed at all (entirely out of viewport,
|
|
111
|
-
//
|
|
112
|
-
// unanswerable, and don't-know must not silence a finding — but the
|
|
125
|
+
// Nothing could be probed at all (entirely out of viewport, or a
|
|
126
|
+
// shadow-tree element the document's hit-test cannot see): the OCCLUSION
|
|
127
|
+
// question is unanswerable, and don't-know must not silence a finding — but the
|
|
113
128
|
// fragment GEOMETRY is measurable regardless, so the fragments stand,
|
|
114
129
|
// never the bounding box. A wrapped link's box is mostly leading and
|
|
115
130
|
// line-gap no pointer can activate; counting that empty space as
|
|
@@ -158,6 +173,68 @@ function isInTextLine(element) {
|
|
|
158
173
|
return false;
|
|
159
174
|
}
|
|
160
175
|
|
|
176
|
+
// ── User Agent Control, proved from the stylesheets ──────────────────
|
|
177
|
+
// The exception covers a native control whose size "is determined by the
|
|
178
|
+
// user agent and is not modified by the author". A bare <button>Save</button>
|
|
179
|
+
// at 46×21 was asserted under 2.5.5, and two stacked ones under 2.5.8's
|
|
180
|
+
// spacing test, until this proof existed (2026-08-25 overnight audit).
|
|
181
|
+
// Proof: no inline style and no readable author rule that matches the
|
|
182
|
+
// element declares a property that can change its box. An unreadable
|
|
183
|
+
// (cross-origin) sheet, or a selector the browser cannot parse, leaves the
|
|
184
|
+
// proof open: the finding then reviews instead of asserting. The sheet walk
|
|
185
|
+
// happens once per root and is cached on it for the audit; media rules that
|
|
186
|
+
// do not apply now are skipped, since they size nothing now.
|
|
187
|
+
const NATIVE_CONTROL = /^(button|input|select|textarea)$/i;
|
|
188
|
+
const SIZING = /^(width|height|(min|max)-(width|height|inline-size|block-size)|inline-size|block-size|padding(-.+)?|border(-.+)?|font(-.+)?|line-height|box-sizing|transform|scale|zoom|appearance|all)$/;
|
|
189
|
+
const sizingRulesByRoot = new WeakMap();
|
|
190
|
+
|
|
191
|
+
function declaresSizing(style) {
|
|
192
|
+
for (let k = 0; k < style.length; k++) if (SIZING.test(style[k])) return true;
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function authorSizingRules(root) {
|
|
197
|
+
const sheets = [...(root.styleSheets ?? []), ...(root.adoptedStyleSheets ?? [])];
|
|
198
|
+
const key = sheets.map((s) => { try { return s.cssRules.length; } catch { return 'x'; } }).join(',');
|
|
199
|
+
const cached = sizingRulesByRoot.get(root);
|
|
200
|
+
if (cached?.key === key) return cached;
|
|
201
|
+
const win = (root.ownerDocument ?? root).defaultView;
|
|
202
|
+
const probe = (root.ownerDocument ?? root).createElement('div');
|
|
203
|
+
const entry = { key, selectors: [], open: false };
|
|
204
|
+
const walk = (rules, parent) => {
|
|
205
|
+
for (const rule of rules) {
|
|
206
|
+
if (rule.media && win?.matchMedia && !win.matchMedia(rule.media.mediaText).matches) continue;
|
|
207
|
+
let selector = rule.selectorText ?? parent; // nested declarations belong to the parent
|
|
208
|
+
// CSS nesting: "& .x" means ":is(parent) .x", and a child with no "&" is
|
|
209
|
+
// a descendant of the parent.
|
|
210
|
+
if (parent && rule.selectorText) {
|
|
211
|
+
selector = /&/.test(selector) ? selector.replace(/&/g, `:is(${parent})`) : `:is(${parent}) ${selector}`;
|
|
212
|
+
}
|
|
213
|
+
if (selector && rule.style && declaresSizing(rule.style)) {
|
|
214
|
+
try { probe.matches(selector); entry.selectors.push(selector); } catch { entry.open = true; }
|
|
215
|
+
}
|
|
216
|
+
if (rule.cssRules?.length) walk(rule.cssRules, rule.selectorText ? selector : parent);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
for (const sheet of sheets) {
|
|
220
|
+
try { walk(sheet.cssRules, null); } catch { entry.open = true; }
|
|
221
|
+
}
|
|
222
|
+
entry.combined = entry.selectors.join(', ');
|
|
223
|
+
sizingRulesByRoot.set(root, entry);
|
|
224
|
+
return entry;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 'yes' when the browser provably sized this native control, 'no' when the
|
|
228
|
+
* author touched its box (or it is not a native control), 'open' when a
|
|
229
|
+
* sheet or selector could not be read. */
|
|
230
|
+
function uaSized(element) {
|
|
231
|
+
if (!NATIVE_CONTROL.test(element.tagName) || element.type === 'image') return 'no';
|
|
232
|
+
if (declaresSizing(element.style)) return 'no';
|
|
233
|
+
const entry = authorSizingRules(element.getRootNode());
|
|
234
|
+
if (entry.combined && element.matches(entry.combined)) return 'no';
|
|
235
|
+
return entry.open ? 'open' : 'yes';
|
|
236
|
+
}
|
|
237
|
+
|
|
161
238
|
/** Nearest distance from a point to a rectangle (0 when inside it). */
|
|
162
239
|
function pointToRectDistance(point, rect) {
|
|
163
240
|
const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
|
|
@@ -173,6 +250,21 @@ function pointToRectDistance(point, rect) {
|
|
|
173
250
|
* lists only equivalent/inline/user-agent/essential there)
|
|
174
251
|
*/
|
|
175
252
|
export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingException }) {
|
|
253
|
+
// The verdict compares exact floats, so display must not round a failing
|
|
254
|
+
// dimension up to the minimum: a 43.8px-tall button failing the AAA 44px
|
|
255
|
+
// bar must not read "44×44px — below the 44×44px minimum" (World Bank,
|
|
256
|
+
// 2026-08-25). One decimal only where integer rounding would cross the line,
|
|
257
|
+
// and a second decimal where one decimal STILL rounds up to it: toFixed
|
|
258
|
+
// rounds too, so 23.98 printed "24.0" and 43.98 printed "44.0" (2026-08-25
|
|
259
|
+
// overnight audit). Anything closer than that reads "just under".
|
|
260
|
+
const px = (v) => {
|
|
261
|
+
if (!(Math.round(v) >= min && v < min)) return String(Math.round(v));
|
|
262
|
+
for (const digits of [1, 2]) {
|
|
263
|
+
const shown = v.toFixed(digits);
|
|
264
|
+
if (Number(shown) < min) return shown;
|
|
265
|
+
}
|
|
266
|
+
return `just under ${min}`;
|
|
267
|
+
};
|
|
176
268
|
return {
|
|
177
269
|
id,
|
|
178
270
|
impact: 'serious',
|
|
@@ -381,6 +473,36 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
381
473
|
return effective;
|
|
382
474
|
};
|
|
383
475
|
|
|
476
|
+
// ── Equivalent targets ────────────────────────────────────────────
|
|
477
|
+
// Both criteria except a target whose function is available through
|
|
478
|
+
// another control on the same page that meets the minimum. Same
|
|
479
|
+
// destination is the one form of "same function" the DOM can prove,
|
|
480
|
+
// and obscuredRect above already reads matching hrefs that way. The
|
|
481
|
+
// card pattern (a 200×150 image link over a 16px title link to the
|
|
482
|
+
// same article) failed on the title link until the undersized path
|
|
483
|
+
// asked the same question (2026-08-25 overnight audit). A bare "#" or
|
|
484
|
+
// a javascript: URL names no destination, so it never pairs.
|
|
485
|
+
const destinationOf = (element) => {
|
|
486
|
+
if (typeof element.href !== 'string' || !element.href) return null;
|
|
487
|
+
const attr = (element.getAttribute('href') ?? '').trim();
|
|
488
|
+
if (!attr || attr === '#' || /^javascript:/i.test(attr)) return null;
|
|
489
|
+
return element.href;
|
|
490
|
+
};
|
|
491
|
+
let adequateDestinations = null;
|
|
492
|
+
const equivalentElsewhere = (i) => {
|
|
493
|
+
const destination = destinationOf(elements[i]);
|
|
494
|
+
if (!destination) return false;
|
|
495
|
+
if (!adequateDestinations) {
|
|
496
|
+
adequateDestinations = new Set();
|
|
497
|
+
elements.forEach((other, j) => {
|
|
498
|
+
if (!laidOut[j] || undersized[j] || other.ownerDocument !== elements[i].ownerDocument) return;
|
|
499
|
+
const d = destinationOf(other);
|
|
500
|
+
if (d) adequateDestinations.add(d);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
return adequateDestinations.has(destination);
|
|
504
|
+
};
|
|
505
|
+
|
|
384
506
|
return elements.map((element, i) => {
|
|
385
507
|
if (!laidOut[i]) return { status: 'pass' }; // not laid out / not a pointer target
|
|
386
508
|
if (!undersized[i]) {
|
|
@@ -417,7 +539,7 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
417
539
|
const rect = rects[i];
|
|
418
540
|
return {
|
|
419
541
|
status: 'fail',
|
|
420
|
-
message: `This target's box is ${Math.round(rect.width)}×${Math.round(rect.height)}px, but another target overlaps it and clicks in the overlap go there — the part that still accepts the pointer is ${
|
|
542
|
+
message: `This target's box is ${Math.round(rect.width)}×${Math.round(rect.height)}px, but another target overlaps it and clicks in the overlap go there — the part that still accepts the pointer is ${px(effective.width)}×${px(effective.height)}px, below the ${min}×${min}px minimum.`,
|
|
421
543
|
fix: 'Space the targets so they no longer overlap, or enlarge this one until the uncovered part reaches the minimum.',
|
|
422
544
|
data: {
|
|
423
545
|
box: { width: Math.round(rect.width), height: Math.round(rect.height) },
|
|
@@ -426,6 +548,11 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
426
548
|
};
|
|
427
549
|
}
|
|
428
550
|
if (uaControlled(element)) return { status: 'pass' }; // UA Control exception
|
|
551
|
+
// The same exception for every other native control, proved from the
|
|
552
|
+
// sheets; an open proof (unreadable sheet) reviews at the end instead
|
|
553
|
+
// of asserting, and only if nothing else rescues the target first.
|
|
554
|
+
const uaProof = uaSized(element);
|
|
555
|
+
if (uaProof === 'yes') return { status: 'pass' };
|
|
429
556
|
if (isInTextLine(element)) return { status: 'pass' };
|
|
430
557
|
if (spacingException) {
|
|
431
558
|
// Spacing exception: the min-sized circle around this target must
|
|
@@ -471,12 +598,19 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
471
598
|
// dismissed, and suppressing it would hide a real defect behind a
|
|
472
599
|
// transient overlay, which is the wrong way round.
|
|
473
600
|
}
|
|
601
|
+
if (equivalentElsewhere(i)) return { status: 'pass' }; // Equivalent exception, proved by href
|
|
474
602
|
const rect = rects[i];
|
|
603
|
+
if (uaProof === 'open') {
|
|
604
|
+
return {
|
|
605
|
+
status: 'incomplete',
|
|
606
|
+
message: `This native control is ${px(rect.width)}×${px(rect.height)}px, under the ${min}×${min}px minimum${spacingException ? ', and another target crowds it' : ''}. The browser sizes an unstyled control (the User Agent Control exception), but a stylesheet this audit cannot read might set its size. Check whether author CSS sizes this control; if nothing does, it passes.`,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
475
609
|
return {
|
|
476
610
|
status: 'fail',
|
|
477
611
|
message: spacingException
|
|
478
|
-
? `This target is ${
|
|
479
|
-
: `This target is ${
|
|
612
|
+
? `This target is ${px(rect.width)}×${px(rect.height)}px AND another target crowds it (within ${min}px) — small targets are only acceptable with clear space around them; crowded ones are hard to hit for users with motor impairments.`
|
|
613
|
+
: `This target is ${px(rect.width)}×${px(rect.height)}px — below the ${min}×${min}px minimum, hard to hit for users with motor impairments.`,
|
|
480
614
|
fix: `Increase the element’s size or padding to at least ${min}×${min}px${spacingException ? ', or add spacing between the targets' : ''}.`,
|
|
481
615
|
};
|
|
482
616
|
});
|
|
@@ -21,13 +21,24 @@ export default {
|
|
|
21
21
|
visibleOnly: false,
|
|
22
22
|
evaluate(element) {
|
|
23
23
|
const lang = element.getAttribute('lang')?.trim();
|
|
24
|
-
if (!lang) {
|
|
24
|
+
if (!element.hasAttribute('lang')) {
|
|
25
25
|
return {
|
|
26
26
|
status: 'fail',
|
|
27
27
|
message: 'No lang attribute: screen readers will guess the language and may mispronounce everything.',
|
|
28
28
|
fix: 'Add lang to the html element, e.g. <html lang="en">.',
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
|
+
// H57 step 1 (the attribute exists) passes here and step 2 (a BCP 47
|
|
32
|
+
// value) fails: an empty value declares nothing. Named separately so
|
|
33
|
+
// the author looks at the value, not for a missing attribute
|
|
34
|
+
// (2026-08-25 overnight audit).
|
|
35
|
+
if (!lang) {
|
|
36
|
+
return {
|
|
37
|
+
status: 'fail',
|
|
38
|
+
message: 'The lang attribute is present but empty, so the page declares no language: screen readers will guess it and may mispronounce everything.',
|
|
39
|
+
fix: 'Give lang a BCP 47 value, e.g. <html lang="en">.',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
31
42
|
if (!LANG_PATTERN.test(lang)) {
|
|
32
43
|
return {
|
|
33
44
|
status: 'fail',
|
|
@@ -16,7 +16,12 @@
|
|
|
16
16
|
// Writes and calls only: reading location (analytics, hash checks) is not
|
|
17
17
|
// navigation, so the pattern demands an assignment to location or one of
|
|
18
18
|
// the navigating members/calls.
|
|
19
|
-
|
|
19
|
+
// The href branch demands an assignment too (2026-08-25 overnight audit):
|
|
20
|
+
// `location.href` READ as an argument (track(location.href, …)) matched the
|
|
21
|
+
// old pattern, so an analytics handler was reported as navigation. Now
|
|
22
|
+
// `location =`, `location.href =` (or `+=`) write, and assign / replace /
|
|
23
|
+
// reload must be called.
|
|
24
|
+
const NAVIGATES = /\blocation\s*(\+?=(?!=)|\.\s*(href\s*\+?=(?!=)|(assign|replace|reload)\s*\())|\.(submit|requestSubmit)\s*\(|window\.open\s*\(/;
|
|
20
25
|
|
|
21
26
|
export default {
|
|
22
27
|
id: 'on-input-navigation',
|
|
@@ -35,13 +40,18 @@ export default {
|
|
|
35
40
|
fix: 'Trigger navigation from activation (click/Enter), never from focus.',
|
|
36
41
|
};
|
|
37
42
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// Both change-shaped events are read on their own (2026-08-25 overnight
|
|
44
|
+
// audit): a harmless onchange used to hide a submitting oninput, and F36
|
|
45
|
+
// applies whichever event fires the submit.
|
|
46
|
+
for (const attr of ['onchange', 'oninput']) {
|
|
47
|
+
const handler = element.getAttribute(attr);
|
|
48
|
+
if (handler && NAVIGATES.test(handler)) {
|
|
49
|
+
return {
|
|
50
|
+
status: 'incomplete',
|
|
51
|
+
message: `Changing this control appears to navigate or submit (its ${attr} handler reaches for location/submit). WCAG 3.2.2 allows that only when users are told beforehand: check the page says so before the control.`,
|
|
52
|
+
fix: 'Describe the behaviour before the control, or navigate from an explicit Go button instead of the change event.',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
45
55
|
}
|
|
46
56
|
return { status: 'pass' };
|
|
47
57
|
},
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// WCAG SC 3.3.7 Redundant Entry (Level A, new in 2.2)
|
|
2
|
+
//
|
|
3
|
+
// Information the user already gave in a process must be auto-populated or
|
|
4
|
+
// available to select, unless re-entry is essential, needed for security,
|
|
5
|
+
// or the earlier value has gone stale. A cross-page "process" is invisible
|
|
6
|
+
// to a one-shot scan, but one slice is visible in a single form: two
|
|
7
|
+
// enabled fields declaring the SAME autocomplete purpose. A form with two
|
|
8
|
+
// `autocomplete="email"` fields is asking for the email twice — exactly
|
|
9
|
+
// the shape the criterion exists for — or it is a confirmation field,
|
|
10
|
+
// which is the human's call (the SC's "essential" escape). So this asks,
|
|
11
|
+
// it never asserts.
|
|
12
|
+
//
|
|
13
|
+
// The security escape is honoured structurally: password and one-time-code
|
|
14
|
+
// purposes never count (re-entering a password to confirm it IS the
|
|
15
|
+
// documented security exception). Distinct scopes are honoured too: the
|
|
16
|
+
// autocomplete grammar prefixes a field token with `section-*` / shipping /
|
|
17
|
+
// billing, and a shipping address plus a billing address are two different
|
|
18
|
+
// pieces of information — only fully identical normalized token lists pair
|
|
19
|
+
// up. Fields must be user-reachable: hidden inputs, disabled and readonly
|
|
20
|
+
// fields auto-populate without the user typing, which is the SC being
|
|
21
|
+
// SATISFIED, not violated.
|
|
22
|
+
const EXEMPT = new Set(['new-password', 'current-password', 'one-time-code', 'on', 'off']);
|
|
23
|
+
|
|
24
|
+
export default {
|
|
25
|
+
id: 'redundant-entry',
|
|
26
|
+
name: 'Redundant entry',
|
|
27
|
+
impact: 'moderate',
|
|
28
|
+
tags: ['wcag22a', 'wcag337'],
|
|
29
|
+
help: 'A form should not ask for the same information twice',
|
|
30
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/redundant-entry.html',
|
|
31
|
+
selector: 'form',
|
|
32
|
+
evaluate(element, { isVisible }) {
|
|
33
|
+
const seen = new Map();
|
|
34
|
+
for (const field of element.querySelectorAll('input[autocomplete], select[autocomplete], textarea[autocomplete]')) {
|
|
35
|
+
if (field.disabled || field.readOnly) continue;
|
|
36
|
+
// A field the user cannot reach (a display:none branch of a
|
|
37
|
+
// mutually-exclusive section, a collapsed disclosure) is not
|
|
38
|
+
// "required to be entered again" (2026-08-25 overnight audit).
|
|
39
|
+
if (!isVisible(field)) continue;
|
|
40
|
+
if (field instanceof HTMLInputElement && (field.type === 'hidden' || field.type === 'password')) continue;
|
|
41
|
+
const purpose = (field.getAttribute('autocomplete') || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
42
|
+
if (!purpose || EXEMPT.has(purpose)) continue;
|
|
43
|
+
if (seen.has(purpose)) {
|
|
44
|
+
return {
|
|
45
|
+
status: 'incomplete',
|
|
46
|
+
message: `This form asks for "${purpose}" twice — 3.3.7 says information the user already entered must be auto-populated or selectable, not typed again. A deliberate confirmation field can be essential (the criterion's own escape); that judgement is yours.`,
|
|
47
|
+
fix: 'Auto-populate the second field from the first, offer the earlier value for selection, or drop the duplicate. Keep it only if re-entry is genuinely essential here.',
|
|
48
|
+
data: { purpose },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
seen.set(purpose, field);
|
|
52
|
+
}
|
|
53
|
+
return { status: 'pass' };
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -11,6 +11,22 @@
|
|
|
11
11
|
// emailed link) that this rule cannot see. Blocked paste is therefore
|
|
12
12
|
// reported for REVIEW, not failed. Both mechanisms obstructed at once is the
|
|
13
13
|
// case worth asserting.
|
|
14
|
+
//
|
|
15
|
+
// Paste only (2026-08-25 overnight audit): an ondrop handler used to count
|
|
16
|
+
// as a paste blocker. It is not. Drag and drop is a different event and a
|
|
17
|
+
// different user action, appears nowhere in the SC, its notes or F109, and
|
|
18
|
+
// leaves copy and paste fully working, so ondrop is no evidence at all.
|
|
19
|
+
//
|
|
20
|
+
// Scope (2026-08-25 overnight audit): the Understanding says the criterion
|
|
21
|
+
// "is focused on authentication of existing users. It does not cover
|
|
22
|
+
// creation of a username or initiation of an account." Choosing a new
|
|
23
|
+
// password recalls nothing. So autocomplete="new-password" fields are out
|
|
24
|
+
// of the selector, and a bare type="password" is only PROVABLY a login
|
|
25
|
+
// credential when the author says so (autocomplete="current-password") or
|
|
26
|
+
// the form has the classic sign-in shape: exactly one password field beside
|
|
27
|
+
// a username or email field. Two password fields in one form is the shape
|
|
28
|
+
// of sign-up or a password change, and that field is asked about, never
|
|
29
|
+
// failed. The fail branch fires only on a provable login credential.
|
|
14
30
|
export default {
|
|
15
31
|
id: 'auth-field-obstruction',
|
|
16
32
|
name: 'Accessible login fields',
|
|
@@ -18,36 +34,50 @@ export default {
|
|
|
18
34
|
tags: ['wcag22aa', 'wcag338'],
|
|
19
35
|
help: 'Login fields must not block paste or password managers',
|
|
20
36
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-minimum.html',
|
|
21
|
-
selector: 'input[type="password"]
|
|
37
|
+
selector: 'input[type="password"]:not([autocomplete~="new-password"]), input[autocomplete~="current-password"], input[autocomplete~="one-time-code"]',
|
|
22
38
|
evaluate(element) {
|
|
23
39
|
// Inline handlers are the only statically-visible blockers; framework
|
|
24
40
|
// listeners are invisible to a DOM audit.
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
const blocksPaste = /return\s+false|preventDefault/.test(element.getAttribute('onpaste') ?? '');
|
|
42
|
+
const tokens = (element.getAttribute('autocomplete') ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
43
|
+
const autocompleteOff = tokens.length === 1 && tokens[0] === 'off';
|
|
44
|
+
if (!blocksPaste && !autocompleteOff) return { status: 'pass' };
|
|
45
|
+
|
|
46
|
+
// Is this field provably an existing credential, i.e. inside 3.3.8?
|
|
47
|
+
const form = element.form ?? element.closest('form');
|
|
48
|
+
const passwordFields = form ? form.querySelectorAll('input[type="password"]').length : (element.type === 'password' ? 1 : 0);
|
|
49
|
+
const identityField = form?.querySelector('input[autocomplete~="username"], input[autocomplete~="email"], input[type="email"]');
|
|
50
|
+
const provenLogin = tokens.includes('current-password') || tokens.includes('one-time-code')
|
|
51
|
+
|| (passwordFields === 1 && Boolean(identityField));
|
|
52
|
+
const scopeQuestion = passwordFields >= 2
|
|
53
|
+
? 'This form holds more than one password field, the shape of account sign-up or a password change, which 3.3.8 does not cover unless the field takes an existing credential. Confirm whether this field is part of an authentication step at all.'
|
|
54
|
+
: 'Nothing here shows the field takes an existing credential (no current-password purpose, and no username or email field beside it), so confirm it is part of signing in before treating this as a failure.';
|
|
55
|
+
const step = provenLogin ? 'this login' : 'this step';
|
|
56
|
+
|
|
57
|
+
if (blocksPaste && autocompleteOff) {
|
|
58
|
+
if (provenLogin) {
|
|
59
|
+
return {
|
|
60
|
+
status: 'fail',
|
|
61
|
+
message: `This authentication field blocks pasting AND sets autocomplete="off", so both of the mechanisms 3.3.8 names are obstructed at once: copy and paste, and password-manager entry. What is left is typing the credential from memory.`,
|
|
62
|
+
fix: 'Remove the paste blocking, and drop autocomplete="off" so a password manager can fill the field.',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
39
65
|
return {
|
|
40
66
|
status: 'incomplete',
|
|
41
|
-
message: `This
|
|
42
|
-
fix:
|
|
67
|
+
message: `This password field blocks pasting AND sets autocomplete="off". If it is part of signing in, both of the mechanisms 3.3.8 names are obstructed at once (copy and paste, and password-manager entry) and it fails. ${scopeQuestion}`,
|
|
68
|
+
fix: 'Remove the paste blocking, and drop autocomplete="off" so a password manager can fill the field.',
|
|
43
69
|
};
|
|
44
70
|
}
|
|
45
|
-
if (
|
|
71
|
+
if (blocksPaste) {
|
|
46
72
|
return {
|
|
47
73
|
status: 'incomplete',
|
|
48
|
-
message:
|
|
74
|
+
message: `This field blocks pasting, which removes copy and paste, one of the two mechanisms 3.3.8 names. A password manager fills the field directly and is unaffected, so this is only a failure if nothing else here helps. Check whether a password manager can complete ${step}, or whether the page offers another way in such as a passkey or a federated sign-in.${provenLogin ? '' : ` ${scopeQuestion}`}`,
|
|
75
|
+
fix: 'Remove the paste blocking, or make sure another way to sign in is available that does not rely on recalling the credential.',
|
|
49
76
|
};
|
|
50
77
|
}
|
|
51
|
-
return {
|
|
78
|
+
return {
|
|
79
|
+
status: 'incomplete',
|
|
80
|
+
message: `This field sets autocomplete="off", discouraging password managers. Browsers often fill anyway: verify a password manager can complete ${step} without retyping.${provenLogin ? '' : ` ${scopeQuestion}`}`,
|
|
81
|
+
};
|
|
52
82
|
},
|
|
53
83
|
};
|
|
@@ -68,7 +68,7 @@ export default {
|
|
|
68
68
|
const names = disallowed.map((n) => `aria-${n}`);
|
|
69
69
|
return {
|
|
70
70
|
status: 'incomplete',
|
|
71
|
-
message: `${names.join(', ')} is not supported on role "${role}", so the browser drops it and assistive technology never sees it. Nothing is announced wrongly, but nothing is announced at all. Does this element really have that state? If it does, it is invisible to a screen reader and this is a 4.1.2 failure. If it does not, the attribute is simply stray and no one is affected.`,
|
|
71
|
+
message: `${names.join(', ')} is not supported on role "${role}", so the browser drops it and assistive technology never sees it. Nothing is announced wrongly, but nothing is announced at all. Does this element really have that state? If it does, it is invisible to a screen reader and this is a 4.1.2 failure. If it does not, the attribute is simply stray and no one is affected. Invalid 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 whether the state reaches users, not the syntax.`,
|
|
72
72
|
fix: `Move ${names.join('/')} to the element whose role supports it, usually the control that toggles this one, or remove it if the element has no such state. Adding a role to this element to make the attribute legal is rarely right: the host language restricts which roles each element may take.`,
|
|
73
73
|
};
|
|
74
74
|
},
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
2
|
// ARIA 1.2 attribute names (shared vocabulary in lib/roles.js), and the
|
|
3
3
|
// allowed values for enumerated ones.
|
|
4
|
-
import { KNOWN_ARIA as KNOWN } from '../../lib/roles.js';
|
|
4
|
+
import { KNOWN_ARIA as KNOWN, GLOBAL_ARIA, ROLE_ARIA, effectiveRole } from '../../lib/roles.js';
|
|
5
5
|
|
|
6
6
|
// Note: "undefined" is a legal token for the tristate/undefinable attributes
|
|
7
7
|
// (aria-expanded, aria-selected, aria-checked, aria-pressed) per ARIA 1.2.
|
|
8
|
+
// aria-hidden and aria-orientation list it too, as their default token
|
|
9
|
+
// ("undefined (default): determined by the user agent" / "orientation is
|
|
10
|
+
// unknown/ambiguous"). Added 2026-08-25 overnight audit: rejecting it was a
|
|
11
|
+
// false assertion on conforming markup.
|
|
8
12
|
const ENUMS = {
|
|
9
13
|
'aria-atomic': ['true', 'false'], 'aria-busy': ['true', 'false'],
|
|
10
14
|
'aria-disabled': ['true', 'false'], 'aria-expanded': ['true', 'false', 'undefined'],
|
|
11
|
-
'aria-hidden': ['true', 'false'], 'aria-modal': ['true', 'false'],
|
|
15
|
+
'aria-hidden': ['true', 'false', 'undefined'], 'aria-modal': ['true', 'false'],
|
|
12
16
|
'aria-multiline': ['true', 'false'], 'aria-multiselectable': ['true', 'false'],
|
|
13
17
|
'aria-readonly': ['true', 'false'], 'aria-required': ['true', 'false'],
|
|
14
18
|
'aria-selected': ['true', 'false', 'undefined'],
|
|
15
19
|
'aria-checked': ['true', 'false', 'mixed', 'undefined'], 'aria-pressed': ['true', 'false', 'mixed', 'undefined'],
|
|
16
20
|
'aria-live': ['off', 'polite', 'assertive'],
|
|
17
|
-
'aria-orientation': ['horizontal', 'vertical'],
|
|
21
|
+
'aria-orientation': ['horizontal', 'vertical', 'undefined'],
|
|
18
22
|
'aria-sort': ['none', 'ascending', 'descending', 'other'],
|
|
19
23
|
'aria-autocomplete': ['none', 'inline', 'list', 'both'],
|
|
20
24
|
'aria-invalid': ['true', 'false', 'grammar', 'spelling'],
|
|
@@ -32,20 +36,52 @@ export default {
|
|
|
32
36
|
selector: '*',
|
|
33
37
|
visibleOnly: false, // applies to hidden elements too; also avoids visibility cost on every node
|
|
34
38
|
evaluate(element) {
|
|
35
|
-
|
|
39
|
+
// Two different findings live here (split 2026-08-25 overnight audit).
|
|
40
|
+
//
|
|
41
|
+
// An UNKNOWN attribute name is ignored outright by the browser: it can
|
|
42
|
+
// alter no name, role or value, so by itself it proves no 4.1.2 failure
|
|
43
|
+
// (the criterion that policed validity, 4.1.1 Parsing, is gone from
|
|
44
|
+
// WCAG 2.2). What it may hide is a real state or name the author reached
|
|
45
|
+
// for and mistyped; where that leaves a control nameless the name rules
|
|
46
|
+
// already assert. So unknown names are a review question, the same
|
|
47
|
+
// stance aria-allowed-attr takes for an unsupported attribute.
|
|
48
|
+
//
|
|
49
|
+
// An enumerated VALUE the browser cannot parse, on a role that supports
|
|
50
|
+
// the state, is different: the author wrote a state and the browser drops
|
|
51
|
+
// it, so the state the element has is invisible. That stays asserted.
|
|
52
|
+
// When the element's role is modelled and does not support the attribute
|
|
53
|
+
// at all, the value is moot and aria-allowed-attr already reviews the
|
|
54
|
+
// attribute; reporting the value too would be the same finding twice.
|
|
55
|
+
const unknown = [];
|
|
56
|
+
const invalid = [];
|
|
57
|
+
let role;
|
|
36
58
|
for (const { name, value } of element.attributes) {
|
|
37
59
|
if (!name.startsWith('aria-')) continue;
|
|
38
|
-
|
|
39
|
-
|
|
60
|
+
const attr = name.slice(5);
|
|
61
|
+
if (!KNOWN.has(attr)) {
|
|
62
|
+
unknown.push(name);
|
|
40
63
|
} else if (ENUMS[name] && !ENUMS[name].includes(value.trim().toLowerCase())) {
|
|
41
|
-
|
|
64
|
+
if (!GLOBAL_ARIA.has(attr)) {
|
|
65
|
+
role ??= effectiveRole(element);
|
|
66
|
+
if (role && ROLE_ARIA[role] && !ROLE_ARIA[role].includes(attr)) continue;
|
|
67
|
+
}
|
|
68
|
+
invalid.push(`${name}="${value}" — allowed values: ${ENUMS[name].join(', ')}`);
|
|
42
69
|
}
|
|
43
70
|
}
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
71
|
+
if (invalid.length) {
|
|
72
|
+
return {
|
|
73
|
+
status: 'fail',
|
|
74
|
+
message: `Invalid ARIA: ${invalid.join('; ')}. Assistive technology ignores attributes it doesn't recognise.`,
|
|
75
|
+
fix: 'Correct the attribute name/value against the ARIA specification, or remove it.',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (unknown.length) {
|
|
79
|
+
return {
|
|
80
|
+
status: 'incomplete',
|
|
81
|
+
message: `${unknown.join(', ')} is not an ARIA attribute, so the browser ignores it and assistive technology never sees it. Nothing is announced wrongly, but nothing is announced at all. Was a real ARIA state or name intended here? If it was, that state or name is missing and this is a 4.1.2 failure. If not, the attribute is stray and no one is affected. Invalid markup is not by itself a WCAG failure: the criterion that policed validity, 4.1.1 Parsing, was removed in WCAG 2.2.`,
|
|
82
|
+
fix: 'Correct the attribute name against the ARIA specification, or remove it.',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { status: 'pass' };
|
|
50
86
|
},
|
|
51
87
|
};
|
|
@@ -23,8 +23,13 @@ export default {
|
|
|
23
23
|
// Transparency is measured up the flat tree: opacity is not an inherited
|
|
24
24
|
// property, so a faded WRAPPER (the standard off-canvas drawer) leaves
|
|
25
25
|
// every control inside it computing opacity: 1 while none of them paint.
|
|
26
|
+
// Any NEGATIVE tabindex takes an element out of sequential focus (HTML:
|
|
27
|
+
// "a negative integer"), not only the literal "-1"; tabIndex parses the
|
|
28
|
+
// attribute the way the browser does. Guarded by hasAttribute because
|
|
29
|
+
// Chromium reports tabIndex -1 for a contenteditable div that has no
|
|
30
|
+
// attribute at all, and that one IS reachable. 2026-08-25 overnight audit.
|
|
26
31
|
const focusable = [element, ...element.querySelectorAll(FOCUSABLE)].filter(
|
|
27
|
-
(el) => el.matches?.(FOCUSABLE) && el.
|
|
32
|
+
(el) => el.matches?.(FOCUSABLE) && !(el.hasAttribute('tabindex') && el.tabIndex < 0) && !el.matches(':disabled')
|
|
28
33
|
// `inert` removes focusability outright — aria-hidden + inert is
|
|
29
34
|
// the CORRECT modern pattern for off-canvas content, not a defect.
|
|
30
35
|
&& !el.closest('[inert]')
|
|
@@ -72,7 +72,7 @@ export default {
|
|
|
72
72
|
})();
|
|
73
73
|
return {
|
|
74
74
|
status: 'incomplete',
|
|
75
|
-
message: `${attempt} on 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.`,
|
|
75
|
+
message: `${attempt} on 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
76
|
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
77
|
};
|
|
78
78
|
},
|