@surea11y/core 1.6.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 +140 -0
- package/README.md +179 -90
- package/docs/ACT_RULE_MAPPING.md +10 -8
- package/docs/API_STABILITY.md +67 -6
- package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +162 -2
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +109 -5
- package/docs/I18N.md +62 -20
- package/docs/INTEGRATION.md +4 -2
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +4 -1
- package/docs/OUTPUT_SCHEMA.md +62 -11
- package/docs/POLICY.md +1 -1
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +83 -17
- package/docs/RULE_CATALOG.md +212 -139
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +390 -0
- package/docs/RULE_TAXONOMY.md +27 -6
- package/docs/SARIF.md +23 -3
- package/docs/WCAG_CONFORMANCE.md +64 -3
- package/package.json +41 -12
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-allowed-attr.js +6 -0
- package/src/checks/automatic/aria-allowed-role.js +32 -23
- package/src/checks/automatic/aria-braille-equivalent.js +43 -17
- package/src/checks/automatic/aria-conditional-attr.js +17 -10
- package/src/checks/automatic/aria-deprecated-role.js +12 -0
- package/src/checks/automatic/aria-hidden-body.js +1 -1
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +22 -4
- package/src/checks/automatic/aria-prohibited-children.js +6 -6
- package/src/checks/automatic/aria-required-attr.js +88 -12
- package/src/checks/automatic/aria-required-children.js +33 -16
- package/src/checks/automatic/aria-required-parent.js +32 -6
- package/src/checks/automatic/aria-role-name-present.js +20 -3
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +89 -24
- package/src/checks/automatic/aria-valid-attr.js +14 -9
- package/src/checks/automatic/autocomplete-valid.js +152 -26
- package/src/checks/automatic/avoid-inline-spacing.js +207 -15
- 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 +45 -4
- package/src/checks/automatic/contrast-enhanced.js +16 -4
- package/src/checks/automatic/contrast-minimum.js +57 -11
- package/src/checks/automatic/css-orientation-lock.js +171 -12
- 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-aria.js +5 -0
- package/src/checks/automatic/duplicate-id.js +19 -10
- package/src/checks/automatic/form-control-single-label.js +9 -0
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +12 -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 +78 -69
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +512 -44
- 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-alt-present.js → 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 +84 -16
- package/src/checks/automatic/td-has-header.js +60 -23
- 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 +243 -29
- 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/landmark-complementary-is-top-level-manual.js +231 -0
- 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/password-paste-enabled-manual.js +255 -0
- 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 +18061 -46194
- 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/earl.js +144 -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 +56 -5
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +11 -41039
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- package/surea11y.i18n.ja.js +3 -0
- package/src/checks/manual/area-alt-decorative-manual.js +0 -255
|
@@ -9,41 +9,48 @@
|
|
|
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
|
|
31
35
|
* of the actual adjacent text node(s), a deliberate scope-down, since
|
|
32
36
|
* plain text nodes inherit their rendering from the parent in the
|
|
33
37
|
* overwhelming majority of real markup.
|
|
34
|
-
* -
|
|
35
|
-
*
|
|
36
|
-
* `contrast-
|
|
37
|
-
*
|
|
38
|
-
* reserved for deterministic
|
|
39
|
-
*
|
|
40
|
-
*
|
|
38
|
+
* - A candidate that cannot be evaluated reports cantTell, not pass:
|
|
39
|
+
* contrast not confidently computable (the blockers `contrast-minimum`/
|
|
40
|
+
* `contrast-computable` use), or `text-decoration` unreadable in both
|
|
41
|
+
* the computed style and the CSSOM (see `decorationInfo`). `fail` stays
|
|
42
|
+
* reserved for deterministic violations; this is the computability gate
|
|
43
|
+
* RULE_TAXONOMY.md §1.1 allows automatic rules, as in `contrast-minimum`
|
|
44
|
+
* and `target-size-minimum`.
|
|
41
45
|
* - Reuses the shared `helpers.contrast` subsystem (same
|
|
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'
|
|
@@ -103,14 +110,324 @@ function runInPage(ctx) {
|
|
|
103
110
|
return null;
|
|
104
111
|
}
|
|
105
112
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
113
|
+
// Whether the element is underlined, and whether the computed style can be
|
|
114
|
+
// trusted to say so. A conforming CSSOM serialises the `text-decoration`
|
|
115
|
+
// shorthand with the line value first, so it and `text-decoration-line`
|
|
116
|
+
// always agree on whether `underline` is present. jsdom does not cascade
|
|
117
|
+
// the property at all: the shorthand reads back as the UA's "underline"
|
|
118
|
+
// for every <a> whatever the author CSS says, and the longhand as "none"
|
|
119
|
+
// unless the author used the longhand. Either one taken alone is wrong in
|
|
120
|
+
// one direction, so disagreement is the signal to stop trusting both.
|
|
121
|
+
function decorationInfo(cs) {
|
|
122
|
+
if (!cs) return { underlined: false, trustworthy: false };
|
|
123
|
+
|
|
124
|
+
const lineRaw = String(cs.textDecorationLine || '')
|
|
125
|
+
.trim()
|
|
126
|
+
.toLowerCase();
|
|
127
|
+
const shortRaw = String(cs.textDecoration || '')
|
|
128
|
+
.trim()
|
|
129
|
+
.toLowerCase();
|
|
130
|
+
|
|
131
|
+
const lineTokens = lineRaw.split(/\s+/).filter(Boolean);
|
|
132
|
+
const shortTokens = shortRaw.split(/\s+/).filter(Boolean);
|
|
133
|
+
|
|
134
|
+
// Only one of the two exposed: nothing to cross-check against, take it.
|
|
135
|
+
if (!lineTokens.length) {
|
|
136
|
+
return { underlined: shortTokens.includes('underline'), trustworthy: shortTokens.length > 0 };
|
|
137
|
+
}
|
|
138
|
+
if (!shortTokens.length) {
|
|
139
|
+
return { underlined: lineTokens.includes('underline'), trustworthy: true };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const byLine = lineTokens.includes('underline');
|
|
143
|
+
const byShort = shortTokens.includes('underline');
|
|
144
|
+
return { underlined: byLine, trustworthy: byLine === byShort };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Resolves `text-decoration` from the author stylesheets when the computed
|
|
148
|
+
// style is untrustworthy, reading the CSSOM as `css-orientation-lock` and
|
|
149
|
+
// `css-focus-indicator-suppressed` do. Without it the rule could not decide
|
|
150
|
+
// anything under a DOM emulator, which is how the CLI scans static HTML.
|
|
151
|
+
//
|
|
152
|
+
// A narrow cascade is enough: `text-decoration-line` is not inherited, so
|
|
153
|
+
// only declarations matching the element itself and its inline style apply,
|
|
154
|
+
// ordered by specificity. With no author declaration the UA default stands,
|
|
155
|
+
// and for a link that is an underline. Anything that would make the answer a
|
|
156
|
+
// guess yields `resolved: false` and the caller reports cantTell.
|
|
157
|
+
const CSS_STYLE_RULE = 1;
|
|
158
|
+
const MAX_NESTED_DEPTH = 8;
|
|
159
|
+
|
|
160
|
+
function splitSelectorList(selectorText) {
|
|
161
|
+
const parts = [];
|
|
162
|
+
let depth = 0;
|
|
163
|
+
let current = '';
|
|
164
|
+
for (const ch of String(selectorText || '')) {
|
|
165
|
+
if (ch === '(') depth += 1;
|
|
166
|
+
if (ch === ')') depth = Math.max(0, depth - 1);
|
|
167
|
+
if (ch === ',' && depth === 0) {
|
|
168
|
+
parts.push(current);
|
|
169
|
+
current = '';
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
current += ch;
|
|
173
|
+
}
|
|
174
|
+
parts.push(current);
|
|
175
|
+
return parts.map((p) => p.trim()).filter(Boolean);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Approximate CSS specificity as a single sortable integer. Exactness is
|
|
179
|
+
// not required: this only orders declarations of one property against each
|
|
180
|
+
// other, and near-ties are broken by document order as the cascade does.
|
|
181
|
+
function specificityOf(selector) {
|
|
182
|
+
const s = String(selector || '');
|
|
183
|
+
const ids = (s.match(/#[\w-]+/g) || []).length;
|
|
184
|
+
const classesEtc = (s.match(/\.[\w-]+|\[[^\]]*\]|:(?!:)[\w-]+/g) || []).length;
|
|
185
|
+
const types = (s.match(/(^|[\s>+~])[a-z][\w-]*/gi) || []).length;
|
|
186
|
+
return ids * 10000 + classesEtc * 100 + types;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A declaration wins if it is the last one, in (specificity, order), whose
|
|
190
|
+
// selector matches. `!important` outranks everything non-important.
|
|
191
|
+
function underlineFromDeclaration(style) {
|
|
192
|
+
if (!style || typeof style.getPropertyValue !== 'function') return null;
|
|
193
|
+
for (const prop of ['text-decoration-line', 'text-decoration']) {
|
|
194
|
+
const raw = String(style.getPropertyValue(prop) || '')
|
|
195
|
+
.trim()
|
|
196
|
+
.toLowerCase();
|
|
197
|
+
if (!raw) continue;
|
|
198
|
+
const important = String(style.getPropertyPriority(prop) || '') === 'important';
|
|
199
|
+
return { underlined: /\bunderline\b/.test(raw), important };
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
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
|
+
|
|
214
|
+
function resolveUnderlineFromCssom(el) {
|
|
215
|
+
const doc = el && el.ownerDocument ? el.ownerDocument : null;
|
|
216
|
+
if (!doc || typeof el.matches !== 'function') return { underlined: false, resolved: false };
|
|
217
|
+
|
|
218
|
+
let best = null; // { rank, order, underlined }
|
|
219
|
+
let order = 0;
|
|
220
|
+
let unreadableSheet = false;
|
|
221
|
+
let unparsableSelector = false;
|
|
222
|
+
|
|
223
|
+
function consider(cssRule) {
|
|
224
|
+
const decl = underlineFromDeclaration(cssRule.style);
|
|
225
|
+
if (!decl) return;
|
|
226
|
+
for (const part of splitSelectorList(cssRule.selectorText)) {
|
|
227
|
+
// A pseudo-element rule paints a box other than the link's own text.
|
|
228
|
+
if (/::[a-z-]+/i.test(part)) continue;
|
|
229
|
+
// A state the static DOM is not in (:hover/:focus/...) does not
|
|
230
|
+
// describe the link's resting appearance, which is what this rule is
|
|
231
|
+
// about.
|
|
232
|
+
if (/:(hover|focus|focus-visible|focus-within|active|target|visited)\b/i.test(part)) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
let matched;
|
|
236
|
+
try {
|
|
237
|
+
matched = el.matches(part);
|
|
238
|
+
} catch {
|
|
239
|
+
unparsableSelector = true;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (!matched) continue;
|
|
243
|
+
order += 1;
|
|
244
|
+
const rank = (decl.important ? 1e9 : 0) + specificityOf(part);
|
|
245
|
+
if (!best || rank >= best.rank) best = { rank, order, underlined: decl.underlined };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function walk(rules, depth) {
|
|
250
|
+
if (!rules || depth > MAX_NESTED_DEPTH) return;
|
|
251
|
+
for (const cssRule of rules) {
|
|
252
|
+
if (!cssRule) continue;
|
|
253
|
+
if (cssRule.type === CSS_STYLE_RULE && cssRule.selectorText) {
|
|
254
|
+
consider(cssRule);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
let nested;
|
|
258
|
+
try {
|
|
259
|
+
nested = cssRule.cssRules || null;
|
|
260
|
+
} catch {
|
|
261
|
+
nested = null;
|
|
262
|
+
}
|
|
263
|
+
if (nested) walk(nested, depth + 1);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
for (const sheet of doc.styleSheets || []) {
|
|
269
|
+
let rules = null;
|
|
270
|
+
try {
|
|
271
|
+
rules = sheet && sheet.cssRules ? sheet.cssRules : null;
|
|
272
|
+
} catch {
|
|
273
|
+
unreadableSheet = true; // cross-origin, not inspectable
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (rules) walk(rules, 0);
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
return { underlined: false, resolved: false };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// The inline style attribute outranks every stylesheet declaration.
|
|
283
|
+
const inline = underlineFromDeclaration(el.style);
|
|
284
|
+
if (inline) return { underlined: inline.underlined, resolved: true };
|
|
285
|
+
|
|
286
|
+
if (best) return { underlined: best.underlined, resolved: true };
|
|
287
|
+
|
|
288
|
+
// No author declaration reached this element. If a sheet or selector was
|
|
289
|
+
// unreadable, one of them might have, so the answer is unknown; otherwise
|
|
290
|
+
// the UA default stands, and for a link that means underlined.
|
|
291
|
+
if (unreadableSheet || unparsableSelector) return { underlined: false, resolved: false };
|
|
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;
|
|
110
414
|
}
|
|
111
415
|
|
|
112
|
-
function
|
|
113
|
-
return
|
|
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);
|
|
114
431
|
}
|
|
115
432
|
|
|
116
433
|
function hasSurroundingText(el, parent) {
|
|
@@ -135,13 +452,22 @@ function runInPage(ctx) {
|
|
|
135
452
|
|
|
136
453
|
const c = helpers && helpers.contrast ? helpers.contrast : null;
|
|
137
454
|
|
|
138
|
-
const selector = 'a[href]';
|
|
455
|
+
const selector = 'a[href], [role="link"]';
|
|
139
456
|
const nodes = helpers.queryAllSmart
|
|
140
457
|
? helpers.queryAllSmart(selector)
|
|
141
458
|
: helpers.queryAll(selector);
|
|
142
459
|
|
|
143
460
|
const occurrences = [];
|
|
461
|
+
const undecided = [];
|
|
462
|
+
const contrastOnly = [];
|
|
144
463
|
let applicableCount = 0;
|
|
464
|
+
let decidedCount = 0;
|
|
465
|
+
|
|
466
|
+
// Applicable, but not evaluable. Held separately so the outcome below can
|
|
467
|
+
// tell "checked and sound" apart from "never decided".
|
|
468
|
+
function markUndecided(el, reasonCode) {
|
|
469
|
+
undecided.push({ el, reasonCode });
|
|
470
|
+
}
|
|
145
471
|
|
|
146
472
|
for (const el of nodes) {
|
|
147
473
|
if (!el || !el.getAttribute) continue;
|
|
@@ -159,27 +485,48 @@ function runInPage(ctx) {
|
|
|
159
485
|
const linkCs = safeComputedStyle(el);
|
|
160
486
|
const parentCs = safeComputedStyle(parent);
|
|
161
487
|
|
|
162
|
-
|
|
163
|
-
|
|
488
|
+
// Cues that do not depend on `text-decoration` come first, so a link
|
|
489
|
+
// carrying one is decided even where decoration is unreadable.
|
|
164
490
|
const linkWeight = c && linkCs ? c.normalizeFontWeight(linkCs.fontWeight) : 400;
|
|
165
491
|
const parentWeight = c && parentCs ? c.normalizeFontWeight(parentCs.fontWeight) : 400;
|
|
166
|
-
if (linkWeight !== parentWeight)
|
|
492
|
+
if (linkWeight !== parentWeight) {
|
|
493
|
+
decidedCount += 1;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
167
496
|
|
|
168
497
|
const linkStyle = (linkCs && linkCs.fontStyle) || 'normal';
|
|
169
498
|
const parentStyle = (parentCs && parentCs.fontStyle) || 'normal';
|
|
170
|
-
if (linkStyle !== parentStyle)
|
|
499
|
+
if (linkStyle !== parentStyle) {
|
|
500
|
+
decidedCount += 1;
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
171
503
|
|
|
172
|
-
|
|
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
|
+
|
|
511
|
+
if (!c) {
|
|
512
|
+
markUndecided(el, 'CONTRAST_HELPERS_UNAVAILABLE');
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
173
515
|
|
|
174
516
|
let flagged = false;
|
|
517
|
+
let computed = false;
|
|
518
|
+
let backgroundDiffers = false;
|
|
175
519
|
let ratio = null;
|
|
176
520
|
let fgLinkHex = '';
|
|
177
521
|
let fgParentHex = '';
|
|
522
|
+
let undecidedReason = 'COLOR_NOT_COMPUTABLE';
|
|
178
523
|
|
|
179
524
|
try {
|
|
180
525
|
const blocker = c.getComputabilityBlocker(el);
|
|
181
526
|
if (blocker && blocker.ok === false) {
|
|
182
|
-
// Not confidently computable
|
|
527
|
+
// Not confidently computable: recorded below rather than skipped, so
|
|
528
|
+
// it cannot be mistaken for a clean result.
|
|
529
|
+
if (blocker.reasonCode) undecidedReason = String(blocker.reasonCode);
|
|
183
530
|
} else {
|
|
184
531
|
const bg = c.computeEffectiveBackground(el, {
|
|
185
532
|
contrast: { mode, rootCanvasFallback },
|
|
@@ -202,15 +549,69 @@ function runInPage(ctx) {
|
|
|
202
549
|
fgLinkHex = c.rgbToHex ? c.rgbToHex(fgLinkOpaque) : '';
|
|
203
550
|
fgParentHex = c.rgbToHex ? c.rgbToHex(fgParentOpaque) : '';
|
|
204
551
|
|
|
552
|
+
computed = true;
|
|
205
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
|
+
}
|
|
206
569
|
}
|
|
207
|
-
// else: not confidently computable,
|
|
570
|
+
// else: not confidently computable, recorded below.
|
|
208
571
|
}
|
|
209
572
|
} catch {
|
|
210
|
-
//
|
|
573
|
+
// No-throw: treat as not computable and record it.
|
|
574
|
+
undecidedReason = 'ENGINE_EXCEPTION';
|
|
211
575
|
}
|
|
212
576
|
|
|
213
|
-
if (!
|
|
577
|
+
if (!computed) {
|
|
578
|
+
markUndecided(el, undecidedReason);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (backgroundDiffers) {
|
|
583
|
+
decidedCount += 1;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// An underline is the last remaining non-color cue -- and only now does
|
|
588
|
+
// it matter whether this environment can actually report one.
|
|
589
|
+
const decoration = decorationInfo(linkCs);
|
|
590
|
+
let underlined;
|
|
591
|
+
if (decoration.trustworthy) {
|
|
592
|
+
underlined = decoration.underlined;
|
|
593
|
+
} else {
|
|
594
|
+
const fromCssom = resolveUnderlineFromCssom(el);
|
|
595
|
+
if (!fromCssom.resolved) {
|
|
596
|
+
markUndecided(el, 'TEXT_DECORATION_NOT_RESOLVABLE');
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
underlined = fromCssom.underlined;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (underlined) {
|
|
603
|
+
decidedCount += 1;
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
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
|
+
|
|
614
|
+
decidedCount += 1;
|
|
214
615
|
|
|
215
616
|
const eligInfo = helpers.getEligibilityInfo
|
|
216
617
|
? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
|
|
@@ -222,7 +623,7 @@ function runInPage(ctx) {
|
|
|
222
623
|
helpers.reportOccurrence(el, {
|
|
223
624
|
summary:
|
|
224
625
|
'This link in a block of text relies on color alone to be distinguished from the surrounding text.',
|
|
225
|
-
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.',
|
|
226
627
|
i18n: {
|
|
227
628
|
summaryKey: 'linkInTextBlock_summary_fail',
|
|
228
629
|
hintKey: 'linkInTextBlock_hint_fail',
|
|
@@ -243,15 +644,82 @@ function runInPage(ctx) {
|
|
|
243
644
|
if (applicableCount === 0) {
|
|
244
645
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
245
646
|
}
|
|
246
|
-
|
|
647
|
+
|
|
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
|
+
);
|
|
247
712
|
return {
|
|
248
713
|
ruleId: rule.ruleId,
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
occurrences
|
|
714
|
+
...resolved,
|
|
715
|
+
...(resolved.outcome === 'cantTell' ? { confidence: 'low' } : null)
|
|
252
716
|
};
|
|
253
717
|
}
|
|
254
|
-
|
|
718
|
+
|
|
719
|
+
if (decidedCount > 0) {
|
|
720
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
721
|
+
}
|
|
722
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
255
723
|
}
|
|
256
724
|
|
|
257
725
|
module.exports = { id, meta, runInPage };
|