@surea11y/core 1.7.0 → 1.8.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/CHANGELOG.md +94 -1
- package/README.md +157 -54
- package/docs/ACT_RULE_MAPPING.md +2 -2
- package/docs/API_STABILITY.md +18 -5
- package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +97 -3
- package/docs/EARL.md +1 -1
- package/docs/ENGINE_OPTIONS.md +81 -3
- package/docs/I18N.md +62 -20
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +1 -0
- package/docs/OUTPUT_SCHEMA.md +19 -6
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +73 -6
- package/docs/RULE_CATALOG.md +139 -116
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +62 -5
- package/docs/RULE_TAXONOMY.md +2 -2
- package/docs/SARIF.md +2 -1
- package/docs/WCAG_CONFORMANCE.md +56 -3
- package/package.json +34 -11
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-braille-equivalent.js +25 -7
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +17 -4
- package/src/checks/automatic/aria-required-attr.js +29 -0
- package/src/checks/automatic/aria-role-name-present.js +19 -2
- package/src/checks/automatic/aria-valid-attr-value.js +28 -16
- package/src/checks/automatic/autocomplete-valid.js +152 -26
- package/src/checks/automatic/avoid-inline-spacing.js +105 -40
- package/src/checks/automatic/button-name-present.js +2 -1
- package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
- package/src/checks/automatic/combobox-name-present.js +34 -51
- package/src/checks/automatic/contrast-computable.js +35 -4
- package/src/checks/automatic/contrast-enhanced.js +4 -4
- package/src/checks/automatic/contrast-minimum.js +45 -11
- package/src/checks/automatic/css-orientation-lock.js +152 -30
- package/src/checks/automatic/definition-list-children-valid.js +67 -23
- package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
- package/src/checks/automatic/dialog-name-present.js +28 -9
- package/src/checks/automatic/duplicate-id.js +6 -2
- package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
- package/src/checks/automatic/iframe-focusable-content.js +7 -4
- package/src/checks/automatic/iframe-title-unique.js +36 -81
- package/src/checks/automatic/input-image-alt-present.js +32 -20
- package/src/checks/automatic/label-in-name.js +40 -13
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +272 -60
- package/src/checks/automatic/link-name-present.js +13 -5
- package/src/checks/automatic/list-children-valid.js +18 -1
- package/src/checks/automatic/listbox-name-present.js +19 -49
- package/src/checks/automatic/listitem-parent-valid.js +4 -3
- package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
- package/src/checks/automatic/page-title-present.js +16 -4
- package/src/checks/automatic/progressbar-name-present.js +11 -1
- package/src/checks/automatic/role-img-text-alternative-present.js +9 -5
- package/src/checks/automatic/searchbox-name-present.js +32 -49
- package/src/checks/automatic/server-side-image-map-absent.js +48 -28
- package/src/checks/automatic/slider-name-present.js +38 -52
- package/src/checks/automatic/spinbutton-name-present.js +32 -49
- package/src/checks/automatic/target-size-minimum.js +0 -11
- package/src/checks/automatic/td-has-header.js +41 -5
- package/src/checks/automatic/text-spacing-content-loss.js +548 -0
- package/src/checks/automatic/textbox-name-present.js +32 -49
- package/src/checks/automatic/valid-lang.js +15 -10
- package/src/checks/manual/area-alt-quality-manual.js +113 -31
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
- package/src/checks/manual/css-hidden-focus.js +215 -7
- package/src/checks/manual/form-control-label-quality-manual.js +109 -5
- package/src/checks/manual/heading-order-manual.js +9 -1
- package/src/checks/manual/heading-quality-manual.js +143 -9
- package/src/checks/manual/img-alt-decorative-manual.js +6 -3
- package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
- package/src/checks/manual/link-name-quality-manual.js +130 -4
- package/src/checks/manual/media-transcript-present-manual.js +65 -8
- package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
- package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
- package/src/checks/manual/p-as-heading-manual.js +89 -44
- package/src/checks/manual/page-title-patterns-manual.js +77 -8
- package/src/checks/manual/skip-link-manual.js +42 -14
- package/src/checks/manual/table-fake-caption-manual.js +32 -1
- package/src/checks/manual/video-caption-manual.js +47 -24
- package/src/checks/manual-review.js +0 -4
- package/src/core.js +14419 -2231
- package/src/coverage/en301549-map.js +187 -0
- package/src/coverage/standards.js +279 -0
- package/src/coverage/wcag-facets.js +1119 -0
- package/src/coverage/wcag-version-map.js +101 -0
- package/src/en301549.js +33 -0
- package/src/junit.js +321 -0
- package/src/profile-kit.js +163 -0
- package/src/report.js +343 -74
- package/src/sarif.js +34 -3
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +5 -4
- package/surea11y.i18n.de.js +1 -1
- package/surea11y.i18n.es.js +1 -1
- package/surea11y.i18n.fr.js +1 -1
- package/surea11y.i18n.ja.js +3 -0
- package/src/checks/manual/area-alt-decorative-manual.js +0 -255
|
@@ -9,22 +9,26 @@
|
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 1.4.1
|
|
11
11
|
* @applicability
|
|
12
|
-
* Applies to
|
|
13
|
-
* at least one direct-child text node
|
|
14
|
-
* (i.e. the link sits inline within a run of
|
|
15
|
-
* standalone item, e.g. not the sole content of a
|
|
12
|
+
* Applies to links (`<a href>` and elements with `role="link"`) whose
|
|
13
|
+
* immediate parent element also has at least one direct-child text node
|
|
14
|
+
* with non-whitespace content (i.e. the link sits inline within a run of
|
|
15
|
+
* plain text, not as a standalone item, e.g. not the sole content of a
|
|
16
|
+
* <li> nav item).
|
|
16
17
|
* @expectation
|
|
17
18
|
* A link inside a text block must be visually distinguishable from the
|
|
18
19
|
* surrounding text by at least one non-color means:
|
|
19
20
|
* - text-decoration: underline, OR
|
|
20
|
-
* - a different font-weight than the surrounding text, OR
|
|
21
|
-
* -
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
21
|
+
* - a different font-weight or font-style than the surrounding text, OR
|
|
22
|
+
* - another visible mark on the link itself: a border, box-shadow or
|
|
23
|
+
* outline, a background color different from the surrounding one, a
|
|
24
|
+
* background image, an image or svg inside it, or ::before/::after
|
|
25
|
+
* content.
|
|
26
|
+
* A link with none of these is distinguished by color alone. When its
|
|
27
|
+
* color contrasts with the surrounding text by at least 3:1, technique
|
|
28
|
+
* G183 is met only if hover and focus also bring a non-color cue, which
|
|
29
|
+
* a static scan cannot see, so the link is reported as cantTell. Below
|
|
30
|
+
* 3:1, with contrast confidently computable, color is demonstrably the
|
|
31
|
+
* only cue and the link fails.
|
|
28
32
|
* @implementation-notes
|
|
29
33
|
* - "Surrounding text style" is approximated as the link's immediate
|
|
30
34
|
* parent element's own computed style, not a full inline-context walk
|
|
@@ -42,8 +46,11 @@
|
|
|
42
46
|
* computeEffectiveForeground/Background, getComputabilityBlocker,
|
|
43
47
|
* contrastRatio helpers as `contrast-minimum`), rather than re-deriving
|
|
44
48
|
* color math independently.
|
|
45
|
-
* -
|
|
46
|
-
*
|
|
49
|
+
* - ::before/::after content is read from the CSSOM only (a DOM emulator
|
|
50
|
+
* does not compute pseudo-element styles), so content declared in a
|
|
51
|
+
* cross-origin stylesheet is not seen.
|
|
52
|
+
* - Only the resting state is evaluated. The :hover, :focus and :visited
|
|
53
|
+
* states are not.
|
|
47
54
|
*/
|
|
48
55
|
|
|
49
56
|
const id = 'link-in-text-block';
|
|
@@ -52,7 +59,7 @@ const meta = {
|
|
|
52
59
|
title:
|
|
53
60
|
'Links in text blocks must be distinguishable from surrounding text without relying on color alone',
|
|
54
61
|
description:
|
|
55
|
-
'Checks that a link inside a run of text is visually distinguishable from the surrounding text by underline,
|
|
62
|
+
'Checks that a link inside a run of text is visually distinguishable from the surrounding text by a non-color cue (underline, font-weight or style, border, background, icon), and asks about links distinguished only by a >=3:1 color difference, which also need a hover and focus cue.',
|
|
56
63
|
i18n: {
|
|
57
64
|
titleKey: 'linkInTextBlock_title',
|
|
58
65
|
descriptionKey: 'linkInTextBlock_description'
|
|
@@ -194,6 +201,16 @@ function runInPage(ctx) {
|
|
|
194
201
|
return null;
|
|
195
202
|
}
|
|
196
203
|
|
|
204
|
+
// The user agent underlines `a[href]`; an element with role="link" has no
|
|
205
|
+
// default decoration.
|
|
206
|
+
function uaUnderlines(el) {
|
|
207
|
+
return (
|
|
208
|
+
String(el.localName || '').toLowerCase() === 'a' &&
|
|
209
|
+
typeof el.hasAttribute === 'function' &&
|
|
210
|
+
el.hasAttribute('href')
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
197
214
|
function resolveUnderlineFromCssom(el) {
|
|
198
215
|
const doc = el && el.ownerDocument ? el.ownerDocument : null;
|
|
199
216
|
if (!doc || typeof el.matches !== 'function') return { underlined: false, resolved: false };
|
|
@@ -272,7 +289,145 @@ function runInPage(ctx) {
|
|
|
272
289
|
// unreadable, one of them might have, so the answer is unknown; otherwise
|
|
273
290
|
// the UA default stands, and for a link that means underlined.
|
|
274
291
|
if (unreadableSheet || unparsableSelector) return { underlined: false, resolved: false };
|
|
275
|
-
return { underlined:
|
|
292
|
+
return { underlined: uaUnderlines(el), resolved: true };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---- Non-color cues on the link itself ----
|
|
296
|
+
const LINE_STYLES = /^(solid|dashed|dotted|double|groove|ridge|inset|outset|auto)$/;
|
|
297
|
+
|
|
298
|
+
function isZeroWidth(v) {
|
|
299
|
+
return /^0(\.0+)?[a-z%]*$/i.test(String(v || '').trim());
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function hasBorder(cs) {
|
|
303
|
+
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
|
|
304
|
+
const style = String(cs['border' + side + 'Style'] || '')
|
|
305
|
+
.trim()
|
|
306
|
+
.toLowerCase();
|
|
307
|
+
if (!style || style === 'none' || style === 'hidden') continue;
|
|
308
|
+
if (!isZeroWidth(cs['border' + side + 'Width'])) return true;
|
|
309
|
+
}
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Some environments do not expand the `outline` shorthand into its
|
|
314
|
+
// longhands, so it is read too.
|
|
315
|
+
function hasOutline(cs) {
|
|
316
|
+
const style = String(cs.outlineStyle || '')
|
|
317
|
+
.trim()
|
|
318
|
+
.toLowerCase();
|
|
319
|
+
if (style && style !== 'none' && style !== 'hidden') return !isZeroWidth(cs.outlineWidth);
|
|
320
|
+
const tokens = String((cs.getPropertyValue && cs.getPropertyValue('outline')) || '')
|
|
321
|
+
.trim()
|
|
322
|
+
.toLowerCase()
|
|
323
|
+
.split(/\s+/)
|
|
324
|
+
.filter(Boolean);
|
|
325
|
+
return tokens.some((t) => LINE_STYLES.test(t)) && !tokens.some((t) => isZeroWidth(t));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function hasBoxShadow(cs) {
|
|
329
|
+
const v = String(cs.boxShadow || '')
|
|
330
|
+
.trim()
|
|
331
|
+
.toLowerCase();
|
|
332
|
+
return !!v && v !== 'none';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function hasBackgroundImage(cs) {
|
|
336
|
+
const v = String(cs.backgroundImage || '')
|
|
337
|
+
.trim()
|
|
338
|
+
.toLowerCase();
|
|
339
|
+
return !!v && v !== 'none';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function hasVisibleImageChild(el) {
|
|
343
|
+
let imgs;
|
|
344
|
+
try {
|
|
345
|
+
imgs = Array.from(el.querySelectorAll('img, svg, picture, canvas, [role="img"]'));
|
|
346
|
+
} catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
return imgs.some((img) => {
|
|
350
|
+
if (!helpers.isDomVisibleEligible) return true;
|
|
351
|
+
try {
|
|
352
|
+
const vis = helpers.isDomVisibleEligible(img, ctx, {
|
|
353
|
+
visibilityMode: 'styleOnly',
|
|
354
|
+
disableGeometry: true
|
|
355
|
+
});
|
|
356
|
+
return !(vis && vis.eligible === false);
|
|
357
|
+
} catch {
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const EMPTY_CONTENT = ['', 'none', 'normal', '""', "''"];
|
|
364
|
+
let pseudoContentRules = null;
|
|
365
|
+
// Style rules that put content in a ::before/::after box, by the selector
|
|
366
|
+
// of the element that box belongs to.
|
|
367
|
+
function getPseudoContentRules(doc) {
|
|
368
|
+
if (pseudoContentRules) return pseudoContentRules;
|
|
369
|
+
pseudoContentRules = [];
|
|
370
|
+
function consider(cssRule) {
|
|
371
|
+
const style = cssRule.style;
|
|
372
|
+
if (!style || typeof style.getPropertyValue !== 'function') return;
|
|
373
|
+
const content = String(style.getPropertyValue('content') || '').trim();
|
|
374
|
+
if (EMPTY_CONTENT.indexOf(content.toLowerCase()) !== -1) return;
|
|
375
|
+
for (const part of splitSelectorList(cssRule.selectorText)) {
|
|
376
|
+
if (!/::?(before|after)\s*$/i.test(part)) continue;
|
|
377
|
+
if (/:(hover|focus|focus-visible|focus-within|active|target|visited)\b/i.test(part)) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
pseudoContentRules.push(part.replace(/::?(before|after)\s*$/i, '').trim() || '*');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function walk(rules, depth) {
|
|
384
|
+
if (!rules || depth > MAX_NESTED_DEPTH) return;
|
|
385
|
+
for (const cssRule of rules) {
|
|
386
|
+
if (!cssRule) continue;
|
|
387
|
+
if (cssRule.type === CSS_STYLE_RULE && cssRule.selectorText) {
|
|
388
|
+
consider(cssRule);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
let nested;
|
|
392
|
+
try {
|
|
393
|
+
nested = cssRule.cssRules || null;
|
|
394
|
+
} catch {
|
|
395
|
+
nested = null;
|
|
396
|
+
}
|
|
397
|
+
if (nested) walk(nested, depth + 1);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
for (const sheet of (doc && doc.styleSheets) || []) {
|
|
402
|
+
let rules = null;
|
|
403
|
+
try {
|
|
404
|
+
rules = sheet && sheet.cssRules ? sheet.cssRules : null;
|
|
405
|
+
} catch {
|
|
406
|
+
continue; // cross-origin, not inspectable
|
|
407
|
+
}
|
|
408
|
+
if (rules) walk(rules, 0);
|
|
409
|
+
}
|
|
410
|
+
} catch {
|
|
411
|
+
// no readable stylesheets
|
|
412
|
+
}
|
|
413
|
+
return pseudoContentRules;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function hasPseudoContent(el) {
|
|
417
|
+
return getPseudoContentRules(el.ownerDocument).some((base) => {
|
|
418
|
+
try {
|
|
419
|
+
return el.matches(base);
|
|
420
|
+
} catch {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function hasNonColorMark(el, cs) {
|
|
427
|
+
if (cs && (hasBorder(cs) || hasOutline(cs) || hasBoxShadow(cs) || hasBackgroundImage(cs))) {
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
return hasVisibleImageChild(el) || hasPseudoContent(el);
|
|
276
431
|
}
|
|
277
432
|
|
|
278
433
|
function hasSurroundingText(el, parent) {
|
|
@@ -297,13 +452,14 @@ function runInPage(ctx) {
|
|
|
297
452
|
|
|
298
453
|
const c = helpers && helpers.contrast ? helpers.contrast : null;
|
|
299
454
|
|
|
300
|
-
const selector = 'a[href]';
|
|
455
|
+
const selector = 'a[href], [role="link"]';
|
|
301
456
|
const nodes = helpers.queryAllSmart
|
|
302
457
|
? helpers.queryAllSmart(selector)
|
|
303
458
|
: helpers.queryAll(selector);
|
|
304
459
|
|
|
305
460
|
const occurrences = [];
|
|
306
461
|
const undecided = [];
|
|
462
|
+
const contrastOnly = [];
|
|
307
463
|
let applicableCount = 0;
|
|
308
464
|
let decidedCount = 0;
|
|
309
465
|
|
|
@@ -345,6 +501,13 @@ function runInPage(ctx) {
|
|
|
345
501
|
continue;
|
|
346
502
|
}
|
|
347
503
|
|
|
504
|
+
// A border, box-shadow, outline, background image, icon or generated
|
|
505
|
+
// content marks the link without relying on color.
|
|
506
|
+
if (hasNonColorMark(el, linkCs)) {
|
|
507
|
+
decidedCount += 1;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
|
|
348
511
|
if (!c) {
|
|
349
512
|
markUndecided(el, 'CONTRAST_HELPERS_UNAVAILABLE');
|
|
350
513
|
continue;
|
|
@@ -352,6 +515,7 @@ function runInPage(ctx) {
|
|
|
352
515
|
|
|
353
516
|
let flagged = false;
|
|
354
517
|
let computed = false;
|
|
518
|
+
let backgroundDiffers = false;
|
|
355
519
|
let ratio = null;
|
|
356
520
|
let fgLinkHex = '';
|
|
357
521
|
let fgParentHex = '';
|
|
@@ -387,6 +551,21 @@ function runInPage(ctx) {
|
|
|
387
551
|
|
|
388
552
|
computed = true;
|
|
389
553
|
if (!(ratio >= 3)) flagged = true;
|
|
554
|
+
|
|
555
|
+
// A background color of the link's own, different from the one
|
|
556
|
+
// behind the surrounding text, marks it like a highlight.
|
|
557
|
+
const ownBg = String((linkCs && linkCs.backgroundColor) || '').replace(/\s+/g, '');
|
|
558
|
+
const transparentBg =
|
|
559
|
+
!ownBg || ownBg === 'transparent' || /^rgba\(\d+,\d+,\d+,0(\.0+)?\)$/.test(ownBg);
|
|
560
|
+
if (!transparentBg && c.rgbToHex) {
|
|
561
|
+
const parentBg = c.computeEffectiveBackground(parent, {
|
|
562
|
+
contrast: { mode, rootCanvasFallback },
|
|
563
|
+
collectStack: false
|
|
564
|
+
});
|
|
565
|
+
if (parentBg && parentBg.ok && parentBg.rgba) {
|
|
566
|
+
backgroundDiffers = c.rgbToHex(parentBg.rgba) !== c.rgbToHex(bg.rgba);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
390
569
|
}
|
|
391
570
|
// else: not confidently computable, recorded below.
|
|
392
571
|
}
|
|
@@ -400,14 +579,12 @@ function runInPage(ctx) {
|
|
|
400
579
|
continue;
|
|
401
580
|
}
|
|
402
581
|
|
|
403
|
-
|
|
404
|
-
// link clearing 3:1 is distinguishable regardless of decoration.
|
|
405
|
-
if (!flagged) {
|
|
582
|
+
if (backgroundDiffers) {
|
|
406
583
|
decidedCount += 1;
|
|
407
584
|
continue;
|
|
408
585
|
}
|
|
409
586
|
|
|
410
|
-
//
|
|
587
|
+
// An underline is the last remaining non-color cue -- and only now does
|
|
411
588
|
// it matter whether this environment can actually report one.
|
|
412
589
|
const decoration = decorationInfo(linkCs);
|
|
413
590
|
let underlined;
|
|
@@ -427,6 +604,13 @@ function runInPage(ctx) {
|
|
|
427
604
|
continue;
|
|
428
605
|
}
|
|
429
606
|
|
|
607
|
+
// Color is the only cue at rest. At 3:1 or more, G183 also needs a
|
|
608
|
+
// non-color cue on hover and focus, which a static scan cannot see.
|
|
609
|
+
if (!flagged) {
|
|
610
|
+
contrastOnly.push({ el, ratio, fgLinkHex, fgParentHex });
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
|
|
430
614
|
decidedCount += 1;
|
|
431
615
|
|
|
432
616
|
const eligInfo = helpers.getEligibilityInfo
|
|
@@ -439,7 +623,7 @@ function runInPage(ctx) {
|
|
|
439
623
|
helpers.reportOccurrence(el, {
|
|
440
624
|
summary:
|
|
441
625
|
'This link in a block of text relies on color alone to be distinguished from the surrounding text.',
|
|
442
|
-
hint: 'Add an underline
|
|
626
|
+
hint: 'Add an underline or another non-color cue (a font-weight or style difference, a border, an icon). Raising the color contrast with the surrounding text to 3:1 is enough only if hovering and focusing the link also add a non-color cue.',
|
|
443
627
|
i18n: {
|
|
444
628
|
summaryKey: 'linkInTextBlock_summary_fail',
|
|
445
629
|
hintKey: 'linkInTextBlock_hint_fail',
|
|
@@ -461,46 +645,74 @@ function runInPage(ctx) {
|
|
|
461
645
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
462
646
|
}
|
|
463
647
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
648
|
+
const cantTellOccurrences = undecided.map(({ el, reasonCode }) =>
|
|
649
|
+
helpers.reportOccurrence(el, {
|
|
650
|
+
occurrenceOutcome: 'cantTell',
|
|
651
|
+
summary:
|
|
652
|
+
'Whether this link is distinguishable from the surrounding text by non-color means could not be determined.',
|
|
653
|
+
hint: 'Confirm by eye that the link carries an underline, a font-weight or font-style difference or another non-color mark, or at least 3:1 contrast against the surrounding text together with a non-color cue on hover and focus. Running the engine in a real browser rather than a DOM emulator resolves most cases automatically.',
|
|
654
|
+
i18n: {
|
|
655
|
+
summaryKey: 'linkInTextBlock_summary_cantTell',
|
|
656
|
+
hintKey: 'linkInTextBlock_hint_cantTell'
|
|
657
|
+
},
|
|
658
|
+
uncertainty: {
|
|
659
|
+
code: 'not-computable',
|
|
660
|
+
needed:
|
|
661
|
+
'Whether the link carries an underline, weight or style difference, or 3:1 contrast against its surrounding text.',
|
|
662
|
+
evidence: { reasonCode }
|
|
663
|
+
},
|
|
664
|
+
data: {
|
|
665
|
+
visibilityFilter: helpers.getEligibilityInfo
|
|
666
|
+
? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
|
|
667
|
+
: { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
668
|
+
details: { reasonCode }
|
|
669
|
+
}
|
|
670
|
+
})
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
const contrastOnlyOccurrences = contrastOnly.map(({ el, ratio, fgLinkHex, fgParentHex }) => {
|
|
674
|
+
const ratioStr = c && c.round2 ? c.round2(ratio) : String(ratio);
|
|
675
|
+
return helpers.reportOccurrence(el, {
|
|
676
|
+
occurrenceOutcome: 'cantTell',
|
|
677
|
+
summary: `This link in a block of text is distinguished from the surrounding text only by its color (contrast ${ratioStr}:1). That is enough only if hovering and focusing it also show a non-color cue, such as an underline.`,
|
|
678
|
+
hint: 'Hover over the link and move keyboard focus to it: confirm that each state adds a non-color cue (an underline, a border, a weight change). Otherwise underline the link at rest.',
|
|
679
|
+
i18n: {
|
|
680
|
+
summaryKey: 'linkInTextBlock_summary_cantTell_contrastOnly',
|
|
681
|
+
hintKey: 'linkInTextBlock_hint_cantTell_contrastOnly',
|
|
682
|
+
params: { ratio: String(ratioStr), threshold: '3' }
|
|
683
|
+
},
|
|
684
|
+
uncertainty: {
|
|
685
|
+
code: 'runtime-dependent',
|
|
686
|
+
needed: 'Whether hovering and focusing the link add a non-color cue.',
|
|
687
|
+
evidence: { reasonCode: 'LINK_COLOR_CONTRAST_ONLY', ratio }
|
|
688
|
+
},
|
|
689
|
+
data: {
|
|
690
|
+
visibilityFilter: helpers.getEligibilityInfo
|
|
691
|
+
? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
|
|
692
|
+
: { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
693
|
+
details: {
|
|
694
|
+
reasonCode: 'LINK_COLOR_CONTRAST_ONLY',
|
|
695
|
+
metrics: { ratio, threshold: 3 },
|
|
696
|
+
colors: { linkForegroundHex: fgLinkHex, surroundingTextForegroundHex: fgParentHex }
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// See helpers.resolveTieredOutcome (src/core/dom-helpers.js): a proven
|
|
703
|
+
// violation outranks an undecided candidate for the rule's own outcome, but
|
|
704
|
+
// never discards it, so an unevaluable link survives a failure elsewhere in
|
|
705
|
+
// the same run.
|
|
706
|
+
if (occurrences.length || cantTellOccurrences.length || contrastOnlyOccurrences.length) {
|
|
707
|
+
const resolved = helpers.resolveTieredOutcome(
|
|
708
|
+
occurrences,
|
|
709
|
+
contrastOnlyOccurrences.concat(cantTellOccurrences),
|
|
710
|
+
rule.defaultSeverity || 'serious'
|
|
711
|
+
);
|
|
476
712
|
return {
|
|
477
713
|
ruleId: rule.ruleId,
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
confidence: 'low',
|
|
481
|
-
occurrences: undecided.map(({ el, reasonCode }) =>
|
|
482
|
-
helpers.reportOccurrence(el, {
|
|
483
|
-
summary:
|
|
484
|
-
'Whether this link is distinguishable from the surrounding text by non-color means could not be determined.',
|
|
485
|
-
hint: 'Confirm by eye that the link carries an underline, a font-weight or font-style difference, or at least 3:1 contrast against the surrounding text. Running the engine in a real browser rather than a DOM emulator resolves most cases automatically.',
|
|
486
|
-
i18n: {
|
|
487
|
-
summaryKey: 'linkInTextBlock_summary_cantTell',
|
|
488
|
-
hintKey: 'linkInTextBlock_hint_cantTell'
|
|
489
|
-
},
|
|
490
|
-
uncertainty: {
|
|
491
|
-
code: 'not-computable',
|
|
492
|
-
needed:
|
|
493
|
-
'Whether the link carries an underline, weight or style difference, or 3:1 contrast against its surrounding text.',
|
|
494
|
-
evidence: { reasonCode }
|
|
495
|
-
},
|
|
496
|
-
data: {
|
|
497
|
-
visibilityFilter: helpers.getEligibilityInfo
|
|
498
|
-
? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
|
|
499
|
-
: { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
500
|
-
details: { reasonCode }
|
|
501
|
-
}
|
|
502
|
-
})
|
|
503
|
-
)
|
|
714
|
+
...resolved,
|
|
715
|
+
...(resolved.outcome === 'cantTell' ? { confidence: 'low' } : null)
|
|
504
716
|
};
|
|
505
717
|
}
|
|
506
718
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @atomic true
|
|
8
8
|
* @summary Links must have an accessible name
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
|
-
* @sc 4.1.2
|
|
10
|
+
* @sc 2.4.4, 4.1.2
|
|
11
11
|
* @applicability
|
|
12
12
|
* Applies to <a href>, <area href> and elements with role="link" that are
|
|
13
13
|
* included in the accessibility tree. An <a> without an href is not a link
|
|
@@ -33,9 +33,18 @@ const meta = {
|
|
|
33
33
|
descriptionKey: 'linkNamePresent_description'
|
|
34
34
|
},
|
|
35
35
|
helpUrl: null,
|
|
36
|
-
tags: ['wcag2a', 'wcag412', 'navigation', 'atomic', 'automatic', 'links', 'name'],
|
|
37
|
-
wcagSc: ['4.1.2'],
|
|
36
|
+
tags: ['wcag2a', 'wcag244', 'wcag412', 'navigation', 'atomic', 'automatic', 'links', 'name'],
|
|
37
|
+
wcagSc: ['2.4.4', '4.1.2'],
|
|
38
|
+
// 2.4.4 as well as 4.1.2, as ACT rule c487ae maps it: a link with no name
|
|
39
|
+
// has no purpose to determine, in context or otherwise.
|
|
38
40
|
normativeMappings: [
|
|
41
|
+
{
|
|
42
|
+
standard: 'WCAG',
|
|
43
|
+
version: '2.2',
|
|
44
|
+
requirement: '2.4.4',
|
|
45
|
+
title: 'Link Purpose (In Context)',
|
|
46
|
+
conformanceLevel: 'A'
|
|
47
|
+
},
|
|
39
48
|
{
|
|
40
49
|
standard: 'WCAG',
|
|
41
50
|
version: '2.2',
|
|
@@ -48,8 +57,7 @@ const meta = {
|
|
|
48
57
|
category: 'robust',
|
|
49
58
|
type: 'automatic',
|
|
50
59
|
defaultConfidence: 'high',
|
|
51
|
-
|
|
52
|
-
coverage: { facetsBySc: { '4.1.2': ['link-name-present'] } }
|
|
60
|
+
coverage: { facetsBySc: { '2.4.4': ['link-name-present'], '4.1.2': ['link-name-present'] } }
|
|
53
61
|
};
|
|
54
62
|
|
|
55
63
|
function runInPage(ctx) {
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
* @sc 1.3.1
|
|
11
11
|
* @applicability
|
|
12
12
|
* Applies to <ul>/<ol> elements that have at least one direct element
|
|
13
|
-
* child
|
|
13
|
+
* child and whose role is list: no role attribute, role="list", or a role
|
|
14
|
+
* attribute naming no concrete ARIA role. A <ul>/<ol> given another role
|
|
15
|
+
* (listbox, menubar, tablist, none, ...) is not a list, so its children
|
|
16
|
+
* follow that role's rules instead.
|
|
14
17
|
* @expectation
|
|
15
18
|
* Every direct element child is <li>, <script>, or <template>. UNLESS it
|
|
16
19
|
* has an explicit `role` attribute, in which case the explicit role wins
|
|
@@ -96,9 +99,23 @@ function runInPage(ctx) {
|
|
|
96
99
|
const occurrences = [];
|
|
97
100
|
let applicableCount = 0;
|
|
98
101
|
|
|
102
|
+
// The first role token that names a concrete ARIA role, or '' when none
|
|
103
|
+
// does (the element keeps its native list role).
|
|
104
|
+
const aria = helpers && helpers.aria;
|
|
105
|
+
function resolvedExplicitRole(el) {
|
|
106
|
+
const tokens = String((el.getAttribute && el.getAttribute('role')) || '')
|
|
107
|
+
.toLowerCase()
|
|
108
|
+
.split(/\s+/)
|
|
109
|
+
.filter(Boolean);
|
|
110
|
+
if (!aria || typeof aria.isValidConcreteRole !== 'function') return tokens[0] || '';
|
|
111
|
+
return tokens.find((t) => aria.isValidConcreteRole(t)) || '';
|
|
112
|
+
}
|
|
113
|
+
|
|
99
114
|
for (const el of nodes) {
|
|
100
115
|
if (!el || !el.children) continue;
|
|
101
116
|
if (!el.children.length) continue;
|
|
117
|
+
const listRole = resolvedExplicitRole(el);
|
|
118
|
+
if (listRole && listRole !== 'list') continue;
|
|
102
119
|
|
|
103
120
|
applicableCount += 1;
|
|
104
121
|
|
|
@@ -149,21 +149,6 @@ function runInPage(ctx) {
|
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
function buildLabelForMap(doc) {
|
|
153
|
-
const map = new Map(); // id -> label element (first)
|
|
154
|
-
try {
|
|
155
|
-
const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
|
|
156
|
-
for (let i = 0; i < labels.length; i += 1) {
|
|
157
|
-
const lab = labels[i];
|
|
158
|
-
if (!lab || !lab.getAttribute) continue;
|
|
159
|
-
const f = normalizeWs(lab.getAttribute('for'));
|
|
160
|
-
if (!f) continue;
|
|
161
|
-
if (!map.has(f)) map.set(f, lab);
|
|
162
|
-
}
|
|
163
|
-
} catch {}
|
|
164
|
-
return map;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
152
|
const occurrences = [];
|
|
168
153
|
let applicableCount = 0;
|
|
169
154
|
|
|
@@ -172,42 +157,27 @@ function runInPage(ctx) {
|
|
|
172
157
|
? helpers.queryAllSmart(selector)
|
|
173
158
|
: helpers.queryAll(selector);
|
|
174
159
|
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
160
|
+
// Delegates to the shared, spec-guarded lookup (dom-helpers.js's
|
|
161
|
+
// getAssociatedLabelElements): a <label> -- wrapping or via `for` --
|
|
162
|
+
// only ever associates with a genuinely labelable element, so this
|
|
163
|
+
// correctly returns nothing for role-only widgets a <label> merely sits
|
|
164
|
+
// next to, and the real thing for a labelable element like
|
|
165
|
+
// <select multiple role="listbox">.
|
|
179
166
|
function getNativeLabelText(el) {
|
|
167
|
+
if (!helpers || typeof helpers.getAssociatedLabelElements !== 'function') return '';
|
|
168
|
+
let labels;
|
|
180
169
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
} catch {}
|
|
193
|
-
try {
|
|
194
|
-
if (el.closest) {
|
|
195
|
-
const wrap = el.closest('label');
|
|
196
|
-
if (wrap) {
|
|
197
|
-
const t = getLabelText(wrap);
|
|
198
|
-
if (t) return t;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
} catch {}
|
|
202
|
-
try {
|
|
203
|
-
const idAttr = getAttr(el, 'id');
|
|
204
|
-
if (idAttr && labelForMap.has(idAttr)) {
|
|
205
|
-
const lab = labelForMap.get(idAttr);
|
|
206
|
-
const t = lab ? getLabelText(lab) : '';
|
|
207
|
-
if (t) return t;
|
|
208
|
-
}
|
|
209
|
-
} catch {}
|
|
210
|
-
return '';
|
|
170
|
+
labels = helpers.getAssociatedLabelElements(el) || [];
|
|
171
|
+
} catch {
|
|
172
|
+
labels = [];
|
|
173
|
+
}
|
|
174
|
+
const parts = [];
|
|
175
|
+
const max = Math.min(4, labels.length);
|
|
176
|
+
for (let i = 0; i < max; i += 1) {
|
|
177
|
+
const t = getLabelText(labels[i]);
|
|
178
|
+
if (t) parts.push(t);
|
|
179
|
+
}
|
|
180
|
+
return normalizeWs(parts.join(' '));
|
|
211
181
|
}
|
|
212
182
|
|
|
213
183
|
function hasName(el) {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* @applicability
|
|
12
12
|
* Applies to <li> elements that have a parent element.
|
|
13
13
|
* @expectation
|
|
14
|
-
* The parent is <ul>/<ol> with no role override
|
|
14
|
+
* The parent is <ul>/<ol>/<menu> with no role override (all three have
|
|
15
|
+
* the implicit role list), or an element with an
|
|
15
16
|
* explicit role of "list", "presentation", or "none". An <li> used
|
|
16
17
|
* outside a real list container (e.g. as a generic flex/grid item under
|
|
17
18
|
* a <div>) is not exposed as a list item to assistive technologies.
|
|
@@ -47,7 +48,7 @@ const id = 'listitem-parent-valid';
|
|
|
47
48
|
const meta = {
|
|
48
49
|
title: 'List items must be inside a list container',
|
|
49
50
|
description:
|
|
50
|
-
'Checks that <li> elements are contained by <ul>, <ol>, or an element with role="list".',
|
|
51
|
+
'Checks that <li> elements are contained by <ul>, <ol>, <menu>, or an element with role="list".',
|
|
51
52
|
i18n: {
|
|
52
53
|
titleKey: 'listitemParentValid_title',
|
|
53
54
|
descriptionKey: 'listitemParentValid_description'
|
|
@@ -111,7 +112,7 @@ function runInPage(ctx) {
|
|
|
111
112
|
// direction, see the header comment.
|
|
112
113
|
valid = explicitRole === 'list' || explicitRole === 'presentation' || explicitRole === 'none';
|
|
113
114
|
} else {
|
|
114
|
-
valid = parentTag === 'ul' || parentTag === 'ol';
|
|
115
|
+
valid = parentTag === 'ul' || parentTag === 'ol' || parentTag === 'menu';
|
|
115
116
|
}
|
|
116
117
|
|
|
117
118
|
if (valid) continue;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @check meta-refresh-no-exceptions
|
|
7
7
|
* @atomic true
|
|
8
|
-
* @summary At the AAA level, a meta refresh must not
|
|
8
|
+
* @summary At the AAA level, a meta refresh must not use a positive delay, however long
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 2.2.4, 3.2.5
|
|
11
11
|
* @applicability
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
const id = 'meta-refresh-no-exceptions';
|
|
38
38
|
|
|
39
39
|
const meta = {
|
|
40
|
-
title: 'Page must not use a meta refresh
|
|
40
|
+
title: 'Page must not use a timed meta refresh (AAA)',
|
|
41
41
|
description:
|
|
42
|
-
'Checks that <meta http-equiv="refresh">
|
|
42
|
+
'Checks that <meta http-equiv="refresh"> does not set a positive delay, however long; an immediate redirect (delay 0) passes. This is the stricter AAA-level counterpart of the A-level check, which exempts delays over 20 hours.',
|
|
43
43
|
i18n: {
|
|
44
44
|
titleKey: 'metaRefreshNoExceptions_title',
|
|
45
45
|
descriptionKey: 'metaRefreshNoExceptions_description'
|