pour-engine 1.12.0 → 1.14.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/rules/index.js +6 -3
- package/engine/rules/wcag/1.3.1-listitem-parent.js +20 -3
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +73 -15
- package/engine/rules/wcag/2.5.8-target-size.js +175 -1
- package/engine/rules/wcag/4.1.2-aria-label-misuse.js +55 -0
- package/package.json +1 -1
- package/engine/rules/best-practice/aria-label-misuse.js +0 -36
package/config/project.config.js
CHANGED
package/engine/rules/index.js
CHANGED
|
@@ -50,9 +50,12 @@ import ariaAttrValid from './wcag/4.1.2-aria-attr-valid.js';
|
|
|
50
50
|
// instead of claiming a failure (see the note in the rule file).
|
|
51
51
|
import ariaAllowedAttr from './wcag/4.1.2-aria-allowed-attr.js';
|
|
52
52
|
import ariaFieldName from './wcag/4.1.2-aria-field-name.js';
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
53
|
+
// Asks instead of asserting: a prohibited name on a generic element is a
|
|
54
|
+
// real ARIA MUST violation the browser silently drops, so it runs in the
|
|
55
|
+
// default scope as a review question (see the note in the rule file —
|
|
56
|
+
// it has been asserted, demoted, and now asks; same route as
|
|
57
|
+
// aria-allowed-attr).
|
|
58
|
+
import ariaLabelMisuse from './wcag/4.1.2-aria-label-misuse.js';
|
|
56
59
|
import roleRequiredAria from './wcag/4.1.2-role-required-aria.js';
|
|
57
60
|
import labelForValid from './wcag/4.1.2-label-for-valid.js';
|
|
58
61
|
import listitemParent from './wcag/1.3.1-listitem-parent.js';
|
|
@@ -20,7 +20,17 @@ export default {
|
|
|
20
20
|
// owned elements, so the <li>s in a <ul role="none"> are presentational
|
|
21
21
|
// too. There is no listitem left to be orphaned, and stepping over the
|
|
22
22
|
// <ul> used to blame whatever happened to be above it.
|
|
23
|
-
|
|
23
|
+
//
|
|
24
|
+
// Any OTHER explicit role replaces the native list semantics entirely:
|
|
25
|
+
// a <ul role="navigation"> is a navigation landmark, not a list, so it
|
|
26
|
+
// cannot own listitems and its <li> children are orphaned exactly as if
|
|
27
|
+
// the <ul> were a <div>. Matching the tag alone waved those through
|
|
28
|
+
// (found on ar.wikipedia.org: portal links in <ul role="navigation">).
|
|
29
|
+
const listContainer = (el) => {
|
|
30
|
+
const role = el.getAttribute('role');
|
|
31
|
+
if (el.matches('ul, ol, menu')) return !role || ['list', 'presentation', 'none'].includes(role);
|
|
32
|
+
return role === 'list';
|
|
33
|
+
};
|
|
24
34
|
while (parent && !listContainer(parent) && (
|
|
25
35
|
['presentation', 'none'].includes(parent.getAttribute('role') ?? '')
|
|
26
36
|
|| (!parent.hasAttribute('role') && (parent.tagName === 'DIV' || parent.tagName === 'SPAN'))
|
|
@@ -28,10 +38,17 @@ export default {
|
|
|
28
38
|
parent = parent.assignedSlot?.parentElement ?? parent.parentElement ?? parent.getRootNode()?.host;
|
|
29
39
|
}
|
|
30
40
|
if (parent && listContainer(parent)) return { status: 'pass' };
|
|
41
|
+
const parentLabel = parent
|
|
42
|
+
? `<${parent.tagName.toLowerCase()}${parent.getAttribute('role') ? ` role="${parent.getAttribute('role')}"` : ''}>`
|
|
43
|
+
: 'nothing';
|
|
31
44
|
return {
|
|
32
45
|
status: 'fail',
|
|
33
|
-
message: `This <li> sits inside
|
|
34
|
-
|
|
46
|
+
message: `This <li> sits inside ${parentLabel} — ${parent?.matches('ul, ol, menu') && parent.getAttribute('role')
|
|
47
|
+
? 'the explicit role replaces the list semantics, so'
|
|
48
|
+
: 'outside a list,'} screen readers lose the item's list context entirely.`,
|
|
49
|
+
fix: parent?.matches('ul, ol, menu') && parent.getAttribute('role')
|
|
50
|
+
? 'Remove the role from the list (use a wrapping element for the landmark), or give the items roles the container expects.'
|
|
51
|
+
: 'Wrap it in a <ul> or <ol>, or change it to a <div>/<p> if it isn’t really a list item.',
|
|
35
52
|
};
|
|
36
53
|
},
|
|
37
54
|
};
|
|
@@ -1,17 +1,42 @@
|
|
|
1
1
|
// WCAG SC 2.1.1 Keyboard (Level A) · SC 2.1.3 Keyboard (No Exception) (Level AAA)
|
|
2
|
+
//
|
|
3
|
+
// Matches EVERY element rather than a tag list: overflow makes a scroll
|
|
4
|
+
// container out of whatever it lands on — nav, header, footer, form, table
|
|
5
|
+
// wrappers, custom elements — and a tag list silently skipped all of them
|
|
6
|
+
// (found on carbondesignsystem.com: a scrollable <nav> the rule never saw).
|
|
7
|
+
// The cost stays flat because the overwhelming majority of elements exit on
|
|
8
|
+
// two integer reads before any style or visibility work happens.
|
|
2
9
|
const FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]';
|
|
3
10
|
|
|
4
|
-
/**
|
|
11
|
+
/** Reachable with the Tab key — what actually lets a keyboard user move
|
|
12
|
+
* focus into the region and scroll it. Focusable-but-not-tabbable content
|
|
13
|
+
* (tabindex="-1") does not help: measured in Chrome 151, a scroller whose
|
|
14
|
+
* content is all tabindex="-1" is treated by the browser itself as holding
|
|
15
|
+
* nothing reachable (it auto-focuses the scroller, same as when empty). */
|
|
16
|
+
function isTabbable(element) {
|
|
17
|
+
if (element.tabIndex < 0 || element.disabled) return false;
|
|
18
|
+
if (typeof element.checkVisibility === 'function') {
|
|
19
|
+
return element.checkVisibility({ visibilityProperty: true });
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Tabbable content in the FLAT tree: the node's own descendants, nodes
|
|
5
25
|
* slotted in from the light DOM, and content of open shadow roots. */
|
|
6
|
-
function
|
|
7
|
-
|
|
26
|
+
function hasTabbableContent(node) {
|
|
27
|
+
for (const el of node.querySelectorAll?.(FOCUSABLE) ?? []) {
|
|
28
|
+
if (isTabbable(el)) return true;
|
|
29
|
+
}
|
|
8
30
|
for (const slot of node.querySelectorAll?.('slot') ?? []) {
|
|
9
31
|
for (const assigned of slot.assignedElements?.() ?? []) {
|
|
10
|
-
if (assigned.matches?.(FOCUSABLE)
|
|
32
|
+
if ((assigned.matches?.(FOCUSABLE) && isTabbable(assigned))) return true;
|
|
33
|
+
for (const el of assigned.querySelectorAll?.(FOCUSABLE) ?? []) {
|
|
34
|
+
if (isTabbable(el)) return true;
|
|
35
|
+
}
|
|
11
36
|
}
|
|
12
37
|
}
|
|
13
38
|
for (const el of node.querySelectorAll?.('*') ?? []) {
|
|
14
|
-
if (el.shadowRoot &&
|
|
39
|
+
if (el.shadowRoot && hasTabbableContent(el.shadowRoot)) return true;
|
|
15
40
|
}
|
|
16
41
|
return false;
|
|
17
42
|
}
|
|
@@ -22,27 +47,60 @@ export default {
|
|
|
22
47
|
tags: ['wcag2a', 'wcag211', 'wcag213'], // 2.1.3 is 2.1.1's AAA twin — same check satisfies both
|
|
23
48
|
help: 'Scrollable regions must be reachable by keyboard',
|
|
24
49
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html',
|
|
25
|
-
selector: '
|
|
26
|
-
|
|
50
|
+
selector: '*',
|
|
51
|
+
// Visibility is checked inside, AFTER the cheap overflow gate: filtering
|
|
52
|
+
// every element of the page up front would cost a style read per element;
|
|
53
|
+
// gating first pays it only on the handful that actually overflow.
|
|
54
|
+
visibleOnly: false,
|
|
55
|
+
evaluate(element, { isVisible }) {
|
|
56
|
+
// Cheap-first: two integer reads reject almost every element with no
|
|
57
|
+
// style resolution at all. display:none subtrees have no layout, so
|
|
58
|
+
// both deltas are 0 and they exit here too.
|
|
59
|
+
const overflowsX = element.scrollWidth > element.clientWidth + 1;
|
|
60
|
+
const overflowsY = element.scrollHeight > element.clientHeight + 1;
|
|
61
|
+
if (!overflowsX && !overflowsY) return { status: 'pass' };
|
|
62
|
+
// The document scroller belongs to the browser: arrow keys scroll the
|
|
63
|
+
// page without any author-supplied focus stop. Overflow set on <body>
|
|
64
|
+
// can also propagate to the viewport, which is the same UA-owned
|
|
65
|
+
// scrollbar wearing different markup.
|
|
66
|
+
if (element === element.ownerDocument.documentElement || element === element.ownerDocument.body) {
|
|
67
|
+
return { status: 'pass' };
|
|
68
|
+
}
|
|
69
|
+
// A zero-extent box (a collapsed drawer at max-height: 0) presents no
|
|
70
|
+
// scrollable region — nothing of it is on screen to scroll. Its open
|
|
71
|
+
// state is a different audit state, judged when it is actually open.
|
|
72
|
+
if (!element.clientWidth || !element.clientHeight) return { status: 'pass' };
|
|
27
73
|
const style = getComputedStyle(element);
|
|
28
74
|
// Judged PER AXIS: an overflow the user can't scroll on its own axis
|
|
29
75
|
// (overflow-x: hidden marquees whose overflow-y merely computes to
|
|
30
76
|
// auto) is not a scrollable region — content moves by animation, not
|
|
31
77
|
// by user scrolling.
|
|
32
|
-
const scrollableX = /(auto|scroll)/.test(style.overflowX)
|
|
33
|
-
|
|
34
|
-
const scrollableY = /(auto|scroll)/.test(style.overflowY)
|
|
35
|
-
&& element.scrollHeight > element.clientHeight + 1;
|
|
78
|
+
const scrollableX = overflowsX && /(auto|scroll)/.test(style.overflowX);
|
|
79
|
+
const scrollableY = overflowsY && /(auto|scroll)/.test(style.overflowY);
|
|
36
80
|
if (!scrollableX && !scrollableY) return { status: 'pass' };
|
|
81
|
+
if (!isVisible(element)) return { status: 'pass' };
|
|
37
82
|
if (element.tabIndex >= 0) return { status: 'pass' };
|
|
38
|
-
//
|
|
83
|
+
// Tabbable content inside lets keyboard users scroll by moving focus —
|
|
39
84
|
// including content slotted in from the light DOM and content inside
|
|
40
85
|
// open shadow roots (carousels built as web components).
|
|
41
|
-
if (
|
|
86
|
+
if (hasTabbableContent(element)) return { status: 'pass' };
|
|
87
|
+
// An explicit negative tabindex is the author OPTING OUT of the tab
|
|
88
|
+
// order, and the browser honours it: measured in Chrome 151, a scroller
|
|
89
|
+
// with tabindex="-1" is skipped by Tab even though the same scroller
|
|
90
|
+
// without the attribute would be auto-focused. Nothing inside is
|
|
91
|
+
// tabbable either, so the overflowed content is unreachable by keyboard
|
|
92
|
+
// in every engine — assertable, not browser-dependent.
|
|
93
|
+
if (element.hasAttribute('tabindex')) {
|
|
94
|
+
return {
|
|
95
|
+
status: 'fail',
|
|
96
|
+
message: 'This region scrolls, holds nothing tabbable, and its tabindex="-1" removes the region itself from the tab order — keyboard users cannot reach the overflowed content in any browser.',
|
|
97
|
+
fix: 'Change tabindex="-1" to tabindex="0" (plus role="region" and an aria-label describing it), or make something inside it tabbable.',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
42
100
|
// NOT a failure, because the browser may already have solved it.
|
|
43
101
|
//
|
|
44
102
|
// Chromium puts a scroll container in the tab order by itself when the
|
|
45
|
-
// container holds nothing
|
|
103
|
+
// container holds nothing tabbable — and that condition is exactly the
|
|
46
104
|
// one this rule fires on, so in Chrome every element reaching this line
|
|
47
105
|
// IS keyboard reachable. Measured, not assumed: Chrome 151 tabs
|
|
48
106
|
// before → the scroller → after, while `element.tabIndex` still reports
|
|
@@ -57,7 +115,7 @@ export default {
|
|
|
57
115
|
// don't.
|
|
58
116
|
return {
|
|
59
117
|
status: 'incomplete',
|
|
60
|
-
message: 'This region scrolls and holds nothing
|
|
118
|
+
message: 'This region scrolls and holds nothing tabbable, so reaching its overflowed content depends on the browser. Chromium gives such containers keyboard focus automatically; not every engine does. Tab to it in the browsers you support, and add tabindex="0" if it cannot be reached.',
|
|
61
119
|
fix: 'If you support browsers that do not focus scroll containers, add tabindex="0" (plus role="region" and an aria-label describing it).',
|
|
62
120
|
};
|
|
63
121
|
},
|
|
@@ -199,9 +199,183 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
199
199
|
const encloses = (a, b) =>
|
|
200
200
|
a.left <= b.left && a.right >= b.right && a.top <= b.top && a.bottom >= b.bottom;
|
|
201
201
|
|
|
202
|
+
// ── Obscured targets ──────────────────────────────────────────────
|
|
203
|
+
// The criterion measures the region that ACCEPTS the pointer action.
|
|
204
|
+
// When another target paints over part of this one, clicks in the
|
|
205
|
+
// overlap go to the other target, so the accepting region is only the
|
|
206
|
+
// part left uncovered — a 26px logo link tiled 20px from the next link
|
|
207
|
+
// offers the pointer 20px, whatever its box says (found on ant.design).
|
|
208
|
+
// Box-sized-fine targets are therefore re-measured as the largest
|
|
209
|
+
// uncovered sub-rectangle before they may pass.
|
|
210
|
+
//
|
|
211
|
+
// Overlap only counts between targets that MOVE TOGETHER. A footer
|
|
212
|
+
// link sliding under a fixed header overlaps it at this scroll
|
|
213
|
+
// position and not the next, and asserting a size failure from scroll
|
|
214
|
+
// state invents a defect the layout does not have (the same trap the
|
|
215
|
+
// reachability test below documents). Targets share motion when they
|
|
216
|
+
// share a fixed/sticky context — both inside the same pinned bar, or
|
|
217
|
+
// neither pinned at all.
|
|
218
|
+
const fixedContexts = new Map();
|
|
219
|
+
const fixedContextOf = (element) => {
|
|
220
|
+
if (fixedContexts.has(element)) return fixedContexts.get(element);
|
|
221
|
+
let context = null;
|
|
222
|
+
for (let node = element; node && node !== element.ownerDocument.documentElement; node = node.parentElement) {
|
|
223
|
+
const position = getComputedStyle(node).position;
|
|
224
|
+
if (position === 'fixed' || position === 'sticky') { context = node; break; }
|
|
225
|
+
}
|
|
226
|
+
fixedContexts.set(element, context);
|
|
227
|
+
return context;
|
|
228
|
+
};
|
|
229
|
+
const overlapOf = (a, b) => {
|
|
230
|
+
const left = Math.max(a.left, b.left);
|
|
231
|
+
const top = Math.max(a.top, b.top);
|
|
232
|
+
const right = Math.min(a.right, b.right);
|
|
233
|
+
const bottom = Math.min(a.bottom, b.bottom);
|
|
234
|
+
// Only MATERIAL overlap obscures. A 1–2px intrusion is the border-
|
|
235
|
+
// collapse convention (button groups with negative margins) plus
|
|
236
|
+
// layout rounding — asserting a size failure from a 1px shave off a
|
|
237
|
+
// 24px control invents a defect no pointer will ever notice (seen
|
|
238
|
+
// live: a consent button measured 112×23 through a 1px overlap).
|
|
239
|
+
return right - left > 2 && bottom - top > 2
|
|
240
|
+
? { left, top, right, bottom, width: right - left, height: bottom - top } : null;
|
|
241
|
+
};
|
|
242
|
+
/** Does target j's paint sit ABOVE target i where they overlap? The
|
|
243
|
+
* paint stack at the overlap's midpoint answers it directly; the first
|
|
244
|
+
* layer belonging to exactly one of the two decides. Off-viewport
|
|
245
|
+
* points cannot be probed, and guessing from document order or
|
|
246
|
+
* z-index is not evidence on pages full of stacking contexts
|
|
247
|
+
* (transforms, opacity), so an unprovable overlap asserts nothing —
|
|
248
|
+
* an audit run with the region scrolled into view proves it then. */
|
|
249
|
+
const paintsOver = (j, i, overlap) => {
|
|
250
|
+
const doc = elements[i].ownerDocument;
|
|
251
|
+
const win = doc.defaultView;
|
|
252
|
+
const x = overlap.left + overlap.width / 2;
|
|
253
|
+
const y = overlap.top + overlap.height / 2;
|
|
254
|
+
if (!win || typeof doc.elementsFromPoint !== 'function'
|
|
255
|
+
|| x < 0 || y < 0 || x >= win.innerWidth || y >= win.innerHeight) return false;
|
|
256
|
+
for (const layer of doc.elementsFromPoint(x, y)) {
|
|
257
|
+
const inJ = elements[j] === layer || elements[j].contains(layer);
|
|
258
|
+
const inI = elements[i] === layer || elements[i].contains(layer);
|
|
259
|
+
if (inJ && !inI) return true;
|
|
260
|
+
if (inI && !inJ) return false;
|
|
261
|
+
}
|
|
262
|
+
return false; // neither reachable there — a third element's problem, not j's
|
|
263
|
+
};
|
|
264
|
+
/** Largest uncovered sub-rectangle after removing the overlap: the best
|
|
265
|
+
* of the four slabs beside/above/below it (exact for one obscurer,
|
|
266
|
+
* a safe under-approximation applied sequentially for several). */
|
|
267
|
+
const uncovered = (rect, overlap) => {
|
|
268
|
+
const slabs = [
|
|
269
|
+
{ left: rect.left, top: rect.top, right: overlap.left, bottom: rect.bottom },
|
|
270
|
+
{ left: overlap.right, top: rect.top, right: rect.right, bottom: rect.bottom },
|
|
271
|
+
{ left: rect.left, top: rect.top, right: rect.right, bottom: overlap.top },
|
|
272
|
+
{ left: rect.left, top: overlap.bottom, right: rect.right, bottom: rect.bottom },
|
|
273
|
+
].map((s) => ({ ...s, width: s.right - s.left, height: s.bottom - s.top }))
|
|
274
|
+
.filter((s) => s.width >= 1 && s.height >= 1);
|
|
275
|
+
// Note: rect may be a DOMRect, whose properties do not survive object
|
|
276
|
+
// spread — the zero-rect is built from explicit reads.
|
|
277
|
+
if (!slabs.length) {
|
|
278
|
+
return { left: rect.left, top: rect.top, right: rect.left, bottom: rect.top, width: 0, height: 0 };
|
|
279
|
+
}
|
|
280
|
+
return slabs.reduce((best, s) => (s.width * s.height > best.width * best.height ? s : best));
|
|
281
|
+
};
|
|
282
|
+
// Spatial hash so finding overlap candidates stays near-linear on pages
|
|
283
|
+
// with tens of thousands of targets: only targets sharing a cell can
|
|
284
|
+
// intersect, so each target is tested against its cell-mates only.
|
|
285
|
+
const CELL = 256;
|
|
286
|
+
let cellIndex = null;
|
|
287
|
+
const cellsOf = (r) => {
|
|
288
|
+
const keys = [];
|
|
289
|
+
for (let cx = Math.floor(r.left / CELL); cx <= Math.floor(r.right / CELL); cx++) {
|
|
290
|
+
for (let cy = Math.floor(r.top / CELL); cy <= Math.floor(r.bottom / CELL); cy++) {
|
|
291
|
+
keys.push(`${cx}:${cy}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return keys;
|
|
295
|
+
};
|
|
296
|
+
const overlapCandidates = (i) => {
|
|
297
|
+
if (!cellIndex) {
|
|
298
|
+
cellIndex = new Map();
|
|
299
|
+
rects.forEach((r, j) => {
|
|
300
|
+
if (!laidOut[j]) return;
|
|
301
|
+
for (const key of cellsOf(r)) {
|
|
302
|
+
if (!cellIndex.has(key)) cellIndex.set(key, []);
|
|
303
|
+
cellIndex.get(key).push(j);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
const seen = new Set();
|
|
308
|
+
for (const key of cellsOf(rects[i])) {
|
|
309
|
+
for (const j of cellIndex.get(key) ?? []) if (j !== i) seen.add(j);
|
|
310
|
+
}
|
|
311
|
+
return seen;
|
|
312
|
+
};
|
|
313
|
+
/** The pointer-accepting rect of a box-sized-fine target, reduced by
|
|
314
|
+
* every overlapping target painted above it — or null when nothing
|
|
315
|
+
* qualifying overlaps. */
|
|
316
|
+
const obscuredRect = (i) => {
|
|
317
|
+
let effective = null;
|
|
318
|
+
for (const j of overlapCandidates(i)) {
|
|
319
|
+
if (elements[j].contains(elements[i]) || elements[i].contains(elements[j])) continue; // same control, nested markup
|
|
320
|
+
if (encloses(rects[j], rects[i]) || encloses(rects[i], rects[j])) continue; // one control drawn twice
|
|
321
|
+
// Two links to the same place are one control in two boxes (a
|
|
322
|
+
// card's image link over its title link): clicks in the overlap
|
|
323
|
+
// land on the same destination, which is the criterion's
|
|
324
|
+
// "equivalent" case, provable here because the hrefs match.
|
|
325
|
+
if (typeof elements[i].href === 'string' && elements[i].href === elements[j].href) continue;
|
|
326
|
+
const overlap = overlapOf(effective ?? rects[i], rects[j]);
|
|
327
|
+
if (!overlap) continue;
|
|
328
|
+
if (fixedContextOf(elements[j]) !== fixedContextOf(elements[i])) continue; // scroll-state, not layout
|
|
329
|
+
if (!paintsOver(j, i, overlap)) continue;
|
|
330
|
+
effective = uncovered(effective ?? rects[i], overlap);
|
|
331
|
+
}
|
|
332
|
+
return effective;
|
|
333
|
+
};
|
|
334
|
+
|
|
202
335
|
return elements.map((element, i) => {
|
|
203
336
|
if (!laidOut[i]) return { status: 'pass' }; // not laid out / not a pointer target
|
|
204
|
-
if (!undersized[i])
|
|
337
|
+
if (!undersized[i]) {
|
|
338
|
+
// Box is big enough — but the pointer only gets the uncovered part.
|
|
339
|
+
const effective = obscuredRect(i);
|
|
340
|
+
if (!effective || (effective.width >= min && effective.height >= min)) return { status: 'pass' };
|
|
341
|
+
if (isInTextLine(element)) return { status: 'pass' };
|
|
342
|
+
// A target the pointer never actually hits is a keyboard
|
|
343
|
+
// affordance, not a pointer target — an in-flow skip link whose
|
|
344
|
+
// box sits beneath the header's paint (python.org's is hit-tested
|
|
345
|
+
// to its container at every probe point). Probed per fragment:
|
|
346
|
+
// if every in-viewport probe misses the element, pointers never
|
|
347
|
+
// interact with it and its pointer size is nobody's defect.
|
|
348
|
+
// (Hit-tests paid only here, on candidate failures.)
|
|
349
|
+
const everHit = (() => {
|
|
350
|
+
const doc = element.ownerDocument;
|
|
351
|
+
const win = doc.defaultView;
|
|
352
|
+
if (!win || typeof doc.elementsFromPoint !== 'function') return true;
|
|
353
|
+
const fragments = [...element.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
|
|
354
|
+
let tested = false;
|
|
355
|
+
for (const fragment of (fragments.length ? fragments : [rects[i]])) {
|
|
356
|
+
const x = fragment.left + fragment.width / 2;
|
|
357
|
+
const y = fragment.top + fragment.height / 2;
|
|
358
|
+
if (x < 0 || y < 0 || x >= win.innerWidth || y >= win.innerHeight) continue;
|
|
359
|
+
tested = true;
|
|
360
|
+
if (doc.elementsFromPoint(x, y).includes(element)) return true;
|
|
361
|
+
}
|
|
362
|
+
return !tested; // nothing probe-able → don't know → don't silence
|
|
363
|
+
})();
|
|
364
|
+
if (!everHit) return { status: 'pass' };
|
|
365
|
+
// The spacing exception cannot apply: the obscuring target is at
|
|
366
|
+
// distance zero. UA-control cannot either: the browser sized the
|
|
367
|
+
// box, but the author's layout took part of it away.
|
|
368
|
+
const rect = rects[i];
|
|
369
|
+
return {
|
|
370
|
+
status: 'fail',
|
|
371
|
+
message: `This target's box is ${Math.round(rect.width)}×${Math.round(rect.height)}px, but another target overlaps it and clicks in the overlap go there — the part that still accepts the pointer is ${Math.round(effective.width)}×${Math.round(effective.height)}px, below the ${min}×${min}px minimum.`,
|
|
372
|
+
fix: 'Space the targets so they no longer overlap, or enlarge this one until the uncovered part reaches the minimum.',
|
|
373
|
+
data: {
|
|
374
|
+
box: { width: Math.round(rect.width), height: Math.round(rect.height) },
|
|
375
|
+
effective: { width: Math.round(effective.width), height: Math.round(effective.height) },
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
205
379
|
if (uaControlled(element)) return { status: 'pass' }; // UA Control exception
|
|
206
380
|
if (isInTextLine(element)) return { status: 'pass' };
|
|
207
381
|
if (spacingException) {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// ARIA 1.2 §5.2.8.6 ("Roles which cannot be named"): for generic, "the
|
|
2
|
+
// element does not support name from author. Authors MUST NOT use the
|
|
3
|
+
// aria-label or aria-labelledby attributes to name the element." A label on
|
|
4
|
+
// a plain div/span/p breaks that MUST, so it is a real, provable authoring
|
|
5
|
+
// error — and browsers respond by dropping the prohibited name, which is
|
|
6
|
+
// exactly what makes the WCAG verdict undecidable from the DOM.
|
|
7
|
+
//
|
|
8
|
+
// It is not, on its own, a 4.1.2 failure. A static text container is not a
|
|
9
|
+
// "user interface component", and the dropped name leaves whatever name,
|
|
10
|
+
// role and value the element legitimately has untouched. Nothing is
|
|
11
|
+
// announced wrongly; nothing the criterion requires is provably absent.
|
|
12
|
+
//
|
|
13
|
+
// What CAN fail is the thing underneath: if the author reached for a label
|
|
14
|
+
// because assistive technology genuinely should announce this element — a
|
|
15
|
+
// hand-rolled widget missing its role, a region missing its landmark — then
|
|
16
|
+
// the name they wrote is invisible and the missing role is the defect.
|
|
17
|
+
// Whether that is so is exactly what the DOM cannot establish.
|
|
18
|
+
//
|
|
19
|
+
// This rule has lived on both sides of the line. It asserted under WCAG
|
|
20
|
+
// scope originally, was demoted to best-practice when an independent audit
|
|
21
|
+
// found its volume dominating WCAG results on news/commerce pages, and the
|
|
22
|
+
// demotion buried it: teams act on what a default audit shows, so a real
|
|
23
|
+
// ARIA MUST violation went unseen. Asking is the third option (the same
|
|
24
|
+
// route aria-allowed-attr took): it runs in the default scope, lands in
|
|
25
|
+
// needs-review rather than violations, and hands the human the one question
|
|
26
|
+
// that decides it.
|
|
27
|
+
/** Roles whose accessible name is built from their contents, so a label on
|
|
28
|
+
* anything inside them contributes to that name rather than being dropped. */
|
|
29
|
+
const NAME_FROM_CONTENT = 'a[href], button, summary, h1, h2, h3, h4, h5, h6, th, td,'
|
|
30
|
+
+ ' [role="button"], [role="link"], [role="menuitem"], [role="menuitemcheckbox"],'
|
|
31
|
+
+ ' [role="menuitemradio"], [role="option"], [role="tab"], [role="treeitem"],'
|
|
32
|
+
+ ' [role="checkbox"], [role="radio"], [role="switch"], [role="heading"],'
|
|
33
|
+
+ ' [role="cell"], [role="gridcell"], [role="columnheader"], [role="rowheader"], [role="tooltip"]';
|
|
34
|
+
|
|
35
|
+
export default {
|
|
36
|
+
id: 'aria-label-misuse',
|
|
37
|
+
impact: 'moderate',
|
|
38
|
+
tags: ['wcag2a', 'wcag412'],
|
|
39
|
+
help: 'aria-label on a generic element is dropped by the browser',
|
|
40
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
41
|
+
selector: 'div[aria-label]:not([role]):not([tabindex]), span[aria-label]:not([role]):not([tabindex]), p[aria-label]:not([role]):not([tabindex])',
|
|
42
|
+
evaluate(element) {
|
|
43
|
+
// Inside a name-from-content ancestor the label is NOT ignored: accname
|
|
44
|
+
// applies the whole algorithm to each descendant while building the
|
|
45
|
+
// ancestor's name, so this label becomes the button's or link's name.
|
|
46
|
+
// Chromium reports exactly that. Claiming it does nothing is false, and
|
|
47
|
+
// the advised fix would delete the control's only name.
|
|
48
|
+
if (element.closest(NAME_FROM_CONTENT)) return { status: 'pass' };
|
|
49
|
+
return {
|
|
50
|
+
status: 'incomplete',
|
|
51
|
+
message: `aria-label="${element.getAttribute('aria-label')}" on a plain <${element.tagName.toLowerCase()}> is prohibited by ARIA and dropped by the browser, so assistive technology never sees it. Nothing is announced wrongly, but nothing is announced at all. Should this element be announced? If it plays a role worth naming — a widget, a region, a group — the missing role is the defect and this is a 4.1.2 failure. If it is just a styled container, the attribute is stray and no one is affected.`,
|
|
52
|
+
fix: 'If the element should be announced, give it the role it is playing (a landmark, group, or widget role) so the name is legal and exposed. If it should not, remove the aria-label. Do not add a role solely to legalise the label.',
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
package/package.json
CHANGED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
// aria-label on a generic element (plain div/span/p) is PROHIBITED by ARIA
|
|
2
|
-
// 1.2 — the role doesn't support naming, so conforming assistive technology
|
|
3
|
-
// ignores the label. That makes it an authoring defect worth surfacing, but
|
|
4
|
-
// NOT a WCAG failure: a static text container is not a "user interface
|
|
5
|
-
// component", so no success criterion (4.1.2 included) actually fails.
|
|
6
|
-
// Best-practice scope is the honest home; demoted from WCAG scope after an
|
|
7
|
-
// independent audit found it dominating WCAG results on news/commerce pages.
|
|
8
|
-
/** Roles whose accessible name is built from their contents, so a label on
|
|
9
|
-
* anything inside them contributes to that name rather than being dropped. */
|
|
10
|
-
const NAME_FROM_CONTENT = 'a[href], button, summary, h1, h2, h3, h4, h5, h6, th, td,'
|
|
11
|
-
+ ' [role="button"], [role="link"], [role="menuitem"], [role="menuitemcheckbox"],'
|
|
12
|
-
+ ' [role="menuitemradio"], [role="option"], [role="tab"], [role="treeitem"],'
|
|
13
|
-
+ ' [role="checkbox"], [role="radio"], [role="switch"], [role="heading"],'
|
|
14
|
-
+ ' [role="cell"], [role="gridcell"], [role="columnheader"], [role="rowheader"], [role="tooltip"]';
|
|
15
|
-
|
|
16
|
-
export default {
|
|
17
|
-
id: 'aria-label-misuse',
|
|
18
|
-
impact: 'moderate',
|
|
19
|
-
tags: ['best-practice'],
|
|
20
|
-
help: 'aria-label on generic elements is ignored by assistive technology',
|
|
21
|
-
helpUrl: 'https://www.w3.org/TR/using-aria/#practical-support-aria-label-aria-labelledby-and-aria-describedby',
|
|
22
|
-
selector: 'div[aria-label]:not([role]):not([tabindex]), span[aria-label]:not([role]):not([tabindex]), p[aria-label]:not([role]):not([tabindex])',
|
|
23
|
-
evaluate(element) {
|
|
24
|
-
// Inside a name-from-content ancestor the label is NOT ignored: accname
|
|
25
|
-
// applies the whole algorithm to each descendant while building the
|
|
26
|
-
// ancestor's name, so this label becomes the button's or link's name.
|
|
27
|
-
// Chromium reports exactly that. Claiming it does nothing is false, and
|
|
28
|
-
// the advised fix would delete the control's only name.
|
|
29
|
-
if (element.closest(NAME_FROM_CONTENT)) return { status: 'pass' };
|
|
30
|
-
return {
|
|
31
|
-
status: 'fail',
|
|
32
|
-
message: `aria-label="${element.getAttribute('aria-label')}" on a plain <${element.tagName.toLowerCase()}> does nothing — screen readers only announce labels on interactive elements or landmarks.`,
|
|
33
|
-
fix: 'Give the element a suitable role (or use a landmark/heading), or remove the aria-label.',
|
|
34
|
-
};
|
|
35
|
-
},
|
|
36
|
-
};
|