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,50 @@
|
|
|
1
|
+
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
// ARIA 1.2 attribute names (shared vocabulary in lib/roles.js), and the
|
|
3
|
+
// allowed values for enumerated ones.
|
|
4
|
+
import { KNOWN_ARIA as KNOWN } from '../../lib/roles.js';
|
|
5
|
+
|
|
6
|
+
// Note: "undefined" is a legal token for the tristate/undefinable attributes
|
|
7
|
+
// (aria-expanded, aria-selected, aria-checked, aria-pressed) per ARIA 1.2.
|
|
8
|
+
const ENUMS = {
|
|
9
|
+
'aria-atomic': ['true', 'false'], 'aria-busy': ['true', 'false'],
|
|
10
|
+
'aria-disabled': ['true', 'false'], 'aria-expanded': ['true', 'false', 'undefined'],
|
|
11
|
+
'aria-hidden': ['true', 'false'], 'aria-modal': ['true', 'false'],
|
|
12
|
+
'aria-multiline': ['true', 'false'], 'aria-multiselectable': ['true', 'false'],
|
|
13
|
+
'aria-readonly': ['true', 'false'], 'aria-required': ['true', 'false'],
|
|
14
|
+
'aria-selected': ['true', 'false', 'undefined'],
|
|
15
|
+
'aria-checked': ['true', 'false', 'mixed', 'undefined'], 'aria-pressed': ['true', 'false', 'mixed', 'undefined'],
|
|
16
|
+
'aria-live': ['off', 'polite', 'assertive'],
|
|
17
|
+
'aria-orientation': ['horizontal', 'vertical'],
|
|
18
|
+
'aria-sort': ['none', 'ascending', 'descending', 'other'],
|
|
19
|
+
'aria-autocomplete': ['none', 'inline', 'list', 'both'],
|
|
20
|
+
'aria-invalid': ['true', 'false', 'grammar', 'spelling'],
|
|
21
|
+
'aria-haspopup': ['true', 'false', 'menu', 'listbox', 'tree', 'grid', 'dialog'],
|
|
22
|
+
'aria-current': ['true', 'false', 'page', 'step', 'location', 'date', 'time'],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default {
|
|
26
|
+
id: 'aria-attr-valid',
|
|
27
|
+
impact: 'critical',
|
|
28
|
+
tags: ['wcag2a', 'wcag412'],
|
|
29
|
+
help: 'aria attributes must exist and have valid values',
|
|
30
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
31
|
+
selector: '*',
|
|
32
|
+
visibleOnly: false, // applies to hidden elements too; also avoids visibility cost on every node
|
|
33
|
+
evaluate(element) {
|
|
34
|
+
const problems = [];
|
|
35
|
+
for (const { name, value } of element.attributes) {
|
|
36
|
+
if (!name.startsWith('aria-')) continue;
|
|
37
|
+
if (!KNOWN.has(name.slice(5))) {
|
|
38
|
+
problems.push(`${name} is not an ARIA attribute`);
|
|
39
|
+
} else if (ENUMS[name] && !ENUMS[name].includes(value.trim().toLowerCase())) {
|
|
40
|
+
problems.push(`${name}="${value}" — allowed values: ${ENUMS[name].join(', ')}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!problems.length) return { status: 'pass' };
|
|
44
|
+
return {
|
|
45
|
+
status: 'fail',
|
|
46
|
+
message: `Invalid ARIA: ${problems.join('; ')}. Assistive technology ignores attributes it doesn't recognise.`,
|
|
47
|
+
fix: 'Correct the attribute name/value against the ARIA specification, or remove it.',
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
// ARIA-built form fields (role="textbox" divs etc.) need names just like
|
|
3
|
+
// native inputs — native fields are handled by form-label.
|
|
4
|
+
//
|
|
5
|
+
// Spec note (accname): input-like roles are NOT "name from content" — the
|
|
6
|
+
// text inside a listbox/textbox does not name it, only author-provided
|
|
7
|
+
// naming counts. Toggle roles (checkbox/radio/switch) DO name from content.
|
|
8
|
+
const AUTHOR_ONLY = ['textbox', 'searchbox', 'combobox', 'listbox', 'spinbutton', 'slider'];
|
|
9
|
+
const FROM_CONTENT = ['checkbox', 'radio', 'switch'];
|
|
10
|
+
|
|
11
|
+
function authorName(element) {
|
|
12
|
+
const labelledby = element.getAttribute('aria-labelledby');
|
|
13
|
+
if (labelledby) {
|
|
14
|
+
const text = labelledby
|
|
15
|
+
.split(/\s+/)
|
|
16
|
+
.map((id) => element.getRootNode().getElementById?.(id)?.textContent ?? '')
|
|
17
|
+
.join(' ')
|
|
18
|
+
.trim();
|
|
19
|
+
if (text) return text;
|
|
20
|
+
}
|
|
21
|
+
return element.getAttribute('aria-label')?.trim() || element.getAttribute('title')?.trim() || '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default {
|
|
25
|
+
id: 'aria-field-name',
|
|
26
|
+
impact: 'serious',
|
|
27
|
+
tags: ['wcag2a', 'wcag412'],
|
|
28
|
+
help: 'ARIA form fields must have an accessible name',
|
|
29
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
30
|
+
selector: [...AUTHOR_ONLY, ...FROM_CONTENT]
|
|
31
|
+
.map((role) => `[role="${role}"]:not(input):not(select):not(textarea)`)
|
|
32
|
+
.join(', '),
|
|
33
|
+
evaluate(element, { accessibleName }) {
|
|
34
|
+
const role = element.getAttribute('role');
|
|
35
|
+
const name = FROM_CONTENT.includes(role) ? accessibleName(element) : authorName(element);
|
|
36
|
+
if (name) return { status: 'pass' };
|
|
37
|
+
// ARIA 1.1 combobox pattern: a non-focusable wrapper div carries the
|
|
38
|
+
// role while the real, focusable input inside carries the label. The
|
|
39
|
+
// wrapper's missing name is spec-untidy but the field users actually
|
|
40
|
+
// reach IS named — not a 4.1.2 failure in practice.
|
|
41
|
+
if (role === 'combobox' && element.tabIndex < 0) {
|
|
42
|
+
const input = element.querySelector('input, [role="textbox"], [role="searchbox"]');
|
|
43
|
+
if (input && (input.labels?.length || input.getAttribute('aria-label') || input.getAttribute('aria-labelledby'))) {
|
|
44
|
+
return { status: 'pass' };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
status: 'fail',
|
|
49
|
+
message: `This role="${role}" field has no accessible name — screen readers announce the role with no idea what it's for.${
|
|
50
|
+
AUTHOR_ONLY.includes(role) ? ' (Content inside it does not count as a name for this role.)' : ''}`,
|
|
51
|
+
fix: 'Add aria-label="…" or aria-labelledby pointing at its visible label.',
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
const FOCUSABLE = 'a[href], area[href], button, input, select, textarea, summary, iframe, '
|
|
3
|
+
+ 'audio[controls], video[controls], [contenteditable]:not([contenteditable="false"]), [tabindex]';
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
id: 'aria-hidden-focus',
|
|
7
|
+
impact: 'serious',
|
|
8
|
+
tags: ['wcag2a', 'wcag412'],
|
|
9
|
+
help: 'Nothing focusable may sit inside aria-hidden="true"',
|
|
10
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
11
|
+
selector: '[aria-hidden="true"]',
|
|
12
|
+
visibleOnly: false, // these elements are hidden from AT by definition
|
|
13
|
+
evaluate(element, { isRendered }) {
|
|
14
|
+
// Only elements that are actually rendered can receive focus — an
|
|
15
|
+
// aria-hidden menu that is ALSO display:none is fine until it opens.
|
|
16
|
+
// Fully transparent controls are excluded too: nothing is SEEN when
|
|
17
|
+
// focus lands there, so the harm this rule describes (sighted keyboard
|
|
18
|
+
// users interacting with SR-invisible content) doesn't materialise —
|
|
19
|
+
// an invisible tab stop is a focus-order/visible-focus concern instead.
|
|
20
|
+
const focusable = [element, ...element.querySelectorAll(FOCUSABLE)].filter(
|
|
21
|
+
(el) => el.matches?.(FOCUSABLE) && el.getAttribute('tabindex') !== '-1' && !el.disabled
|
|
22
|
+
// `inert` removes focusability outright — aria-hidden + inert is
|
|
23
|
+
// the CORRECT modern pattern for off-canvas content, not a defect.
|
|
24
|
+
&& !el.closest('[inert]')
|
|
25
|
+
&& isRendered(el) && getComputedStyle(el).opacity !== '0',
|
|
26
|
+
);
|
|
27
|
+
if (!focusable.length) return { status: 'pass' };
|
|
28
|
+
// While a modal dialog is open, the page behind it is DELIBERATELY
|
|
29
|
+
// aria-hidden and its focusability is script-managed (focus traps).
|
|
30
|
+
// A static snapshot can't tell a correct trap from a broken one —
|
|
31
|
+
// that's a human check, not an assertable failure.
|
|
32
|
+
const doc = element.ownerDocument;
|
|
33
|
+
let modal = null;
|
|
34
|
+
try { modal = doc.querySelector('dialog:modal'); } catch { /* older engine */ }
|
|
35
|
+
modal ??= [...doc.querySelectorAll('[aria-modal="true"]')].find(isRendered) ?? null;
|
|
36
|
+
if (modal && !element.contains(modal) && !modal.contains(element)) {
|
|
37
|
+
return {
|
|
38
|
+
status: 'incomplete',
|
|
39
|
+
message: `This aria-hidden element contains ${focusable.length} focusable element(s) while a modal dialog is open — fine if the modal traps keyboard focus, a failure if Tab can reach them. Check the trap by keyboard.`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
status: 'fail',
|
|
44
|
+
message: `This aria-hidden element contains ${focusable.length} focusable element(s) — keyboard users can Tab into content that screen readers cannot see.`,
|
|
45
|
+
fix: 'Add tabindex="-1" (or disabled) to focusable elements inside aria-hidden regions, or remove aria-hidden.',
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
// aria-activedescendant and aria-owns matter most: a dangling
|
|
3
|
+
// activedescendant breaks composite-widget focus reporting entirely.
|
|
4
|
+
const REF_ATTRIBUTES = [
|
|
5
|
+
'aria-labelledby', 'aria-describedby', 'aria-controls',
|
|
6
|
+
'aria-activedescendant', 'aria-owns', 'aria-errormessage', 'aria-details',
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
id: 'aria-valid-refs',
|
|
11
|
+
// Moderate, not critical: with 4.1.1 Parsing retired in WCAG 2.2 these
|
|
12
|
+
// are 4.1.2 name/state defects, and aria-controls in particular has weak
|
|
13
|
+
// assistive-technology support — real, but rarely blocking.
|
|
14
|
+
impact: 'moderate',
|
|
15
|
+
tags: ['wcag2a', 'wcag412'],
|
|
16
|
+
help: 'ARIA id references must point to elements that exist',
|
|
17
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
18
|
+
selector: REF_ATTRIBUTES.map((attr) => `[${attr}]`).join(', '),
|
|
19
|
+
evaluate(element) {
|
|
20
|
+
// ARIA id references cannot cross shadow boundaries: they resolve only
|
|
21
|
+
// within the element's own root (document or shadow tree).
|
|
22
|
+
const root = element.getRootNode();
|
|
23
|
+
// Collapsed-disclosure pattern: a trigger with aria-expanded="false"
|
|
24
|
+
// whose aria-controls panel doesn't exist YET (rendered on expand) is
|
|
25
|
+
// ubiquitous and harmless while collapsed — name computation doesn't
|
|
26
|
+
// touch aria-controls, so nothing resolves wrongly today.
|
|
27
|
+
const collapsed = element.getAttribute('aria-expanded') === 'false';
|
|
28
|
+
const missing = [];
|
|
29
|
+
const ambiguous = [];
|
|
30
|
+
for (const attr of REF_ATTRIBUTES) {
|
|
31
|
+
for (const id of (element.getAttribute(attr) ?? '').split(/\s+/).filter(Boolean)) {
|
|
32
|
+
if (!root.getElementById?.(id)) {
|
|
33
|
+
if (attr === 'aria-controls' && collapsed) continue;
|
|
34
|
+
missing.push(`${attr}="${id}" points to nothing — assistive technology silently ignores it`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const count = root.querySelectorAll(`[id="${CSS.escape(id)}"]`).length;
|
|
38
|
+
if (count > 1) {
|
|
39
|
+
ambiguous.push(`${attr}="${id}" — ${count} elements share this id; the reference binds to the FIRST one in the DOM`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (missing.length) {
|
|
44
|
+
return {
|
|
45
|
+
status: 'fail',
|
|
46
|
+
message: `Broken ARIA references: ${missing.join('; ')}.`,
|
|
47
|
+
fix: 'Correct or remove the reference, or give the target element that id.',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (ambiguous.length) {
|
|
51
|
+
// Duplicate ids resolve deterministically to the first copy — often
|
|
52
|
+
// the right one (desktop/mobile double-renders). Real, but a human
|
|
53
|
+
// must judge whether the first copy is the wrong copy.
|
|
54
|
+
return {
|
|
55
|
+
status: 'incomplete',
|
|
56
|
+
message: `Ambiguous ARIA references: ${ambiguous.join('; ')}. If the first copy in the DOM is the intended target, this works today — but it's fragile.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return { status: 'pass' };
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
export default {
|
|
3
|
+
id: 'button-name',
|
|
4
|
+
impact: 'critical',
|
|
5
|
+
tags: ['wcag2a', 'wcag412'],
|
|
6
|
+
help: 'Buttons must have an accessible name',
|
|
7
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
8
|
+
selector: 'button, [role="button"], input[type="button"], input[type="submit"], input[type="reset"]',
|
|
9
|
+
evaluate(element, { accessibleName }) {
|
|
10
|
+
if (accessibleName(element)) return { status: 'pass' };
|
|
11
|
+
return {
|
|
12
|
+
status: 'fail',
|
|
13
|
+
message: 'This button has no accessible name — a screen reader announces just "button".',
|
|
14
|
+
fix: 'Add visible text inside the button, or aria-label="What it does" for icon-only buttons.',
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
// ARIA 1.2 marks dialog/alertdialog as name-required roles, and dialogs are
|
|
3
|
+
// NOT name-from-content — the body text is the dialog's content, never its
|
|
4
|
+
// name. An open, unnamed modal announces as just "dialog", leaving
|
|
5
|
+
// screen-reader users with no idea what interrupted them (cookie-consent
|
|
6
|
+
// modals are the canonical offender).
|
|
7
|
+
export default {
|
|
8
|
+
id: 'dialog-name',
|
|
9
|
+
impact: 'serious',
|
|
10
|
+
tags: ['wcag2a', 'wcag412'],
|
|
11
|
+
help: 'Dialogs must have an accessible name',
|
|
12
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
13
|
+
// Default visibility filter: a closed <dialog> is display:none, so only
|
|
14
|
+
// dialogs the user can actually encounter are judged.
|
|
15
|
+
selector: 'dialog, [role="dialog"], [role="alertdialog"]',
|
|
16
|
+
evaluate(element) {
|
|
17
|
+
const labelledby = element.getAttribute('aria-labelledby');
|
|
18
|
+
if (labelledby) {
|
|
19
|
+
const text = labelledby
|
|
20
|
+
.split(/\s+/)
|
|
21
|
+
.map((id) => element.getRootNode().getElementById?.(id)?.textContent ?? '')
|
|
22
|
+
.join(' ')
|
|
23
|
+
.trim();
|
|
24
|
+
if (text) return { status: 'pass' };
|
|
25
|
+
}
|
|
26
|
+
if (element.getAttribute('aria-label')?.trim() || element.getAttribute('title')?.trim()) {
|
|
27
|
+
return { status: 'pass' };
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
status: 'fail',
|
|
31
|
+
message: 'This dialog has no accessible name — screen readers announce just "dialog" with no hint of what it is or why it appeared.',
|
|
32
|
+
fix: 'Point aria-labelledby at the dialog\'s heading, or add aria-label="What this dialog is".',
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
export default {
|
|
3
|
+
id: 'frame-title',
|
|
4
|
+
impact: 'serious',
|
|
5
|
+
tags: ['wcag2a', 'wcag412'],
|
|
6
|
+
help: 'Frames must have a title describing their content',
|
|
7
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
8
|
+
selector: 'iframe, frame',
|
|
9
|
+
evaluate(element, { accessibleName }) {
|
|
10
|
+
if (element.getAttribute('title')?.trim() || accessibleName(element)) return { status: 'pass' };
|
|
11
|
+
return {
|
|
12
|
+
status: 'fail',
|
|
13
|
+
message: 'This frame has no title — screen-reader users cannot tell what it contains before entering it.',
|
|
14
|
+
fix: `Add title="…" describing the embedded content, e.g. <iframe title="Newsletter signup" src="${element.getAttribute('src') ?? ''}">.`,
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
const LABELABLE = 'input, select, textarea, button, meter, output, progress';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
id: 'label-for-valid',
|
|
6
|
+
impact: 'serious',
|
|
7
|
+
tags: ['wcag2a', 'wcag412'],
|
|
8
|
+
help: 'label[for] must reference a form control that exists',
|
|
9
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
10
|
+
selector: 'label[for]',
|
|
11
|
+
evaluate(element) {
|
|
12
|
+
const id = element.getAttribute('for');
|
|
13
|
+
// id references resolve within the element's own root — a label in a
|
|
14
|
+
// shadow tree can only point at controls in that same tree.
|
|
15
|
+
const target = element.getRootNode().getElementById?.(id);
|
|
16
|
+
if (target?.matches(LABELABLE)) return { status: 'pass' };
|
|
17
|
+
return {
|
|
18
|
+
status: 'fail',
|
|
19
|
+
message: target
|
|
20
|
+
? `for="${id}" points at a <${target.tagName.toLowerCase()}>, which cannot be labelled — the association is ignored.`
|
|
21
|
+
: `for="${id}" points at nothing — the label is not associated with any field.`,
|
|
22
|
+
fix: 'Point the for attribute at the id of the input/select/textarea it labels.',
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
// Deliberately excludes bare [tabindex]: a focusable container (e.g. a
|
|
3
|
+
// scrollable region with tabindex="0") holding links is correct markup.
|
|
4
|
+
const INTERACTIVE =
|
|
5
|
+
'a[href], button, input, select, textarea, summary, audio[controls], video[controls], ' +
|
|
6
|
+
'[role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="switch"], ' +
|
|
7
|
+
'[role="menuitem"], [role="tab"], [role="option"]';
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
id: 'nested-interactive',
|
|
11
|
+
impact: 'serious',
|
|
12
|
+
tags: ['wcag2a', 'wcag412'],
|
|
13
|
+
help: 'A control must not contain another control',
|
|
14
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
15
|
+
selector: INTERACTIVE,
|
|
16
|
+
evaluate(element, { isRendered }) {
|
|
17
|
+
// Only descendants a user can actually reach count: hidden inputs,
|
|
18
|
+
// disabled controls, display:none templates, and tabindex="-1" nodes
|
|
19
|
+
// inside a control are inert markup, not competing tab stops.
|
|
20
|
+
const nested = [...element.querySelectorAll(INTERACTIVE)].find((el) =>
|
|
21
|
+
!el.disabled && el.getAttribute('tabindex') !== '-1'
|
|
22
|
+
&& !(el.tagName === 'INPUT' && el.type === 'hidden') && isRendered(el));
|
|
23
|
+
if (!nested) return { status: 'pass' };
|
|
24
|
+
return {
|
|
25
|
+
status: 'fail',
|
|
26
|
+
message: `This ${element.tagName.toLowerCase()} contains another interactive element (<${nested.tagName.toLowerCase()}>) — focus order and announcements become unpredictable.`,
|
|
27
|
+
fix: 'Restructure so interactive elements are siblings, not ancestors of each other.',
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
// Roles that are meaningless without a state attribute alongside them.
|
|
3
|
+
const REQUIRED = {
|
|
4
|
+
checkbox: 'aria-checked',
|
|
5
|
+
switch: 'aria-checked',
|
|
6
|
+
radio: 'aria-checked',
|
|
7
|
+
slider: 'aria-valuenow',
|
|
8
|
+
scrollbar: 'aria-valuenow',
|
|
9
|
+
combobox: 'aria-expanded',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default {
|
|
13
|
+
id: 'role-required-aria',
|
|
14
|
+
impact: 'serious',
|
|
15
|
+
tags: ['wcag2a', 'wcag412'],
|
|
16
|
+
help: 'Some roles require a state attribute to work',
|
|
17
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
18
|
+
selector: Object.keys(REQUIRED).map((role) => `[role="${role}"]`).join(', '),
|
|
19
|
+
evaluate(element) {
|
|
20
|
+
const role = element.getAttribute('role');
|
|
21
|
+
const required = REQUIRED[role];
|
|
22
|
+
if (element.hasAttribute(required)) return { status: 'pass' };
|
|
23
|
+
// A redundant explicit role on the native element (input type=checkbox
|
|
24
|
+
// role=checkbox — common framework output) needs no ARIA state: the
|
|
25
|
+
// host language supplies checkedness/value natively.
|
|
26
|
+
const type = element.tagName === 'INPUT' ? element.type : null;
|
|
27
|
+
const NATIVE_STATE = { checkbox: 'checkbox', radio: 'radio', range: 'slider' };
|
|
28
|
+
if (type && NATIVE_STATE[type] === role) return { status: 'pass' };
|
|
29
|
+
if (element.tagName === 'SELECT' && role === 'combobox') return { status: 'pass' };
|
|
30
|
+
return {
|
|
31
|
+
status: 'fail',
|
|
32
|
+
message: `role="${role}" without ${required} — screen readers can't announce the control's state.`,
|
|
33
|
+
fix: `Add ${required} and keep it updated from your script, or use the native HTML element instead.`,
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
// ARIA 1.2 roles (concrete, usable roles — abstract roles are invalid in markup).
|
|
3
|
+
const VALID_ROLES = new Set([
|
|
4
|
+
'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button',
|
|
5
|
+
'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', 'complementary',
|
|
6
|
+
'contentinfo', 'definition', 'deletion', 'dialog', 'directory', 'document', 'emphasis',
|
|
7
|
+
'feed', 'figure', 'form', 'generic', 'grid', 'gridcell', 'group', 'heading', 'img',
|
|
8
|
+
'insertion', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math',
|
|
9
|
+
'menu', 'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'meter', 'navigation',
|
|
10
|
+
'none', 'note', 'option', 'paragraph', 'presentation', 'progressbar', 'radio', 'radiogroup',
|
|
11
|
+
'region', 'row', 'rowgroup', 'rowheader', 'scrollbar', 'search', 'searchbox', 'separator',
|
|
12
|
+
'slider', 'spinbutton', 'status', 'strong', 'subscript', 'superscript', 'switch', 'tab',
|
|
13
|
+
'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer', 'toolbar', 'tooltip',
|
|
14
|
+
'tree', 'treegrid', 'treeitem',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export default {
|
|
18
|
+
id: 'valid-role',
|
|
19
|
+
impact: 'critical',
|
|
20
|
+
tags: ['wcag2a', 'wcag412'],
|
|
21
|
+
help: 'role attributes must use valid ARIA roles',
|
|
22
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
23
|
+
selector: '[role]',
|
|
24
|
+
evaluate(element) {
|
|
25
|
+
// A role attribute may list fallbacks; the first valid token wins.
|
|
26
|
+
// doc-* (DPub-ARIA) and graphics-* (Graphics-ARIA) modules are valid too.
|
|
27
|
+
const tokens = element.getAttribute('role').trim().split(/\s+/).filter(Boolean);
|
|
28
|
+
const isValid = (token) => {
|
|
29
|
+
const t = token.toLowerCase();
|
|
30
|
+
return VALID_ROLES.has(t) || t.startsWith('doc-') || t.startsWith('graphics-');
|
|
31
|
+
};
|
|
32
|
+
if (tokens.some(isValid)) return { status: 'pass' };
|
|
33
|
+
// An invalid role is ignored and the element falls back to its NATIVE
|
|
34
|
+
// role. When that fallback is generic anyway (plain div/span, not
|
|
35
|
+
// focusable, no ARIA states riding on the bogus role), nothing an AT
|
|
36
|
+
// user receives is wrong — role="text" on presentational spans is the
|
|
37
|
+
// canonical harmless case. The failure worth reporting is an invalid
|
|
38
|
+
// role MASKING real semantics or decorating an interactive element.
|
|
39
|
+
const tag = element.tagName.toLowerCase();
|
|
40
|
+
const genericFallback = tag === 'div' || tag === 'span';
|
|
41
|
+
const focusable = element.tabIndex >= 0
|
|
42
|
+
|| element.matches('a[href], button, input, select, textarea, summary');
|
|
43
|
+
const hasAriaProps = [...element.attributes].some(
|
|
44
|
+
(attr) => attr.name.startsWith('aria-') && attr.name !== 'aria-hidden');
|
|
45
|
+
if (genericFallback && !focusable && !hasAriaProps) return { status: 'pass' };
|
|
46
|
+
return {
|
|
47
|
+
status: 'fail',
|
|
48
|
+
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`}.`,
|
|
49
|
+
fix: 'Use a valid role from the ARIA specification, or remove the attribute to keep the element’s native role.',
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
};
|
package/engine/wcag22.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// The complete WCAG 2.2 success-criteria catalog — all 86 criteria (2.2
|
|
2
|
+
// removed 4.1.1 Parsing). This is the engine's source of truth for what
|
|
3
|
+
// exists in the spec, independent of what we can automate.
|
|
4
|
+
//
|
|
5
|
+
// `automation` is an honest assessment:
|
|
6
|
+
// 'auto' — rules can meaningfully test this
|
|
7
|
+
// 'partial' — rules catch some failures; humans must still review
|
|
8
|
+
// 'manual' — requires human judgment (most of the spec!)
|
|
9
|
+
// The coverage report (scripts/wcag-coverage.js → src/engine/COVERAGE.md) joins
|
|
10
|
+
// this catalog with the rule registry to show real coverage.
|
|
11
|
+
|
|
12
|
+
const sc = (num, name, level, automation, since = '2.0') => ({
|
|
13
|
+
num,
|
|
14
|
+
name,
|
|
15
|
+
level,
|
|
16
|
+
automation,
|
|
17
|
+
since, // WCAG version that introduced this criterion
|
|
18
|
+
principle: { 1: 'Perceivable', 2: 'Operable', 3: 'Understandable', 4: 'Robust' }[num[0]],
|
|
19
|
+
url: `https://www.w3.org/WAI/WCAG22/Understanding/${name
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[(),]/g, '')
|
|
22
|
+
.replace(/\s+/g, '-')}.html`,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export default [
|
|
26
|
+
// 1. Perceivable
|
|
27
|
+
sc('1.1.1', 'Non-text Content', 'A', 'partial'),
|
|
28
|
+
sc('1.2.1', 'Audio-only and Video-only (Prerecorded)', 'A', 'manual'),
|
|
29
|
+
sc('1.2.2', 'Captions (Prerecorded)', 'A', 'partial'),
|
|
30
|
+
sc('1.2.3', 'Audio Description or Media Alternative (Prerecorded)', 'A', 'manual'),
|
|
31
|
+
sc('1.2.4', 'Captions (Live)', 'AA', 'manual'),
|
|
32
|
+
sc('1.2.5', 'Audio Description (Prerecorded)', 'AA', 'manual'),
|
|
33
|
+
sc('1.2.6', 'Sign Language (Prerecorded)', 'AAA', 'manual'),
|
|
34
|
+
sc('1.2.7', 'Extended Audio Description (Prerecorded)', 'AAA', 'manual'),
|
|
35
|
+
sc('1.2.8', 'Media Alternative (Prerecorded)', 'AAA', 'manual'),
|
|
36
|
+
sc('1.2.9', 'Audio-only (Live)', 'AAA', 'manual'),
|
|
37
|
+
sc('1.3.1', 'Info and Relationships', 'A', 'partial'),
|
|
38
|
+
sc('1.3.2', 'Meaningful Sequence', 'A', 'manual'),
|
|
39
|
+
sc('1.3.3', 'Sensory Characteristics', 'A', 'manual'),
|
|
40
|
+
sc('1.3.4', 'Orientation', 'AA', 'manual', '2.1'),
|
|
41
|
+
sc('1.3.5', 'Identify Input Purpose', 'AA', 'partial', '2.1'), // partial: wrong tokens are provable; MISSING autocomplete on identity fields needs judgment
|
|
42
|
+
sc('1.3.6', 'Identify Purpose', 'AAA', 'manual', '2.1'),
|
|
43
|
+
sc('1.4.1', 'Use of Color', 'A', 'partial'), // link-in-text-block automates the link case
|
|
44
|
+
sc('1.4.2', 'Audio Control', 'A', 'partial'), // partial: declarative autoplay is provable; JS-initiated audio is not
|
|
45
|
+
sc('1.4.3', 'Contrast (Minimum)', 'AA', 'partial'),
|
|
46
|
+
sc('1.4.4', 'Resize Text', 'AA', 'partial'),
|
|
47
|
+
sc('1.4.5', 'Images of Text', 'AA', 'manual'),
|
|
48
|
+
sc('1.4.6', 'Contrast (Enhanced)', 'AAA', 'partial'),
|
|
49
|
+
sc('1.4.7', 'Low or No Background Audio', 'AAA', 'manual'),
|
|
50
|
+
sc('1.4.8', 'Visual Presentation', 'AAA', 'manual'),
|
|
51
|
+
sc('1.4.9', 'Images of Text (No Exception)', 'AAA', 'manual'),
|
|
52
|
+
sc('1.4.10', 'Reflow', 'AA', 'partial', '2.1'), // partial: reflow (current-viewport overflow heuristic); full test is at 320px
|
|
53
|
+
sc('1.4.11', 'Non-text Contrast', 'AA', 'partial', '2.1'), // partial: non-text-contrast covers field boundaries; icons/focus indicators need eyes
|
|
54
|
+
sc('1.4.12', 'Text Spacing', 'AA', 'partial', '2.1'), // partial: text-spacing probes the SC's own override test for clipping
|
|
55
|
+
sc('1.4.13', 'Content on Hover or Focus', 'AA', 'manual', '2.1'),
|
|
56
|
+
// 2. Operable
|
|
57
|
+
sc('2.1.1', 'Keyboard', 'A', 'partial'),
|
|
58
|
+
sc('2.1.2', 'No Keyboard Trap', 'A', 'manual'),
|
|
59
|
+
sc('2.1.3', 'Keyboard (No Exception)', 'AAA', 'partial'), // 2.1.1's rules apply; no-exception scope needs humans
|
|
60
|
+
sc('2.1.4', 'Character Key Shortcuts', 'A', 'manual', '2.1'),
|
|
61
|
+
sc('2.2.1', 'Timing Adjustable', 'A', 'partial'), // meta-refresh automates the redirect/refresh case
|
|
62
|
+
sc('2.2.2', 'Pause, Stop, Hide', 'A', 'partial'),
|
|
63
|
+
sc('2.2.3', 'No Timing', 'AAA', 'manual'),
|
|
64
|
+
sc('2.2.4', 'Interruptions', 'AAA', 'manual'),
|
|
65
|
+
sc('2.2.5', 'Re-authenticating', 'AAA', 'manual'),
|
|
66
|
+
sc('2.2.6', 'Timeouts', 'AAA', 'manual', '2.1'),
|
|
67
|
+
sc('2.3.1', 'Three Flashes or Below Threshold', 'A', 'manual'),
|
|
68
|
+
sc('2.3.2', 'Three Flashes', 'AAA', 'manual'),
|
|
69
|
+
sc('2.3.3', 'Animation from Interactions', 'AAA', 'manual', '2.1'),
|
|
70
|
+
sc('2.4.1', 'Bypass Blocks', 'A', 'partial'), // partial: "repeated across pages" can't be established from one page
|
|
71
|
+
sc('2.4.2', 'Page Titled', 'A', 'auto'),
|
|
72
|
+
sc('2.4.3', 'Focus Order', 'A', 'manual'), // no rule automates this yet; order judgment needs humans
|
|
73
|
+
sc('2.4.4', 'Link Purpose (In Context)', 'A', 'partial'),
|
|
74
|
+
sc('2.4.5', 'Multiple Ways', 'AA', 'manual'),
|
|
75
|
+
sc('2.4.6', 'Headings and Labels', 'AA', 'manual'),
|
|
76
|
+
sc('2.4.7', 'Focus Visible', 'AA', 'partial'), // partial: focus-visible flags outline suppression; the indicator itself needs eyes
|
|
77
|
+
sc('2.4.8', 'Location', 'AAA', 'manual'),
|
|
78
|
+
sc('2.4.9', 'Link Purpose (Link Only)', 'AAA', 'manual'),
|
|
79
|
+
sc('2.4.10', 'Section Headings', 'AAA', 'manual'),
|
|
80
|
+
sc('2.4.11', 'Focus Not Obscured (Minimum)', 'AA', 'partial', '2.2'), // partial: focus-not-obscured catches fully-covered targets at rest
|
|
81
|
+
sc('2.4.12', 'Focus Not Obscured (Enhanced)', 'AAA', 'manual', '2.2'),
|
|
82
|
+
sc('2.4.13', 'Focus Appearance', 'AAA', 'manual', '2.2'),
|
|
83
|
+
sc('2.5.1', 'Pointer Gestures', 'A', 'manual', '2.1'),
|
|
84
|
+
sc('2.5.2', 'Pointer Cancellation', 'A', 'manual', '2.1'),
|
|
85
|
+
sc('2.5.3', 'Label in Name', 'A', 'manual', '2.1'), // manual WHILE the label-in-name rule is parked (see rules/index.js) — 'auto' with no active rule made this SC vanish from both the results AND the manual checklist
|
|
86
|
+
sc('2.5.4', 'Motion Actuation', 'A', 'manual', '2.1'),
|
|
87
|
+
sc('2.5.5', 'Target Size (Enhanced)', 'AAA', 'partial', '2.1'),
|
|
88
|
+
sc('2.5.6', 'Concurrent Input Mechanisms', 'AAA', 'manual', '2.1'),
|
|
89
|
+
sc('2.5.7', 'Dragging Movements', 'AA', 'manual', '2.2'),
|
|
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
|
+
// 3. Understandable
|
|
92
|
+
sc('3.1.1', 'Language of Page', 'A', 'auto'),
|
|
93
|
+
sc('3.1.2', 'Language of Parts', 'AA', 'partial'), // partial: invalid lang values are provable; UNMARKED foreign passages need language identification
|
|
94
|
+
sc('3.1.3', 'Unusual Words', 'AAA', 'manual'),
|
|
95
|
+
sc('3.1.4', 'Abbreviations', 'AAA', 'manual'),
|
|
96
|
+
sc('3.1.5', 'Reading Level', 'AAA', 'manual'),
|
|
97
|
+
sc('3.1.6', 'Pronunciation', 'AAA', 'manual'),
|
|
98
|
+
sc('3.2.1', 'On Focus', 'A', 'manual'),
|
|
99
|
+
sc('3.2.2', 'On Input', 'A', 'manual'),
|
|
100
|
+
sc('3.2.3', 'Consistent Navigation', 'AA', 'manual'),
|
|
101
|
+
sc('3.2.4', 'Consistent Identification', 'AA', 'manual'),
|
|
102
|
+
sc('3.2.5', 'Change on Request', 'AAA', 'manual'),
|
|
103
|
+
sc('3.2.6', 'Consistent Help', 'A', 'manual', '2.2'),
|
|
104
|
+
sc('3.3.1', 'Error Identification', 'A', 'manual'),
|
|
105
|
+
sc('3.3.2', 'Labels or Instructions', 'A', 'partial'),
|
|
106
|
+
sc('3.3.3', 'Error Suggestion', 'AA', 'manual'),
|
|
107
|
+
sc('3.3.4', 'Error Prevention (Legal, Financial, Data)', 'AA', 'manual'),
|
|
108
|
+
sc('3.3.5', 'Help', 'AAA', 'manual'),
|
|
109
|
+
sc('3.3.6', 'Error Prevention (All)', 'AAA', 'manual'),
|
|
110
|
+
sc('3.3.7', 'Redundant Entry', 'A', 'manual', '2.2'),
|
|
111
|
+
sc('3.3.8', 'Accessible Authentication (Minimum)', 'AA', 'partial', '2.2'), // partial: auth-field-obstruction catches paste blocking; alternatives need judgment
|
|
112
|
+
sc('3.3.9', 'Accessible Authentication (Enhanced)', 'AAA', 'manual', '2.2'),
|
|
113
|
+
// 4. Robust (4.1.1 Parsing was removed in WCAG 2.2)
|
|
114
|
+
sc('4.1.2', 'Name, Role, Value', 'A', 'partial'),
|
|
115
|
+
sc('4.1.3', 'Status Messages', 'AA', 'manual', '2.1'),
|
|
116
|
+
];
|