pour-engine 1.42.4 → 1.44.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/COVERAGE.md +2 -2
- package/engine/README.md +1 -1
- package/engine/lib/contrast.js +226 -4
- package/engine/rules/index.js +7 -6
- package/engine/rules/wcag/1.4.3-color-contrast.js +145 -12
- package/engine/rules/wcag/1.4.3-control-contrast.js +8 -5
- package/engine/rules/wcag/3.1.2-valid-lang-parts.js +144 -1
- package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +56 -40
- package/engine/rules/wcag/4.1.2-aria-state-unreachable.js +56 -0
- package/package.json +1 -1
package/config/project.config.js
CHANGED
package/engine/COVERAGE.md
CHANGED
|
@@ -113,7 +113,7 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
113
113
|
|
|
114
114
|
| SC | Name | Level | Coverage | Rules |
|
|
115
115
|
|---|---|---|---|---|
|
|
116
|
-
| 4.1.2 | [Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) | A | Partially automated | input-image-alt, form-label, link-name, aria-allowed-attr, aria-attr-valid, aria-field-name, aria-hidden-focus, aria-label-misuse, aria-valid-refs, button-name, composite-widget-name, frame-title, invoker-target, label-for-valid, nested-interactive, role-required-aria, summary-name, valid-role |
|
|
116
|
+
| 4.1.2 | [Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) | A | Partially automated | input-image-alt, form-label, link-name, aria-allowed-attr, aria-attr-valid, aria-field-name, aria-hidden-focus, aria-label-misuse, aria-state-unreachable, aria-valid-refs, button-name, composite-widget-name, frame-title, invoker-target, label-for-valid, nested-interactive, role-required-aria, summary-name, valid-role |
|
|
117
117
|
| 4.1.3 | [Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | AA | Manual review | — |
|
|
118
118
|
|
|
119
|
-
**Totals:** 51 partially automated · 35 manual — with
|
|
119
|
+
**Totals:** 51 partially automated · 35 manual — with 99 rules.
|
package/engine/README.md
CHANGED
|
@@ -117,7 +117,7 @@ assessment, including interaction states and any checks reported as skipped.
|
|
|
117
117
|
|
|
118
118
|
## Honest current state
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
99 rules, hardened on real sites and the `testbeds/` pages. Audits walk into
|
|
121
121
|
open shadow roots (targets come back as `host >>> inner` selector chains);
|
|
122
122
|
iframe traversal is the host's job, and the extension does it. Known gaps:
|
|
123
123
|
the full accname spec (ours is a close approximation) and the language
|
package/engine/lib/contrast.js
CHANGED
|
@@ -101,6 +101,14 @@ export function contrastRatio(foreground, background) {
|
|
|
101
101
|
* (no text threshold lives up there), and "21:1" reads better than
|
|
102
102
|
* "21.0:1" — a trailing zero implies precision the eye can use.
|
|
103
103
|
*/
|
|
104
|
+
/** Serialise a judged colour for the report and the checker link. Composited
|
|
105
|
+
* channels keep up to two decimals: rounding them to integers changes the
|
|
106
|
+
* pair enough that the checker scores a different ratio than the audit
|
|
107
|
+
* reported (4.21 vs 4.19 on a 70%-opacity footer). Shared by the text and
|
|
108
|
+
* the form-control contrast rules (2026-09-17). */
|
|
109
|
+
const channelText = (value) => String(Math.round(value * 100) / 100);
|
|
110
|
+
export const asRgb = (color) => `rgb(${channelText(color.r)}, ${channelText(color.g)}, ${channelText(color.b)})`;
|
|
111
|
+
|
|
104
112
|
export function showRatio(ratio) {
|
|
105
113
|
const text = ratio >= 10
|
|
106
114
|
? (Math.floor(ratio * 10) / 10).toFixed(1)
|
|
@@ -135,6 +143,8 @@ let firstLineRulesCache = new WeakMap();
|
|
|
135
143
|
let chainEffectCache = new WeakMap();
|
|
136
144
|
let uncoveredEffectCache = new WeakMap();
|
|
137
145
|
let blendBackdropCache = new WeakMap();
|
|
146
|
+
let labelReferrersCache = new WeakMap();
|
|
147
|
+
let stackingContextCache = new WeakMap();
|
|
138
148
|
const HAS_IMAGE = Symbol('background-image in chain');
|
|
139
149
|
|
|
140
150
|
export function resetAuditCaches() {
|
|
@@ -149,6 +159,35 @@ export function resetAuditCaches() {
|
|
|
149
159
|
chainEffectCache = new WeakMap();
|
|
150
160
|
uncoveredEffectCache = new WeakMap();
|
|
151
161
|
blendBackdropCache = new WeakMap();
|
|
162
|
+
labelReferrersCache = new WeakMap();
|
|
163
|
+
stackingContextCache = new WeakMap();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Who names themselves from each id through aria-labelledby, per root
|
|
168
|
+
* (document or shadow root): how many of the referrers are inactive widgets
|
|
169
|
+
* and how many are anything else. Counted once per audit, because the
|
|
170
|
+
* contrast rule asks for every element with text of its own and a query
|
|
171
|
+
* over the whole page for each would make that walk quadratic. `isInactive`
|
|
172
|
+
* is the rule's own reading of a switched-off widget, so this library does
|
|
173
|
+
* not carry a second one.
|
|
174
|
+
*/
|
|
175
|
+
export function labelReferrers(root, isInactive) {
|
|
176
|
+
let referrers = labelReferrersCache.get(root);
|
|
177
|
+
if (!referrers) {
|
|
178
|
+
referrers = new Map();
|
|
179
|
+
for (const referrer of root.querySelectorAll('[aria-labelledby]')) {
|
|
180
|
+
const inactive = isInactive(referrer);
|
|
181
|
+
for (const id of referrer.getAttribute('aria-labelledby').split(/\s+/)) {
|
|
182
|
+
if (!id) continue;
|
|
183
|
+
const count = referrers.get(id) ?? { inactive: 0, other: 0 };
|
|
184
|
+
count[inactive ? 'inactive' : 'other'] += 1;
|
|
185
|
+
referrers.set(id, count);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
labelReferrersCache.set(root, referrers);
|
|
189
|
+
}
|
|
190
|
+
return referrers;
|
|
152
191
|
}
|
|
153
192
|
|
|
154
193
|
const flatParentOf = (node) => node.assignedSlot ?? node.parentElement ?? node.getRootNode()?.host ?? null;
|
|
@@ -770,6 +809,21 @@ function pseudoLayers(host) {
|
|
|
770
809
|
continue;
|
|
771
810
|
}
|
|
772
811
|
if (rect.empty) continue; // placed, and covers nothing
|
|
812
|
+
// A mask or a clip-path cuts the box down to a shape, and how much of
|
|
813
|
+
// the box that shape leaves is not computed here. The box covering the
|
|
814
|
+
// text then proves nothing about the paint doing so: a struck-through
|
|
815
|
+
// price drawn as a text-sized red ::after masked to one diagonal stroke
|
|
816
|
+
// was read as black text on red, 3.21:1, where the pixels are black on
|
|
817
|
+
// white at 18.88:1 (twelve false failures on one retailer's home page,
|
|
818
|
+
// 2026-09-21). Where the box misses the text it still paints nothing
|
|
819
|
+
// there, so the rect is kept, and the walk turns the layer into a film
|
|
820
|
+
// only when it covers the sample point.
|
|
821
|
+
const mask = style.maskImage || style.webkitMaskImage;
|
|
822
|
+
if ((mask && mask !== 'none') || (style.clipPath && style.clipPath !== 'none')) {
|
|
823
|
+
const alphaBound = Math.min(1, opacityFactor * (style.backgroundImage !== 'none' ? 1 : color.a));
|
|
824
|
+
if (alphaBound > 0) layers.push({ rect, shaped: alphaBound });
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
773
827
|
let layerColor = color;
|
|
774
828
|
if (layerColor && opacityFactor < 1) layerColor = { ...layerColor, a: layerColor.a * opacityFactor };
|
|
775
829
|
// Gradient and image paint rides along as CSS so callers can sample it,
|
|
@@ -821,6 +875,7 @@ export function pseudoBackdropForText(element) {
|
|
|
821
875
|
let image = null;
|
|
822
876
|
let settled = false; // an opaque colour or an image hit: deeper layers are covered
|
|
823
877
|
let film = 0;
|
|
878
|
+
let shaped = false; // some of that film is a masked or clipped box over the text
|
|
824
879
|
// Paint (an opaque background-color or a background-image) on an element
|
|
825
880
|
// BETWEEN the text and a pseudo's host makes the pseudo's place in the
|
|
826
881
|
// stack unprovable from here: CSS paint order can put an out-of-flow
|
|
@@ -851,6 +906,15 @@ export function pseudoBackdropForText(element) {
|
|
|
851
906
|
if (settled) continue;
|
|
852
907
|
const { rect, color, imageCss } = layer;
|
|
853
908
|
if (point.x < rect.left || point.x > rect.right || point.y < rect.top || point.y > rect.bottom) continue;
|
|
909
|
+
// A masked or clipped box over the text: paint of unknown extent,
|
|
910
|
+
// bounded by its alpha like any other film. It is placed, so opaque
|
|
911
|
+
// paint nearer the text says nothing about it and `crossed` is not
|
|
912
|
+
// consulted.
|
|
913
|
+
if (layer.shaped) {
|
|
914
|
+
film = 1 - (1 - film) * (1 - layer.shaped);
|
|
915
|
+
shaped = true;
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
854
918
|
// Gradient/image paint is bracketable by sampling, so hand it to the
|
|
855
919
|
// caller rather than giving up on the whole element. The pseudo's box
|
|
856
920
|
// and image geometry travel with it: covering the sample point is a
|
|
@@ -876,9 +940,10 @@ export function pseudoBackdropForText(element) {
|
|
|
876
940
|
|| (parseColor(style.backgroundColor)?.a ?? 0) >= 1) crossed = true;
|
|
877
941
|
}
|
|
878
942
|
}
|
|
879
|
-
|
|
880
|
-
if (
|
|
881
|
-
return
|
|
943
|
+
const filmed = film > 0 && { film, ...(shaped && { shaped }) };
|
|
944
|
+
if (image) return { image, ...filmed, ...(beyondPaint && { beyondPaint }) };
|
|
945
|
+
if (acc) return { color: acc, ...filmed, ...(beyondPaint && { beyondPaint }) };
|
|
946
|
+
return filmed || null;
|
|
882
947
|
}
|
|
883
948
|
|
|
884
949
|
/**
|
|
@@ -1213,7 +1278,7 @@ export function textShadowNegligible(cssText, fontSize) {
|
|
|
1213
1278
|
* uncertain path keeps the FULL box, erring toward review, never past a
|
|
1214
1279
|
* real overlap. Returns null when the clip leaves nothing paintable.
|
|
1215
1280
|
*/
|
|
1216
|
-
function paintableRect(element, rect) {
|
|
1281
|
+
export function paintableRect(element, rect) {
|
|
1217
1282
|
let { left, top, right, bottom } = rect;
|
|
1218
1283
|
let mode = getComputedStyle(element).position;
|
|
1219
1284
|
for (let a = element.parentElement; a; a = a.parentElement) {
|
|
@@ -1262,6 +1327,163 @@ export function mediaRects(doc) {
|
|
|
1262
1327
|
return mediaRectsCache;
|
|
1263
1328
|
}
|
|
1264
1329
|
|
|
1330
|
+
/** Properties whose non-initial value makes a stacking context, and which
|
|
1331
|
+
* will-change makes one for by being named. */
|
|
1332
|
+
const CONTEXT_PROPERTIES = ['transform', 'translate', 'rotate', 'scale', 'filter', 'backdropFilter', 'perspective',
|
|
1333
|
+
'clipPath', 'maskImage', 'webkitMaskImage', 'maskBorderSource', 'viewTransitionName'];
|
|
1334
|
+
const WILL_CHANGE_CONTEXTS = /(^|,\s*)(transform|translate|rotate|scale|opacity|filter|backdrop-filter|perspective|clip-path|mask|mask-image|mask-border|isolation|mix-blend-mode|z-index|contain|view-transition-name)\s*(,|$)/;
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* Does this element create a stacking context? The list is CSS's own (CSS
|
|
1338
|
+
* Positioned Layout 3, and the properties that say so in their own
|
|
1339
|
+
* specifications): the root; fixed and sticky; a positioned element, or a
|
|
1340
|
+
* flex or grid item, with a z-index; opacity under 1; a transform, filter,
|
|
1341
|
+
* perspective, clip-path or mask; a blend mode or isolation; containment
|
|
1342
|
+
* of layout or paint, a container, content-visibility; a view transition
|
|
1343
|
+
* name; and will-change naming any of those. It is kept complete on
|
|
1344
|
+
* purpose: missing one and inventing one both put an element in the wrong
|
|
1345
|
+
* layer, so beneathOpaqueAncestor asks for a whole chain it can read.
|
|
1346
|
+
*/
|
|
1347
|
+
function createsStackingContext(element) {
|
|
1348
|
+
let known = stackingContextCache.get(element);
|
|
1349
|
+
if (known !== undefined) return known;
|
|
1350
|
+
const parent = element.parentElement;
|
|
1351
|
+
const style = getComputedStyle(element);
|
|
1352
|
+
known = !parent
|
|
1353
|
+
|| style.position === 'fixed' || style.position === 'sticky'
|
|
1354
|
+
|| (style.zIndex !== 'auto' && (style.position !== 'static'
|
|
1355
|
+
|| /flex|grid/.test(getComputedStyle(parent).display)))
|
|
1356
|
+
|| parseFloat(style.opacity) < 1
|
|
1357
|
+
|| CONTEXT_PROPERTIES.some((name) => style[name] && style[name] !== 'none')
|
|
1358
|
+
|| (style.mixBlendMode && style.mixBlendMode !== 'normal')
|
|
1359
|
+
|| style.isolation === 'isolate'
|
|
1360
|
+
|| /layout|paint|strict|content/.test(style.contain ?? '')
|
|
1361
|
+
|| (style.containerType && style.containerType !== 'normal')
|
|
1362
|
+
|| (style.contentVisibility && style.contentVisibility !== 'visible')
|
|
1363
|
+
|| WILL_CHANGE_CONTEXTS.test(style.willChange ?? '');
|
|
1364
|
+
stackingContextCache.set(element, known);
|
|
1365
|
+
return known;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/** The top layer paints over every stacking context and is outside the
|
|
1369
|
+
* ordering read below. */
|
|
1370
|
+
function inTopLayer(element) {
|
|
1371
|
+
for (const selector of [':modal', ':popover-open', ':fullscreen']) {
|
|
1372
|
+
try { if (element.closest(selector)) return true; }
|
|
1373
|
+
catch { /* a selector this browser does not know matches nothing */ }
|
|
1374
|
+
}
|
|
1375
|
+
return false;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/**
|
|
1379
|
+
* Where an element paints within the stacking context `root`, as a rank to
|
|
1380
|
+
* compare (CSS 2.1 Appendix E): child contexts with a negative z-index,
|
|
1381
|
+
* then in-flow content, then positioned content and z-index 0 contexts in
|
|
1382
|
+
* tree order, then positive z-index contexts. A context nested in another
|
|
1383
|
+
* is carried by the outermost, which is the one `root` orders; with no
|
|
1384
|
+
* context between, a positioned box is ordered by itself, so the nearest
|
|
1385
|
+
* positioned ancestor speaks for its in-flow content. Null when the walk
|
|
1386
|
+
* leaves the tree before reaching `root` (a shadow boundary).
|
|
1387
|
+
*/
|
|
1388
|
+
function paintRank(element, root) {
|
|
1389
|
+
let context = null;
|
|
1390
|
+
let positioned = null;
|
|
1391
|
+
let node = element;
|
|
1392
|
+
for (; node && node !== root; node = node.parentElement) {
|
|
1393
|
+
if (createsStackingContext(node)) context = node;
|
|
1394
|
+
else if (!positioned && getComputedStyle(node).position !== 'static') positioned = node;
|
|
1395
|
+
}
|
|
1396
|
+
if (node !== root) return null;
|
|
1397
|
+
if (context) {
|
|
1398
|
+
// z-index orders only a positioned box or a flex or grid item. A
|
|
1399
|
+
// context made some other way (a transform, say) sits at zero.
|
|
1400
|
+
const style = getComputedStyle(context);
|
|
1401
|
+
const applies = style.zIndex !== 'auto' && (style.position !== 'static'
|
|
1402
|
+
|| /flex|grid/.test(getComputedStyle(context.parentElement ?? context).display));
|
|
1403
|
+
const z = applies ? parseInt(style.zIndex, 10) || 0 : 0;
|
|
1404
|
+
return { node: context, layer: z < 0 ? 0 : z > 0 ? 3 : 2, z };
|
|
1405
|
+
}
|
|
1406
|
+
return positioned ? { node: positioned, layer: 2, z: 0 } : { node: null, layer: 1, z: 0 };
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/** Is the whole box inside the ancestor's border box, rounded corners
|
|
1410
|
+
* allowed for? Null radii that cannot be read are taken as not inside. */
|
|
1411
|
+
function insideRoundedBox(box, outer, style) {
|
|
1412
|
+
if (box.left < outer.left || box.right > outer.right || box.top < outer.top || box.bottom > outer.bottom) return false;
|
|
1413
|
+
const radius = (css, width) => {
|
|
1414
|
+
const first = (css ?? '').split(' ')[0];
|
|
1415
|
+
const value = parseFloat(first);
|
|
1416
|
+
if (!Number.isFinite(value)) return null;
|
|
1417
|
+
return first.endsWith('%') ? (value / 100) * width : value;
|
|
1418
|
+
};
|
|
1419
|
+
const corners = [
|
|
1420
|
+
[style.borderTopLeftRadius, box.left - outer.left, box.top - outer.top],
|
|
1421
|
+
[style.borderTopRightRadius, outer.right - box.right, box.top - outer.top],
|
|
1422
|
+
[style.borderBottomLeftRadius, box.left - outer.left, outer.bottom - box.bottom],
|
|
1423
|
+
[style.borderBottomRightRadius, outer.right - box.right, outer.bottom - box.bottom],
|
|
1424
|
+
];
|
|
1425
|
+
for (const [css, dx, dy] of corners) {
|
|
1426
|
+
const r = radius(css, outer.width);
|
|
1427
|
+
if (r === null) return false;
|
|
1428
|
+
// The box's corner lies in this corner's square and outside its arc.
|
|
1429
|
+
if (dx < r && dy < r && (r - dx) ** 2 + (r - dy) ** 2 > r ** 2) return false;
|
|
1430
|
+
}
|
|
1431
|
+
return true;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Can this media not be the text's backdrop, because an opaque ancestor of
|
|
1436
|
+
* the text provably paints over it? Hit-test-blind media (pointer-events:
|
|
1437
|
+
* none) sharing the text's space is a hazard the browser cannot be asked
|
|
1438
|
+
* about, and the rule abstains on it. But a solid card laid over a
|
|
1439
|
+
* decorative graphic in a z-index -1 layer sent every line of the card to a
|
|
1440
|
+
* person (a consultancy's landing page, fifteen reviews that all pass by
|
|
1441
|
+
* eye, 2026-09-21). Two facts of CSS paint order settle it:
|
|
1442
|
+
* - an ancestor that creates a stacking context paints as one unit, its
|
|
1443
|
+
* background first and its descendants over it, so nothing from
|
|
1444
|
+
* outside can come between that background and the text;
|
|
1445
|
+
* - media outside that ancestor is then wholly over it or wholly under
|
|
1446
|
+
* it, and which is read from their ranks in the lowest stacking
|
|
1447
|
+
* context they share.
|
|
1448
|
+
* Under it, and with the ancestor's background opaque, unclipped, at full
|
|
1449
|
+
* opacity, unblended and covering the text's box, the media cannot show
|
|
1450
|
+
* behind the text. Everything else (media inside the ancestor, an order
|
|
1451
|
+
* that comes down to in-flow interleaving, the top layer, a shadow
|
|
1452
|
+
* boundary, a radius that cannot be read) is left unproven, and the rule
|
|
1453
|
+
* abstains as before.
|
|
1454
|
+
*/
|
|
1455
|
+
export function beneathOpaqueAncestor(element, media) {
|
|
1456
|
+
if (inTopLayer(element) || inTopLayer(media)) return false;
|
|
1457
|
+
const box = element.getBoundingClientRect();
|
|
1458
|
+
for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) {
|
|
1459
|
+
if (ancestor.contains(media)) return false; // from here up the media is inside
|
|
1460
|
+
if (!createsStackingContext(ancestor)) continue;
|
|
1461
|
+
const style = getComputedStyle(ancestor);
|
|
1462
|
+
const color = parseColor(style.backgroundColor);
|
|
1463
|
+
if (!color || color.a < 1) continue;
|
|
1464
|
+
if (cumulativeOpacity(ancestor) < 1) continue;
|
|
1465
|
+
if (style.mixBlendMode !== 'normal') continue;
|
|
1466
|
+
if ((style.clipPath && style.clipPath !== 'none')
|
|
1467
|
+
|| [style.maskImage, style.webkitMaskImage].some((mask) => mask && mask !== 'none')) continue;
|
|
1468
|
+
if (!/^(border-box|padding-box)$/.test(style.backgroundClip)) continue;
|
|
1469
|
+
if (!insideRoundedBox(box, ancestor.getBoundingClientRect(), style)) continue;
|
|
1470
|
+
// The lowest stacking context holding both.
|
|
1471
|
+
let shared = ancestor.parentElement;
|
|
1472
|
+
while (shared && !(createsStackingContext(shared) && shared.contains(media))) shared = shared.parentElement;
|
|
1473
|
+
if (!shared) return false;
|
|
1474
|
+
const over = paintRank(ancestor, shared);
|
|
1475
|
+
const under = paintRank(media, shared);
|
|
1476
|
+
if (!over || !under || !over.node || over.node === under.node) return false;
|
|
1477
|
+
if (under.layer !== over.layer) return under.layer < over.layer;
|
|
1478
|
+
if (under.layer === 1) return false; // both in flow: interleaving, unproven
|
|
1479
|
+
if (under.z !== over.z) return under.z < over.z;
|
|
1480
|
+
// Same layer and z-index: tree order, earlier paints first.
|
|
1481
|
+
return Boolean(under.node.compareDocumentPosition(over.node) & 4 /* FOLLOWING */)
|
|
1482
|
+
&& !under.node.contains(over.node);
|
|
1483
|
+
}
|
|
1484
|
+
return false;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1265
1487
|
/**
|
|
1266
1488
|
* The nearest background-image being painted behind this element (walking
|
|
1267
1489
|
* up until an opaque background colour would hide anything deeper).
|
package/engine/rules/index.js
CHANGED
|
@@ -42,13 +42,13 @@ import audioControl from './wcag/1.4.2-audio-control.js';
|
|
|
42
42
|
import pauseStopHide from './wcag/2.2.2-pause-stop-hide.js';
|
|
43
43
|
import mediaCaptions from './wcag/1.2.2-media-captions.js';
|
|
44
44
|
import ariaAttrValid from './wcag/4.1.2-aria-attr-valid.js';
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
// instead of claiming a failure (see the note in the rule file).
|
|
45
|
+
// An unsupported ARIA attribute breaks an ARIA author MUST (1.2 §8.6) but is
|
|
46
|
+
// INERT, so name, role and value survive: asserted at minor since 2026-09-17
|
|
47
|
+
// (it reviewed before). Where the DOM proves the element really has the
|
|
48
|
+
// state (a native readonly, required, disabled or checked beside the aria-
|
|
49
|
+
// attribute) the finding is aria-state-unreachable's, at moderate.
|
|
51
50
|
import ariaAllowedAttr from './wcag/4.1.2-aria-allowed-attr.js';
|
|
51
|
+
import ariaStateUnreachable from './wcag/4.1.2-aria-state-unreachable.js';
|
|
52
52
|
import ariaFieldName from './wcag/4.1.2-aria-field-name.js';
|
|
53
53
|
// Asks instead of asserting: a prohibited name on a generic element is a
|
|
54
54
|
// real ARIA MUST violation the browser silently drops, so it runs in the
|
|
@@ -191,6 +191,7 @@ export default [
|
|
|
191
191
|
mediaCaptions,
|
|
192
192
|
ariaAttrValid,
|
|
193
193
|
ariaAllowedAttr,
|
|
194
|
+
ariaStateUnreachable,
|
|
194
195
|
ariaFieldName,
|
|
195
196
|
ariaLabelMisuse,
|
|
196
197
|
roleRequiredAria,
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
opacityAnimating, restingOpacity, mediaRects, inZeroClipSubtree,
|
|
7
7
|
paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
|
|
8
8
|
pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
|
|
9
|
-
showRatio, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors, BOLD_WEIGHT, hasPaintEffects, isolatedBlendBackdrop } from '../../lib/contrast.js';
|
|
9
|
+
showRatio, asRgb, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors, BOLD_WEIGHT, hasPaintEffects, isolatedBlendBackdrop, labelReferrers, beneathOpaqueAncestor } from '../../lib/contrast.js';
|
|
10
10
|
|
|
11
11
|
/** The first url() among a background-image list's layers, or null. */
|
|
12
12
|
const firstLayerUrl = (css) => splitBackgroundLayers(css ?? '').map(backgroundLayerUrl).find(Boolean) ?? null;
|
|
@@ -20,12 +20,6 @@ const extentOf = (rect) => (rect ? { width: rect.right - rect.left, height: rect
|
|
|
20
20
|
* every character that carries grouping, rating or code meaning. */
|
|
21
21
|
const SEPARATOR_GLYPHS = /^([|¦·•∙‧/⁄\\‐‑‒–—―⁃«»‹›-])\1*$/;
|
|
22
22
|
|
|
23
|
-
/** Serialise a judged colour for the report and the checker link. Composited
|
|
24
|
-
* channels keep up to two decimals: rounding them to integers changes the
|
|
25
|
-
* pair enough that the checker scores a different ratio than the audit
|
|
26
|
-
* reported (4.21 vs 4.19 on a 70%-opacity footer). */
|
|
27
|
-
const channelText = (value) => String(Math.round(value * 100) / 100);
|
|
28
|
-
const asRgb = (color) => `rgb(${channelText(color.r)}, ${channelText(color.g)}, ${channelText(color.b)})`;
|
|
29
23
|
|
|
30
24
|
/** The one case a full-page veil still can't be judged for the author: the
|
|
31
25
|
* resting page and the dimmed one land on opposite sides of the threshold,
|
|
@@ -80,6 +74,116 @@ function inactiveComponentText(element) {
|
|
|
80
74
|
return Boolean(ariaDisabled && ariaDisabled.matches(ARIA_DISABLED_HOSTS));
|
|
81
75
|
}
|
|
82
76
|
|
|
77
|
+
/** A widget switched off: natively disabled, which :disabled sees through
|
|
78
|
+
* an ancestor <fieldset disabled> where .disabled does not, or
|
|
79
|
+
* aria-disabled="true" on a role that honours it. Groups are left out: a
|
|
80
|
+
* disabled fieldset keeps its first legend active (above), and a caption
|
|
81
|
+
* it takes through aria-labelledby is read the same way. */
|
|
82
|
+
function inactiveWidget(widget) {
|
|
83
|
+
if (widget.matches('fieldset, optgroup, [role="group"], [role="toolbar"], [role="application"]')) return false;
|
|
84
|
+
if (widget.matches(':disabled')) return true;
|
|
85
|
+
const ariaDisabled = widget.closest('[aria-disabled="true"]');
|
|
86
|
+
return Boolean(ariaDisabled && ariaDisabled.matches(ARIA_DISABLED_HOSTS));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What a label does not hold: blocks of content, or controls of its own. */
|
|
90
|
+
const MORE_THAN_A_LABEL = 'p, div, ul, ol, dl, table, section, article, aside, nav, header, footer, form, '
|
|
91
|
+
+ 'h1, h2, h3, h4, h5, h6, a[href], button, input, select, textarea';
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Is this text the label of an inactive control? The browser greys
|
|
95
|
+
* <label>My name <input disabled></label> as one unit, and that unit is
|
|
96
|
+
* what the glossary calls a user interface component: "a part of the
|
|
97
|
+
* content that is perceived by users as a single control for a distinct
|
|
98
|
+
* function". So the label is "part of an inactive user interface
|
|
99
|
+
* component" with its control. ACT afw4f7 and 09o5cg say so in their
|
|
100
|
+
* applicability, which leaves out text whose ancestor "is used in the
|
|
101
|
+
* accessible name of an inheriting semantic widget that is disabled"
|
|
102
|
+
* (engine repository issue #5, reported with the fixtures by Jeff Witt).
|
|
103
|
+
*
|
|
104
|
+
* A label reaches its widget two ways: the <label> element's own
|
|
105
|
+
* association, and aria-labelledby on the widget. Both are held to the
|
|
106
|
+
* same two limits, because the exemption is for a label and nothing wider:
|
|
107
|
+
* - every widget that takes its name from this element is inactive. A
|
|
108
|
+
* caption shared by a disabled field and a live one still labels the
|
|
109
|
+
* live one, and a heading that also names a region is still a heading.
|
|
110
|
+
* - a referenced ancestor must be label-sized. aria-labelledby aimed at a
|
|
111
|
+
* whole card does not make the card's paragraphs part of the control.
|
|
112
|
+
* Text inside the widget itself is inactiveComponentText's, above.
|
|
113
|
+
*/
|
|
114
|
+
function labelsInactiveComponent(element) {
|
|
115
|
+
const referrers = labelReferrers(element.getRootNode(), inactiveWidget);
|
|
116
|
+
const sharedWithLive = (node) => Boolean(node.id && referrers.get(node.id)?.other);
|
|
117
|
+
const label = element.closest('label');
|
|
118
|
+
if (label?.control && inactiveWidget(label.control) && !sharedWithLive(label)) return true;
|
|
119
|
+
if (!referrers.size) return false;
|
|
120
|
+
for (let node = element; node; node = node.parentElement) {
|
|
121
|
+
const count = node.id && referrers.get(node.id);
|
|
122
|
+
if (!count || !count.inactive || count.other) continue;
|
|
123
|
+
if (node === element || !node.querySelector(MORE_THAN_A_LABEL)) return true;
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** User-perceived characters, so a flag or a skin-toned emoji counts as
|
|
129
|
+
* one. Falls back to code points where Intl.Segmenter is missing. */
|
|
130
|
+
function graphemes(text) {
|
|
131
|
+
if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
|
|
132
|
+
return [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text)].map((part) => part.segment);
|
|
133
|
+
}
|
|
134
|
+
return [...text];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A character the browser draws from a colour font: one whose default
|
|
138
|
+
* presentation is emoji, or any emoji asked for in colour with U+FE0F.
|
|
139
|
+
* A check mark or a telephone with no selector is drawn as text, in the
|
|
140
|
+
* element's colour, and is judged as text. */
|
|
141
|
+
const drawnInColour = (grapheme) => /\p{Emoji_Presentation}/u.test(grapheme)
|
|
142
|
+
|| (grapheme.includes('️') && /\p{Emoji}/u.test(grapheme));
|
|
143
|
+
|
|
144
|
+
/** The controls a lone glyph stands in as an icon for. */
|
|
145
|
+
const ICON_HOSTS = 'button, a[href], summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"]';
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Is this text a glyph and not words? WCAG defines text as characters
|
|
149
|
+
* "expressing something in human language", and its note on non-text
|
|
150
|
+
* content names emoticons. Three shapes are provable from the markup, and
|
|
151
|
+
* no others are taken: punctuation and symbols are part of written
|
|
152
|
+
* language (a price band in pound signs, the asterisk on a required field,
|
|
153
|
+
* the prompt in a code sample), so text without a letter in it is still
|
|
154
|
+
* judged as text.
|
|
155
|
+
* - colour emoji only. Beyond the definition, the measurement is void:
|
|
156
|
+
* a colour glyph paints its own colours and ignores `color`, so the
|
|
157
|
+
* ratio describes paint that is not there.
|
|
158
|
+
* - private-use characters only: an icon-font glyph. Unicode gives these
|
|
159
|
+
* no meaning in any language.
|
|
160
|
+
* - a single Latin letter or symbol that is all a control shows, where
|
|
161
|
+
* the author named the control through aria-label or aria-labelledby
|
|
162
|
+
* and the name does not contain the character. The X in <button
|
|
163
|
+
* aria-label="Close">X</button> is a drawn cross, and the author's own
|
|
164
|
+
* name for it is the evidence (ACT afw4f7 Passed Example 7). A letter
|
|
165
|
+
* from another script is left alone: one CJK character is a word.
|
|
166
|
+
* Returns what the glyph is, and whether it is the whole face of a
|
|
167
|
+
* control, which decides what it owes under 1.4.11 (see the wrapper).
|
|
168
|
+
*/
|
|
169
|
+
function glyphNotWords(element, text, accessibleName) {
|
|
170
|
+
const trimmed = text.trim();
|
|
171
|
+
if (!trimmed) return null;
|
|
172
|
+
const parts = graphemes(trimmed).filter((part) => /\S/.test(part));
|
|
173
|
+
if (parts.every(drawnInColour)) return { emoji: true, what: 'this text is emoji, which the browser draws in its own colours whatever the text colour is' };
|
|
174
|
+
const host = element.closest(ICON_HOSTS);
|
|
175
|
+
const faceOfControl = Boolean(host && host.textContent.trim() === trimmed);
|
|
176
|
+
if (/^[\p{Co}\s]+$/u.test(trimmed)) {
|
|
177
|
+
return { faceOfControl, what: 'this text is an icon-font glyph (a private-use character), not words' };
|
|
178
|
+
}
|
|
179
|
+
if (parts.length !== 1 || !faceOfControl) return null;
|
|
180
|
+
if (!/^(\p{Script=Latin}|[^\p{L}\p{N}])/u.test(trimmed)) return null;
|
|
181
|
+
if (!host.hasAttribute('aria-label') && !host.hasAttribute('aria-labelledby')) return null;
|
|
182
|
+
const name = accessibleName(host);
|
|
183
|
+
if (!name || name.toLowerCase().includes(trimmed.toLowerCase())) return null;
|
|
184
|
+
return { faceOfControl, what: `the single character "${trimmed}" stands in for an icon: the ${host.tagName.toLowerCase()} is named "${name}", which does not contain it` };
|
|
185
|
+
}
|
|
186
|
+
|
|
83
187
|
/** A control the keyboard cannot reach either: with pointer-events: none
|
|
84
188
|
* on top, nothing can operate it, which is what "inactive" means. Native
|
|
85
189
|
* controls are focusable unless told otherwise; a role-only widget is
|
|
@@ -522,8 +626,9 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
522
626
|
async evaluate(element, { ownText }) {
|
|
523
627
|
if (!ownText(element)) return { status: 'pass' }; // no text of its own to judge
|
|
524
628
|
|
|
525
|
-
// The criterion exempts "inactive user interface components"
|
|
526
|
-
|
|
629
|
+
// The criterion exempts "inactive user interface components": the text
|
|
630
|
+
// inside one, and the label that is greyed out with it.
|
|
631
|
+
if (inactiveComponentText(element) || labelsInactiveComponent(element)) return { status: 'pass' };
|
|
527
632
|
|
|
528
633
|
// A shadow HOST's own text renders through its shadow tree: when a
|
|
529
634
|
// <slot> projects it, the text inherits styles from the slot's
|
|
@@ -980,7 +1085,9 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
980
1085
|
}
|
|
981
1086
|
const filmIncomplete = {
|
|
982
1087
|
status: 'incomplete',
|
|
983
|
-
message:
|
|
1088
|
+
message: pseudoResolved?.shaped
|
|
1089
|
+
? 'A pseudo-element with its own background covers this text, but a mask or clip-path cuts it down to a shape, so how much of it paints over or behind the text can\'t be computed. Check the contrast by eye.'
|
|
1090
|
+
: '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.',
|
|
984
1091
|
};
|
|
985
1092
|
// Sampled pixels, veils and text-shadow halos can't be film-bracketed
|
|
986
1093
|
// (the film may sit between the sample and the glyphs): those verdicts
|
|
@@ -1150,10 +1257,13 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
1150
1257
|
const box = element.getBoundingClientRect();
|
|
1151
1258
|
const near = (r) => Math.min(box.right, r.right) - Math.max(box.left, r.left) >= 3
|
|
1152
1259
|
&& Math.min(box.bottom, r.bottom) - Math.max(box.top, r.top) >= 3;
|
|
1260
|
+
// Asked last, and only of media that would otherwise send this text
|
|
1261
|
+
// to a person: can paint order prove an opaque ancestor lies over it?
|
|
1153
1262
|
const overMedia = mediaRects(doc).some(({ element: media, rect: mediaRect, hitTestBlind }) =>
|
|
1154
1263
|
(!blindOnly || hitTestBlind) && near(mediaRect)
|
|
1155
1264
|
&& !media.contains(element) && !element.contains(media)
|
|
1156
|
-
&& textIntersects(element, mediaRect)
|
|
1265
|
+
&& textIntersects(element, mediaRect)
|
|
1266
|
+
&& !beneathOpaqueAncestor(element, media));
|
|
1157
1267
|
if (overMedia) {
|
|
1158
1268
|
return {
|
|
1159
1269
|
status: 'incomplete',
|
|
@@ -1394,7 +1504,30 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
1394
1504
|
const judge = rule.evaluate.bind(rule);
|
|
1395
1505
|
rule.evaluate = async (element, helpers) => {
|
|
1396
1506
|
const verdict = await judge(element, helpers);
|
|
1397
|
-
if (verdict.status
|
|
1507
|
+
if (verdict.status !== 'fail') return verdict;
|
|
1508
|
+
// A miss on a glyph that is not words: see glyphNotWords. Every fail
|
|
1509
|
+
// path passes through here, so this is said once. A glyph still owes
|
|
1510
|
+
// 3:1 under 1.4.11 when it is needed to identify a control, so one that
|
|
1511
|
+
// is the whole face of a control and measures under 3:1 fails whichever
|
|
1512
|
+
// criterion it is read under, and keeps its failure. Anything else is a
|
|
1513
|
+
// question of which criterion applies, and goes to a person. Emoji
|
|
1514
|
+
// always does, its ratio being a number for paint that is not there.
|
|
1515
|
+
const glyph = glyphNotWords(element, helpers.ownText(element), helpers.accessibleName);
|
|
1516
|
+
if (glyph) {
|
|
1517
|
+
const ratio = verdict.data?.ratio;
|
|
1518
|
+
if (!glyph.emoji && glyph.faceOfControl && ratio < 3) {
|
|
1519
|
+
verdict.message += ' Read as an icon and not as text, it still falls short: 1.4.11 asks 3:1 of what identifies a control.';
|
|
1520
|
+
}
|
|
1521
|
+
else {
|
|
1522
|
+
return {
|
|
1523
|
+
status: 'incomplete',
|
|
1524
|
+
message: glyph.emoji
|
|
1525
|
+
? `The text colour misses the contrast minimum, but ${glyph.what}, so that ratio does not describe what is seen. If the emoji carries meaning, judge it by eye against 1.4.11 non-text contrast (3:1).`
|
|
1526
|
+
: `Contrast is ${ratio ? `${ratio}:1, below the ${verdict.data.required}:1 minimum for text` : 'below the minimum for text'}, but ${glyph.what}. 1.4.3 covers text in a human language; a glyph that works as a graphic is judged under 1.4.11 non-text contrast (3:1) where it is needed to understand the content. Decide by eye which this is.`,
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
if (element.closest('[aria-hidden="true"]')) {
|
|
1398
1531
|
verdict.message += ' aria-hidden hides this from screen readers, not from sighted users; contrast is judged for the people who see it.';
|
|
1399
1532
|
}
|
|
1400
1533
|
return verdict;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import {
|
|
6
6
|
parseColor, contrastRatio, composite, effectiveBackground, isLargeText,
|
|
7
7
|
backgroundImageSource, cumulativeOpacity, opacityAnimating, restingOpacity, showRatio,
|
|
8
|
-
hasPaintEffects, opacityGroupPaint, splitBackgroundLayers, sampledGradientRange,
|
|
8
|
+
hasPaintEffects, opacityGroupPaint, splitBackgroundLayers, sampledGradientRange, asRgb,
|
|
9
9
|
} from '../../lib/contrast.js';
|
|
10
10
|
|
|
11
11
|
export default {
|
|
@@ -108,7 +108,7 @@ export default {
|
|
|
108
108
|
if (group?.unresolved) { unresolvedGroup = true; return null; }
|
|
109
109
|
if (group) {
|
|
110
110
|
const ratio = contrastRatio(group.foreground, group.background);
|
|
111
|
-
return ratio >= required ? null : { what, ratio };
|
|
111
|
+
return ratio >= required ? null : { what, ratio, fg: group.foreground, bg: group.background };
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
const painted = opacity * ownOpacity;
|
|
@@ -116,7 +116,7 @@ export default {
|
|
|
116
116
|
const fg = faded.a < 1 ? composite(faded, background) : faded;
|
|
117
117
|
const ratio = contrastRatio(fg, background);
|
|
118
118
|
if (ratio >= required) return null;
|
|
119
|
-
return { what, ratio };
|
|
119
|
+
return { what, ratio, fg, bg: background };
|
|
120
120
|
};
|
|
121
121
|
|
|
122
122
|
const failures = [];
|
|
@@ -151,8 +151,11 @@ export default {
|
|
|
151
151
|
return {
|
|
152
152
|
status: 'fail',
|
|
153
153
|
message: `This field's ${worst.what} has ${showRatio(worst.ratio)}:1 contrast against the field background — below the ${required}:1 minimum.`,
|
|
154
|
-
fix: `Darken the ${worst.what.includes('placeholder') ? 'placeholder colour (::placeholder)' : 'text colour'} until it reaches ${required}:1 against the field background.`,
|
|
155
|
-
|
|
154
|
+
fix: `Darken the ${worst.what.includes('placeholder') ? 'placeholder colour (::placeholder)' : 'text colour'} until it reaches ${required}:1 against the field background (currently ${asRgb(worst.fg)} on ${asRgb(worst.bg)}).`,
|
|
155
|
+
// The judged pair, as the text rule reports it, so the UIs can link
|
|
156
|
+
// out to the checker with it (the link was missing on this rule's
|
|
157
|
+
// findings until 2026-09-17: it carried the ratio and nothing else).
|
|
158
|
+
data: { foreground: asRgb(worst.fg), background: asRgb(worst.bg), ratio: Number(showRatio(worst.ratio)), required },
|
|
156
159
|
};
|
|
157
160
|
},
|
|
158
161
|
};
|
|
@@ -2,8 +2,135 @@
|
|
|
2
2
|
// Same shape as 3.1.1-html-lang.js, and must stay in step with it: subtags
|
|
3
3
|
// may be a single character, because BCP 47 extension and private-use
|
|
4
4
|
// singletons ("-u-", "-t-", "-x-") are exactly one.
|
|
5
|
+
import { paintableRect } from '../../lib/contrast.js';
|
|
6
|
+
|
|
5
7
|
const LANG_PATTERN = /^([a-zA-Z]{2,3}(-[a-zA-Z0-9]{1,8})*|[xXiI](-[a-zA-Z0-9]{1,8})+)$/;
|
|
6
8
|
|
|
9
|
+
/** Attributes whose value is spoken and never shown. */
|
|
10
|
+
const SPOKEN_ATTRIBUTES = ['aria-label', 'aria-description', 'aria-placeholder', 'aria-valuetext', 'aria-roledescription'];
|
|
11
|
+
|
|
12
|
+
/** Attributes whose value is shown, and spoken too. */
|
|
13
|
+
const SHOWN_ATTRIBUTES = ['title', 'placeholder'];
|
|
14
|
+
|
|
15
|
+
/** Input types whose value attribute is words the page wrote. Submit and
|
|
16
|
+
* reset are here without one too: the browser writes their label. */
|
|
17
|
+
const WORDED_INPUT_TYPES = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'button', 'submit', 'reset']);
|
|
18
|
+
|
|
19
|
+
const spoken = (node, name) => Boolean(node.getAttribute(name)?.trim());
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Does this element carry words of its own, outside its text nodes? The
|
|
23
|
+
* accessible name and description count as much as visible text: a
|
|
24
|
+
* placeholder or a title is a phrase, and it is spoken in whatever
|
|
25
|
+
* language the nearest lang says. Inside aria-hidden content (`unspoken`)
|
|
26
|
+
* nothing is spoken, so only the words that are shown count there: an alt
|
|
27
|
+
* or an aria-label under aria-hidden reaches no one.
|
|
28
|
+
*/
|
|
29
|
+
function ownWords(node, unspoken) {
|
|
30
|
+
if (!unspoken) {
|
|
31
|
+
if (SPOKEN_ATTRIBUTES.some((name) => spoken(node, name))) return true;
|
|
32
|
+
if (node.matches('img, area, input[type="image"]') && spoken(node, 'alt')) return true;
|
|
33
|
+
if (node.matches('optgroup, track') && spoken(node, 'label')) return true;
|
|
34
|
+
}
|
|
35
|
+
if (SHOWN_ATTRIBUTES.some((name) => spoken(node, name))) return true;
|
|
36
|
+
if (node.tagName === 'OPTION' && spoken(node, 'label')) return true;
|
|
37
|
+
if (node.tagName === 'INPUT') {
|
|
38
|
+
const type = (node.getAttribute('type') ?? '').trim().toLowerCase();
|
|
39
|
+
if (type === 'submit' || type === 'reset') return true;
|
|
40
|
+
if (WORDED_INPUT_TYPES.has(type) && spoken(node, 'value')) return true;
|
|
41
|
+
}
|
|
42
|
+
// A nested document with no lang of its own takes this one, and what it
|
|
43
|
+
// holds cannot always be read from here, so it counts as words.
|
|
44
|
+
if (node.matches('iframe, frame, object, embed')) return true;
|
|
45
|
+
for (const pseudo of ['::before', '::after']) {
|
|
46
|
+
const content = getComputedStyle(node, pseudo).content;
|
|
47
|
+
if (/"[^"]*\S[^"]*"/.test(content)) return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Can this box be seen? Asked only inside aria-hidden content, where being
|
|
54
|
+
* seen is the one way left for words to reach anyone: a carousel slide that
|
|
55
|
+
* is aria-hidden and clipped out of its strip is neither spoken nor seen,
|
|
56
|
+
* and the tag on it misleads no one. Outside aria-hidden the question is
|
|
57
|
+
* never put, since off-screen text is still spoken. The clip walk keeps the
|
|
58
|
+
* whole box wherever it is unsure, so a doubtful case counts as seen and
|
|
59
|
+
* keeps its failure; so does everything where there is no layout to ask
|
|
60
|
+
* (the editor's static lane), which the root's empty box gives away.
|
|
61
|
+
*/
|
|
62
|
+
function seen(box, element) {
|
|
63
|
+
const view = element.ownerDocument.defaultView;
|
|
64
|
+
if (!element.ownerDocument.documentElement.getBoundingClientRect().width) return true;
|
|
65
|
+
if (!box.width || !box.height) return false;
|
|
66
|
+
if (box.right + view.scrollX <= 0 || box.bottom + view.scrollY <= 0) return false;
|
|
67
|
+
return Boolean(paintableRect(element, box));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Does any text take its language from this element? 3.1.2 asks that "the
|
|
72
|
+
* human language of each passage or phrase in the content can be
|
|
73
|
+
* programmatically determined", so a lang value can only fail it through a
|
|
74
|
+
* passage it governs. A lang governs its subtree down to the next element
|
|
75
|
+
* with a non-empty lang of its own. When every word beneath an invalid tag
|
|
76
|
+
* is re-tagged (<article lang="invalid"><div lang="en">...</div></article>),
|
|
77
|
+
* or there are no words at all (an empty element, a decorative image, text
|
|
78
|
+
* that is display:none), each passage on the page still has a language and
|
|
79
|
+
* the criterion is met. ACT de46e4 draws the same line in its
|
|
80
|
+
* applicability: "some text inheriting its programmatic language from the
|
|
81
|
+
* element which is neither empty nor only whitespace" (issue #6 on the
|
|
82
|
+
* engine repository, reported with the fixtures by Jeff Witt).
|
|
83
|
+
*
|
|
84
|
+
* Words are rendered text nodes and everything ownWords() counts. The walk
|
|
85
|
+
* is over the flat tree: shadow content in place of a host's light
|
|
86
|
+
* children, assigned nodes in place of a slot. display:none ends a branch.
|
|
87
|
+
* visibility:hidden silences that level only, since a descendant can turn
|
|
88
|
+
* itself back on. aria-hidden text still counts where it is seen, and
|
|
89
|
+
* off-screen text where it is spoken; text that is both aria-hidden and out
|
|
90
|
+
* of sight reaches no one and does not. Anything the walk cannot rule on counts as
|
|
91
|
+
* words, so a doubtful case keeps its failure.
|
|
92
|
+
*/
|
|
93
|
+
function governsText(element) {
|
|
94
|
+
// The rule is handed every [lang] element, rendered or not, so nothing
|
|
95
|
+
// beneath an ancestor that is display:none counts: the walk below only
|
|
96
|
+
// looks down.
|
|
97
|
+
for (let node = element.parentElement ?? element.getRootNode()?.host; node; node = node.parentElement ?? node.getRootNode()?.host) {
|
|
98
|
+
if (getComputedStyle(node).display === 'none') return false;
|
|
99
|
+
}
|
|
100
|
+
const stack = [[element, Boolean(element.closest('[aria-hidden="true"]'))]];
|
|
101
|
+
while (stack.length) {
|
|
102
|
+
const [node, above] = stack.pop();
|
|
103
|
+
const unspoken = above || node.getAttribute('aria-hidden') === 'true';
|
|
104
|
+
if (node !== element && spoken(node, 'lang')) continue;
|
|
105
|
+
if (node.matches('script, style, noscript, template')) continue;
|
|
106
|
+
const style = getComputedStyle(node);
|
|
107
|
+
if (style.display === 'none') continue;
|
|
108
|
+
const shown = style.visibility !== 'hidden' && style.visibility !== 'collapse';
|
|
109
|
+
if (shown && ownWords(node, unspoken) && (!unspoken || seen(node.getBoundingClientRect(), node))) return true;
|
|
110
|
+
let children = node.childNodes;
|
|
111
|
+
if (node.tagName === 'SLOT') {
|
|
112
|
+
const assigned = node.assignedNodes?.() ?? [];
|
|
113
|
+
if (assigned.length) children = assigned;
|
|
114
|
+
}
|
|
115
|
+
else if (node.shadowRoot) children = node.shadowRoot.childNodes;
|
|
116
|
+
for (const child of children) {
|
|
117
|
+
if (child.nodeType === 3 /* TEXT_NODE */) {
|
|
118
|
+
if (!shown || !/\S/.test(child.textContent)) continue;
|
|
119
|
+
if (!unspoken) return true;
|
|
120
|
+
// A range's box is the text's own, where the parent's may be a
|
|
121
|
+
// block far wider than its words. Where ranges have no geometry
|
|
122
|
+
// (the static lane) the parent's box is asked, and says "seen".
|
|
123
|
+
const range = node.ownerDocument.createRange();
|
|
124
|
+
range.selectNodeContents(child);
|
|
125
|
+
const box = typeof range.getBoundingClientRect === 'function' ? range.getBoundingClientRect() : node.getBoundingClientRect();
|
|
126
|
+
if (seen(box, node)) return true;
|
|
127
|
+
}
|
|
128
|
+
else if (child.nodeType === 1 /* ELEMENT_NODE */) stack.push([child, unspoken]);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
7
134
|
export default {
|
|
8
135
|
id: 'valid-lang-parts',
|
|
9
136
|
name: 'Part language tags',
|
|
@@ -12,12 +139,28 @@ export default {
|
|
|
12
139
|
help: 'lang attributes on page parts must be valid',
|
|
13
140
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html',
|
|
14
141
|
selector: '[lang]:not(html)',
|
|
142
|
+
// Not the default filter, which drops an element that is aria-hidden or
|
|
143
|
+
// has no box of its own. Neither settles whether words inherit the tag:
|
|
144
|
+
// aria-hidden text is still seen, and the language of seen text is used
|
|
145
|
+
// by more than screen readers (hyphenation, font choice, translation,
|
|
146
|
+
// spelling); visibility:hidden on the element leaves a descendant free to
|
|
147
|
+
// show itself. governsText rules on all of it (ACT de46e4 Failed
|
|
148
|
+
// Example 4 generalised to the element itself).
|
|
149
|
+
visibleOnly: false,
|
|
15
150
|
evaluate(element) {
|
|
16
151
|
const lang = element.getAttribute('lang').trim();
|
|
17
152
|
if (lang === '' || LANG_PATTERN.test(lang)) return { status: 'pass' }; // empty resets to page language
|
|
153
|
+
// An invalid tag no passage inherits leaves every passage with a
|
|
154
|
+
// language. Asked after the syntax test, so only invalid tags pay for
|
|
155
|
+
// the walk.
|
|
156
|
+
if (!governsText(element)) return { status: 'pass' };
|
|
157
|
+
// Where no screen reader will read it, say who else the tag is for.
|
|
158
|
+
const unspoken = element.closest('[aria-hidden="true"]');
|
|
18
159
|
return {
|
|
19
160
|
status: 'fail',
|
|
20
|
-
message:
|
|
161
|
+
message: unspoken
|
|
162
|
+
? `lang="${lang}" is not a valid language tag. aria-hidden keeps this text from screen readers, but it is still seen, and browsers and translation tools use the tag to hyphenate, pick fonts and translate it.`
|
|
163
|
+
: `lang="${lang}" is not a valid language tag, so screen readers may switch to the wrong pronunciation.`,
|
|
21
164
|
fix: 'Use a BCP 47 tag such as lang="fr" or lang="de-AT".',
|
|
22
165
|
};
|
|
23
166
|
},
|
|
@@ -1,30 +1,33 @@
|
|
|
1
1
|
// ARIA 1.2 §8.6, repeated verbatim in the 1.3 draft: "authors MUST only use
|
|
2
2
|
// non-global states and properties on elements with a role supporting the
|
|
3
3
|
// state or property". An unsupported attribute breaks that MUST, so this is a
|
|
4
|
-
// real, provable authoring error and the rule asserts it
|
|
4
|
+
// real, provable authoring error, and since 2026-09-17 the rule asserts it
|
|
5
|
+
// as a violation at minor impact (David: invalid according to the spec is
|
|
6
|
+
// marked as such, low). It reviewed until then, on the reasoning below,
|
|
7
|
+
// which still decides the impact.
|
|
5
8
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// while the same attribute on
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// An unsupported attribute is INERT under Core-AAM: user agents expose the
|
|
10
|
+
// states and properties a role supports, so aria-expanded on a button
|
|
11
|
+
// reaches assistive technology as expanded=true while the same attribute on
|
|
12
|
+
// complementary, heading or list reaches nothing (Chromium measured the
|
|
13
|
+
// same, as a starting point, not the arbiter). It is dropped, not
|
|
14
|
+
// misreported. The role is still exposed, the name is still computed, and
|
|
15
|
+
// nothing is announced incorrectly, which is why the impact is minor and
|
|
16
|
+
// not serious.
|
|
12
17
|
//
|
|
13
|
-
// What
|
|
14
|
-
// the state the author reached for, that state is now
|
|
15
|
-
// reader
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// does not actually cause; staying silent leaves a genuine authoring error
|
|
23
|
-
// unreported. Asking is the honest third option, and it is the one the
|
|
24
|
-
// engine is built around.
|
|
18
|
+
// What makes it more than a slip is the thing underneath: if the element
|
|
19
|
+
// genuinely has the state the author reached for, that state is now
|
|
20
|
+
// invisible to a screen reader, and that is a 4.1.2 failure in the full
|
|
21
|
+
// sense. The DOM sometimes proves it: a native readonly, required, disabled
|
|
22
|
+
// or checked attribute beside the aria- one (a text input given
|
|
23
|
+
// role="region" with readonly and aria-readonly on privatebanking.hsbc.com,
|
|
24
|
+
// 2026-09-17). Those attributes belong to aria-state-unreachable, which
|
|
25
|
+
// asserts them at moderate; this rule leaves them to it so an element gets
|
|
26
|
+
// one finding per attribute, and keeps the stray ones.
|
|
25
27
|
//
|
|
26
28
|
// Note "unsupported" is distinct from "prohibited" (§5.2.5), a separate
|
|
27
|
-
// category about naming that this rule does not cover
|
|
29
|
+
// category about naming that this rule does not cover; aria-label and
|
|
30
|
+
// aria-labelledby misuse have their own rule.
|
|
28
31
|
//
|
|
29
32
|
// Worth knowing when comparing against other tools: aria-expanded WAS
|
|
30
33
|
// inherited into complementary, banner, heading, img and list under ARIA 1.1,
|
|
@@ -38,38 +41,51 @@ import { GLOBAL_ARIA, ROLE_ARIA, KNOWN_ARIA, effectiveRole } from '../../lib/rol
|
|
|
38
41
|
// (aria-label-misuse) — excluded here to avoid double-reporting.
|
|
39
42
|
const HANDLED_ELSEWHERE = new Set(['label', 'labelledby']);
|
|
40
43
|
|
|
44
|
+
// aria- states whose native HTML attribute of the same name proves the
|
|
45
|
+
// element really has the state the unsupported attribute was reaching for.
|
|
46
|
+
// Those are aria-state-unreachable's, which imports this.
|
|
47
|
+
export const NATIVE_STATE = { readonly: 'readonly', required: 'required', disabled: 'disabled', checked: 'checked' };
|
|
48
|
+
|
|
49
|
+
/** The element's role and the non-global aria- attributes (names without
|
|
50
|
+
* the prefix) that role does not support, or null when there are none or
|
|
51
|
+
* the role is not modelled. Shared with aria-state-unreachable. */
|
|
52
|
+
export function unsupportedAria(element) {
|
|
53
|
+
const ariaAttrs = [];
|
|
54
|
+
for (const { name } of attributesOf(element)) {
|
|
55
|
+
if (!name.startsWith('aria-')) continue;
|
|
56
|
+
const attr = name.slice(5);
|
|
57
|
+
// Unknown/misspelled attributes are aria-attr-valid's finding — judging
|
|
58
|
+
// them against a role too would report the same typo twice.
|
|
59
|
+
if (!HANDLED_ELSEWHERE.has(attr) && KNOWN_ARIA.has(attr)) ariaAttrs.push(attr);
|
|
60
|
+
}
|
|
61
|
+
if (!ariaAttrs.length) return null;
|
|
62
|
+
const role = effectiveRole(element);
|
|
63
|
+
const allowed = role && ROLE_ARIA[role];
|
|
64
|
+
if (!allowed) return null; // role not modelled: don't guess
|
|
65
|
+
const names = ariaAttrs.filter((name) => !GLOBAL_ARIA.has(name) && !allowed.includes(name));
|
|
66
|
+
return names.length ? { role, names } : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
41
69
|
export default {
|
|
42
70
|
id: 'aria-allowed-attr',
|
|
43
71
|
name: 'Allowed ARIA attributes',
|
|
44
|
-
impact: '
|
|
72
|
+
impact: 'minor',
|
|
45
73
|
tags: ['wcag2a', 'wcag412'],
|
|
46
74
|
help: 'ARIA attributes must be supported by the element’s role',
|
|
47
75
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
48
76
|
selector: '*',
|
|
49
77
|
visibleOnly: false,
|
|
50
78
|
evaluate(element) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (!name.startsWith('aria-')) continue;
|
|
56
|
-
const attr = name.slice(5);
|
|
57
|
-
// Unknown/misspelled attributes are aria-attr-valid's finding — judging
|
|
58
|
-
// them against a role too would report the same typo twice.
|
|
59
|
-
if (!HANDLED_ELSEWHERE.has(attr) && KNOWN_ARIA.has(attr)) ariaAttrs.push(attr);
|
|
60
|
-
}
|
|
61
|
-
if (!ariaAttrs.length) return { status: 'pass' };
|
|
62
|
-
|
|
63
|
-
const role = effectiveRole(element);
|
|
64
|
-
const allowed = role && ROLE_ARIA[role];
|
|
65
|
-
if (!allowed) return { status: 'pass' }; // role not modelled: don't guess
|
|
66
|
-
|
|
67
|
-
const disallowed = ariaAttrs.filter((name) => !GLOBAL_ARIA.has(name) && !allowed.includes(name));
|
|
79
|
+
const found = unsupportedAria(element);
|
|
80
|
+
if (!found) return { status: 'pass' };
|
|
81
|
+
// A state the DOM proves the element has is aria-state-unreachable's.
|
|
82
|
+
const disallowed = found.names.filter((name) => !(NATIVE_STATE[name] && element.hasAttribute(NATIVE_STATE[name])));
|
|
68
83
|
if (!disallowed.length) return { status: 'pass' };
|
|
84
|
+
const role = found.role;
|
|
69
85
|
const names = disallowed.map((n) => `aria-${n}`);
|
|
70
86
|
return {
|
|
71
|
-
status: '
|
|
72
|
-
message: `${names.join(', ')} is not supported on role "${role}", so
|
|
87
|
+
status: 'fail',
|
|
88
|
+
message: `${names.join(', ')} is not supported on role "${role}" (ARIA 1.2 §8.6), so user agents drop it and assistive technology never sees it. Nothing is announced wrongly, but nothing is announced at all. If the element really has that state, it is invisible to a screen reader; if it does not, the attribute is stray and no one is affected, but ARIA 1.2 still says authors MUST NOT write it.`,
|
|
73
89
|
fix: `Move ${names.join('/')} to the element whose role supports it, usually the control that toggles this one, or remove it if the element has no such state. Adding a role to this element to make the attribute legal is rarely right: the host language restricts which roles each element may take.`,
|
|
74
90
|
};
|
|
75
91
|
},
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
// The proven half of aria-allowed-attr, split out on 2026-09-17 so it can
|
|
3
|
+
// carry its own impact. That rule asserts every non-global ARIA attribute
|
|
4
|
+
// an element's role does not support (ARIA 1.2 §8.6, an author MUST) at
|
|
5
|
+
// minor, because such an attribute is inert: user agents expose only the
|
|
6
|
+
// states a role supports, so nothing false is announced and the name and
|
|
7
|
+
// role survive. Most of the time the attribute is stray and nobody is
|
|
8
|
+
// affected.
|
|
9
|
+
//
|
|
10
|
+
// This rule fires when the DOM proves the state is real: a native readonly,
|
|
11
|
+
// required, disabled or checked attribute sits beside the unsupported aria-
|
|
12
|
+
// one. Then the element genuinely has the state, the host language applies
|
|
13
|
+
// it, and no assistive technology can learn it through the role the author
|
|
14
|
+
// chose. That is what 4.1.2 asks for and does not get, so the impact is
|
|
15
|
+
// moderate: a screen reader user meets a readonly field announced as a
|
|
16
|
+
// region and tries to type into it, or a required field they cannot tell is
|
|
17
|
+
// required. Moderate rather than serious because the control still works
|
|
18
|
+
// and its value is still there; the state is what is lost.
|
|
19
|
+
//
|
|
20
|
+
// The shape it was written for: four calculator output fields on
|
|
21
|
+
// privatebanking.hsbc.com, <input type="text" role="region" readonly
|
|
22
|
+
// aria-readonly="true">. ARIA 1.2 does not give region aria-readonly, and
|
|
23
|
+
// Core-AAM maps the author's role, so the field reaches assistive
|
|
24
|
+
// technology as a landmark. (ARIA in HTML also forbids region on a text
|
|
25
|
+
// input, which is a role finding this rule does not make.) Decided from the
|
|
26
|
+
// spec text; Chromium's tree, which exposes the region with no readonly
|
|
27
|
+
// property, was the starting point and not the arbiter.
|
|
28
|
+
//
|
|
29
|
+
// aria-allowed-attr leaves these attributes to this rule, so an element gets
|
|
30
|
+
// one finding per attribute, not two. An element with a proven state and a
|
|
31
|
+
// second, unproven unsupported attribute gets one of each.
|
|
32
|
+
import { unsupportedAria, NATIVE_STATE } from './4.1.2-aria-allowed-attr.js';
|
|
33
|
+
|
|
34
|
+
export default {
|
|
35
|
+
id: 'aria-state-unreachable',
|
|
36
|
+
name: 'ARIA state the element really has cannot be reached',
|
|
37
|
+
impact: 'moderate',
|
|
38
|
+
tags: ['wcag2a', 'wcag412'],
|
|
39
|
+
help: 'A state the element has must be exposed through a role that supports it',
|
|
40
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
41
|
+
selector: '[aria-readonly], [aria-required], [aria-disabled], [aria-checked]',
|
|
42
|
+
visibleOnly: false,
|
|
43
|
+
evaluate(element) {
|
|
44
|
+
const found = unsupportedAria(element);
|
|
45
|
+
if (!found) return { status: 'pass' };
|
|
46
|
+
const proven = found.names.filter((name) => NATIVE_STATE[name] && element.hasAttribute(NATIVE_STATE[name]));
|
|
47
|
+
if (!proven.length) return { status: 'pass' };
|
|
48
|
+
const states = proven.map((name) => `aria-${name}`);
|
|
49
|
+
const natives = proven.map((name) => NATIVE_STATE[name]);
|
|
50
|
+
return {
|
|
51
|
+
status: 'fail',
|
|
52
|
+
message: `This element really is ${natives.join(' and ')}: its native ${natives.join(' and ')} attribute says so. But ${states.join(' and ')} is not supported on role "${found.role}" (ARIA 1.2 §8.6), so user agents drop it and assistive technology cannot learn the state. The control works, and nobody using a screen reader is told how.`,
|
|
53
|
+
fix: `Give the element a role that supports ${states.join(' and ')}, which for a form control is usually its own native role with no role attribute at all, or move the state to the element that carries the control's role. Do not add a role the host language forbids on this element to make the attribute legal.`,
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
package/package.json
CHANGED