@surea11y/core 1.0.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 +49 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/core.js +244 -0
- package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
- package/docs/CLI.md +49 -0
- package/docs/ENGINE_OPTIONS.md +155 -0
- package/docs/I18N.md +47 -0
- package/docs/INTEGRATION.md +156 -0
- package/docs/LIMITATIONS.md +31 -0
- package/docs/OUTPUT_SCHEMA.md +237 -0
- package/docs/POLICY.md +71 -0
- package/docs/RULE_AUTHORING.md +375 -0
- package/docs/RULE_CATALOG.md +180 -0
- package/docs/RULE_TAXONOMY.md +145 -0
- package/docs/TROUBLESHOOTING.md +48 -0
- package/docs/WCAG_CONFORMANCE.md +49 -0
- package/package.json +60 -0
- package/src/catalogs/composites.wcag.js +490 -0
- package/src/checks/automatic/area-alt-present.js +225 -0
- package/src/checks/automatic/aria-allowed-attr.js +206 -0
- package/src/checks/automatic/aria-allowed-role.js +102 -0
- package/src/checks/automatic/aria-braille-equivalent.js +139 -0
- package/src/checks/automatic/aria-conditional-attr.js +110 -0
- package/src/checks/automatic/aria-deprecated-role.js +106 -0
- package/src/checks/automatic/aria-hidden-body.js +87 -0
- package/src/checks/automatic/aria-hidden-focus.js +480 -0
- package/src/checks/automatic/aria-prohibited-attr.js +156 -0
- package/src/checks/automatic/aria-prohibited-children.js +265 -0
- package/src/checks/automatic/aria-required-attr.js +154 -0
- package/src/checks/automatic/aria-required-children.js +274 -0
- package/src/checks/automatic/aria-required-parent.js +222 -0
- package/src/checks/automatic/aria-role-name-present.js +201 -0
- package/src/checks/automatic/aria-roles-valid.js +110 -0
- package/src/checks/automatic/aria-valid-attr-value.js +123 -0
- package/src/checks/automatic/aria-valid-attr.js +109 -0
- package/src/checks/automatic/autocomplete-valid.js +134 -0
- package/src/checks/automatic/avoid-inline-spacing.js +107 -0
- package/src/checks/automatic/binary-control-name-present.js +294 -0
- package/src/checks/automatic/button-name-present.js +146 -0
- package/src/checks/automatic/bypass-blocks-present.js +162 -0
- package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
- package/src/checks/automatic/combobox-name-present.js +267 -0
- package/src/checks/automatic/contrast-computable.js +378 -0
- package/src/checks/automatic/contrast-enhanced.js +517 -0
- package/src/checks/automatic/contrast-minimum.js +512 -0
- package/src/checks/automatic/css-orientation-lock.js +206 -0
- package/src/checks/automatic/definition-list-children-valid.js +148 -0
- package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
- package/src/checks/automatic/dialog-name-present.js +209 -0
- package/src/checks/automatic/dlitem-parent-valid.js +100 -0
- package/src/checks/automatic/duplicate-id-aria.js +126 -0
- package/src/checks/automatic/embed-text-alternative-present.js +190 -0
- package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
- package/src/checks/automatic/form-control-single-label.js +117 -0
- package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
- package/src/checks/automatic/iframe-focusable-content.js +141 -0
- package/src/checks/automatic/iframe-name-present.js +102 -0
- package/src/checks/automatic/iframe-title-unique.js +107 -0
- package/src/checks/automatic/img-alt-present.js +223 -0
- package/src/checks/automatic/input-image-alt-present.js +155 -0
- package/src/checks/automatic/label-in-name.js +326 -0
- package/src/checks/automatic/language-page-present.js +159 -0
- package/src/checks/automatic/link-in-text-block.js +218 -0
- package/src/checks/automatic/link-name-present.js +114 -0
- package/src/checks/automatic/list-children-valid.js +152 -0
- package/src/checks/automatic/listbox-name-present.js +236 -0
- package/src/checks/automatic/listitem-parent-valid.js +118 -0
- package/src/checks/automatic/menuitem-name-present.js +201 -0
- package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
- package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
- package/src/checks/automatic/meter-name-present.js +160 -0
- package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
- package/src/checks/automatic/object-text-alternative-present.js +193 -0
- package/src/checks/automatic/option-name-present.js +157 -0
- package/src/checks/automatic/page-title-present.js +86 -0
- package/src/checks/automatic/progressbar-name-present.js +165 -0
- package/src/checks/automatic/role-img-alt-present.js +206 -0
- package/src/checks/automatic/searchbox-name-present.js +236 -0
- package/src/checks/automatic/server-side-image-map-absent.js +88 -0
- package/src/checks/automatic/slider-name-present.js +276 -0
- package/src/checks/automatic/spinbutton-name-present.js +236 -0
- package/src/checks/automatic/summary-name-present.js +153 -0
- package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
- package/src/checks/automatic/svg-text-alternative-present.js +298 -0
- package/src/checks/automatic/tab-name-present.js +200 -0
- package/src/checks/automatic/table-headers-attr-valid.js +122 -0
- package/src/checks/automatic/table-th-has-data-cells.js +117 -0
- package/src/checks/automatic/target-size-minimum.js +605 -0
- package/src/checks/automatic/td-has-header.js +151 -0
- package/src/checks/automatic/textbox-name-present.js +236 -0
- package/src/checks/automatic/tooltip-name-present.js +158 -0
- package/src/checks/automatic/treeitem-name-present.js +157 -0
- package/src/checks/automatic/valid-lang.js +100 -0
- package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
- package/src/checks/manual/accesskeys-manual.js +93 -0
- package/src/checks/manual/area-alt-decorative-manual.js +247 -0
- package/src/checks/manual/area-alt-quality-manual.js +204 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
- package/src/checks/manual/aria-text-manual.js +109 -0
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
- package/src/checks/manual/css-hidden-focus.js +259 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
- package/src/checks/manual/empty-heading-manual.js +182 -0
- package/src/checks/manual/empty-table-header-manual.js +163 -0
- package/src/checks/manual/focus-order-semantics-manual.js +117 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
- package/src/checks/manual/heading-order-manual.js +130 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
- package/src/checks/manual/image-redundant-alt-manual.js +118 -0
- package/src/checks/manual/img-alt-decorative-manual.js +148 -0
- package/src/checks/manual/img-alt-quality-manual.js +182 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
- package/src/checks/manual/label-title-only-manual.js +115 -0
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
- package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
- package/src/checks/manual/landmark-one-main-manual.js +151 -0
- package/src/checks/manual/landmark-unique-manual.js +252 -0
- package/src/checks/manual/link-name-quality-manual.js +143 -0
- package/src/checks/manual/media-transcript-present-manual.js +373 -0
- package/src/checks/manual/meta-viewport-large-manual.js +119 -0
- package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
- package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
- package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
- package/src/checks/manual/p-as-heading-manual.js +163 -0
- package/src/checks/manual/page-has-heading-one-manual.js +110 -0
- package/src/checks/manual/page-title-patterns-manual.js +262 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
- package/src/checks/manual/region-manual.js +183 -0
- package/src/checks/manual/scope-attr-valid-manual.js +93 -0
- package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
- package/src/checks/manual/skip-link-manual.js +150 -0
- package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
- package/src/checks/manual/tabindex-manual.js +94 -0
- package/src/checks/manual/table-duplicate-name-manual.js +99 -0
- package/src/checks/manual/table-fake-caption-manual.js +122 -0
- package/src/checks/manual/video-caption-manual.js +118 -0
- package/src/checks/manual-review.js +95 -0
- package/src/checks/rules-and-tags.full.csv +19 -0
- package/src/checks/rules-and-tags.full.json +259 -0
- package/src/core/aria-helpers.js +906 -0
- package/src/core/contrast-helpers.js +1147 -0
- package/src/core/dom-helpers.js +4085 -0
- package/src/core/dom-runner.js +627 -0
- package/src/core/frame-messaging.js +210 -0
- package/src/core/frame-scan.js +178 -0
- package/src/core/rollup-composites.js +135 -0
- package/src/core/rule-meta.js +140 -0
- package/src/core.js +79055 -0
- package/src/coverage/wcag-facets.js +1079 -0
- package/src/coverage/wcag-version-map.js +84 -0
- package/src/i18n/en.js +919 -0
- package/src/i18n/fr.js +527 -0
- package/src/index.js +4 -0
- package/src/policy/contracts.js +18 -0
- package/src/policy/resolvePolicy.js +55 -0
- package/src/policy/schemas/engine-options.schema.json +103 -0
- package/src/policy/schemas/policy-contract.schema.json +40 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check link-in-text-block
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Links surrounded by text must be distinguishable from that text by non-color means
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.4.1
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to <a href> elements whose immediate parent element also has
|
|
11
|
+
* at least one direct-child text node with non-whitespace content
|
|
12
|
+
* (i.e. the link sits inline within a run of plain text, not as a
|
|
13
|
+
* standalone item — e.g. not the sole content of a <li> nav item).
|
|
14
|
+
* @expectation
|
|
15
|
+
* A link inside a text block must be visually distinguishable from the
|
|
16
|
+
* surrounding text by at least one non-color means:
|
|
17
|
+
* - text-decoration: underline, OR
|
|
18
|
+
* - a different font-weight than the surrounding text, OR
|
|
19
|
+
* - a different font-style than the surrounding text, OR
|
|
20
|
+
* - a contrast ratio of at least 3:1 between the link's text color and
|
|
21
|
+
* the surrounding text's color (WCAG technique G183's threshold —
|
|
22
|
+
* sufficient contrast alone is an accepted alternative to underline).
|
|
23
|
+
* Fails only when none of the above hold AND the color contrast between
|
|
24
|
+
* link and surrounding text is confidently computable and below 3:1 —
|
|
25
|
+
* i.e. color is demonstrably the only cue.
|
|
26
|
+
* @implementation-notes
|
|
27
|
+
* - "Surrounding text style" is approximated as the link's immediate
|
|
28
|
+
* parent element's own computed style, not a full inline-context walk
|
|
29
|
+
* of the actual adjacent text node(s) — a deliberate scope-down, since
|
|
30
|
+
* plain text nodes inherit their rendering from the parent in the
|
|
31
|
+
* overwhelming majority of real markup.
|
|
32
|
+
* - When contrast is not confidently computable (background image/
|
|
33
|
+
* gradient, blend mode, filter, non-opaque ancestor — same blockers
|
|
34
|
+
* `contrast-minimum`/`contrast-computable` use), the link is silently
|
|
35
|
+
* skipped rather than flagged or reported as cantTell, to keep `fail`
|
|
36
|
+
* reserved for deterministic, high-confidence violations. This means
|
|
37
|
+
* the rule never emits cantTell — outcome is notApplicable/pass/fail
|
|
38
|
+
* only, matching this repo's other Tier 2 mechanical rules.
|
|
39
|
+
* - Reuses the shared `helpers.contrast` subsystem (same
|
|
40
|
+
* computeEffectiveForeground/Background, getComputabilityBlocker,
|
|
41
|
+
* contrastRatio helpers as `contrast-minimum`), rather than re-deriving
|
|
42
|
+
* color math independently.
|
|
43
|
+
* - Scoped to `a[href]` only (not `area[href]` or `[role="link"]`) —
|
|
44
|
+
* matches the common real-world shape of this issue (prose links).
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
const id = 'link-in-text-block';
|
|
48
|
+
|
|
49
|
+
const meta = {
|
|
50
|
+
title: 'Links in text blocks must be distinguishable from surrounding text without relying on color alone',
|
|
51
|
+
description:
|
|
52
|
+
'Checks that a link inside a run of text is visually distinguishable from the surrounding text by underline, a font-weight/style difference, or a sufficient (>=3:1) color-contrast difference — not by color alone.',
|
|
53
|
+
i18n: {
|
|
54
|
+
titleKey: 'linkInTextBlock_title',
|
|
55
|
+
descriptionKey: 'linkInTextBlock_description'
|
|
56
|
+
},
|
|
57
|
+
helpUrl: null,
|
|
58
|
+
tags: ['wcag2a', 'wcag141', 'navigation', 'color', 'links', 'contrast', 'atomic', 'automatic', 'dom'],
|
|
59
|
+
wcagSc: ['1.4.1'],
|
|
60
|
+
normativeMappings: [
|
|
61
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.4.1', title: 'Use of Color', conformanceLevel: 'A' }
|
|
62
|
+
],
|
|
63
|
+
defaultSeverity: 'serious',
|
|
64
|
+
category: 'perceivable',
|
|
65
|
+
type: 'automatic',
|
|
66
|
+
defaultConfidence: 'high',
|
|
67
|
+
coverage: { facetsBySc: { '1.4.1': ['link-in-text-block'] } }
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function runInPage(ctx) {
|
|
71
|
+
const { document, root, helpers, rule, engineOptions } = ctx;
|
|
72
|
+
const safeRoot = root || document;
|
|
73
|
+
|
|
74
|
+
function safeComputedStyle(el) {
|
|
75
|
+
try {
|
|
76
|
+
if (!el || el.nodeType !== 1) return null;
|
|
77
|
+
if (helpers && typeof helpers.computedStyle === 'function') {
|
|
78
|
+
const cs = helpers.computedStyle(el);
|
|
79
|
+
if (cs) return cs;
|
|
80
|
+
}
|
|
81
|
+
const view = (el.ownerDocument && el.ownerDocument.defaultView) ? el.ownerDocument.defaultView : null;
|
|
82
|
+
if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(el);
|
|
83
|
+
} catch {}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function decorationTokens(cs) {
|
|
88
|
+
const raw = `${(cs && cs.textDecorationLine) || ''} ${(cs && cs.textDecoration) || ''}`.toLowerCase();
|
|
89
|
+
return raw.split(/\s+/).filter(Boolean);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function hasUnderline(cs) {
|
|
93
|
+
return decorationTokens(cs).includes('underline');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hasSurroundingText(el, parent) {
|
|
97
|
+
if (!parent || !parent.childNodes) return false;
|
|
98
|
+
for (let i = 0; i < parent.childNodes.length; i++) {
|
|
99
|
+
const n = parent.childNodes[i];
|
|
100
|
+
if (n === el) continue;
|
|
101
|
+
if (n.nodeType === 3 && n.nodeValue && n.nodeValue.trim().length > 0) return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const contrastOpts = engineOptions && typeof engineOptions.contrast === 'object' && engineOptions.contrast
|
|
107
|
+
? engineOptions.contrast
|
|
108
|
+
: {};
|
|
109
|
+
const mode = contrastOpts.mode === 'auditorAssist' ? 'auditorAssist' : 'strictConformance';
|
|
110
|
+
const rootCanvasFallback =
|
|
111
|
+
typeof contrastOpts.rootCanvasFallback === 'string' && contrastOpts.rootCanvasFallback.trim()
|
|
112
|
+
? contrastOpts.rootCanvasFallback.trim()
|
|
113
|
+
: '#ffffff';
|
|
114
|
+
|
|
115
|
+
const c = helpers && helpers.contrast ? helpers.contrast : null;
|
|
116
|
+
|
|
117
|
+
const selector = 'a[href]';
|
|
118
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
|
|
119
|
+
|
|
120
|
+
const occurrences = [];
|
|
121
|
+
let applicableCount = 0;
|
|
122
|
+
|
|
123
|
+
for (const el of nodes) {
|
|
124
|
+
if (!el || !el.getAttribute) continue;
|
|
125
|
+
|
|
126
|
+
const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
|
|
127
|
+
const eligible = typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
|
|
128
|
+
if (!eligible) continue;
|
|
129
|
+
|
|
130
|
+
const parent = el.parentElement;
|
|
131
|
+
if (!hasSurroundingText(el, parent)) continue;
|
|
132
|
+
|
|
133
|
+
applicableCount += 1;
|
|
134
|
+
|
|
135
|
+
const linkCs = safeComputedStyle(el);
|
|
136
|
+
const parentCs = safeComputedStyle(parent);
|
|
137
|
+
|
|
138
|
+
if (hasUnderline(linkCs)) continue;
|
|
139
|
+
|
|
140
|
+
const linkWeight = c && linkCs ? c.normalizeFontWeight(linkCs.fontWeight) : 400;
|
|
141
|
+
const parentWeight = c && parentCs ? c.normalizeFontWeight(parentCs.fontWeight) : 400;
|
|
142
|
+
if (linkWeight !== parentWeight) continue;
|
|
143
|
+
|
|
144
|
+
const linkStyle = (linkCs && linkCs.fontStyle) || 'normal';
|
|
145
|
+
const parentStyle = (parentCs && parentCs.fontStyle) || 'normal';
|
|
146
|
+
if (linkStyle !== parentStyle) continue;
|
|
147
|
+
|
|
148
|
+
if (!c) continue;
|
|
149
|
+
|
|
150
|
+
let flagged = false;
|
|
151
|
+
let ratio = null;
|
|
152
|
+
let fgLinkHex = '';
|
|
153
|
+
let fgParentHex = '';
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const blocker = c.getComputabilityBlocker(el);
|
|
157
|
+
if (blocker && blocker.ok === false) {
|
|
158
|
+
// Not confidently computable — skip (benefit of the doubt).
|
|
159
|
+
} else {
|
|
160
|
+
const bg = c.computeEffectiveBackground(el, { contrast: { mode, rootCanvasFallback }, collectStack: false });
|
|
161
|
+
const fgLink = c.computeEffectiveForeground(el);
|
|
162
|
+
const fgParent = c.computeEffectiveForeground(parent);
|
|
163
|
+
|
|
164
|
+
if (bg && bg.ok && bg.rgba && fgLink && fgLink.rgba && fgParent && fgParent.rgba) {
|
|
165
|
+
const fgLinkOpaque = fgLink.rgba.a < 1 ? c.compositeRgba(fgLink.rgba, bg.rgba) : { r: fgLink.rgba.r, g: fgLink.rgba.g, b: fgLink.rgba.b, a: 1 };
|
|
166
|
+
const fgParentOpaque = fgParent.rgba.a < 1 ? c.compositeRgba(fgParent.rgba, bg.rgba) : { r: fgParent.rgba.r, g: fgParent.rgba.g, b: fgParent.rgba.b, a: 1 };
|
|
167
|
+
|
|
168
|
+
ratio = c.contrastRatio(fgLinkOpaque, fgParentOpaque);
|
|
169
|
+
fgLinkHex = c.rgbToHex ? c.rgbToHex(fgLinkOpaque) : '';
|
|
170
|
+
fgParentHex = c.rgbToHex ? c.rgbToHex(fgParentOpaque) : '';
|
|
171
|
+
|
|
172
|
+
if (!(ratio >= 3)) flagged = true;
|
|
173
|
+
}
|
|
174
|
+
// else: not confidently computable — skip.
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
// no-throw: treat as not computable, skip.
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!flagged) continue;
|
|
181
|
+
|
|
182
|
+
const eligInfo = helpers.getEligibilityInfo ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
183
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
184
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
185
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
186
|
+
const ratioStr = c.round2 ? c.round2(ratio) : String(ratio);
|
|
187
|
+
|
|
188
|
+
occurrences.push({
|
|
189
|
+
selector: stableSelector,
|
|
190
|
+
html,
|
|
191
|
+
summary: 'This link in a block of text relies on color alone to be distinguished from the surrounding text.',
|
|
192
|
+
hint: 'Add an underline, a font-weight/style difference, or increase the color contrast between the link and surrounding text to at least 3:1.',
|
|
193
|
+
i18n: {
|
|
194
|
+
summaryKey: 'linkInTextBlock_summary_fail',
|
|
195
|
+
hintKey: 'linkInTextBlock_hint_fail',
|
|
196
|
+
params: { element: tag, ratio: String(ratioStr), threshold: '3' }
|
|
197
|
+
},
|
|
198
|
+
data: {
|
|
199
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
200
|
+
details: {
|
|
201
|
+
reasonCode: 'COLOR_ONLY_DIFFERENTIATION',
|
|
202
|
+
metrics: { ratio, threshold: 3 },
|
|
203
|
+
colors: { linkForegroundHex: fgLinkHex, surroundingTextForegroundHex: fgParentHex }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (applicableCount === 0) {
|
|
210
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
211
|
+
}
|
|
212
|
+
if (occurrences.length) {
|
|
213
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
|
|
214
|
+
}
|
|
215
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const id = 'link-name-present';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Links have an accessible name',
|
|
7
|
+
description: 'Checks that links expose a non-empty accessible name.',
|
|
8
|
+
i18n: {
|
|
9
|
+
titleKey: 'linkNamePresent_title',
|
|
10
|
+
descriptionKey: 'linkNamePresent_description'
|
|
11
|
+
},
|
|
12
|
+
helpUrl: null,
|
|
13
|
+
tags: ['wcag2a', 'wcag412', 'navigation', 'atomic', 'automatic', 'links', 'name'],
|
|
14
|
+
wcagSc: ['4.1.2'],
|
|
15
|
+
normativeMappings: [
|
|
16
|
+
{ standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
|
|
17
|
+
],
|
|
18
|
+
defaultSeverity: 'serious',
|
|
19
|
+
category: 'robust',
|
|
20
|
+
type: 'automatic',
|
|
21
|
+
defaultConfidence: 'high',
|
|
22
|
+
// IMPORTANT: update your facets registry to include "link-name-present" under 4.1.2
|
|
23
|
+
coverage: { facetsBySc: { '4.1.2': ['link-name-present'] } }
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function runInPage(ctx) {
|
|
27
|
+
const { document, root, helpers, rule } = ctx;
|
|
28
|
+
const safeRoot = root || document;
|
|
29
|
+
|
|
30
|
+
const occurrences = [];
|
|
31
|
+
let applicableCount = 0;
|
|
32
|
+
|
|
33
|
+
function getConservativeSubtreeText(container) {
|
|
34
|
+
// "Name from content" — recurses into descendants and uses each one's
|
|
35
|
+
// own accessible name (img alt, aria-label/aria-labelledby, title) when
|
|
36
|
+
// it has one, not just literal text nodes. See getContentNameInfo's
|
|
37
|
+
// header comment in src/core/dom-helpers.js for the full rationale
|
|
38
|
+
// (this replaced a text-node-only TreeWalker that missed the common
|
|
39
|
+
// "<a><img alt='...'></a>" logo-link pattern).
|
|
40
|
+
if (helpers.getContentNameInfo) {
|
|
41
|
+
const info = helpers.getContentNameInfo(container, ctx);
|
|
42
|
+
return info && info.present ? info.value : '';
|
|
43
|
+
}
|
|
44
|
+
const t = (container && container.textContent) ? String(container.textContent) : '';
|
|
45
|
+
return t.replace(/\s+/g, ' ').trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const selector = 'a[href], area[href], [role="link"]';
|
|
49
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
|
|
50
|
+
|
|
51
|
+
for (const el of nodes) {
|
|
52
|
+
// isAccTreeEligible returns { eligible, reasons }, not a boolean.
|
|
53
|
+
const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
|
|
54
|
+
const eligible = typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
|
|
55
|
+
if (!eligible) continue;
|
|
56
|
+
|
|
57
|
+
applicableCount += 1;
|
|
58
|
+
|
|
59
|
+
const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
|
|
60
|
+
const programmaticName = (nameInfo && typeof nameInfo.value === 'string') ? nameInfo.value : '';
|
|
61
|
+
|
|
62
|
+
const contentName = programmaticName.trim().length === 0 ? getConservativeSubtreeText(el) : '';
|
|
63
|
+
const finalName = (programmaticName.trim().length ? programmaticName : contentName).trim();
|
|
64
|
+
|
|
65
|
+
if (finalName.length === 0) {
|
|
66
|
+
// Only compute the richer eligibility-info payload (used solely for
|
|
67
|
+
// the occurrence's visibilityFilter) once we know an occurrence is
|
|
68
|
+
// actually being built, rather than for every applicable element.
|
|
69
|
+
const eligInfo = helpers.getEligibilityInfo ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
70
|
+
|
|
71
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
72
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
73
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
74
|
+
|
|
75
|
+
occurrences.push({
|
|
76
|
+
selector: stableSelector,
|
|
77
|
+
html,
|
|
78
|
+
|
|
79
|
+
// Human fallbacks (allowed)
|
|
80
|
+
summary: 'This link has no accessible name.',
|
|
81
|
+
hint: 'Provide link text or an accessible-name mechanism (for example aria-label) so assistive technologies can identify the link.',
|
|
82
|
+
|
|
83
|
+
// Validator requires these keys to exist in the English dictionary
|
|
84
|
+
i18n: {
|
|
85
|
+
summaryKey: 'linkNamePresent_summary_fail',
|
|
86
|
+
hintKey: 'linkNamePresent_hint_fail',
|
|
87
|
+
params: { element: tag }
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
data: {
|
|
91
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
92
|
+
details: {
|
|
93
|
+
reasonCode: 'name_missing',
|
|
94
|
+
metrics: {
|
|
95
|
+
programmaticNameLength: programmaticName.trim().length,
|
|
96
|
+
contentNameLength: contentName.trim().length
|
|
97
|
+
},
|
|
98
|
+
refs: { accessibleName: nameInfo || null }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (applicableCount === 0) {
|
|
106
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
107
|
+
}
|
|
108
|
+
if (occurrences.length) {
|
|
109
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
110
|
+
}
|
|
111
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check list-children-valid
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary <ul>/<ol> must only directly contain <li>, <script>, or <template>
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.3.1
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to <ul>/<ol> elements that have at least one direct element
|
|
11
|
+
* child.
|
|
12
|
+
* @expectation
|
|
13
|
+
* Every direct element child is <li>, <script>, or <template> — UNLESS it
|
|
14
|
+
* has an explicit `role` attribute, in which case the explicit role wins
|
|
15
|
+
* over the tag entirely: a child is valid iff that role is "listitem"
|
|
16
|
+
* (so `<li role="presentation">`/`<li role="menuitem">` are invalid
|
|
17
|
+
* despite the <li> tag, and conversely a non-<li> element explicitly
|
|
18
|
+
* given `role="listitem"` is valid). A wrapper <div> used for styling
|
|
19
|
+
* (no role at all) still breaks list semantics the same as before.
|
|
20
|
+
* @implementation-notes
|
|
21
|
+
* - Checked via el.children, which already excludes text/comment nodes —
|
|
22
|
+
* no whitespace-node filtering needed.
|
|
23
|
+
* - Distinct, atomic decision from listitem-parent-valid (the
|
|
24
|
+
* inverse relationship: does a given <li> have a valid parent).
|
|
25
|
+
* - Direct children that are not exposed to the accessibility tree (e.g.
|
|
26
|
+
* display:none, [hidden], aria-hidden="true") are excluded from
|
|
27
|
+
* consideration entirely — matching a widely-used reference engine's own
|
|
28
|
+
* `only-listitems` check, which likewise skips non-AT-visible children. Confirmed against
|
|
29
|
+
* two real sites: Spotify's newsroom has a stray `<input type="hidden">`
|
|
30
|
+
* as a direct <ul> child (UA-stylesheet display:none by spec for
|
|
31
|
+
* input[type=hidden]), and Stanford's main nav interleaves `<li>` with
|
|
32
|
+
* `<span style="display:none">` hydration markers. Neither is reachable
|
|
33
|
+
* by assistive technology, so neither breaks the list semantics a screen
|
|
34
|
+
* reader actually announces.
|
|
35
|
+
* - The explicit-role-overrides-tag behavior above was added after reading
|
|
36
|
+
* a widely-used reference engine's ACTUAL `only-listitems` evaluate function
|
|
37
|
+
* directly (`invalidChildrenEvaluate`/`getInvalidSelector` — note this is a
|
|
38
|
+
* DIFFERENT function from the similarly-named, unused `onlyListitemsEvaluate`
|
|
39
|
+
* elsewhere in that engine's source; don't confuse the two when re-verifying). Its
|
|
40
|
+
* exact rule: if a child has an explicit role, only `validRoles` (here,
|
|
41
|
+
* `['listitem']`) is consulted — the tag name is never checked. Only
|
|
42
|
+
* without an explicit role does the tag name (`validNodeNames`) matter.
|
|
43
|
+
* Found via two real sites: Notion's footer nav lists include a
|
|
44
|
+
* `<li role="none">` used purely to host the list's own visually-hidden
|
|
45
|
+
* label (referenced by the `<ul>`'s `aria-labelledby`), and LinkedIn has
|
|
46
|
+
* both an all-`<li role="menuitem">` menu list and a footer list mixing
|
|
47
|
+
* real `<li>`s with one `<li role="presentation">` — that reference engine
|
|
48
|
+
* correctly flags all of these, surea11y's tag-only check missed them entirely.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
const id = 'list-children-valid';
|
|
52
|
+
|
|
53
|
+
const meta = {
|
|
54
|
+
title: 'Lists must only directly contain list items',
|
|
55
|
+
description: 'Checks that <ul>/<ol> elements only have <li>, <script>, or <template> as direct children.',
|
|
56
|
+
i18n: {
|
|
57
|
+
titleKey: 'listChildrenValid_title',
|
|
58
|
+
descriptionKey: 'listChildrenValid_description'
|
|
59
|
+
},
|
|
60
|
+
helpUrl: null,
|
|
61
|
+
tags: ['wcag2a', 'wcag131', 'structure', 'atomic', 'automatic', 'list'],
|
|
62
|
+
wcagSc: ['1.3.1'],
|
|
63
|
+
normativeMappings: [
|
|
64
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' }
|
|
65
|
+
],
|
|
66
|
+
defaultSeverity: 'serious',
|
|
67
|
+
category: 'perceivable',
|
|
68
|
+
type: 'automatic',
|
|
69
|
+
defaultConfidence: 'high',
|
|
70
|
+
coverage: { facetsBySc: { '1.3.1': ['list-children-valid'] } }
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function runInPage(ctx) {
|
|
74
|
+
const { document, root, helpers, rule } = ctx;
|
|
75
|
+
const safeRoot = root || document;
|
|
76
|
+
|
|
77
|
+
// Declared inside runInPage — see scripts/build-core.js header
|
|
78
|
+
// ("runInPage MUST be self-contained").
|
|
79
|
+
const ALLOWED_CHILD_TAGS = new Set(['li', 'script', 'template']);
|
|
80
|
+
|
|
81
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('ul, ol', safeRoot) : helpers.queryAll('ul, ol', safeRoot);
|
|
82
|
+
|
|
83
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
84
|
+
|
|
85
|
+
function isExposedToAt(el) {
|
|
86
|
+
if (!isAccTreeEligible) return true;
|
|
87
|
+
try {
|
|
88
|
+
const r = isAccTreeEligible(el, ctx);
|
|
89
|
+
if (typeof r === 'boolean') return r;
|
|
90
|
+
return !!(r && r.eligible);
|
|
91
|
+
} catch {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const occurrences = [];
|
|
97
|
+
let applicableCount = 0;
|
|
98
|
+
|
|
99
|
+
for (const el of nodes) {
|
|
100
|
+
if (!el || !el.children) continue;
|
|
101
|
+
if (!el.children.length) continue;
|
|
102
|
+
|
|
103
|
+
applicableCount += 1;
|
|
104
|
+
|
|
105
|
+
const invalidTags = [];
|
|
106
|
+
for (const child of el.children) {
|
|
107
|
+
if (!child || !child.tagName) continue;
|
|
108
|
+
if (!isExposedToAt(child)) continue;
|
|
109
|
+
const tag = child.tagName.toLowerCase();
|
|
110
|
+
|
|
111
|
+
const roleAttr = child.getAttribute ? String(child.getAttribute('role') || '').trim() : '';
|
|
112
|
+
const explicitRole = roleAttr ? (roleAttr.split(/\s+/)[0] || '').toLowerCase() : '';
|
|
113
|
+
|
|
114
|
+
// An explicit role always wins over the tag — see the header
|
|
115
|
+
// comment's Notion/LinkedIn examples.
|
|
116
|
+
const valid = explicitRole ? explicitRole === 'listitem' : ALLOWED_CHILD_TAGS.has(tag);
|
|
117
|
+
|
|
118
|
+
if (!valid) invalidTags.push(tag);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!invalidTags.length) continue;
|
|
122
|
+
|
|
123
|
+
const tag = el.tagName.toLowerCase();
|
|
124
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
125
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
126
|
+
|
|
127
|
+
occurrences.push({
|
|
128
|
+
selector: stableSelector,
|
|
129
|
+
html,
|
|
130
|
+
summary: 'This list contains a direct child that is not a list item.',
|
|
131
|
+
hint: 'Only use <li> (or <script>/<template>) as direct children of <ul>/<ol>; move other markup inside an <li>.',
|
|
132
|
+
i18n: {
|
|
133
|
+
summaryKey: 'listChildrenValid_summary_fail',
|
|
134
|
+
hintKey: 'listChildrenValid_hint_fail',
|
|
135
|
+
params: { element: tag, invalidChildren: invalidTags.join(', ') }
|
|
136
|
+
},
|
|
137
|
+
data: {
|
|
138
|
+
details: { reasonCode: 'LIST_INVALID_CHILD', element: tag, invalidChildren: invalidTags }
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (applicableCount === 0) {
|
|
144
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
145
|
+
}
|
|
146
|
+
if (occurrences.length) {
|
|
147
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
|
|
148
|
+
}
|
|
149
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { id, meta, runInPage };
|