@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,220 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check svg-image-text-alternative-present
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Accessible SVG <image> elements must provide a text alternative
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.1.1
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to SVG <image> elements that are exposed to assistive technologies.
|
|
11
|
+
* Elements otherwise hidden from the accessibility tree remain applicable
|
|
12
|
+
* if they are tabbable or referenced by IDREF relationships (per engine eligibility checks).
|
|
13
|
+
* SVG <image> elements with role="presentation" or role="none" are excluded only when they are not focusable.
|
|
14
|
+
* @expectation
|
|
15
|
+
* Each applicable SVG <image> element has a text alternative via:
|
|
16
|
+
* - a non-empty direct <title> child, OR
|
|
17
|
+
* - a non-empty direct <desc> child, OR
|
|
18
|
+
* - an accessible name (aria-label / aria-labelledby / title attribute).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const id = 'svg-image-text-alternative-present';
|
|
22
|
+
|
|
23
|
+
const meta = {
|
|
24
|
+
title: 'SVG <image> must have a text alternative',
|
|
25
|
+
description: 'Checks that SVG <image> elements provide a text alternative via <title>/<desc> or an ARIA accessible name.',
|
|
26
|
+
i18n: {
|
|
27
|
+
titleKey: 'svgImage_textAltPresent_title',
|
|
28
|
+
descriptionKey: 'svgImage_textAltPresent_description'
|
|
29
|
+
},
|
|
30
|
+
helpUrl: null,
|
|
31
|
+
tags: ['wcag2a', 'wcag111', 'nontext', 'svg', 'image', 'atomic', 'automatic'],
|
|
32
|
+
wcagSc: ['1.1.1'],
|
|
33
|
+
normativeMappings: [
|
|
34
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
|
|
35
|
+
],
|
|
36
|
+
defaultSeverity: 'serious',
|
|
37
|
+
category: 'perceivable',
|
|
38
|
+
type: 'automatic',
|
|
39
|
+
defaultConfidence: 'medium',
|
|
40
|
+
coverage: {
|
|
41
|
+
facetsBySc: {
|
|
42
|
+
'1.1.1': ['svg-image-text-alt-present']
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function runInPage(ctx) {
|
|
48
|
+
const { document, root, helpers, rule } = ctx;
|
|
49
|
+
const safeRoot = root || document;
|
|
50
|
+
|
|
51
|
+
const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
52
|
+
const queryAll = helpers && typeof helpers.queryAll === 'function'
|
|
53
|
+
? helpers.queryAll
|
|
54
|
+
: (sel) => {
|
|
55
|
+
try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
|
|
56
|
+
catch { return []; }
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
|
|
60
|
+
? helpers.getEligibilityInfo
|
|
61
|
+
: null;
|
|
62
|
+
|
|
63
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
64
|
+
? helpers.isAccTreeEligible
|
|
65
|
+
: null;
|
|
66
|
+
|
|
67
|
+
const isFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
|
|
68
|
+
? helpers.getFocusableInfo
|
|
69
|
+
: null;
|
|
70
|
+
|
|
71
|
+
const getAccessibleNameInfo = helpers && typeof helpers.getAccessibleNameInfo === 'function'
|
|
72
|
+
? helpers.getAccessibleNameInfo
|
|
73
|
+
: null;
|
|
74
|
+
|
|
75
|
+
function trim(v) { return (v == null ? '' : String(v)).trim(); }
|
|
76
|
+
|
|
77
|
+
// Per SVG accessible-name conventions, only a <title> that is literally
|
|
78
|
+
// the first child element is used by assistive technologies as the
|
|
79
|
+
// element's accessible name; a <title> appearing later among the
|
|
80
|
+
// children is commonly ignored by AT even though it's still a valid DOM
|
|
81
|
+
// child.
|
|
82
|
+
function firstChildTitleText(el) {
|
|
83
|
+
try {
|
|
84
|
+
if (!el) return '';
|
|
85
|
+
const first = el.firstElementChild;
|
|
86
|
+
const tn = first ? (first.localName || first.tagName || '').toLowerCase() : '';
|
|
87
|
+
if (tn === 'title') {
|
|
88
|
+
const t = trim(first.textContent);
|
|
89
|
+
if (t) return t;
|
|
90
|
+
}
|
|
91
|
+
} catch {}
|
|
92
|
+
return '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// <desc> counts when it is the first child, or the second child
|
|
96
|
+
// immediately following a <title> — the standard <title>+<desc> pairing.
|
|
97
|
+
// A <desc> appearing later than that is not reliably read by AT.
|
|
98
|
+
function descText(el) {
|
|
99
|
+
try {
|
|
100
|
+
if (!el) return '';
|
|
101
|
+
const first = el.firstElementChild;
|
|
102
|
+
const firstTag = first ? (first.localName || first.tagName || '').toLowerCase() : '';
|
|
103
|
+
if (firstTag === 'desc') {
|
|
104
|
+
const t = trim(first.textContent);
|
|
105
|
+
if (t) return t;
|
|
106
|
+
} else if (firstTag === 'title' && first.nextElementSibling) {
|
|
107
|
+
const second = first.nextElementSibling;
|
|
108
|
+
const secondTag = (second.localName || second.tagName || '').toLowerCase();
|
|
109
|
+
if (secondTag === 'desc') {
|
|
110
|
+
const t = trim(second.textContent);
|
|
111
|
+
if (t) return t;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch {}
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const rawImages = (() => {
|
|
119
|
+
try { return Array.from((queryAllSmart ? queryAllSmart('image') : queryAll('image')) || []); }
|
|
120
|
+
catch { return queryAll('svg image'); }
|
|
121
|
+
})();
|
|
122
|
+
|
|
123
|
+
const images = rawImages.filter((el) => {
|
|
124
|
+
try { return el && (el.namespaceURI === 'http://www.w3.org/2000/svg') && (String(el.localName).toLowerCase() === 'image'); }
|
|
125
|
+
catch { return false; }
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (!images.length) return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
129
|
+
|
|
130
|
+
const occurrences = [];
|
|
131
|
+
let applicableCount = 0;
|
|
132
|
+
|
|
133
|
+
for (const el of images) {
|
|
134
|
+
if (!el || !el.getAttribute) continue;
|
|
135
|
+
|
|
136
|
+
if (isAccTreeEligible) {
|
|
137
|
+
const elig = (() => {
|
|
138
|
+
try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
|
|
139
|
+
})();
|
|
140
|
+
if (elig && elig.eligible === false) continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const role = trim(el.getAttribute('role')).toLowerCase();
|
|
144
|
+
if (role === 'presentation' || role === 'none') {
|
|
145
|
+
let focusable = false;
|
|
146
|
+
if (isFocusableInfo) {
|
|
147
|
+
const fi = (() => { try { return isFocusableInfo(el, ctx); } catch { return null; } })();
|
|
148
|
+
focusable = !!(fi && fi.focusable);
|
|
149
|
+
} else {
|
|
150
|
+
const tabindex = el.getAttribute('tabindex');
|
|
151
|
+
focusable = tabindex != null && trim(tabindex) !== '' && !Number.isNaN(Number(trim(tabindex)));
|
|
152
|
+
}
|
|
153
|
+
if (!focusable) continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
applicableCount += 1;
|
|
157
|
+
|
|
158
|
+
const titleText = firstChildTitleText(el);
|
|
159
|
+
if (titleText) continue;
|
|
160
|
+
|
|
161
|
+
const desc = descText(el);
|
|
162
|
+
if (desc) continue;
|
|
163
|
+
|
|
164
|
+
// Only now check “accessible name” (but scoped to allowed mechanisms)
|
|
165
|
+
const ariaLabelRaw = (() => { try { return el.getAttribute('aria-label'); } catch { return null; } })();
|
|
166
|
+
const ariaLabelledbyRaw = (() => { try { return el.getAttribute('aria-labelledby'); } catch { return null; } })();
|
|
167
|
+
const titleAttrRaw = (() => { try { return el.getAttribute('title'); } catch { return null; } })();
|
|
168
|
+
|
|
169
|
+
const ariaLabel = trim(ariaLabelRaw);
|
|
170
|
+
const ariaLabelledby = trim(ariaLabelledbyRaw);
|
|
171
|
+
const titleAttr = trim(titleAttrRaw);
|
|
172
|
+
|
|
173
|
+
const hasMechanism =
|
|
174
|
+
(ariaLabelRaw !== null && ariaLabel.length > 0) ||
|
|
175
|
+
(ariaLabelledbyRaw !== null && ariaLabelledby.length > 0) ||
|
|
176
|
+
(titleAttrRaw !== null && titleAttr.length > 0);
|
|
177
|
+
|
|
178
|
+
let nameInfo = null;
|
|
179
|
+
let hasName = false;
|
|
180
|
+
|
|
181
|
+
if (hasMechanism) {
|
|
182
|
+
if (getAccessibleNameInfo) {
|
|
183
|
+
nameInfo = (() => { try { return getAccessibleNameInfo(el, ctx); } catch { return null; } })();
|
|
184
|
+
hasName = !!(nameInfo && nameInfo.present && trim(nameInfo.value));
|
|
185
|
+
} else {
|
|
186
|
+
// Without helper, accept presence of non-empty allowed attributes
|
|
187
|
+
hasName = true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (hasName) continue;
|
|
192
|
+
|
|
193
|
+
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
194
|
+
|
|
195
|
+
const baseOccurrence = {
|
|
196
|
+
summary: 'Missing text alternative on SVG <image>.',
|
|
197
|
+
hint: 'Add a <title> (and optionally <desc>) inside <image>, or provide aria-label/aria-labelledby.',
|
|
198
|
+
i18n: {
|
|
199
|
+
summaryKey: 'svgImage_textAltPresent_summary_fail',
|
|
200
|
+
hintKey: 'svgImage_textAltPresent_hint_fail',
|
|
201
|
+
params: { element: 'image' }
|
|
202
|
+
},
|
|
203
|
+
data: {
|
|
204
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
209
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
210
|
+
} else {
|
|
211
|
+
occurrences.push({ selector: '', html: '', ...baseOccurrence });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (applicableCount === 0) return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
216
|
+
if (!occurrences.length) return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
217
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check svg-text-alternative-present
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Accessible <svg> elements must provide a text alternative
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.1.1
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to inline <svg> elements that are exposed to assistive technologies AND appear intended to be conveyed.
|
|
11
|
+
* "Intended to be conveyed" is approximated deterministically by at least one of:
|
|
12
|
+
* - role="img"
|
|
13
|
+
* - aria-label / aria-labelledby present
|
|
14
|
+
* - <title> or <desc> present (desc alone is an applicability signal only — see @expectation)
|
|
15
|
+
* - focusable/tabbable (e.g., tabindex, native focusability)
|
|
16
|
+
*
|
|
17
|
+
* Images with role="presentation" or role="none" are excluded only when they are not focusable.
|
|
18
|
+
* Elements otherwise hidden from the accessibility tree remain applicable
|
|
19
|
+
* if they are tabbable-focusable or referenced by IDREF relationships (per engine eligibility checks).
|
|
20
|
+
* @expectation
|
|
21
|
+
* Each applicable <svg> element provides a text alternative via:
|
|
22
|
+
* - non-empty <title> text, OR
|
|
23
|
+
* - an ARIA name (aria-label / aria-labelledby).
|
|
24
|
+
* A <desc> element alone does NOT satisfy this — per the SVG Accessibility
|
|
25
|
+
* API Mappings spec §7.1, <desc> only ever contributes to the accessible
|
|
26
|
+
* DESCRIPTION, never the accessible NAME (verified 2026-07-20 directly
|
|
27
|
+
* against the SVG-AAM spec text, not assumed). An <svg> with only a
|
|
28
|
+
* <desc> and no <title>/ARIA name is still "applicable" (desc signals
|
|
29
|
+
* authorial intent) but fails, matching a widely-used reference engine's svg-img-alt.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const id = 'svg-text-alternative-present';
|
|
33
|
+
|
|
34
|
+
const meta = {
|
|
35
|
+
title: '<svg> must provide a text alternative',
|
|
36
|
+
description: 'Checks that inline <svg> elements provide a text alternative via a <title> element or an ARIA name (a <desc> element alone does not count — it only contributes to the accessible description, not the name).',
|
|
37
|
+
i18n: {
|
|
38
|
+
titleKey: 'svg_textAltPresent_title',
|
|
39
|
+
descriptionKey: 'svg_textAltPresent_description'
|
|
40
|
+
},
|
|
41
|
+
helpUrl: null,
|
|
42
|
+
tags: ['wcag2a', 'wcag111', 'svg', 'nontext', 'images', 'atomic', 'automatic'],
|
|
43
|
+
wcagSc: ['1.1.1'],
|
|
44
|
+
normativeMappings: [
|
|
45
|
+
{standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A'}
|
|
46
|
+
],
|
|
47
|
+
defaultSeverity: 'serious',
|
|
48
|
+
category: 'perceivable',
|
|
49
|
+
type: 'automatic',
|
|
50
|
+
defaultConfidence: 'high',
|
|
51
|
+
coverage: {
|
|
52
|
+
facetsBySc: {
|
|
53
|
+
'1.1.1': ['svg-text-alternative-present']
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function runInPage(ctx) {
|
|
59
|
+
const {document, root, helpers, rule} = ctx;
|
|
60
|
+
const safeRoot = root || document;
|
|
61
|
+
|
|
62
|
+
const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
63
|
+
const queryAll = helpers && typeof helpers.queryAll === 'function'
|
|
64
|
+
? helpers.queryAll
|
|
65
|
+
: (sel) => {
|
|
66
|
+
try {
|
|
67
|
+
return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : [];
|
|
68
|
+
} catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
|
|
74
|
+
? helpers.getEligibilityInfo
|
|
75
|
+
: null;
|
|
76
|
+
|
|
77
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
78
|
+
? helpers.isAccTreeEligible
|
|
79
|
+
: null;
|
|
80
|
+
|
|
81
|
+
const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
|
|
82
|
+
? helpers.getFocusableInfo
|
|
83
|
+
: null;
|
|
84
|
+
|
|
85
|
+
const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function'
|
|
86
|
+
? helpers.getAriaNameInfo
|
|
87
|
+
: null;
|
|
88
|
+
|
|
89
|
+
function trim(v) {
|
|
90
|
+
try {
|
|
91
|
+
return (v == null ? '' : String(v)).trim();
|
|
92
|
+
} catch {
|
|
93
|
+
return '';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Per SVG accessible-name conventions, only a <title> that is literally
|
|
98
|
+
// the first child element is used by assistive technologies as the
|
|
99
|
+
// SVG's accessible name; a <title> appearing later among the children
|
|
100
|
+
// is commonly ignored by AT even though it's still a valid DOM child.
|
|
101
|
+
function nonEmptyFirstChildTitleText(svg) {
|
|
102
|
+
try {
|
|
103
|
+
const first = svg.firstElementChild;
|
|
104
|
+
const tn = first ? (first.localName || first.tagName || '').toLowerCase() : '';
|
|
105
|
+
if (tn === 'title') {
|
|
106
|
+
const txt = trim(first.textContent);
|
|
107
|
+
if (txt) return txt;
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
}
|
|
111
|
+
return '';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// <desc> counts when it is the first child, or the second child
|
|
115
|
+
// immediately following a <title> — the standard <title>+<desc> pairing
|
|
116
|
+
// (e.g. <svg><title>...</title><desc>...</desc>...</svg>). A <desc>
|
|
117
|
+
// appearing later than that is not reliably read by AT.
|
|
118
|
+
function nonEmptyDescText(svg) {
|
|
119
|
+
try {
|
|
120
|
+
const first = svg.firstElementChild;
|
|
121
|
+
const firstTag = first ? (first.localName || first.tagName || '').toLowerCase() : '';
|
|
122
|
+
if (firstTag === 'desc') {
|
|
123
|
+
const txt = trim(first.textContent);
|
|
124
|
+
if (txt) return txt;
|
|
125
|
+
} else if (firstTag === 'title' && first.nextElementSibling) {
|
|
126
|
+
const second = first.nextElementSibling;
|
|
127
|
+
const secondTag = (second.localName || second.tagName || '').toLowerCase();
|
|
128
|
+
if (secondTag === 'desc') {
|
|
129
|
+
const txt = trim(second.textContent);
|
|
130
|
+
if (txt) return txt;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
}
|
|
135
|
+
return '';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isFocusable(svg) {
|
|
139
|
+
if (getFocusableInfo) {
|
|
140
|
+
const fi = (() => {
|
|
141
|
+
try {
|
|
142
|
+
return getFocusableInfo(svg, ctx);
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
})();
|
|
147
|
+
return !!(fi && fi.focusable);
|
|
148
|
+
}
|
|
149
|
+
// deterministic fallback: tabindex presence/valid number
|
|
150
|
+
try {
|
|
151
|
+
const tabindex = svg && svg.getAttribute ? svg.getAttribute('tabindex') : null;
|
|
152
|
+
return tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const svgs = (() => {
|
|
159
|
+
try {
|
|
160
|
+
return Array.from((queryAllSmart ? queryAllSmart('svg') : queryAll('svg')) || []);
|
|
161
|
+
} catch {
|
|
162
|
+
return queryAll('svg');
|
|
163
|
+
}
|
|
164
|
+
})();
|
|
165
|
+
|
|
166
|
+
if (!svgs.length) {
|
|
167
|
+
return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const occurrences = [];
|
|
171
|
+
let applicableCount = 0;
|
|
172
|
+
|
|
173
|
+
for (const el of svgs) {
|
|
174
|
+
if (!el || !el.getAttribute) continue;
|
|
175
|
+
|
|
176
|
+
// Applicability step 1: only acc-tree eligible nodes (with helper exceptions)
|
|
177
|
+
if (isAccTreeEligible) {
|
|
178
|
+
const elig = (() => {
|
|
179
|
+
try {
|
|
180
|
+
return isAccTreeEligible(el, ctx);
|
|
181
|
+
} catch {
|
|
182
|
+
return {eligible: true, reasons: []};
|
|
183
|
+
}
|
|
184
|
+
})();
|
|
185
|
+
if (elig && elig.eligible === false) continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Applicability step 2: role (presentation/none) exclusion only when not focusable
|
|
189
|
+
const role = (() => {
|
|
190
|
+
try {
|
|
191
|
+
return String(el.getAttribute('role') || '').trim().toLowerCase();
|
|
192
|
+
} catch {
|
|
193
|
+
return '';
|
|
194
|
+
}
|
|
195
|
+
})();
|
|
196
|
+
|
|
197
|
+
const focusable = isFocusable(el);
|
|
198
|
+
|
|
199
|
+
if (role === 'presentation' || role === 'none') {
|
|
200
|
+
if (!focusable) continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Applicability step 3: intent signal gating (computed once per element)
|
|
204
|
+
let hasAriaNamingAttr = false;
|
|
205
|
+
try {
|
|
206
|
+
hasAriaNamingAttr = (el.getAttribute('aria-label') != null) || (el.getAttribute('aria-labelledby') != null);
|
|
207
|
+
} catch {
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const titleText = nonEmptyFirstChildTitleText(el);
|
|
211
|
+
const descText = titleText ? '' : nonEmptyDescText(el); // avoid second scan if title already passes
|
|
212
|
+
const hasTitleOrDesc = !!(titleText || descText);
|
|
213
|
+
|
|
214
|
+
const hasIntent =
|
|
215
|
+
role === 'img' ||
|
|
216
|
+
hasAriaNamingAttr ||
|
|
217
|
+
hasTitleOrDesc ||
|
|
218
|
+
focusable;
|
|
219
|
+
|
|
220
|
+
if (!hasIntent) continue;
|
|
221
|
+
|
|
222
|
+
applicableCount += 1;
|
|
223
|
+
|
|
224
|
+
// Expectation: non-empty title/desc OR ARIA name (but only resolve name if attrs exist)
|
|
225
|
+
let hasAriaName = false;
|
|
226
|
+
if (hasAriaNamingAttr) {
|
|
227
|
+
if (getAriaNameInfo) {
|
|
228
|
+
const info = (() => {
|
|
229
|
+
try {
|
|
230
|
+
return getAriaNameInfo(el, ctx);
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
})();
|
|
235
|
+
hasAriaName = !!(info && info.present && trim(info.value));
|
|
236
|
+
} else {
|
|
237
|
+
// minimal deterministic fallback
|
|
238
|
+
const ariaLabel = trim((() => {
|
|
239
|
+
try {
|
|
240
|
+
return el.getAttribute('aria-label');
|
|
241
|
+
} catch {
|
|
242
|
+
return '';
|
|
243
|
+
}
|
|
244
|
+
})());
|
|
245
|
+
const ariaLabelledby = trim((() => {
|
|
246
|
+
try {
|
|
247
|
+
return el.getAttribute('aria-labelledby');
|
|
248
|
+
} catch {
|
|
249
|
+
return '';
|
|
250
|
+
}
|
|
251
|
+
})());
|
|
252
|
+
hasAriaName = !!(ariaLabel || ariaLabelledby);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Per SVG-AAM §7.1: <desc> contributes only to the accessible
|
|
257
|
+
// DESCRIPTION, never the accessible NAME — so descText does not
|
|
258
|
+
// count here even though it does count toward applicability above.
|
|
259
|
+
const ok = !!titleText || hasAriaName;
|
|
260
|
+
if (ok) continue;
|
|
261
|
+
|
|
262
|
+
let eligInfo = null;
|
|
263
|
+
if (getEligibilityInfo) {
|
|
264
|
+
try { eligInfo = getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { eligInfo = null; }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const baseOccurrence = {
|
|
268
|
+
summary: 'Missing text alternative for <svg>.',
|
|
269
|
+
hint: 'Provide a <title> element with text, or an ARIA name (aria-label/aria-labelledby) — a <desc> element alone does not provide an accessible name.',
|
|
270
|
+
i18n: {
|
|
271
|
+
summaryKey: 'svg_textAltPresent_summary_fail',
|
|
272
|
+
hintKey: 'svg_textAltPresent_hint_fail',
|
|
273
|
+
params: {element: 'svg'}
|
|
274
|
+
},
|
|
275
|
+
data: {
|
|
276
|
+
visibilityFilter: eligInfo || {targetSet: 'acc', accEligible: null, reasons: []}
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
281
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
282
|
+
} else {
|
|
283
|
+
occurrences.push({selector: '', html: '', ...baseOccurrence});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (applicableCount === 0) {
|
|
288
|
+
return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!occurrences.length) {
|
|
292
|
+
return {ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: []};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
module.exports = {id, meta, runInPage};
|