pour-engine 1.24.0 → 1.25.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/lib/dom.js +8 -2
- package/engine/rules/wcag/1.3.1-form-label.js +27 -12
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +6 -1
- package/engine/rules/wcag/4.1.2-nested-interactive.js +11 -1
- package/engine/rules/wcag/4.1.2-role-required-aria.js +9 -5
- package/package.json +1 -1
package/config/project.config.js
CHANGED
package/engine/lib/dom.js
CHANGED
|
@@ -47,7 +47,13 @@ export function isVisible(element) {
|
|
|
47
47
|
|
|
48
48
|
/** Selector for an element within its own root (document or shadow root). */
|
|
49
49
|
function cssPathInRoot(element) {
|
|
50
|
-
|
|
50
|
+
// An id anchors the selector only when it is UNIQUE in this root: real
|
|
51
|
+
// pages duplicate ids (three carousel pagers all id="default"), and a
|
|
52
|
+
// bare #id selector would round-trip every one of them to the first,
|
|
53
|
+
// collapsing distinct findings onto a single element.
|
|
54
|
+
const root = element.getRootNode();
|
|
55
|
+
const uniqueId = (el) => el.id && root.querySelectorAll(`#${CSS.escape(el.id)}`).length === 1;
|
|
56
|
+
if (uniqueId(element)) return `#${CSS.escape(element.id)}`;
|
|
51
57
|
const parts = [];
|
|
52
58
|
let current = element;
|
|
53
59
|
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.documentElement) {
|
|
@@ -66,7 +72,7 @@ function cssPathInRoot(element) {
|
|
|
66
72
|
if (repeated) part += `:nth-of-type(${index})`;
|
|
67
73
|
}
|
|
68
74
|
parts.unshift(part);
|
|
69
|
-
if (current.parentElement
|
|
75
|
+
if (current.parentElement && uniqueId(current.parentElement)) {
|
|
70
76
|
parts.unshift(`#${CSS.escape(current.parentElement.id)}`);
|
|
71
77
|
break;
|
|
72
78
|
}
|
|
@@ -21,16 +21,23 @@ export default {
|
|
|
21
21
|
const style = getComputedStyle(label);
|
|
22
22
|
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
23
23
|
});
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
.
|
|
33
|
-
.
|
|
24
|
+
// A label's contribution is its ACCESSIBLE text, not its raw textContent:
|
|
25
|
+
// aria-hidden subtrees inside the label are excluded from the name
|
|
26
|
+
// computation (a custom radio whose only visible text sits in an
|
|
27
|
+
// aria-hidden styled twin names NOTHING — the browser's tree computes an
|
|
28
|
+
// empty name), while aria-label on the label itself or on things inside
|
|
29
|
+
// it (icon-only labels) and image alts DO feed the name per accname.
|
|
30
|
+
const accessibleText = (node) => {
|
|
31
|
+
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
|
|
32
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
|
33
|
+
if (node.getAttribute('aria-hidden') === 'true') return '';
|
|
34
|
+
const aria = node.getAttribute('aria-label');
|
|
35
|
+
if (aria?.trim()) return aria;
|
|
36
|
+
const alt = (node.tagName === 'IMG' || node.tagName === 'AREA') ? node.getAttribute('alt') : null;
|
|
37
|
+
if (alt?.trim()) return alt;
|
|
38
|
+
return [...node.childNodes].map(accessibleText).join(' ');
|
|
39
|
+
};
|
|
40
|
+
const labelsText = visibleLabels.map(accessibleText).join(' ').trim();
|
|
34
41
|
const ariaLabel = element.getAttribute('aria-label')?.trim();
|
|
35
42
|
const labelledby = labelledByName(element);
|
|
36
43
|
if (labelsText || ariaLabel || labelledby) return { status: 'pass' };
|
|
@@ -65,10 +72,18 @@ export default {
|
|
|
65
72
|
};
|
|
66
73
|
}
|
|
67
74
|
if (element.labels?.length) {
|
|
75
|
+
// The label may LOOK fine while announcing nothing: all its text
|
|
76
|
+
// sitting inside an aria-hidden subtree (the styled-twin custom
|
|
77
|
+
// control pattern) is excluded from the name computation.
|
|
78
|
+
const looksLabelled = visibleLabels.some((l) => l.textContent.trim());
|
|
68
79
|
return {
|
|
69
80
|
status: 'fail',
|
|
70
|
-
message:
|
|
71
|
-
|
|
81
|
+
message: looksLabelled
|
|
82
|
+
? 'This field\'s <label> shows text, but all of it is inside aria-hidden content — the accessible name computes to nothing.'
|
|
83
|
+
: 'This field has a <label>, but the label is empty — it announces nothing.',
|
|
84
|
+
fix: looksLabelled
|
|
85
|
+
? 'Move the visible text out of the aria-hidden wrapper, or add aria-label to the field.'
|
|
86
|
+
: 'Put visible text inside the label.',
|
|
72
87
|
};
|
|
73
88
|
}
|
|
74
89
|
return {
|
|
@@ -77,7 +77,12 @@ export default {
|
|
|
77
77
|
// the visible boundary (border, or a fill contrasting with the page
|
|
78
78
|
// around it). The FIELD the user perceives is the wrapper — but only a
|
|
79
79
|
// SNUG wrapper counts: a page section's border is not a field boundary.
|
|
80
|
-
|
|
80
|
+
// Snugness is the guard, so the walk continues through transparent
|
|
81
|
+
// intermediate wrappers (autocomplete shells, JS mount points) as long
|
|
82
|
+
// as each stays field-sized — a search plate three levels up is still
|
|
83
|
+
// the field the user sees, while a page section fails the size check
|
|
84
|
+
// long before any depth cap would matter.
|
|
85
|
+
for (let wrapper = element.parentElement, depth = 0; wrapper && depth < 4; wrapper = wrapper.parentElement, depth++) {
|
|
81
86
|
const wrapperRect = wrapper.getBoundingClientRect();
|
|
82
87
|
if (wrapperRect.height > ownRect.height + 24 || wrapperRect.width > ownRect.width + 160) break;
|
|
83
88
|
const wrapperStyle = getComputedStyle(wrapper);
|
|
@@ -17,9 +17,19 @@ export default {
|
|
|
17
17
|
// Only descendants a user can actually reach count: hidden inputs,
|
|
18
18
|
// disabled controls, display:none templates, and tabindex="-1" nodes
|
|
19
19
|
// inside a control are inert markup, not competing tab stops.
|
|
20
|
+
// Two more shapes are unreachable by the same principle: aria-hidden
|
|
21
|
+
// content (AT ignores it wholesale — a decorative svg role="button"
|
|
22
|
+
// aria-hidden="true" inside a play button competes with nothing; if it
|
|
23
|
+
// is natively focusable that is aria-hidden-focus's finding, not a
|
|
24
|
+
// second one here), and role-only widgets never given a tabindex (a
|
|
25
|
+
// role="button" with no tabindex is not in the tab order — keyboard
|
|
26
|
+
// users cannot reach it, so it cannot be a competing stop).
|
|
27
|
+
const NATIVE = 'a[href], button, input, select, textarea, summary, audio[controls], video[controls]';
|
|
20
28
|
const nested = [...element.querySelectorAll(INTERACTIVE)].find((el) =>
|
|
21
29
|
!el.matches(':disabled') && el.getAttribute('tabindex') !== '-1'
|
|
22
|
-
&& !(el.tagName === 'INPUT' && el.type === 'hidden') && isRendered(el)
|
|
30
|
+
&& !(el.tagName === 'INPUT' && el.type === 'hidden') && isRendered(el)
|
|
31
|
+
&& !el.closest('[aria-hidden="true"]')
|
|
32
|
+
&& (el.matches(NATIVE) || el.hasAttribute('tabindex')));
|
|
23
33
|
if (!nested) return { status: 'pass' };
|
|
24
34
|
return {
|
|
25
35
|
status: 'fail',
|
|
@@ -20,12 +20,16 @@ export default {
|
|
|
20
20
|
const role = element.getAttribute('role');
|
|
21
21
|
const required = REQUIRED[role];
|
|
22
22
|
if (element.hasAttribute(required)) return { status: 'pass' };
|
|
23
|
-
//
|
|
24
|
-
// role
|
|
25
|
-
//
|
|
23
|
+
// Native semantics supply the state regardless of WHICH state-bearing
|
|
24
|
+
// role is claimed: HTML-AAM maps an input's checkedness to aria-checked
|
|
25
|
+
// (input type=checkbox role="switch" — the standard toggle pattern —
|
|
26
|
+
// exposes checked=false/true in the browser's tree with no ARIA at all),
|
|
27
|
+
// and a range input's value to aria-valuenow likewise.
|
|
26
28
|
const type = element.tagName === 'INPUT' ? element.type : null;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
30
|
+
if (required === 'aria-checked') return { status: 'pass' };
|
|
31
|
+
}
|
|
32
|
+
if (type === 'range' && required === 'aria-valuenow') return { status: 'pass' };
|
|
29
33
|
if (element.tagName === 'SELECT' && role === 'combobox') return { status: 'pass' };
|
|
30
34
|
return {
|
|
31
35
|
status: 'fail',
|
package/package.json
CHANGED