pour-engine 1.30.0 → 1.31.1
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/rules/best-practice/heading-order.js +14 -0
- package/engine/rules/best-practice/landmark-one-main.js +10 -2
- package/engine/rules/best-practice/landmark-top-level.js +19 -5
- package/engine/rules/best-practice/redundant-image-alt.js +50 -7
- package/engine/rules/best-practice/region.js +16 -3
- package/engine/rules/wcag/2.5.8-target-size.js +20 -1
- package/engine/rules/wcag/4.1.2-aria-label-misuse.js +18 -3
- package/package.json +1 -1
package/config/project.config.js
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
|
+
import { effectiveRole } from '../../lib/roles.js';
|
|
2
|
+
|
|
1
3
|
const level = (el) =>
|
|
2
4
|
el.hasAttribute('aria-level')
|
|
3
5
|
? parseInt(el.getAttribute('aria-level'), 10)
|
|
4
6
|
: parseInt(el.tagName[1], 10) || 2; // role="heading" without aria-level defaults to 2
|
|
5
7
|
|
|
8
|
+
// An honoured presentational role (or any other role override, e.g. role="tab")
|
|
9
|
+
// removes the element from the page's heading outline: Chromium exposes no
|
|
10
|
+
// heading node for <h3 role="none">, so it can neither skip a level nor set
|
|
11
|
+
// the level the next heading is judged against. A null effective role means an
|
|
12
|
+
// unknown explicit role, which browsers ignore in favour of the implicit one —
|
|
13
|
+
// still a heading for everything this selector matches.
|
|
14
|
+
const inOutline = (el) => {
|
|
15
|
+
const role = effectiveRole(el);
|
|
16
|
+
return role === 'heading' || role === null;
|
|
17
|
+
};
|
|
18
|
+
|
|
6
19
|
export default {
|
|
7
20
|
id: 'heading-order',
|
|
8
21
|
name: 'Heading order',
|
|
@@ -15,6 +28,7 @@ export default {
|
|
|
15
28
|
evaluateAll(elements) {
|
|
16
29
|
let previous = 0;
|
|
17
30
|
return elements.map((element) => {
|
|
31
|
+
if (!inOutline(element)) return { status: 'pass' };
|
|
18
32
|
const current = level(element);
|
|
19
33
|
const skipped = previous > 0 && current > previous + 1;
|
|
20
34
|
const outcome = skipped
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { collectRoots } from '../../lib/dom.js';
|
|
2
|
+
|
|
1
3
|
export default {
|
|
2
4
|
id: 'landmark-one-main',
|
|
3
5
|
name: 'One main landmark',
|
|
@@ -9,8 +11,14 @@ export default {
|
|
|
9
11
|
visibleOnly: false,
|
|
10
12
|
evaluate(element, { isVisible }) {
|
|
11
13
|
// Hidden mains (responsive/SPA double-renders kept display:none) are
|
|
12
|
-
// not exposed and must not count as duplicates.
|
|
13
|
-
|
|
14
|
+
// not exposed and must not count as duplicates. The count spans every
|
|
15
|
+
// open shadow root: a web-component SPA whose only <main> renders
|
|
16
|
+
// inside its app shell's shadow tree HAS a main (measured on a live
|
|
17
|
+
// archive home, where the light-DOM-only count reported a false
|
|
18
|
+
// "no main" on a page whose shadow main is plainly exposed).
|
|
19
|
+
const count = collectRoots(element.ownerDocument)
|
|
20
|
+
.flatMap((root) => [...root.querySelectorAll('main, [role="main"]')])
|
|
21
|
+
.filter(isVisible).length;
|
|
14
22
|
if (count === 1) return { status: 'pass' };
|
|
15
23
|
return {
|
|
16
24
|
status: 'fail',
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
// ARIA APG landmark pattern: banner, main,
|
|
2
|
-
// regions — nesting them inside other landmarks breaks the
|
|
1
|
+
// ARIA APG landmark pattern: banner, main, contentinfo and complementary
|
|
2
|
+
// are page-level regions — nesting them inside other landmarks breaks the
|
|
3
|
+
// page outline. Complementary joins via the aside demotion semantics in
|
|
4
|
+
// lib/roles.js: only an aside that actually reaches the accessibility tree
|
|
5
|
+
// as complementary owes top-levelness.
|
|
3
6
|
import { implicitRole } from '../../lib/roles.js';
|
|
4
7
|
|
|
5
8
|
const LANDMARK_ROLES = new Set([
|
|
@@ -25,9 +28,9 @@ export default {
|
|
|
25
28
|
name: 'Top-level landmarks',
|
|
26
29
|
impact: 'moderate',
|
|
27
30
|
tags: ['best-practice'],
|
|
28
|
-
help: 'banner, main and
|
|
31
|
+
help: 'banner, main, contentinfo and complementary landmarks must be top level',
|
|
29
32
|
helpUrl: 'https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/',
|
|
30
|
-
selector: 'header, footer, main, [role="banner"], [role="contentinfo"], [role="main"]',
|
|
33
|
+
selector: 'header, footer, main, aside, [role="banner"], [role="contentinfo"], [role="main"], [role="complementary"]',
|
|
31
34
|
evaluate(element) {
|
|
32
35
|
const tag = element.tagName.toLowerCase();
|
|
33
36
|
// <header>/<footer> inside sectioning content are not landmarks at all.
|
|
@@ -35,9 +38,20 @@ export default {
|
|
|
35
38
|
element.closest('article, aside, main, nav, section')) {
|
|
36
39
|
return { status: 'pass' };
|
|
37
40
|
}
|
|
41
|
+
// An <aside> is complementary only when implicitRole says so: unnamed
|
|
42
|
+
// inside sectioning content it is generic (Chromium exposes NO
|
|
43
|
+
// complementary node for a news home full of related-content asides,
|
|
44
|
+
// measured live) — there is no landmark to be top level. An aside
|
|
45
|
+
// directly inside <main> is NOT demoted (main is not sectioning
|
|
46
|
+
// content), so an ad-slot aside nested in main is a real finding.
|
|
47
|
+
if (tag === 'aside' && !element.hasAttribute('role') &&
|
|
48
|
+
implicitRole(element) !== 'complementary') {
|
|
49
|
+
return { status: 'pass' };
|
|
50
|
+
}
|
|
38
51
|
const container = landmarkAncestor(element);
|
|
39
52
|
if (!container) return { status: 'pass' };
|
|
40
|
-
const role = element.getAttribute('role') ??
|
|
53
|
+
const role = element.getAttribute('role') ??
|
|
54
|
+
{ header: 'banner', footer: 'contentinfo', main: 'main', aside: 'complementary' }[tag];
|
|
41
55
|
return {
|
|
42
56
|
status: 'fail',
|
|
43
57
|
message: `This ${role} landmark is nested inside a ${container.role} landmark (<${container.element.tagName.toLowerCase()}>) — page-level regions lose their meaning when nested.`,
|
|
@@ -1,3 +1,36 @@
|
|
|
1
|
+
// The question this rule answers: does the image's text alternative
|
|
2
|
+
// duplicate text the control ALREADY announces beside it? Both halves of
|
|
3
|
+
// that must be measured in accname terms, not raw DOM text:
|
|
4
|
+
// - The comparison text is the container's AT-exposed text (aria-hidden,
|
|
5
|
+
// hidden, display:none and visibility:hidden subtrees never reach the
|
|
6
|
+
// name — a display:none "Search" span does not make the alt redundant,
|
|
7
|
+
// it makes the alt the button's ONLY name, and advising alt="" there
|
|
8
|
+
// would delete it. Measured on a live banking home + Chromium AX tree.)
|
|
9
|
+
// - The alternative is alt when present, else title: HTML-AAM's fallback
|
|
10
|
+
// chain names an alt-less image from its title, so a card thumbnail
|
|
11
|
+
// whose title repeats the headline doubles the link name just the same
|
|
12
|
+
// (measured on a live news home where every card did exactly this).
|
|
13
|
+
const HIDDEN_TAGS = new Set(['script', 'style', 'noscript', 'template']);
|
|
14
|
+
|
|
15
|
+
/** The container's name-contributing text EXCLUDING image alternatives:
|
|
16
|
+
* mirrors the accname content walk's hidden-content rules. */
|
|
17
|
+
function exposedText(root) {
|
|
18
|
+
let text = '';
|
|
19
|
+
for (const node of root.childNodes) {
|
|
20
|
+
if (node.nodeType === Node.TEXT_NODE) { text += node.textContent; continue; }
|
|
21
|
+
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
|
22
|
+
const tag = node.tagName.toLowerCase();
|
|
23
|
+
if (HIDDEN_TAGS.has(tag) || tag === 'img' || tag === 'area' || tag === 'svg') continue;
|
|
24
|
+
if (node.getAttribute('aria-hidden') === 'true' || node.hasAttribute('hidden')) continue;
|
|
25
|
+
const style = getComputedStyle(node);
|
|
26
|
+
if (style.display === 'none' || style.visibility === 'hidden') continue;
|
|
27
|
+
text += exposedText(node);
|
|
28
|
+
}
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const norm = (s) => (s ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
33
|
+
|
|
1
34
|
export default {
|
|
2
35
|
id: 'redundant-image-alt',
|
|
3
36
|
name: 'Redundant image alt',
|
|
@@ -5,17 +38,27 @@ export default {
|
|
|
5
38
|
tags: ['best-practice'],
|
|
6
39
|
help: 'Image alt should not repeat adjacent link or button text',
|
|
7
40
|
helpUrl: 'https://www.w3.org/WAI/tutorials/images/functional/',
|
|
8
|
-
selector: 'a[href] img[alt], button img[alt]',
|
|
41
|
+
selector: 'a[href] img[alt], button img[alt], a[href] img[title]:not([alt]), button img[title]:not([alt])',
|
|
9
42
|
evaluate(element) {
|
|
10
|
-
const alt = element.getAttribute('alt')
|
|
11
|
-
|
|
43
|
+
const alt = element.getAttribute('alt');
|
|
44
|
+
const usesTitle = alt === null;
|
|
45
|
+
const alternative = norm(usesTitle ? element.getAttribute('title') : alt);
|
|
46
|
+
if (!alternative) return { status: 'pass' };
|
|
12
47
|
const container = element.closest('a[href], button');
|
|
13
|
-
const text =
|
|
14
|
-
|
|
48
|
+
const text = norm(exposedText(container));
|
|
49
|
+
// No exposed text: the image's alternative is the control's only name.
|
|
50
|
+
// Nothing is announced twice — and removing it would unname the control.
|
|
51
|
+
if (!text) return { status: 'pass' };
|
|
52
|
+
if (alternative !== text) return { status: 'pass' };
|
|
53
|
+
const kind = container.tagName === 'A' ? 'link' : 'button';
|
|
15
54
|
return {
|
|
16
55
|
status: 'fail',
|
|
17
|
-
message:
|
|
18
|
-
|
|
56
|
+
message: usesTitle
|
|
57
|
+
? `The image title repeats the ${kind} text — the title names the image, so screen readers announce "${element.getAttribute('title')}" twice.`
|
|
58
|
+
: `The image alt repeats the ${kind} text — screen readers announce "${element.getAttribute('alt')}" twice.`,
|
|
59
|
+
fix: usesTitle
|
|
60
|
+
? 'Add alt="" to the image (and drop the title if it is not needed as a tooltip); the visible text already names the control.'
|
|
61
|
+
: 'Use alt="" on the image; the visible text already names the control.',
|
|
19
62
|
};
|
|
20
63
|
},
|
|
21
64
|
};
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
+
// header/footer are banner/contentinfo ONLY outside sectioning content
|
|
2
|
+
// (HTML-AAM; mirrored from lib/roles.js implicitRole): a footer buried in an
|
|
3
|
+
// <article> is generic, so treating it as a landmark hid its stray content.
|
|
4
|
+
// Divs do NOT demote — a header wrapped in plain divs is still a banner.
|
|
5
|
+
// role="alertdialog" joins dialog: both are modal containers whose content
|
|
6
|
+
// region navigation reaches through the dialog itself, and consent banners
|
|
7
|
+
// (measured on a live news home) are exactly this shape.
|
|
1
8
|
const LANDMARK =
|
|
2
|
-
'main, nav,
|
|
9
|
+
'main, nav, aside, form[aria-label], form[aria-labelledby], ' +
|
|
10
|
+
'header:not(:is(article, aside, main, nav, section) header), ' +
|
|
11
|
+
'footer:not(:is(article, aside, main, nav, section) footer), ' +
|
|
3
12
|
'section[aria-label], section[aria-labelledby], [role="main"], [role="navigation"], ' +
|
|
4
13
|
'[role="banner"], [role="contentinfo"], [role="complementary"], [role="region"], ' +
|
|
5
|
-
'[role="search"], [role="form"], [role="dialog"]';
|
|
14
|
+
'[role="search"], [role="form"], [role="dialog"], [role="alertdialog"]';
|
|
6
15
|
|
|
7
16
|
export default {
|
|
8
17
|
id: 'region',
|
|
@@ -29,8 +38,12 @@ export default {
|
|
|
29
38
|
// Any perceivable content counts, not just text — but only content in
|
|
30
39
|
// the accessibility tree: decorative images (alt="", presentation) and
|
|
31
40
|
// unnamed inline svg are invisible to AT, so they can't be "unreachable".
|
|
41
|
+
// Bare form controls belong here too: an input has no text of its own,
|
|
42
|
+
// yet it is exactly the content a region-navigating user needs to reach
|
|
43
|
+
// (a booking form outside every landmark was invisible to this rule).
|
|
32
44
|
const isMedia = element.matches(
|
|
33
|
-
'img:not([alt=""]):not([role="presentation"]):not([role="none"]), svg[role="img"], video, audio, canvas, iframe'
|
|
45
|
+
'img:not([alt=""]):not([role="presentation"]):not([role="none"]), svg[role="img"], video, audio, canvas, iframe, ' +
|
|
46
|
+
'input:not([type="hidden"]), select, textarea, button',
|
|
34
47
|
);
|
|
35
48
|
if (!ownText(element) && !isMedia) return { status: 'pass' };
|
|
36
49
|
if (element.closest(LANDMARK)) return { status: 'pass' };
|
|
@@ -211,7 +211,26 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
211
211
|
// usually) belongs to the name rules, not to size judgment. Pointer-
|
|
212
212
|
// invisible targets (clipped skip links etc.) are excluded the same
|
|
213
213
|
// way, on both sides of the crowding test.
|
|
214
|
-
|
|
214
|
+
//
|
|
215
|
+
// Content-visibility-skipped subtrees have PLACEHOLDER geometry, not
|
|
216
|
+
// geometry: an offscreen article under content-visibility:auto skips
|
|
217
|
+
// layout, its 40×40 icon buttons measure 12×12 and stack on top of each
|
|
218
|
+
// other, and the rule read that as "tiny AND crowded" (measured live on
|
|
219
|
+
// a long-feed blog home: the same button is 40×40 the moment it scrolls
|
|
220
|
+
// into view). The browser itself knows the difference — checkVisibility
|
|
221
|
+
// with contentVisibilityAuto reports skipped subtrees as not visible —
|
|
222
|
+
// so those targets are unmeasurable in this state and sit out of the
|
|
223
|
+
// judgment AND the crowding field. Engines that don't recognise the
|
|
224
|
+
// option ignore unknown keys and behave as before.
|
|
225
|
+
const layoutSkipped = (element) => {
|
|
226
|
+
try {
|
|
227
|
+
return typeof element.checkVisibility === 'function'
|
|
228
|
+
&& element.checkVisibility({ visibilityProperty: true })
|
|
229
|
+
&& !element.checkVisibility({ contentVisibilityAuto: true, visibilityProperty: true });
|
|
230
|
+
} catch { return false; }
|
|
231
|
+
};
|
|
232
|
+
const laidOut = rects.map((r, i) =>
|
|
233
|
+
r.width > 0 && r.height > 0 && !isHiddenFromPointer(elements[i], r) && !layoutSkipped(elements[i]));
|
|
215
234
|
const undersized = rects.map((r, i) => laidOut[i] && (r.width < min || r.height < min));
|
|
216
235
|
// One rect fully inside another is one control drawn twice (stretched-
|
|
217
236
|
// link cards, overlay + inner button) — not two targets crowding.
|
|
@@ -45,9 +45,13 @@ export default {
|
|
|
45
45
|
name: 'aria-label placement',
|
|
46
46
|
impact: 'moderate',
|
|
47
47
|
tags: ['wcag2a', 'wcag412'],
|
|
48
|
-
help: 'aria-label on a plain container is prohibited by ARIA, so its announcement is not guaranteed',
|
|
48
|
+
help: 'aria-label or aria-labelledby on a plain container is prohibited by ARIA, so its announcement is not guaranteed',
|
|
49
49
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
50
|
-
|
|
50
|
+
// §5.2.8.6 prohibits BOTH naming attributes on generic — aria-labelledby
|
|
51
|
+
// pointing at perfectly real text is the same authoring error as an inline
|
|
52
|
+
// aria-label, and the more tempting one (a card labelled by its own title).
|
|
53
|
+
selector: 'div[aria-label]:not([role]):not([tabindex]), span[aria-label]:not([role]):not([tabindex]), p[aria-label]:not([role]):not([tabindex]),'
|
|
54
|
+
+ ' div[aria-labelledby]:not([role]):not([tabindex]), span[aria-labelledby]:not([role]):not([tabindex]), p[aria-labelledby]:not([role]):not([tabindex])',
|
|
51
55
|
evaluate(element) {
|
|
52
56
|
// Inside a name-from-content ancestor the label is NOT ignored: accname
|
|
53
57
|
// applies the whole algorithm to each descendant while building the
|
|
@@ -55,9 +59,20 @@ export default {
|
|
|
55
59
|
// Chromium reports exactly that. Claiming it does nothing is false, and
|
|
56
60
|
// the advised fix would delete the control's only name.
|
|
57
61
|
if (element.closest(NAME_FROM_CONTENT)) return { status: 'pass' };
|
|
62
|
+
const label = element.getAttribute('aria-label');
|
|
63
|
+
const attempt = label !== null
|
|
64
|
+
? `aria-label="${label}"`
|
|
65
|
+
: (() => {
|
|
66
|
+
const text = (element.getAttribute('aria-labelledby') ?? '').trim().split(/\s+/)
|
|
67
|
+
.map((id) => element.ownerDocument.getElementById(id)?.textContent?.trim())
|
|
68
|
+
.filter(Boolean).join(' ');
|
|
69
|
+
return text
|
|
70
|
+
? `aria-labelledby (naming it "${text.slice(0, 80)}")`
|
|
71
|
+
: 'aria-labelledby (whose references resolve to no text)';
|
|
72
|
+
})();
|
|
58
73
|
return {
|
|
59
74
|
status: 'incomplete',
|
|
60
|
-
message:
|
|
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.`,
|
|
61
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.',
|
|
62
77
|
};
|
|
63
78
|
},
|
package/package.json
CHANGED