pour-engine 1.1.5 → 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.
- package/config/project.config.js +1 -1
- package/engine/lib/contrast.js +182 -0
- package/engine/lib/roles.js +25 -1
- package/engine/rules/wcag/1.4.3-color-contrast.js +32 -1
- package/engine/rules/wcag/2.4.11-focus-not-obscured.js +37 -9
- package/engine/rules/wcag/4.1.2-valid-role.js +15 -2
- package/package.json +1 -1
package/config/project.config.js
CHANGED
package/engine/lib/contrast.js
CHANGED
|
@@ -52,6 +52,7 @@ let opacityCache = new WeakMap();
|
|
|
52
52
|
let opacityAnimatorsCache = null;
|
|
53
53
|
let mediaRectsCache = null;
|
|
54
54
|
let panelRectsCache = null;
|
|
55
|
+
let pseudoCache = new WeakMap();
|
|
55
56
|
const HAS_IMAGE = Symbol('background-image in chain');
|
|
56
57
|
|
|
57
58
|
export function resetAuditCaches() {
|
|
@@ -60,6 +61,7 @@ export function resetAuditCaches() {
|
|
|
60
61
|
opacityAnimatorsCache = null;
|
|
61
62
|
mediaRectsCache = null;
|
|
62
63
|
panelRectsCache = null;
|
|
64
|
+
pseudoCache = new WeakMap();
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
function canvasColor(doc) {
|
|
@@ -278,6 +280,186 @@ function textSamplePoint(element) {
|
|
|
278
280
|
* missed. The ancestor walk (which callers still run) covers the common
|
|
279
281
|
* cases of that shape.
|
|
280
282
|
*/
|
|
283
|
+
const px = (value) => (typeof value === 'string' && value.endsWith('px') ? parseFloat(value) : NaN);
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The viewport rect a positioned pseudo-element paints into, or null when
|
|
287
|
+
* its geometry isn't statically resolvable.
|
|
288
|
+
*
|
|
289
|
+
* An absolutely positioned box resolves against its containing block's
|
|
290
|
+
* PADDING box, which is the nearest ancestor that establishes one — usually
|
|
291
|
+
* the host itself (authors nearly always set position: relative for exactly
|
|
292
|
+
* this), but not always, so the chain is walked rather than assumed.
|
|
293
|
+
* Non-px insets, rotations and scales return null, which makes callers
|
|
294
|
+
* abstain rather than guess a rect.
|
|
295
|
+
*/
|
|
296
|
+
function containingBlockFor(host) {
|
|
297
|
+
for (let node = host; node && node.nodeType === 1; node = node.parentElement) {
|
|
298
|
+
const style = getComputedStyle(node);
|
|
299
|
+
// Transforms, filters and containment make an element a containing block
|
|
300
|
+
// for its absolutely positioned descendants just as positioning does.
|
|
301
|
+
if (style.position !== 'static' || style.transform !== 'none'
|
|
302
|
+
|| style.filter !== 'none' || /paint|layout|strict|content/.test(style.contain ?? '')) return node;
|
|
303
|
+
}
|
|
304
|
+
return null; // the initial containing block — viewport-relative, not resolved here
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function pseudoRect(host, style) {
|
|
308
|
+
const block = containingBlockFor(host);
|
|
309
|
+
if (!block) return null;
|
|
310
|
+
const hostStyle = getComputedStyle(block);
|
|
311
|
+
// A zero-extent containing block is still a valid ORIGIN — a collapsed
|
|
312
|
+
// positioned wrapper whose pseudo paints outside it is ordinary CSS. Only
|
|
313
|
+
// the opposing-inset derivation needs a real extent, and the degenerate
|
|
314
|
+
// boxes that produces are caught by the sanity check at the end.
|
|
315
|
+
const hostRect = block.getBoundingClientRect();
|
|
316
|
+
|
|
317
|
+
// Absolute positioning resolves against the containing block's PADDING box.
|
|
318
|
+
const borderLeft = px(hostStyle.borderLeftWidth) || 0;
|
|
319
|
+
const borderTop = px(hostStyle.borderTopWidth) || 0;
|
|
320
|
+
const cbLeft = hostRect.left + borderLeft;
|
|
321
|
+
const cbTop = hostRect.top + borderTop;
|
|
322
|
+
const cbWidth = hostRect.width - borderLeft - (px(hostStyle.borderRightWidth) || 0);
|
|
323
|
+
const cbHeight = hostRect.height - borderTop - (px(hostStyle.borderBottomWidth) || 0);
|
|
324
|
+
|
|
325
|
+
// getComputedStyle reports the CONTENT box, so padding and border have to
|
|
326
|
+
// be added back to get the extent that actually paints.
|
|
327
|
+
const extraX = (px(style.paddingLeft) || 0) + (px(style.paddingRight) || 0)
|
|
328
|
+
+ (px(style.borderLeftWidth) || 0) + (px(style.borderRightWidth) || 0);
|
|
329
|
+
const extraY = (px(style.paddingTop) || 0) + (px(style.paddingBottom) || 0)
|
|
330
|
+
+ (px(style.borderTopWidth) || 0) + (px(style.borderBottomWidth) || 0);
|
|
331
|
+
|
|
332
|
+
// An opposing inset pair pins both edges directly; otherwise one inset plus
|
|
333
|
+
// the used size does. `auto` on both sides leaves the box where static flow
|
|
334
|
+
// would have put it — not resolvable here.
|
|
335
|
+
const span = (startValue, endValue, sizeValue, origin, extent, extra) => {
|
|
336
|
+
const start = px(startValue);
|
|
337
|
+
const end = px(endValue);
|
|
338
|
+
const size = px(sizeValue);
|
|
339
|
+
if (Number.isFinite(start) && Number.isFinite(end)) return [origin + start, origin + extent - end];
|
|
340
|
+
if (Number.isFinite(start) && Number.isFinite(size)) return [origin + start, origin + start + size + extra];
|
|
341
|
+
if (Number.isFinite(end) && Number.isFinite(size)) {
|
|
342
|
+
const far = origin + extent - end;
|
|
343
|
+
return [far - size - extra, far];
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
};
|
|
347
|
+
const x = span(style.left, style.right, style.width, cbLeft, cbWidth, extraX);
|
|
348
|
+
const y = span(style.top, style.bottom, style.height, cbTop, cbHeight, extraY);
|
|
349
|
+
if (!x || !y) return null;
|
|
350
|
+
|
|
351
|
+
let [left, right] = x;
|
|
352
|
+
let [top, bottom] = y;
|
|
353
|
+
if (style.transform && style.transform !== 'none') {
|
|
354
|
+
// Only a pure translation keeps the box axis-aligned and the same size;
|
|
355
|
+
// rotation/scale/skew would need real geometry, so they stay unresolved.
|
|
356
|
+
const matrix = /^matrix\(([^)]+)\)$/.exec(style.transform);
|
|
357
|
+
if (!matrix) return null;
|
|
358
|
+
const [a, b, c, d, e, f] = matrix[1].split(',').map(Number);
|
|
359
|
+
if (a !== 1 || b !== 0 || c !== 0 || d !== 1) return null;
|
|
360
|
+
left += e; right += e; top += f; bottom += f;
|
|
361
|
+
}
|
|
362
|
+
// A degenerate box is not unknowable — it is a box that paints nothing
|
|
363
|
+
// (collapsed separator rules are usually exactly this: 1px wide, 0 tall).
|
|
364
|
+
// Callers skip it rather than abstaining on the whole element.
|
|
365
|
+
return { left, top, right, bottom, empty: !(right > left) || !(bottom > top) };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The backdrop a host's own ::before/::after paints over `point`.
|
|
370
|
+
*
|
|
371
|
+
* This is the one resolution path that can see pseudo-element paint at all:
|
|
372
|
+
* they carry no DOM node, so the ancestor walk (which reads element computed
|
|
373
|
+
* styles) and elementsFromPoint (which retargets them to their host) are both
|
|
374
|
+
* structurally blind to them. The sliding indicator pill behind a selected
|
|
375
|
+
* segmented-control option is the canonical case — the text really does sit
|
|
376
|
+
* on the pill, not on the track colour both other paths report.
|
|
377
|
+
*
|
|
378
|
+
* Returns { color } (opaque or translucent paint over the point),
|
|
379
|
+
* 'unresolved' when something paints there that flat colour math can't
|
|
380
|
+
* follow, or null when no pseudo of this host paints over the point.
|
|
381
|
+
*/
|
|
382
|
+
/** The paint a host's pseudo-elements contribute, resolved once per audit.
|
|
383
|
+
* Two extra getComputedStyle calls per host would otherwise land on every
|
|
384
|
+
* text element on the page; cached, the per-point test below is rect math. */
|
|
385
|
+
function pseudoLayers(host) {
|
|
386
|
+
let layers = pseudoCache.get(host);
|
|
387
|
+
if (layers !== undefined) return layers;
|
|
388
|
+
layers = [];
|
|
389
|
+
// Paint order within a host: background, ::before, content, ::after — so a
|
|
390
|
+
// later entry covers an earlier one, and both sit above the host's own
|
|
391
|
+
// background but below its text.
|
|
392
|
+
for (const which of ['::before', '::after']) {
|
|
393
|
+
const style = getComputedStyle(host, which);
|
|
394
|
+
if (style.content === 'none' || style.display === 'none' || style.visibility === 'hidden') continue;
|
|
395
|
+
const paints = style.backgroundImage !== 'none' || (parseColor(style.backgroundColor)?.a ?? 0) > 0;
|
|
396
|
+
if (!paints) continue;
|
|
397
|
+
// In-flow pseudos (the overwhelming majority: bullets, icons, rules,
|
|
398
|
+
// badges) occupy their own box in the content flow beside the text
|
|
399
|
+
// rather than painting behind it. Only out-of-flow ones can be the
|
|
400
|
+
// backdrop, so everything else is simply not a layer here — treating
|
|
401
|
+
// them as unknowable would abstain on half the web.
|
|
402
|
+
if (style.position !== 'absolute' && style.position !== 'fixed') continue;
|
|
403
|
+
const rect = style.position === 'absolute' ? pseudoRect(host, style) : null;
|
|
404
|
+
// Paint we can't place can't be ruled in or out — say so rather than
|
|
405
|
+
// silently leaving it out of the stack.
|
|
406
|
+
if (!rect) { layers = 'unresolved'; break; }
|
|
407
|
+
if (rect.empty) continue; // placed, and covers nothing
|
|
408
|
+
let color = parseColor(style.backgroundColor);
|
|
409
|
+
const opacity = parseFloat(style.opacity);
|
|
410
|
+
if (color && Number.isFinite(opacity) && opacity < 1) color = { ...color, a: color.a * opacity };
|
|
411
|
+
// Gradient and image paint rides along as CSS so callers can sample it,
|
|
412
|
+
// the same way they sample an element's background-image — a gradient
|
|
413
|
+
// scrim over a card headline is a bracketable backdrop, not a mystery.
|
|
414
|
+
const imageCss = style.backgroundImage !== 'none' ? style.backgroundImage : null;
|
|
415
|
+
layers.push({ rect, color, imageCss });
|
|
416
|
+
}
|
|
417
|
+
pseudoCache.set(host, layers);
|
|
418
|
+
return layers;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The nearest pseudo-element paint covering this element's text, walking the
|
|
423
|
+
* flat tree upward: an out-of-flow ::before/::after paints above its host's
|
|
424
|
+
* background but below its host's descendants, so the first one that covers
|
|
425
|
+
* the text is the real backdrop.
|
|
426
|
+
*
|
|
427
|
+
* This is the only path that can see pseudo-element paint at all — they carry
|
|
428
|
+
* no DOM node, so the ancestor walk (element computed styles) and
|
|
429
|
+
* elementsFromPoint (which retargets them to their host) are both
|
|
430
|
+
* structurally blind. The sliding indicator behind a selected
|
|
431
|
+
* segmented-control option is the canonical case: the text really does sit on
|
|
432
|
+
* the pill, not on the track colour both other paths report.
|
|
433
|
+
*
|
|
434
|
+
* Pure geometry, deliberately: unlike the hit-test it needs no viewport, so
|
|
435
|
+
* it answers for text scrolled far off screen too.
|
|
436
|
+
*
|
|
437
|
+
* Returns { color } (opaque, or translucent paint to composite over the
|
|
438
|
+
* walked background), { image } for gradient/image paint the caller should
|
|
439
|
+
* sample, 'unresolved' when paint in the chain can't be placed, or null when
|
|
440
|
+
* no pseudo covers the text.
|
|
441
|
+
*/
|
|
442
|
+
export function pseudoBackdropForText(element) {
|
|
443
|
+
const point = textSamplePoint(element);
|
|
444
|
+
if (!point) return null;
|
|
445
|
+
let acc = null;
|
|
446
|
+
for (let node = element; node && node.nodeType === 1; node = node.parentElement ?? node.getRootNode()?.host) {
|
|
447
|
+
const layers = pseudoLayers(node);
|
|
448
|
+
// Paint we can't place can't be ruled in or out, in either direction.
|
|
449
|
+
if (layers === 'unresolved') return 'unresolved';
|
|
450
|
+
for (const { rect, color, imageCss } of layers) {
|
|
451
|
+
if (point.x < rect.left || point.x > rect.right || point.y < rect.top || point.y > rect.bottom) continue;
|
|
452
|
+
// Gradient/image paint is bracketable by sampling, so hand it to the
|
|
453
|
+
// caller rather than giving up on the whole element.
|
|
454
|
+
if (imageCss) return { image: { css: imageCss, element: node } };
|
|
455
|
+
if (!color || color.a === 0) continue;
|
|
456
|
+
acc = acc ? composite(acc, color) : color;
|
|
457
|
+
if (acc.a >= 1) return { color: acc };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return acc ? { color: acc } : null;
|
|
461
|
+
}
|
|
462
|
+
|
|
281
463
|
/** A layer qualifies as a scrim over `element` when it is translucent paint
|
|
282
464
|
* (or a backdrop-filter) covering most of the viewport and the whole
|
|
283
465
|
* element — the shape of a modal/loading veil, not a badge or header. */
|
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
|
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
backgroundImageSource, backgroundImagePaintRect, imageLuminanceRange, gradientLuminanceRange,
|
|
5
5
|
rangeVerdict, isLargeText, cumulativeOpacity, opacityAnimating, restingOpacity, mediaRects,
|
|
6
6
|
paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
|
|
7
|
+
pseudoBackdropForText,
|
|
7
8
|
} from '../../lib/contrast.js';
|
|
8
9
|
|
|
9
10
|
/** Display a ratio truncated (never rounded up): 4.495 must read "4.49",
|
|
@@ -302,7 +303,28 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
302
303
|
};
|
|
303
304
|
}
|
|
304
305
|
}
|
|
305
|
-
|
|
306
|
+
// Pseudo-element paint sits above everything the hit-test and the walk
|
|
307
|
+
// can report, and neither can see it — so it is resolved before them and
|
|
308
|
+
// wins. Geometry-only, so it settles offscreen text too, where the
|
|
309
|
+
// hit-test is blind and the walk would otherwise assert the track colour
|
|
310
|
+
// of a control the text visibly is not sitting on.
|
|
311
|
+
const pseudoBack = pseudoBackdropForText(element);
|
|
312
|
+
if (pseudoBack === 'unresolved') {
|
|
313
|
+
return {
|
|
314
|
+
status: 'incomplete',
|
|
315
|
+
message: 'A pseudo-element with its own background paints in this element\'s chain, but its position can\'t be computed — so whether it sits behind this text is unknown. Check the contrast by eye.',
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
// Gradient/image paint from a pseudo-element: bracket it by sampling,
|
|
319
|
+
// exactly as an element's own background-image is bracketed.
|
|
320
|
+
if (pseudoBack?.image) {
|
|
321
|
+
const sampled = await sampledVerdict(pseudoBack.image, foreground, required, doc);
|
|
322
|
+
return sampled ?? {
|
|
323
|
+
status: 'incomplete',
|
|
324
|
+
message: 'A pseudo-element paints an image or gradient behind this text, so its real background isn’t the computed colour — contrast must be checked by eye.',
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
if (painted?.image && !pseudoBack) {
|
|
306
328
|
const sampled = await sampledVerdict(painted.image, foreground, required, doc);
|
|
307
329
|
return sampled ?? {
|
|
308
330
|
status: 'incomplete',
|
|
@@ -330,6 +352,15 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
330
352
|
};
|
|
331
353
|
}
|
|
332
354
|
}
|
|
355
|
+
if (pseudoBack?.color) {
|
|
356
|
+
// Opaque pill: it IS the backdrop. Translucent paint composites over
|
|
357
|
+
// whatever the walk resolved beneath it. Either way the geometry is
|
|
358
|
+
// computed, not guessed, so this counts as verified paint truth.
|
|
359
|
+
background = pseudoBack.color.a >= 1 || !background
|
|
360
|
+
? pseudoBack.color
|
|
361
|
+
: composite(pseudoBack.color, background);
|
|
362
|
+
backgroundVerified = true;
|
|
363
|
+
}
|
|
333
364
|
if (!background) {
|
|
334
365
|
return { status: 'incomplete', message: 'The background could not be determined — check contrast by eye.' };
|
|
335
366
|
}
|
|
@@ -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
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
-
//
|
|
2
|
+
// Concrete, usable roles — abstract roles are invalid in markup.
|
|
3
3
|
const VALID_ROLES = new Set([
|
|
4
4
|
'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button',
|
|
5
5
|
'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', 'complementary',
|
|
@@ -12,6 +12,14 @@ const VALID_ROLES = new Set([
|
|
|
12
12
|
'slider', 'spinbutton', 'status', 'strong', 'subscript', 'superscript', 'switch', 'tab',
|
|
13
13
|
'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer', 'toolbar', 'tooltip',
|
|
14
14
|
'tree', 'treegrid', 'treeitem',
|
|
15
|
+
// ARIA 1.3 additions. `image` is the spec's own primary spelling with
|
|
16
|
+
// `img` kept as the synonym — img is the one ARIA role that isn't a whole
|
|
17
|
+
// word, which is why authors reach for `image`, and Safari (2021),
|
|
18
|
+
// Firefox (116) and Chromium all map it. Flagging it was a false positive
|
|
19
|
+
// on real sites; verified against Chromium's accessibility tree, where
|
|
20
|
+
// role="image" resolves to image and only a genuinely bogus role falls
|
|
21
|
+
// back to generic.
|
|
22
|
+
'image', 'comment', 'mark', 'suggestion',
|
|
15
23
|
]);
|
|
16
24
|
|
|
17
25
|
export default {
|
|
@@ -37,7 +45,12 @@ export default {
|
|
|
37
45
|
// canonical harmless case. The failure worth reporting is an invalid
|
|
38
46
|
// role MASKING real semantics or decorating an interactive element.
|
|
39
47
|
const tag = element.tagName.toLowerCase();
|
|
40
|
-
|
|
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';
|
|
41
54
|
const focusable = element.tabIndex >= 0
|
|
42
55
|
|| element.matches('a[href], button, input, select, textarea, summary');
|
|
43
56
|
const hasAriaProps = [...element.attributes].some(
|
package/package.json
CHANGED