pour-engine 1.1.6 → 1.1.7

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.
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.1.6',
9
+ version: '1.1.7',
10
10
  },
11
11
  };
@@ -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
- if (ROLE_ARIA[token.toLowerCase()]) return token.toLowerCase();
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
- // 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.
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: '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.',
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
  },
@@ -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
- const genericFallback = tag === 'div' || tag === 'span';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.1.6",
3
+ "version": "1.1.7",
4
4
  "description": "Accessibility (WCAG 2.2) audit engine written from the W3C specifications — runs entirely in the browser, no dependencies.",
5
5
  "type": "module",
6
6
  "main": "engine/index.js",