pour-engine 1.1.6 → 1.1.8
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
CHANGED
package/engine/lib/roles.js
CHANGED
|
@@ -116,10 +116,34 @@ export function implicitRole(element) {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/** The element's effective role: explicit (first modelled token) or implicit. */
|
|
119
|
+
/**
|
|
120
|
+
* Presentational Roles Conflict Resolution: a `presentation`/`none` role is
|
|
121
|
+
* DISCARDED when the element is focusable or carries a global ARIA property,
|
|
122
|
+
* and the implicit role is exposed instead. Reporting against the written role
|
|
123
|
+
* in those cases names a role the browser already threw away, and sends the
|
|
124
|
+
* author to fix the wrong thing.
|
|
125
|
+
*
|
|
126
|
+
* The two triggers below are what browsers actually implement, verified in
|
|
127
|
+
* Chromium's accessibility tree: `<h2 role="presentation">` exposes StaticText,
|
|
128
|
+
* but adding tabindex or aria-describedby exposes `heading`. Note a non-global
|
|
129
|
+
* property applicable to the implicit role (aria-level on a heading) does NOT
|
|
130
|
+
* trigger it, even though a strict reading of the spec suggests it should.
|
|
131
|
+
*/
|
|
132
|
+
function presentationDiscarded(element) {
|
|
133
|
+
if (element.tabIndex >= 0) return true;
|
|
134
|
+
if (element.matches('a[href], button, input, select, textarea, summary, [contenteditable="true"]')) return true;
|
|
135
|
+
return [...GLOBAL_ARIA].some((name) => element.hasAttribute(`aria-${name}`));
|
|
136
|
+
}
|
|
137
|
+
|
|
119
138
|
export function effectiveRole(element) {
|
|
120
139
|
const explicit = element.getAttribute('role')?.trim().split(/\s+/) ?? [];
|
|
121
140
|
for (const token of explicit) {
|
|
122
|
-
|
|
141
|
+
const role = token.toLowerCase();
|
|
142
|
+
if (!ROLE_ARIA[role]) continue;
|
|
143
|
+
if ((role === 'presentation' || role === 'none') && presentationDiscarded(element)) {
|
|
144
|
+
return implicitRole(element);
|
|
145
|
+
}
|
|
146
|
+
return role;
|
|
123
147
|
}
|
|
124
148
|
return explicit.length ? null : implicitRole(element); // unknown explicit role: don't judge
|
|
125
149
|
}
|
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
// WCAG SC 2.4.11 Focus Not Obscured (Minimum) (Level AA)
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
2
|
+
// An opaque fixed/sticky overlay covering a focusable element is the
|
|
3
|
+
// criterion's core case (bottom cookie bars over footer links). Partial
|
|
4
|
+
// overlap passes by definition ("not entirely hidden"), so only full
|
|
5
|
+
// containment counts.
|
|
6
|
+
//
|
|
7
|
+
// This REVIEWS rather than fails, deliberately. What a snapshot can see is
|
|
8
|
+
// "element X is under the panel right now", and that is a fact about the
|
|
9
|
+
// current scroll position, not about the page: scrolling the same page a few
|
|
10
|
+
// hundred pixels names entirely different elements, or none at all. The
|
|
11
|
+
// criterion asks about the state focus ARRIVES in, which depends on tab order,
|
|
12
|
+
// on whether the browser scrolls (it won't, for an element already inside the
|
|
13
|
+
// viewport — merely covered), and on any author focus handler that moves the
|
|
14
|
+
// page. None of that is knowable from geometry alone, so the honest output is
|
|
15
|
+
// a pointer to the overlay and a request for a keyboard pass.
|
|
16
|
+
//
|
|
17
|
+
// Sites that apply the spec's own sufficient technique — scroll-padding for
|
|
18
|
+
// the overlay's edge — are not flagged at all.
|
|
8
19
|
const FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]:not([tabindex="-1"])';
|
|
9
20
|
|
|
21
|
+
/** Does the scroll container reserve room for an overlay on this edge?
|
|
22
|
+
* scroll-padding is what the Understanding doc cites as sufficient: it keeps
|
|
23
|
+
* the browser's own scroll-into-view clear of a sticky panel. */
|
|
24
|
+
function edgeReserved(doc, edge, needed) {
|
|
25
|
+
const scroller = doc.scrollingElement ?? doc.documentElement;
|
|
26
|
+
const style = getComputedStyle(scroller);
|
|
27
|
+
const value = edge === 'top' ? style.scrollPaddingTop : style.scrollPaddingBottom;
|
|
28
|
+
const px = value && value.endsWith('px') ? parseFloat(value) : 0;
|
|
29
|
+
return px >= needed - 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
10
32
|
export default {
|
|
11
33
|
id: 'focus-not-obscured',
|
|
12
34
|
impact: 'serious',
|
|
@@ -70,10 +92,16 @@ export default {
|
|
|
70
92
|
!layer.contains(element) && !element.contains(layer)
|
|
71
93
|
&& isObscuringOverlay(layer) && covered(rect, layer.getBoundingClientRect()));
|
|
72
94
|
if (!blocker) return { status: 'pass' };
|
|
95
|
+
// Which viewport edge is the panel pinned to? That is the edge the
|
|
96
|
+
// browser's scroll-into-view has to clear.
|
|
97
|
+
const panel = blocker.getBoundingClientRect();
|
|
98
|
+
const edge = panel.top <= 1 && panel.bottom < win.innerHeight ? 'top'
|
|
99
|
+
: panel.bottom >= win.innerHeight - 1 ? 'bottom' : null;
|
|
100
|
+
if (edge && edgeReserved(doc, edge, panel.height)) return { status: 'pass' };
|
|
73
101
|
return {
|
|
74
|
-
status: '
|
|
75
|
-
message: 'This
|
|
76
|
-
fix:
|
|
102
|
+
status: 'incomplete',
|
|
103
|
+
message: 'This element is currently underneath an opaque fixed panel. Whether that breaks 2.4.11 depends on where the page sits when focus reaches it — the browser does not scroll an element that is already in the viewport, merely covered, so focus can land invisibly. Tab through the page and check the focus indicator is never entirely hidden.',
|
|
104
|
+
fix: `Reserve room for the panel with scroll-padding-${edge ?? 'bottom'} on the scrolling container, or move focus clear of it when the panel is up.`,
|
|
77
105
|
};
|
|
78
106
|
});
|
|
79
107
|
},
|
|
@@ -38,6 +38,17 @@ function isHiddenFromPointer(element, rect) {
|
|
|
38
38
|
return false;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** Text this element contributes that is NOT inside a target — i.e. does it
|
|
42
|
+
* read as prose beside the link, or is it just another control in a list? */
|
|
43
|
+
function textOutsideTargets(element) {
|
|
44
|
+
for (const node of element.childNodes) {
|
|
45
|
+
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) return true;
|
|
46
|
+
if (node.nodeType === Node.ELEMENT_NODE && !node.matches(TARGETS)
|
|
47
|
+
&& textOutsideTargets(node)) return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
41
52
|
function isInTextLine(element) {
|
|
42
53
|
let node = element;
|
|
43
54
|
// inline-block/inline-flex links inside a sentence are still "in a line
|
|
@@ -48,9 +59,17 @@ function isInTextLine(element) {
|
|
|
48
59
|
for (const sibling of node.parentNode?.childNodes ?? []) {
|
|
49
60
|
if (sibling === node) continue;
|
|
50
61
|
if (sibling.nodeType === Node.TEXT_NODE && sibling.textContent.trim()) return true;
|
|
62
|
+
// What counts is text the sibling contributes OUTSIDE any target, not
|
|
63
|
+
// merely that the sibling isn't a target itself. Testing identity alone
|
|
64
|
+
// let a list of links exempt itself — each <li> is not an anchor, so a
|
|
65
|
+
// sibling <li><a>t</a></li> read as prose and granted the exception to
|
|
66
|
+
// the whole cluster, exactly the nav list this function's contract says
|
|
67
|
+
// to exclude (Wikipedia's "v · t · e" navbox slipped through there).
|
|
68
|
+
// Testing `querySelector(TARGETS)` instead over-corrects the other way:
|
|
69
|
+
// a prose wrapper containing a link is still prose.
|
|
51
70
|
if (sibling.nodeType === Node.ELEMENT_NODE
|
|
52
71
|
&& !sibling.matches(TARGETS)
|
|
53
|
-
&& sibling
|
|
72
|
+
&& textOutsideTargets(sibling)
|
|
54
73
|
&& getComputedStyle(sibling).display.startsWith('inline')) return true;
|
|
55
74
|
}
|
|
56
75
|
node = node.parentElement;
|
|
@@ -45,7 +45,12 @@ export default {
|
|
|
45
45
|
// canonical harmless case. The failure worth reporting is an invalid
|
|
46
46
|
// role MASKING real semantics or decorating an interactive element.
|
|
47
47
|
const tag = element.tagName.toLowerCase();
|
|
48
|
-
|
|
48
|
+
// What the element falls back to when the bogus role is ignored. div/span
|
|
49
|
+
// expose generic; <svg> exposes image (or graphics-document) in every
|
|
50
|
+
// current engine, which is what an author writing role="img"/"image" on it
|
|
51
|
+
// wanted anyway — so a typo there costs an AT user nothing. Elements with
|
|
52
|
+
// real semantics to mask (headings, lists, controls) stay out of the hatch.
|
|
53
|
+
const genericFallback = tag === 'div' || tag === 'span' || tag === 'svg';
|
|
49
54
|
const focusable = element.tabIndex >= 0
|
|
50
55
|
|| element.matches('a[href], button, input, select, textarea, summary');
|
|
51
56
|
const hasAriaProps = [...element.attributes].some(
|
package/package.json
CHANGED