pour-engine 1.1.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/LICENSE +21 -0
- package/README.md +99 -0
- package/config/project.config.js +11 -0
- package/engine/COVERAGE.md +115 -0
- package/engine/README.md +115 -0
- package/engine/index.js +258 -0
- package/engine/lib/accessible-name.js +126 -0
- package/engine/lib/contrast.js +628 -0
- package/engine/lib/dom.js +116 -0
- package/engine/lib/roles.js +125 -0
- package/engine/rules/best-practice/accesskeys.js +29 -0
- package/engine/rules/best-practice/aria-label-misuse.js +22 -0
- package/engine/rules/best-practice/button-type.js +15 -0
- package/engine/rules/best-practice/empty-heading.js +19 -0
- package/engine/rules/best-practice/fieldset-legend.js +35 -0
- package/engine/rules/best-practice/heading-order.js +30 -0
- package/engine/rules/best-practice/landmark-one-main.js +22 -0
- package/engine/rules/best-practice/landmark-top-level.js +46 -0
- package/engine/rules/best-practice/landmark-unique.js +84 -0
- package/engine/rules/best-practice/multiple-labels.js +23 -0
- package/engine/rules/best-practice/new-window-link.js +21 -0
- package/engine/rules/best-practice/no-autofocus.js +16 -0
- package/engine/rules/best-practice/page-heading-one.js +17 -0
- package/engine/rules/best-practice/positive-tabindex.js +17 -0
- package/engine/rules/best-practice/redundant-alt-phrase.js +22 -0
- package/engine/rules/best-practice/redundant-aria-label.js +18 -0
- package/engine/rules/best-practice/redundant-aria.js +32 -0
- package/engine/rules/best-practice/redundant-image-alt.js +20 -0
- package/engine/rules/best-practice/redundant-role.js +62 -0
- package/engine/rules/best-practice/redundant-tabindex.js +15 -0
- package/engine/rules/best-practice/region.js +73 -0
- package/engine/rules/index.js +179 -0
- package/engine/rules/wcag/1.1.1-area-alt.js +18 -0
- package/engine/rules/wcag/1.1.1-image-alt.js +48 -0
- package/engine/rules/wcag/1.1.1-input-image-alt.js +17 -0
- package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
- package/engine/rules/wcag/1.1.1-svg-img-alt.js +34 -0
- package/engine/rules/wcag/1.2.2-media-captions.js +19 -0
- package/engine/rules/wcag/1.3.1-aria-required-children.js +66 -0
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +49 -0
- package/engine/rules/wcag/1.3.1-definition-list.js +21 -0
- package/engine/rules/wcag/1.3.1-dlitem-parent.js +17 -0
- package/engine/rules/wcag/1.3.1-form-label.js +81 -0
- package/engine/rules/wcag/1.3.1-list-structure.js +59 -0
- package/engine/rules/wcag/1.3.1-listitem-parent.js +33 -0
- package/engine/rules/wcag/1.3.1-p-as-heading.js +25 -0
- package/engine/rules/wcag/1.3.1-table-headers.js +61 -0
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +38 -0
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +74 -0
- package/engine/rules/wcag/1.4.10-reflow.js +25 -0
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +109 -0
- package/engine/rules/wcag/1.4.12-text-spacing.js +71 -0
- package/engine/rules/wcag/1.4.2-audio-control.js +40 -0
- package/engine/rules/wcag/1.4.3-color-contrast.js +462 -0
- package/engine/rules/wcag/1.4.3-control-contrast.js +72 -0
- package/engine/rules/wcag/1.4.4-meta-viewport.js +22 -0
- package/engine/rules/wcag/1.4.6-contrast-enhanced.js +11 -0
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +48 -0
- package/engine/rules/wcag/2.2.1-meta-refresh.js +20 -0
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +25 -0
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +36 -0
- package/engine/rules/wcag/2.4.11-focus-not-obscured.js +80 -0
- package/engine/rules/wcag/2.4.2-document-title.js +18 -0
- package/engine/rules/wcag/2.4.4-link-name.js +19 -0
- package/engine/rules/wcag/2.4.7-focus-visible.js +46 -0
- package/engine/rules/wcag/2.5.3-label-in-name.js +88 -0
- package/engine/rules/wcag/2.5.5-target-size-enhanced.js +14 -0
- package/engine/rules/wcag/2.5.8-target-size.js +159 -0
- package/engine/rules/wcag/3.1.1-html-lang.js +31 -0
- package/engine/rules/wcag/3.1.2-valid-lang-parts.js +20 -0
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +37 -0
- package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +44 -0
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +50 -0
- package/engine/rules/wcag/4.1.2-aria-field-name.js +54 -0
- package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +48 -0
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +61 -0
- package/engine/rules/wcag/4.1.2-button-name.js +17 -0
- package/engine/rules/wcag/4.1.2-dialog-name.js +35 -0
- package/engine/rules/wcag/4.1.2-frame-title.js +17 -0
- package/engine/rules/wcag/4.1.2-label-for-valid.js +25 -0
- package/engine/rules/wcag/4.1.2-nested-interactive.js +30 -0
- package/engine/rules/wcag/4.1.2-role-required-aria.js +36 -0
- package/engine/rules/wcag/4.1.2-valid-role.js +52 -0
- package/engine/wcag22.js +116 -0
- package/engine/wcag30-draft.js +149 -0
- package/package.json +34 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// WCAG SC 2.2.2 Pause, Stop, Hide (Level A)
|
|
2
|
+
export default {
|
|
3
|
+
id: 'pause-stop-hide',
|
|
4
|
+
impact: 'serious',
|
|
5
|
+
tags: ['wcag2a', 'wcag222'],
|
|
6
|
+
help: 'Moving content must be pausable',
|
|
7
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html',
|
|
8
|
+
selector: 'marquee, blink',
|
|
9
|
+
// The criterion is about VISIBLE movement — a display:none marquee moves
|
|
10
|
+
// nothing. aria-hidden content still moves on screen, so the rendered
|
|
11
|
+
// check (not the AT-exposure check) is the right filter.
|
|
12
|
+
visibility: 'visual',
|
|
13
|
+
evaluate(element) {
|
|
14
|
+
// scrollamount="0" is the documented way to ship a <marquee> that
|
|
15
|
+
// doesn't actually move.
|
|
16
|
+
if (element.tagName === 'MARQUEE' && element.getAttribute('scrollamount') === '0') {
|
|
17
|
+
return { status: 'pass' };
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
status: 'fail',
|
|
21
|
+
message: `<${element.tagName.toLowerCase()}> scrolls or blinks with no way to pause it, which is unusable for people with attention or vestibular conditions.`,
|
|
22
|
+
fix: 'Replace it with static content, or a CSS animation with a pause control that honours prefers-reduced-motion.',
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// WCAG SC 2.4.1 Bypass Blocks (Level A)
|
|
2
|
+
import { collectRoots } from '../../lib/dom.js';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
id: 'bypass-blocks',
|
|
6
|
+
impact: 'serious',
|
|
7
|
+
tags: ['wcag2a', 'wcag241'],
|
|
8
|
+
help: 'The page must offer a way to skip repeated blocks',
|
|
9
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html',
|
|
10
|
+
selector: 'html',
|
|
11
|
+
visibleOnly: false,
|
|
12
|
+
evaluate(element) {
|
|
13
|
+
// 2.4.1's sufficient techniques: a skip link, landmarks (ARIA11), or
|
|
14
|
+
// headings (H69) — any one mechanism satisfies the criterion. Modern
|
|
15
|
+
// component pages keep ALL of these inside open shadow roots (the
|
|
16
|
+
// whole app under one custom element), so every root is searched —
|
|
17
|
+
// judging only the light DOM invents failures on web-component sites.
|
|
18
|
+
const doc = element.ownerDocument;
|
|
19
|
+
const roots = collectRoots(doc);
|
|
20
|
+
const inAnyRoot = (selector) => roots.some((root) => root.querySelector?.(selector));
|
|
21
|
+
if (inAnyRoot('main, [role="main"]')) return { status: 'pass' };
|
|
22
|
+
if (inAnyRoot('h1, h2, h3, h4, h5, h6, [role="heading"]')) return { status: 'pass' };
|
|
23
|
+
for (const root of roots) {
|
|
24
|
+
const firstLink = root.querySelector?.('a[href^="#"]');
|
|
25
|
+
const id = firstLink?.getAttribute('href').slice(1);
|
|
26
|
+
if (id && roots.some((r) => r.getElementById?.(id) || r.querySelector?.(`[id="${CSS.escape(id)}"]`))) {
|
|
27
|
+
return { status: 'pass' };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
status: 'fail',
|
|
32
|
+
message: 'No skip link, main landmark, or headings — keyboard users must Tab through the whole header/nav to reach anything.',
|
|
33
|
+
fix: 'Add a skip link like <a href="#content">Skip to content</a>, wrap primary content in <main>, or structure the page with headings.',
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// WCAG SC 2.4.11 Focus Not Obscured (Minimum) (Level AA)
|
|
2
|
+
// When a focusable element's box lies ENTIRELY under an opaque fixed or
|
|
3
|
+
// sticky overlay at its resting position, focusing it shows the user
|
|
4
|
+
// nothing — the criterion's core case (bottom cookie bars covering footer
|
|
5
|
+
// links). Partial overlap passes 2.4.11 by definition ("not entirely
|
|
6
|
+
// hidden"), so only full containment is judged, and only for elements
|
|
7
|
+
// currently inside the viewport where the geometry is real.
|
|
8
|
+
const FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]:not([tabindex="-1"])';
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
id: 'focus-not-obscured',
|
|
12
|
+
impact: 'serious',
|
|
13
|
+
tags: ['wcag22aa', 'wcag2411'],
|
|
14
|
+
help: 'Focused elements must not be fully hidden behind overlays',
|
|
15
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html',
|
|
16
|
+
selector: FOCUSABLE,
|
|
17
|
+
visibility: 'visual',
|
|
18
|
+
evaluateAll(elements) {
|
|
19
|
+
const doc = elements[0]?.ownerDocument ?? document;
|
|
20
|
+
const win = doc.defaultView;
|
|
21
|
+
// While a modal (cookie-consent dialog etc.) is open, the page behind
|
|
22
|
+
// it is INTENTIONALLY covered and focus is supposed to be trapped in
|
|
23
|
+
// the modal — a snapshot in that state proves nothing about 2.4.11.
|
|
24
|
+
// The backdrop is often a separate sibling of the dialog, so the modal
|
|
25
|
+
// state is checked document-wide, not per overlay.
|
|
26
|
+
const modal = [...doc.querySelectorAll('dialog[open], [role="dialog"], [role="alertdialog"], [aria-modal="true"]')]
|
|
27
|
+
.some((el) => {
|
|
28
|
+
const rect = el.getBoundingClientRect();
|
|
29
|
+
return rect.width > 0 && rect.height > 0 && getComputedStyle(el).visibility !== 'hidden';
|
|
30
|
+
});
|
|
31
|
+
if (modal) return elements.map(() => ({ status: 'pass' }));
|
|
32
|
+
|
|
33
|
+
// Hit-test per IN-VIEWPORT focusable instead of style-scanning the
|
|
34
|
+
// whole document for overlays: elementsFromPoint at the target's
|
|
35
|
+
// centre surfaces whatever paints above it, and only the handful of
|
|
36
|
+
// on-screen targets pay any cost — a 200k-node spec page costs the
|
|
37
|
+
// same as a landing page. Per-overlay verdicts are memoized.
|
|
38
|
+
const overlayVerdict = new Map();
|
|
39
|
+
const isObscuringOverlay = (layer) => {
|
|
40
|
+
if (overlayVerdict.has(layer)) return overlayVerdict.get(layer);
|
|
41
|
+
let verdict = false;
|
|
42
|
+
const style = getComputedStyle(layer);
|
|
43
|
+
if (style.position === 'fixed' || style.position === 'sticky') {
|
|
44
|
+
const bg = style.backgroundColor.match(/rgba?\(([^)]+)\)/)?.[1]?.split(',');
|
|
45
|
+
const alpha = bg?.[3] === undefined ? 1 : parseFloat(bg[3]);
|
|
46
|
+
const rect = layer.getBoundingClientRect();
|
|
47
|
+
verdict = alpha >= 0.9 && rect.width >= 40 && rect.height >= 24;
|
|
48
|
+
}
|
|
49
|
+
overlayVerdict.set(layer, verdict);
|
|
50
|
+
return verdict;
|
|
51
|
+
};
|
|
52
|
+
const covered = (rect, overlay) =>
|
|
53
|
+
rect.left >= overlay.left && rect.right <= overlay.right
|
|
54
|
+
&& rect.top >= overlay.top && rect.bottom <= overlay.bottom;
|
|
55
|
+
|
|
56
|
+
return elements.map((element) => {
|
|
57
|
+
if (element.disabled) return { status: 'pass' };
|
|
58
|
+
const rect = element.getBoundingClientRect();
|
|
59
|
+
if (!rect.width || !rect.height) return { status: 'pass' };
|
|
60
|
+
// Only in-viewport geometry is trustworthy (or cheap).
|
|
61
|
+
if (rect.bottom < 0 || rect.right < 0 || rect.top > win.innerHeight || rect.left > win.innerWidth) {
|
|
62
|
+
return { status: 'pass' };
|
|
63
|
+
}
|
|
64
|
+
const x = Math.min(Math.max(rect.left + rect.width / 2, 0), win.innerWidth - 1);
|
|
65
|
+
const y = Math.min(Math.max(rect.top + rect.height / 2, 0), win.innerHeight - 1);
|
|
66
|
+
const stack = doc.elementsFromPoint(x, y);
|
|
67
|
+
const index = stack.indexOf(element);
|
|
68
|
+
if (index <= 0) return { status: 'pass' }; // topmost, or not hit-testable
|
|
69
|
+
const blocker = stack.slice(0, index).find((layer) =>
|
|
70
|
+
!layer.contains(element) && !element.contains(layer)
|
|
71
|
+
&& isObscuringOverlay(layer) && covered(rect, layer.getBoundingClientRect()));
|
|
72
|
+
if (!blocker) return { status: 'pass' };
|
|
73
|
+
return {
|
|
74
|
+
status: 'fail',
|
|
75
|
+
message: 'This focusable element sits entirely behind an opaque fixed/sticky overlay — when keyboard focus lands on it, nothing visible changes and the user is lost.',
|
|
76
|
+
fix: 'Add scroll-padding for the overlay height, or make the overlay dismissible/non-overlapping so focused elements stay at least partly visible.',
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// WCAG SC 2.4.2 Page Titled (Level A)
|
|
2
|
+
export default {
|
|
3
|
+
id: 'document-title',
|
|
4
|
+
impact: 'serious',
|
|
5
|
+
tags: ['wcag2a', 'wcag242'],
|
|
6
|
+
help: 'The page must have a title that describes its topic or purpose',
|
|
7
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/page-titled.html',
|
|
8
|
+
selector: 'html',
|
|
9
|
+
visibleOnly: false,
|
|
10
|
+
evaluate(element) {
|
|
11
|
+
if (element.ownerDocument.title.trim()) return { status: 'pass' };
|
|
12
|
+
return {
|
|
13
|
+
status: 'fail',
|
|
14
|
+
message: 'The page has no title, so users of screen readers and browser tabs cannot tell what it is.',
|
|
15
|
+
fix: 'Add <title>Page name — Site name</title> inside <head>.',
|
|
16
|
+
};
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// WCAG SC 2.4.4 Link Purpose (In Context) (Level A) · SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
export default {
|
|
3
|
+
id: 'link-name',
|
|
4
|
+
impact: 'serious',
|
|
5
|
+
tags: ['wcag2a', 'wcag244', 'wcag412'],
|
|
6
|
+
help: 'Links must have an accessible name',
|
|
7
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html',
|
|
8
|
+
// a[href] has the link role implicitly; role="link" claims it explicitly.
|
|
9
|
+
// (Links without href have no link role and are correctly excluded.)
|
|
10
|
+
selector: 'a[href], [role="link"]',
|
|
11
|
+
evaluate(element, { accessibleName }) {
|
|
12
|
+
if (accessibleName(element)) return { status: 'pass' };
|
|
13
|
+
return {
|
|
14
|
+
status: 'fail',
|
|
15
|
+
message: 'This link has no accessible name — screen readers can only announce its URL.',
|
|
16
|
+
fix: 'Add text content to the link, alt text to the image inside it, or an aria-label.',
|
|
17
|
+
};
|
|
18
|
+
},
|
|
19
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// WCAG SC 2.4.7 Focus Visible (Level AA)
|
|
2
|
+
// A stylesheet that strips focus outlines without providing a replacement
|
|
3
|
+
// indicator is the classic 2.4.7 failure (F78). Cross-origin sheets are
|
|
4
|
+
// unreadable and skipped; a replacement indicator can live in a different
|
|
5
|
+
// rule entirely — so this flags for review, it never asserts a fail.
|
|
6
|
+
function focusSuppressors(doc) {
|
|
7
|
+
const suspects = [];
|
|
8
|
+
const scan = (rules) => {
|
|
9
|
+
for (const rule of rules) {
|
|
10
|
+
if (rule.cssRules) { scan(rule.cssRules); continue; } // @media/@supports
|
|
11
|
+
if (!rule.selectorText || !/:focus/.test(rule.selectorText)) continue;
|
|
12
|
+
const style = rule.style;
|
|
13
|
+
if (!style) continue;
|
|
14
|
+
const outline = `${style.getPropertyValue('outline')} ${style.getPropertyValue('outline-style')} ${style.getPropertyValue('outline-width')}`;
|
|
15
|
+
const removesOutline = /\bnone\b/.test(outline) || /(^|\s)0(px)?(\s|$)/.test(outline);
|
|
16
|
+
if (!removesOutline) continue;
|
|
17
|
+
const replaced = style.getPropertyValue('box-shadow') || style.getPropertyValue('border')
|
|
18
|
+
|| style.getPropertyValue('border-color') || style.getPropertyValue('background')
|
|
19
|
+
|| style.getPropertyValue('background-color') || style.getPropertyValue('text-decoration');
|
|
20
|
+
if (!replaced) suspects.push(rule.selectorText);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
for (const sheet of doc.styleSheets) {
|
|
24
|
+
try { scan(sheet.cssRules); } catch { /* cross-origin sheet */ }
|
|
25
|
+
}
|
|
26
|
+
return suspects;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default {
|
|
30
|
+
id: 'focus-visible',
|
|
31
|
+
impact: 'serious',
|
|
32
|
+
tags: ['wcag2aa', 'wcag247'],
|
|
33
|
+
help: 'Keyboard focus must remain visible',
|
|
34
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html',
|
|
35
|
+
selector: 'html',
|
|
36
|
+
visibleOnly: false,
|
|
37
|
+
evaluate(element) {
|
|
38
|
+
const suspects = focusSuppressors(element.ownerDocument);
|
|
39
|
+
if (!suspects.length) return { status: 'pass' };
|
|
40
|
+
const shown = suspects.slice(0, 3).join(', ');
|
|
41
|
+
return {
|
|
42
|
+
status: 'incomplete',
|
|
43
|
+
message: `${suspects.length} CSS rule(s) remove the focus outline without setting a replacement in the same rule (e.g. ${shown}) — if no other rule provides a visible indicator, keyboard users lose their place. Tab through the page to check.`,
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// WCAG SC 2.5.3 Label in Name (Level A)
|
|
2
|
+
// When a UI COMPONENT has a visible text label AND an aria-provided name,
|
|
3
|
+
// the visible text must appear in the accessible name — otherwise
|
|
4
|
+
// voice-control users saying the visible label cannot activate it.
|
|
5
|
+
//
|
|
6
|
+
// Scope per spec: interactive components only (things a user operates), and
|
|
7
|
+
// only their VISIBLE text. Screen-reader-only text (sr-only clips, offscreen
|
|
8
|
+
// text-indent) is not a visible label; aria-hidden text IS visible to
|
|
9
|
+
// sighted users and counts; symbolic content ("×", "→", emoji) is not a
|
|
10
|
+
// text label at all.
|
|
11
|
+
//
|
|
12
|
+
// Re-enabled 2026-07-27 (was parked 2026-07-25 for marginal hits): rewritten
|
|
13
|
+
// to compute genuinely visible text — sr-only exclusion, input values,
|
|
14
|
+
// aria-labelledby, slotted content — so it can run by default.
|
|
15
|
+
const BASES = [
|
|
16
|
+
'button', 'a[href]', 'summary',
|
|
17
|
+
'input[type="button"]', 'input[type="submit"]', 'input[type="reset"]',
|
|
18
|
+
'[role="button"]', '[role="link"]', '[role="menuitem"]', '[role="tab"]',
|
|
19
|
+
'[role="checkbox"]', '[role="radio"]', '[role="switch"]',
|
|
20
|
+
];
|
|
21
|
+
const COMPONENTS = BASES.flatMap((base) => [`${base}[aria-label]`, `${base}[aria-labelledby]`]).join(', ');
|
|
22
|
+
|
|
23
|
+
const HAS_LETTERS = /\p{L}/u;
|
|
24
|
+
|
|
25
|
+
/** Lowercase, letters/digits only between single spaces — so "Search…" and
|
|
26
|
+
* "search" compare equal. */
|
|
27
|
+
function normalize(text) {
|
|
28
|
+
return text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').replace(/\s+/g, ' ').trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Is this element's own box visually erased (sr-only, offscreen, invisible)?
|
|
32
|
+
* aria-hidden is deliberately NOT checked: it hides content from assistive
|
|
33
|
+
* technology, not from sighted users. */
|
|
34
|
+
function visuallyErased(element) {
|
|
35
|
+
const style = getComputedStyle(element);
|
|
36
|
+
if (style.display === 'none' || style.visibility === 'hidden') return true;
|
|
37
|
+
if (parseFloat(style.opacity) === 0 || parseFloat(style.fontSize) === 0) return true;
|
|
38
|
+
const rect = element.getBoundingClientRect();
|
|
39
|
+
const clipped = /hidden|clip/.test(`${style.overflow} ${style.overflowX} ${style.overflowY}`)
|
|
40
|
+
|| style.clipPath !== 'none' || (style.clip !== 'auto' && style.position === 'absolute');
|
|
41
|
+
if ((rect.width <= 1 || rect.height <= 1) && clipped) return true;
|
|
42
|
+
const indent = parseFloat(style.textIndent) || 0;
|
|
43
|
+
if (Math.abs(indent) > rect.width && (clipped || Math.abs(indent) > 1000)) return true;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The text a sighted user actually sees inside the component (slot-aware). */
|
|
48
|
+
function visibleText(nodes) {
|
|
49
|
+
let text = '';
|
|
50
|
+
for (const node of nodes) {
|
|
51
|
+
if (node.nodeType === 3 /* TEXT_NODE */) { text += node.textContent; continue; }
|
|
52
|
+
if (node.nodeType !== 1 /* ELEMENT_NODE */) continue;
|
|
53
|
+
const tag = node.tagName.toLowerCase();
|
|
54
|
+
if (tag === 'script' || tag === 'style' || tag === 'noscript' || tag === 'template') continue;
|
|
55
|
+
if (node.hasAttribute('hidden') || visuallyErased(node)) continue;
|
|
56
|
+
if (tag === 'slot') {
|
|
57
|
+
const assigned = node.assignedNodes?.() ?? [];
|
|
58
|
+
text += visibleText(assigned.length ? assigned : node.childNodes);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
text += visibleText(node.childNodes);
|
|
62
|
+
}
|
|
63
|
+
return text;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export default {
|
|
67
|
+
id: 'label-in-name',
|
|
68
|
+
impact: 'serious',
|
|
69
|
+
tags: ['wcag21a', 'wcag253'],
|
|
70
|
+
help: 'The visible label must be part of the accessible name',
|
|
71
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html',
|
|
72
|
+
selector: COMPONENTS,
|
|
73
|
+
evaluate(element, { accessibleName }) {
|
|
74
|
+
const rawVisible = element.tagName === 'INPUT'
|
|
75
|
+
? (element.getAttribute('value') ?? '')
|
|
76
|
+
: visibleText(element.childNodes);
|
|
77
|
+
const visible = rawVisible.replace(/\s+/g, ' ').trim();
|
|
78
|
+
// Icon/symbol-only or nothing visible: there is no text label to match.
|
|
79
|
+
if (!visible || !HAS_LETTERS.test(visible)) return { status: 'pass' };
|
|
80
|
+
const name = accessibleName(element);
|
|
81
|
+
if (normalize(name).includes(normalize(visible))) return { status: 'pass' };
|
|
82
|
+
return {
|
|
83
|
+
status: 'fail',
|
|
84
|
+
message: `The visible text "${visible}" is not contained in the accessible name "${name}" — saying the visible label out loud won't activate this control.`,
|
|
85
|
+
fix: 'Make the aria-label start with the visible text, or remove the aria-label so the visible text names the control.',
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// WCAG SC 2.5.5 Target Size (Enhanced) (Level AAA)
|
|
2
|
+
// Same geometry as 2.5.8, at 44×44px — and WITHOUT the spacing exception,
|
|
3
|
+
// which 2.5.5 does not offer (its exceptions: equivalent, inline,
|
|
4
|
+
// user-agent, essential).
|
|
5
|
+
import { createTargetSizeRule } from './2.5.8-target-size.js';
|
|
6
|
+
|
|
7
|
+
export default createTargetSizeRule({
|
|
8
|
+
id: 'target-size-enhanced',
|
|
9
|
+
tags: ['wcag2aaa', 'wcag255'],
|
|
10
|
+
help: 'Interactive targets must be at least 44×44 pixels (AAA)',
|
|
11
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html',
|
|
12
|
+
min: 44,
|
|
13
|
+
spacingException: false,
|
|
14
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// WCAG SC 2.5.8 Target Size (Minimum) (Level AA)
|
|
2
|
+
// WCAG 2.2's new Target Size (Minimum): interactive targets ≥ 24×24 CSS px.
|
|
3
|
+
// Exceptions we honour, per the spec:
|
|
4
|
+
// - inline: the target is in a sentence — an inline element with actual
|
|
5
|
+
// text alongside it on the line. Being display:inline alone is NOT
|
|
6
|
+
// enough: nav/footer link lists are inline by default but are standalone
|
|
7
|
+
// targets, not text, and the spec does not exempt them.
|
|
8
|
+
// - spacing: an undersized target is exempt when a 24px-diameter circle
|
|
9
|
+
// centred on it intersects no other target's rectangle and no other
|
|
10
|
+
// undersized target's circle (the spec's circle test, not a
|
|
11
|
+
// centre-to-centre distance).
|
|
12
|
+
// - zero-size rects (element not laid out — nothing to measure)
|
|
13
|
+
// Not detectable ("essential", "equivalent control elsewhere") — those need
|
|
14
|
+
// the human reviewing the finding.
|
|
15
|
+
|
|
16
|
+
const TARGETS = 'a[href], button, input, select, [role="button"], [role="link"]';
|
|
17
|
+
|
|
18
|
+
/** Is this target genuinely sitting in a line of text (the spec's "in a
|
|
19
|
+
* sentence / size constrained by line-height of non-target text" exception)?
|
|
20
|
+
* True for inline elements that share a line with non-target text — either a
|
|
21
|
+
* text node or an inline non-target element carrying text — checked up the
|
|
22
|
+
* inline-ancestor chain, so a link wrapped in a <sup>/<span> inside a
|
|
23
|
+
* sentence (Wikipedia-style citations) still counts as in-text. A link in a
|
|
24
|
+
* nav list (block/list-item parent, only other links beside it) does not. */
|
|
25
|
+
/**
|
|
26
|
+
* A target the pointer can never meaningfully hit is exempt from a POINTER
|
|
27
|
+
* target-size rule: the sr-only clip pattern (≤1px boxes), skip links
|
|
28
|
+
* parked off-canvas until focused, clip-path'd controls, opacity: 0.
|
|
29
|
+
* These are keyboard affordances, not pointer targets — and their phantom
|
|
30
|
+
* rects must not count as "crowding" for the real targets around them.
|
|
31
|
+
*/
|
|
32
|
+
function isHiddenFromPointer(element, rect) {
|
|
33
|
+
if (rect.width <= 1 || rect.height <= 1) return true;
|
|
34
|
+
if (rect.right <= 0 || rect.bottom <= 0) return true; // parked above/left of the canvas
|
|
35
|
+
const style = getComputedStyle(element);
|
|
36
|
+
if ((parseFloat(style.opacity) || 0) === 0) return true;
|
|
37
|
+
if (style.clipPath !== 'none' || (style.clip !== 'auto' && style.position === 'absolute')) return true;
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isInTextLine(element) {
|
|
42
|
+
let node = element;
|
|
43
|
+
// inline-block/inline-flex links inside a sentence are still "in a line
|
|
44
|
+
// of text" for the spec's exception — the line-height constrains them
|
|
45
|
+
// exactly the same; the text-sibling requirement below is what keeps
|
|
46
|
+
// nav/footer link lists (no non-target text) out of the exemption.
|
|
47
|
+
while (node && getComputedStyle(node).display.startsWith('inline')) {
|
|
48
|
+
for (const sibling of node.parentNode?.childNodes ?? []) {
|
|
49
|
+
if (sibling === node) continue;
|
|
50
|
+
if (sibling.nodeType === Node.TEXT_NODE && sibling.textContent.trim()) return true;
|
|
51
|
+
if (sibling.nodeType === Node.ELEMENT_NODE
|
|
52
|
+
&& !sibling.matches(TARGETS)
|
|
53
|
+
&& sibling.textContent.trim()
|
|
54
|
+
&& getComputedStyle(sibling).display.startsWith('inline')) return true;
|
|
55
|
+
}
|
|
56
|
+
node = node.parentElement;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Nearest distance from a point to a rectangle (0 when inside it). */
|
|
62
|
+
function pointToRectDistance(point, rect) {
|
|
63
|
+
const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
|
|
64
|
+
const dy = Math.max(rect.top - point.y, 0, point.y - rect.bottom);
|
|
65
|
+
return Math.hypot(dx, dy);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Both target-size criteria share the geometry; they differ in minimum and
|
|
70
|
+
* exceptions:
|
|
71
|
+
* 2.5.8 Target Size (Minimum), AA — 24px, WITH the spacing exception
|
|
72
|
+
* 2.5.5 Target Size (Enhanced), AAA — 44px, NO spacing exception (the spec
|
|
73
|
+
* lists only equivalent/inline/user-agent/essential there)
|
|
74
|
+
*/
|
|
75
|
+
export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingException }) {
|
|
76
|
+
return {
|
|
77
|
+
id,
|
|
78
|
+
impact: 'serious',
|
|
79
|
+
tags,
|
|
80
|
+
help,
|
|
81
|
+
helpUrl,
|
|
82
|
+
selector: 'button, a[href], input:not([type="hidden"]), select, [role="button"], [role="link"]',
|
|
83
|
+
visibility: 'visual', // pointer targets are visual regardless of aria-hidden
|
|
84
|
+
// Judged as a set: the spacing exception needs the other targets' positions.
|
|
85
|
+
evaluateAll(elements) {
|
|
86
|
+
// A control's <label> is part of its activation area — clicking the
|
|
87
|
+
// label activates the control, so the TARGET is the union of both
|
|
88
|
+
// boxes. Without this, every label-wrapped checkbox list fails on the
|
|
89
|
+
// 13px input alone.
|
|
90
|
+
const targetRect = (element) => {
|
|
91
|
+
const rect = element.getBoundingClientRect();
|
|
92
|
+
const label = element.labels?.[0];
|
|
93
|
+
if (!label) return rect;
|
|
94
|
+
const labelRect = label.getBoundingClientRect();
|
|
95
|
+
if (!labelRect.width || !labelRect.height) return rect;
|
|
96
|
+
const left = Math.min(rect.left, labelRect.left);
|
|
97
|
+
const top = Math.min(rect.top, labelRect.top);
|
|
98
|
+
const right = Math.max(rect.right, labelRect.right);
|
|
99
|
+
const bottom = Math.max(rect.bottom, labelRect.bottom);
|
|
100
|
+
return { left, top, right, bottom, width: right - left, height: bottom - top };
|
|
101
|
+
};
|
|
102
|
+
// The spec's User Agent Control exception: a default-styled native
|
|
103
|
+
// checkbox/radio is sized by the browser, not the author.
|
|
104
|
+
const uaControlled = (element) => element.tagName === 'INPUT'
|
|
105
|
+
&& (element.type === 'checkbox' || element.type === 'radio')
|
|
106
|
+
&& getComputedStyle(element).appearance !== 'none';
|
|
107
|
+
const rects = elements.map(targetRect);
|
|
108
|
+
const centers = rects.map((r) => ({ x: r.left + r.width / 2, y: r.top + r.height / 2 }));
|
|
109
|
+
// Zero-area rects are degenerate: either not laid out, or an empty
|
|
110
|
+
// control no pointer can hit at all — its real defect (an empty name,
|
|
111
|
+
// usually) belongs to the name rules, not to size judgment. Pointer-
|
|
112
|
+
// invisible targets (clipped skip links etc.) are excluded the same
|
|
113
|
+
// way, on both sides of the crowding test.
|
|
114
|
+
const laidOut = rects.map((r, i) => r.width > 0 && r.height > 0 && !isHiddenFromPointer(elements[i], r));
|
|
115
|
+
const undersized = rects.map((r, i) => laidOut[i] && (r.width < min || r.height < min));
|
|
116
|
+
// One rect fully inside another is one control drawn twice (stretched-
|
|
117
|
+
// link cards, overlay + inner button) — not two targets crowding.
|
|
118
|
+
const encloses = (a, b) =>
|
|
119
|
+
a.left <= b.left && a.right >= b.right && a.top <= b.top && a.bottom >= b.bottom;
|
|
120
|
+
|
|
121
|
+
return elements.map((element, i) => {
|
|
122
|
+
if (!laidOut[i]) return { status: 'pass' }; // not laid out / not a pointer target
|
|
123
|
+
if (!undersized[i]) return { status: 'pass' };
|
|
124
|
+
if (uaControlled(element)) return { status: 'pass' }; // UA Control exception
|
|
125
|
+
if (isInTextLine(element)) return { status: 'pass' };
|
|
126
|
+
if (spacingException) {
|
|
127
|
+
// Spacing exception: the min-sized circle around this target must
|
|
128
|
+
// clear every other target's rect and every other undersized
|
|
129
|
+
// target's circle.
|
|
130
|
+
const crowded = elements.some((other, j) => {
|
|
131
|
+
if (j === i || !laidOut[j]) return false;
|
|
132
|
+
if (other.contains(element) || element.contains(other)) return false; // same control, nested markup
|
|
133
|
+
if (encloses(rects[j], rects[i]) || encloses(rects[i], rects[j])) return false;
|
|
134
|
+
if (undersized[j] && Math.hypot(centers[j].x - centers[i].x, centers[j].y - centers[i].y) < min) return true;
|
|
135
|
+
return pointToRectDistance(centers[i], rects[j]) < min / 2;
|
|
136
|
+
});
|
|
137
|
+
if (!crowded) return { status: 'pass' };
|
|
138
|
+
}
|
|
139
|
+
const rect = rects[i];
|
|
140
|
+
return {
|
|
141
|
+
status: 'fail',
|
|
142
|
+
message: spacingException
|
|
143
|
+
? `This target is ${Math.round(rect.width)}×${Math.round(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.`
|
|
144
|
+
: `This target is ${Math.round(rect.width)}×${Math.round(rect.height)}px — below the ${min}×${min}px minimum, hard to hit for users with motor impairments.`,
|
|
145
|
+
fix: `Increase the element’s size or padding to at least ${min}×${min}px${spacingException ? ', or add spacing between the targets' : ''}.`,
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export default createTargetSizeRule({
|
|
153
|
+
id: 'target-size',
|
|
154
|
+
tags: ['wcag22aa', 'wcag258'],
|
|
155
|
+
help: 'Interactive targets must be at least 24×24 pixels',
|
|
156
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html',
|
|
157
|
+
min: 24,
|
|
158
|
+
spacingException: true,
|
|
159
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// WCAG SC 3.1.1 Language of Page (Level A)
|
|
2
|
+
// Rough BCP 47 shape: primary subtag + optional extras ("en", "en-GB", "zh-Hans").
|
|
3
|
+
const LANG_PATTERN = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
id: 'html-lang',
|
|
7
|
+
impact: 'serious',
|
|
8
|
+
tags: ['wcag2a', 'wcag311'],
|
|
9
|
+
help: 'The <html> element must declare a valid language',
|
|
10
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/language-of-page.html',
|
|
11
|
+
selector: 'html',
|
|
12
|
+
visibleOnly: false,
|
|
13
|
+
evaluate(element) {
|
|
14
|
+
const lang = element.getAttribute('lang')?.trim();
|
|
15
|
+
if (!lang) {
|
|
16
|
+
return {
|
|
17
|
+
status: 'fail',
|
|
18
|
+
message: 'No lang attribute: screen readers will guess the language and may mispronounce everything.',
|
|
19
|
+
fix: 'Add lang to the html element, e.g. <html lang="en">.',
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (!LANG_PATTERN.test(lang)) {
|
|
23
|
+
return {
|
|
24
|
+
status: 'fail',
|
|
25
|
+
message: `lang="${lang}" is not a valid language tag.`,
|
|
26
|
+
fix: 'Use a BCP 47 tag such as lang="en" or lang="en-GB".',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return { status: 'pass' };
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// WCAG SC 3.1.2 Language of Parts (Level AA)
|
|
2
|
+
const LANG_PATTERN = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
id: 'valid-lang-parts',
|
|
6
|
+
impact: 'serious',
|
|
7
|
+
tags: ['wcag2aa', 'wcag312'],
|
|
8
|
+
help: 'lang attributes on page parts must be valid',
|
|
9
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html',
|
|
10
|
+
selector: '[lang]:not(html)',
|
|
11
|
+
evaluate(element) {
|
|
12
|
+
const lang = element.getAttribute('lang').trim();
|
|
13
|
+
if (lang === '' || LANG_PATTERN.test(lang)) return { status: 'pass' }; // empty resets to page language
|
|
14
|
+
return {
|
|
15
|
+
status: 'fail',
|
|
16
|
+
message: `lang="${lang}" is not a valid language tag, so screen readers may switch to the wrong pronunciation.`,
|
|
17
|
+
fix: 'Use a BCP 47 tag such as lang="fr" or lang="de-AT".',
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// WCAG SC 3.3.8 Accessible Authentication (Minimum) (Level AA)
|
|
2
|
+
// The SC requires a cognitive-function-test-free way to authenticate, and
|
|
3
|
+
// names copy-paste and password managers as the mechanisms that provide
|
|
4
|
+
// it. Blocking paste on a password/OTP field removes exactly that
|
|
5
|
+
// mechanism — a provable failure. autocomplete="off" merely ASKS the
|
|
6
|
+
// browser not to help; password managers often fill anyway, so that's a
|
|
7
|
+
// review flag, not an assertion.
|
|
8
|
+
export default {
|
|
9
|
+
id: 'auth-field-obstruction',
|
|
10
|
+
impact: 'serious',
|
|
11
|
+
tags: ['wcag22aa', 'wcag338'],
|
|
12
|
+
help: 'Login fields must not block paste or password managers',
|
|
13
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-minimum.html',
|
|
14
|
+
selector: 'input[type="password"], input[autocomplete~="current-password"], input[autocomplete~="one-time-code"], input[autocomplete~="new-password"]',
|
|
15
|
+
evaluate(element) {
|
|
16
|
+
// Inline handlers are the only statically-visible blockers; framework
|
|
17
|
+
// listeners are invisible to a DOM audit.
|
|
18
|
+
const blocking = ['onpaste', 'ondrop'].find((attr) => {
|
|
19
|
+
const handler = element.getAttribute(attr) ?? '';
|
|
20
|
+
return /return\s+false|preventDefault/.test(handler);
|
|
21
|
+
});
|
|
22
|
+
if (blocking) {
|
|
23
|
+
return {
|
|
24
|
+
status: 'fail',
|
|
25
|
+
message: `This authentication field blocks ${blocking === 'onpaste' ? 'pasting' : 'dropping'} — users of password managers must retype credentials from memory, a cognitive function test 3.3.8 forbids.`,
|
|
26
|
+
fix: 'Remove the paste/drop blocking; let password managers and copy-paste work.',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if ((element.getAttribute('autocomplete') ?? '').trim().toLowerCase() === 'off') {
|
|
30
|
+
return {
|
|
31
|
+
status: 'incomplete',
|
|
32
|
+
message: 'This authentication field sets autocomplete="off", discouraging password managers. Browsers often fill anyway — verify a password manager can complete this login without retyping.',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return { status: 'pass' };
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
// ARIA attributes must be supported by the element's role — aria-selected
|
|
3
|
+
// on a plain link, aria-checked on a heading, etc. are ignored (or worse,
|
|
4
|
+
// misreported) by assistive technology.
|
|
5
|
+
import { GLOBAL_ARIA, ROLE_ARIA, KNOWN_ARIA, effectiveRole } from '../../lib/roles.js';
|
|
6
|
+
|
|
7
|
+
// aria-label/labelledby misuse on generic elements has its own rule
|
|
8
|
+
// (aria-label-misuse) — excluded here to avoid double-reporting.
|
|
9
|
+
const HANDLED_ELSEWHERE = new Set(['label', 'labelledby']);
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
id: 'aria-allowed-attr',
|
|
13
|
+
impact: 'critical',
|
|
14
|
+
tags: ['wcag2a', 'wcag412'],
|
|
15
|
+
help: 'ARIA attributes must be allowed for the element’s role',
|
|
16
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
17
|
+
selector: '*',
|
|
18
|
+
visibleOnly: false,
|
|
19
|
+
evaluate(element) {
|
|
20
|
+
// Plain loop, no intermediate arrays: this runs on every element of the
|
|
21
|
+
// page, and nearly all have no aria-* attributes at all.
|
|
22
|
+
const ariaAttrs = [];
|
|
23
|
+
for (const { name } of element.attributes) {
|
|
24
|
+
if (!name.startsWith('aria-')) continue;
|
|
25
|
+
const attr = name.slice(5);
|
|
26
|
+
// Unknown/misspelled attributes are aria-attr-valid's finding — judging
|
|
27
|
+
// them against a role too would report the same typo twice.
|
|
28
|
+
if (!HANDLED_ELSEWHERE.has(attr) && KNOWN_ARIA.has(attr)) ariaAttrs.push(attr);
|
|
29
|
+
}
|
|
30
|
+
if (!ariaAttrs.length) return { status: 'pass' };
|
|
31
|
+
|
|
32
|
+
const role = effectiveRole(element);
|
|
33
|
+
const allowed = role && ROLE_ARIA[role];
|
|
34
|
+
if (!allowed) return { status: 'pass' }; // role not modelled: don't guess
|
|
35
|
+
|
|
36
|
+
const disallowed = ariaAttrs.filter((name) => !GLOBAL_ARIA.has(name) && !allowed.includes(name));
|
|
37
|
+
if (!disallowed.length) return { status: 'pass' };
|
|
38
|
+
return {
|
|
39
|
+
status: 'fail',
|
|
40
|
+
message: `${disallowed.map((n) => `aria-${n}`).join(', ')} is not supported on role "${role}" — assistive technology ignores it or announces the wrong state.`,
|
|
41
|
+
fix: `Use a role that supports ${disallowed.map((n) => `aria-${n}`).join('/')}, or remove the attribute(s).`,
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
};
|