@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,247 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check area-alt-decorative
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Manual review: text alternative appropriateness (WCAG 1.1.1)
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.1.1
|
|
9
|
+
* @type manual
|
|
10
|
+
* @expectation
|
|
11
|
+
* Human review is required to confirm that the provided text alternative is accurate and appropriate.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const id = "area-alt-decorative";
|
|
15
|
+
|
|
16
|
+
const meta = {
|
|
17
|
+
title: "<area> with alt=\"\" must be decorative (manual review)",
|
|
18
|
+
description: "Flags <area> elements with empty alt for human review that they are decorative/non-informative.",
|
|
19
|
+
i18n: {
|
|
20
|
+
titleKey: "area_altDecorative_title",
|
|
21
|
+
descriptionKey: "area_altDecorative_description"
|
|
22
|
+
},
|
|
23
|
+
helpUrl: null,
|
|
24
|
+
tags: ["wcag2a", "wcag111", "nontext", "images", "imagemap", "manual", "atomic"],
|
|
25
|
+
wcagSc: ['1.1.1'],
|
|
26
|
+
normativeMappings: [
|
|
27
|
+
{standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A'}
|
|
28
|
+
],
|
|
29
|
+
defaultSeverity: 'minor',
|
|
30
|
+
category: 'perceivable',
|
|
31
|
+
type: 'manual',
|
|
32
|
+
defaultConfidence: 'medium',
|
|
33
|
+
coverage: {
|
|
34
|
+
facetsBySc: {
|
|
35
|
+
'1.1.1': ['text-alternative-quality']
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function runInPage(ctx) {
|
|
41
|
+
const {document, root, helpers, rule} = ctx;
|
|
42
|
+
const safeRoot = root || document;
|
|
43
|
+
|
|
44
|
+
const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
45
|
+
const queryAll = helpers && typeof helpers.queryAll === 'function'
|
|
46
|
+
? helpers.queryAll
|
|
47
|
+
: (sel) => {
|
|
48
|
+
try {
|
|
49
|
+
return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : [];
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const __accEligCache = new WeakMap();
|
|
56
|
+
function accEligibleCached(node) {
|
|
57
|
+
if (!isAccTreeEligible) return { eligible: true, reasons: [] };
|
|
58
|
+
if (!node || typeof node !== 'object') return { eligible: true, reasons: [] };
|
|
59
|
+
const cached = __accEligCache.get(node);
|
|
60
|
+
if (cached) return cached;
|
|
61
|
+
let res;
|
|
62
|
+
try { res = isAccTreeEligible(node, ctx); }
|
|
63
|
+
catch { res = { eligible: true, reasons: [] }; }
|
|
64
|
+
res = res && typeof res === 'object' ? res : { eligible: !!res, reasons: [] };
|
|
65
|
+
__accEligCache.set(node, res);
|
|
66
|
+
return res;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
|
|
70
|
+
? helpers.getEligibilityInfo
|
|
71
|
+
: null;
|
|
72
|
+
|
|
73
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
74
|
+
? helpers.isAccTreeEligible
|
|
75
|
+
: null;
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
// --- image-map semantics (rule-local; match automatic <area> applicability) ---
|
|
79
|
+
function normUsemap(val) {
|
|
80
|
+
try {
|
|
81
|
+
const t = String(val || '').trim();
|
|
82
|
+
if (!t) return '';
|
|
83
|
+
return (t[0] === '#') ? t.slice(1).trim().toLowerCase() : t.toLowerCase();
|
|
84
|
+
} catch {
|
|
85
|
+
return '';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getMapName(mapEl) {
|
|
90
|
+
try {
|
|
91
|
+
if (!mapEl || !mapEl.getAttribute) return '';
|
|
92
|
+
const n = String(mapEl.getAttribute('name') || mapEl.getAttribute('id') || '').trim();
|
|
93
|
+
return n ? n.toLowerCase() : '';
|
|
94
|
+
} catch {
|
|
95
|
+
return '';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getReferencingImgForArea(areaEl) {
|
|
100
|
+
try {
|
|
101
|
+
if (!areaEl || !areaEl.closest) return null;
|
|
102
|
+
const map = areaEl.closest('map');
|
|
103
|
+
if (!map) return null;
|
|
104
|
+
const mapName = getMapName(map);
|
|
105
|
+
if (!mapName) return null;
|
|
106
|
+
|
|
107
|
+
const imgs = Array.from(document.querySelectorAll('img[usemap]'));
|
|
108
|
+
for (const img of imgs) {
|
|
109
|
+
const u = normUsemap(img.getAttribute('usemap'));
|
|
110
|
+
if (u && u === mapName) return img; // deterministic: first match in doc order
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
|
|
118
|
+
? helpers.getFocusableInfo
|
|
119
|
+
: null;
|
|
120
|
+
|
|
121
|
+
function isRolePresentationExcluded(el) {
|
|
122
|
+
const role = (() => {
|
|
123
|
+
try {
|
|
124
|
+
return String(el.getAttribute('role') || '').trim().toLowerCase();
|
|
125
|
+
} catch {
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
})();
|
|
129
|
+
if (role !== 'presentation' && role !== 'none') return false;
|
|
130
|
+
|
|
131
|
+
// Exclude only when NOT focusable (mirrors img-alt-present policy)
|
|
132
|
+
let focusable = false;
|
|
133
|
+
if (getFocusableInfo) {
|
|
134
|
+
const fi = (() => {
|
|
135
|
+
try {
|
|
136
|
+
return getFocusableInfo(el, ctx);
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
})();
|
|
141
|
+
focusable = !!(fi && fi.focusable);
|
|
142
|
+
} else {
|
|
143
|
+
const tabindex = el.getAttribute('tabindex');
|
|
144
|
+
focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
|
|
145
|
+
}
|
|
146
|
+
return !focusable;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
const els = (() => {
|
|
151
|
+
try {
|
|
152
|
+
return Array.from((queryAllSmart ? queryAllSmart("area") : queryAll("area")) || []);
|
|
153
|
+
} catch {
|
|
154
|
+
return queryAll("area");
|
|
155
|
+
}
|
|
156
|
+
})();
|
|
157
|
+
|
|
158
|
+
const __usemapIndex = new Map(); // mapName -> img (first in document order)
|
|
159
|
+
try {
|
|
160
|
+
const imgs = Array.from(document.querySelectorAll('img[usemap]'));
|
|
161
|
+
for (const img of imgs) {
|
|
162
|
+
const u = normUsemap(img.getAttribute('usemap'));
|
|
163
|
+
if (!u) continue;
|
|
164
|
+
if (!__usemapIndex.has(u)) __usemapIndex.set(u, img); // keep first match only
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!els.length) {
|
|
170
|
+
return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const occurrences = [];
|
|
174
|
+
let applicableCount = 0;
|
|
175
|
+
|
|
176
|
+
for (const el of els) {
|
|
177
|
+
if (!el || !el.getAttribute) continue;
|
|
178
|
+
|
|
179
|
+
// Must belong to a *used* image map (referenced by an <img usemap>). If unused, not applicable.
|
|
180
|
+
let img = null;
|
|
181
|
+
try {
|
|
182
|
+
const map = el.closest && el.closest('map');
|
|
183
|
+
const mapName = map ? getMapName(map) : '';
|
|
184
|
+
img = mapName ? (__usemapIndex.get(mapName) || null) : null;
|
|
185
|
+
} catch {
|
|
186
|
+
img = null;
|
|
187
|
+
}
|
|
188
|
+
if (!img) continue;
|
|
189
|
+
|
|
190
|
+
// The referencing <img> must be eligible in the accessibility tree.
|
|
191
|
+
if (isAccTreeEligible) {
|
|
192
|
+
const imgElig = accEligibleCached(img);
|
|
193
|
+
if (imgElig && imgElig.eligible === false) continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (isAccTreeEligible) {
|
|
197
|
+
const elig = (() => {
|
|
198
|
+
try {
|
|
199
|
+
return isAccTreeEligible(el, ctx);
|
|
200
|
+
} catch {
|
|
201
|
+
return {eligible: true, reasons: []};
|
|
202
|
+
}
|
|
203
|
+
})();
|
|
204
|
+
if (elig && elig.eligible === false) continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (isRolePresentationExcluded(el)) continue;
|
|
208
|
+
|
|
209
|
+
// Rule-specific applicability (only elements that already have a text alternative mechanism)
|
|
210
|
+
let alt = null;
|
|
211
|
+
try { alt = el.getAttribute('alt'); } catch { alt = null; }
|
|
212
|
+
if (alt === null) continue;
|
|
213
|
+
if (String(alt).trim() !== '') continue; // only alt=""
|
|
214
|
+
|
|
215
|
+
applicableCount += 1;
|
|
216
|
+
|
|
217
|
+
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, {targetSet: 'acc'}) : null;
|
|
218
|
+
|
|
219
|
+
const baseOccurrence = {
|
|
220
|
+
summary: 'Review whether <area> is decorative (alt="").',
|
|
221
|
+
hint: 'Confirm the area does not convey information or function. If it is interactive or meaningful, provide meaningful alt text.',
|
|
222
|
+
i18n: {
|
|
223
|
+
summaryKey: 'area_altDecorative_summary_cantTell',
|
|
224
|
+
hintKey: 'area_altDecorative_hint_cantTell',
|
|
225
|
+
params: {element: (el.tagName || '').toLowerCase()}
|
|
226
|
+
},
|
|
227
|
+
data: {
|
|
228
|
+
visibilityFilter: eligInfo || {targetSet: 'acc', accEligible: null, reasons: []},
|
|
229
|
+
details: null
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
234
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
235
|
+
} else {
|
|
236
|
+
occurrences.push({selector: '', html: '', ...baseOccurrence});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (applicableCount === 0) {
|
|
241
|
+
return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
module.exports = {id, meta, runInPage};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check area-alt-quality
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Manual review: text alternative appropriateness (WCAG 1.1.1)
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.1.1
|
|
9
|
+
* @type manual
|
|
10
|
+
* @expectation
|
|
11
|
+
* Human review is required to confirm that the provided text alternative is accurate and appropriate.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const id = "area-alt-quality";
|
|
15
|
+
|
|
16
|
+
const meta = {
|
|
17
|
+
title: "<area> alt text must be appropriate (manual review)",
|
|
18
|
+
description: "Flags <area> elements with non-empty alt text for human review of appropriateness.",
|
|
19
|
+
i18n: {
|
|
20
|
+
titleKey: "area_altQuality_title",
|
|
21
|
+
descriptionKey: "area_altQuality_description"
|
|
22
|
+
},
|
|
23
|
+
helpUrl: null,
|
|
24
|
+
tags: ["wcag2a", "wcag111", "nontext", "images", "imagemap", "manual", "atomic"],
|
|
25
|
+
wcagSc: ['1.1.1'],
|
|
26
|
+
normativeMappings: [
|
|
27
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
|
|
28
|
+
],
|
|
29
|
+
defaultSeverity: 'minor',
|
|
30
|
+
category: 'perceivable',
|
|
31
|
+
type: 'manual',
|
|
32
|
+
defaultConfidence: 'medium',
|
|
33
|
+
coverage: {
|
|
34
|
+
facetsBySc: {
|
|
35
|
+
'1.1.1': ['text-alternative-quality']
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function runInPage(ctx) {
|
|
41
|
+
|
|
42
|
+
const { document, root, helpers, rule } = ctx;
|
|
43
|
+
const safeRoot = root || document;
|
|
44
|
+
|
|
45
|
+
const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
46
|
+
const queryAll = helpers && typeof helpers.queryAll === 'function'
|
|
47
|
+
? helpers.queryAll
|
|
48
|
+
: (sel) => {
|
|
49
|
+
try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
|
|
50
|
+
catch { return []; }
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
|
|
54
|
+
? helpers.getEligibilityInfo
|
|
55
|
+
: null;
|
|
56
|
+
|
|
57
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
58
|
+
? helpers.isAccTreeEligible
|
|
59
|
+
: null;
|
|
60
|
+
|
|
61
|
+
const __accEligCache = new WeakMap();
|
|
62
|
+
function accEligibleCached(node) {
|
|
63
|
+
if (!isAccTreeEligible) return { eligible: true, reasons: [] };
|
|
64
|
+
if (!node || typeof node !== 'object') return { eligible: true, reasons: [] };
|
|
65
|
+
const c = __accEligCache.get(node);
|
|
66
|
+
if (c) return c;
|
|
67
|
+
let r;
|
|
68
|
+
try { r = isAccTreeEligible(node, ctx); } catch { r = { eligible: true, reasons: [] }; }
|
|
69
|
+
r = r && typeof r === 'object' ? r : { eligible: !!r, reasons: [] };
|
|
70
|
+
__accEligCache.set(node, r);
|
|
71
|
+
return r;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- image-map semantics (rule-local; match automatic <area> applicability) ---
|
|
75
|
+
function normUsemap(val) {
|
|
76
|
+
try {
|
|
77
|
+
const t = String(val || '').trim();
|
|
78
|
+
if (!t) return '';
|
|
79
|
+
return (t[0] === '#') ? t.slice(1).trim().toLowerCase() : t.toLowerCase();
|
|
80
|
+
} catch { return ''; }
|
|
81
|
+
}
|
|
82
|
+
function getMapName(mapEl) {
|
|
83
|
+
try {
|
|
84
|
+
if (!mapEl || !mapEl.getAttribute) return '';
|
|
85
|
+
const n = String(mapEl.getAttribute('name') || mapEl.getAttribute('id') || '').trim();
|
|
86
|
+
return n ? n.toLowerCase() : '';
|
|
87
|
+
} catch { return ''; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
|
|
91
|
+
? helpers.getFocusableInfo
|
|
92
|
+
: null;
|
|
93
|
+
|
|
94
|
+
function isRolePresentationExcluded(el) {
|
|
95
|
+
const role = (() => {
|
|
96
|
+
try { return String(el.getAttribute('role') || '').trim().toLowerCase(); }
|
|
97
|
+
catch { return ''; }
|
|
98
|
+
})();
|
|
99
|
+
if (role !== 'presentation' && role !== 'none') return false;
|
|
100
|
+
|
|
101
|
+
// Exclude only when NOT focusable (mirrors img-alt-present policy)
|
|
102
|
+
let focusable = false;
|
|
103
|
+
if (getFocusableInfo) {
|
|
104
|
+
const fi = (() => { try { return getFocusableInfo(el, ctx); } catch { return null; } })();
|
|
105
|
+
focusable = !!(fi && fi.focusable);
|
|
106
|
+
} else {
|
|
107
|
+
const tabindex = el.getAttribute('tabindex');
|
|
108
|
+
focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
|
|
109
|
+
}
|
|
110
|
+
return !focusable;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
const els = (() => {
|
|
115
|
+
try { return Array.from((queryAllSmart ? queryAllSmart('area') : queryAll('area')) || []); }
|
|
116
|
+
catch { return queryAll('area'); }
|
|
117
|
+
})();
|
|
118
|
+
|
|
119
|
+
if (!els.length) {
|
|
120
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const occurrences = [];
|
|
124
|
+
let applicableCount = 0;
|
|
125
|
+
|
|
126
|
+
const __usemapIndex = new Map(); // mapName -> img (first in document order)
|
|
127
|
+
try {
|
|
128
|
+
const imgs = Array.from(document.querySelectorAll('img[usemap]'));
|
|
129
|
+
for (const img of imgs) {
|
|
130
|
+
const u = normUsemap(img.getAttribute('usemap'));
|
|
131
|
+
if (!u) continue;
|
|
132
|
+
if (!__usemapIndex.has(u)) __usemapIndex.set(u, img);
|
|
133
|
+
}
|
|
134
|
+
} catch {}
|
|
135
|
+
|
|
136
|
+
for (const el of els) {
|
|
137
|
+
if (!el || !el.getAttribute) continue;
|
|
138
|
+
|
|
139
|
+
// Must belong to a *used* image map (referenced by an <img usemap>). If unused, not applicable.
|
|
140
|
+
let img = null;
|
|
141
|
+
try {
|
|
142
|
+
const map = el.closest && el.closest('map');
|
|
143
|
+
const mapName = map ? getMapName(map) : '';
|
|
144
|
+
img = mapName ? (__usemapIndex.get(mapName) || null) : null;
|
|
145
|
+
} catch {
|
|
146
|
+
img = null;
|
|
147
|
+
}
|
|
148
|
+
if (!img) continue;
|
|
149
|
+
|
|
150
|
+
// The referencing <img> must be eligible in the accessibility tree.
|
|
151
|
+
if (isAccTreeEligible) {
|
|
152
|
+
const imgElig = accEligibleCached(img);
|
|
153
|
+
if (imgElig && imgElig.eligible === false) continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (isAccTreeEligible) {
|
|
157
|
+
const elig = accEligibleCached(el);
|
|
158
|
+
if (elig && elig.eligible === false) continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (isRolePresentationExcluded(el)) continue;
|
|
162
|
+
|
|
163
|
+
// Rule-specific applicability (only elements that already have a text alternative mechanism)
|
|
164
|
+
let alt = null;
|
|
165
|
+
try { alt = el.getAttribute('alt'); } catch { alt = null; }
|
|
166
|
+
if (alt === null) continue;
|
|
167
|
+
if (String(alt).trim() === '') continue; // only non-empty alt is applicable here
|
|
168
|
+
|
|
169
|
+
applicableCount += 1;
|
|
170
|
+
|
|
171
|
+
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
172
|
+
|
|
173
|
+
let altVal = '';
|
|
174
|
+
try { altVal = String(el.getAttribute('alt') || ''); } catch { altVal = ''; }
|
|
175
|
+
|
|
176
|
+
const baseOccurrence = {
|
|
177
|
+
summary: 'Review alt text on <area> for accuracy and appropriateness.',
|
|
178
|
+
hint: 'Ensure the alt text identifies the destination/action of the image map area in context.',
|
|
179
|
+
i18n: {
|
|
180
|
+
summaryKey: 'area_altQuality_summary_cantTell',
|
|
181
|
+
hintKey: 'area_altQuality_hint_cantTell',
|
|
182
|
+
params: { element: (el.tagName || '').toLowerCase() }
|
|
183
|
+
},
|
|
184
|
+
data: {
|
|
185
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
186
|
+
details: { alt: altVal.trim() } // optional but useful for manual review
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
191
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
192
|
+
} else {
|
|
193
|
+
occurrences.push({ selector: '', html: '', ...baseOccurrence });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (applicableCount === 0) {
|
|
198
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check aria-checked-state-mismatch
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary A native checkbox/radio's aria-checked should match its actual checked/indeterminate state
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 4.1.2
|
|
9
|
+
* @applicability
|
|
10
|
+
* Native `<input type="checkbox">` / `<input type="radio">` elements
|
|
11
|
+
* that carry an explicit `aria-checked` attribute.
|
|
12
|
+
* @expectation
|
|
13
|
+
* `aria-checked` is redundant on a native checkbox/radio (the role's
|
|
14
|
+
* checked state is already exposed natively), but when an author sets
|
|
15
|
+
* it explicitly it should agree with the element's actual state —
|
|
16
|
+
* otherwise assistive technology is told something different from what
|
|
17
|
+
* a sighted user perceives. This is the same static/live-DOM check
|
|
18
|
+
* a widely-used reference engine's own `aria-conditional-attr` rule
|
|
19
|
+
* performs (verified directly against its `ariaConditionalCheckboxAttr`/
|
|
20
|
+
* `ariaConditionalRadioAttr` source).
|
|
21
|
+
* @implementation-notes
|
|
22
|
+
* - Deliberately authored as `type: 'manual'` (cantTell-capped, never
|
|
23
|
+
* fail), unlike most ARIA-validity rules in this file family. That
|
|
24
|
+
* reference engine runs as a script injected into the LIVE, already-hydrated DOM, so a
|
|
25
|
+
* mismatch it reports is a genuine, current-state fact. This engine
|
|
26
|
+
* analyzes STATIC markup only (no script execution) — `.checked`
|
|
27
|
+
* reliably reflects the static `checked` attribute for freshly-parsed
|
|
28
|
+
* markup (verified directly against jsdom), but a very common, entirely
|
|
29
|
+
* legitimate real-world pattern is a JS-hydrated widget whose
|
|
30
|
+
* server-rendered HTML intentionally ships `aria-checked="true"` (or
|
|
31
|
+
* `"mixed"`) BEFORE client JS sets the native `checked`/`indeterminate`
|
|
32
|
+
* property to match on hydration. A hard `fail` here would misfire on
|
|
33
|
+
* that pattern constantly — real risk given how common it is in exactly
|
|
34
|
+
* the kind of JS-framework-heavy pages this engine's own real-world
|
|
35
|
+
* corpus is full of. Flagging for human review (cantTell) captures the
|
|
36
|
+
* same signal without the false-positive risk of asserting it as a
|
|
37
|
+
* confirmed violation.
|
|
38
|
+
* - `aria-checked="mixed"` is only meaningful for checkboxes (radio
|
|
39
|
+
* buttons have no indeterminate state) — matches that reference engine's
|
|
40
|
+
* own `normalizeAriaChecked` (radio) vs `normalizeAriaChecked2` (checkbox)
|
|
41
|
+
* split, verified directly against its source.
|
|
42
|
+
* - Any `aria-checked` value other than "true"/"false"/"mixed" (checkbox)
|
|
43
|
+
* or "true"/"false" (radio) is treated as equivalent to "false" — also
|
|
44
|
+
* matches that reference engine's own normalization exactly.
|
|
45
|
+
* - Not gated on isAccTreeEligible: whether the accessible state matches
|
|
46
|
+
* is a markup-correctness property independent of current visibility.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const id = 'aria-checked-state-mismatch';
|
|
50
|
+
|
|
51
|
+
const meta = {
|
|
52
|
+
title: 'Native checkbox/radio aria-checked should match its actual state',
|
|
53
|
+
description: 'Flags a native <input type="checkbox">/<input type="radio"> whose explicit aria-checked value disagrees with its actual checked/indeterminate state, for manual review.',
|
|
54
|
+
i18n: {
|
|
55
|
+
titleKey: 'ariaCheckedStateMismatch_title',
|
|
56
|
+
descriptionKey: 'ariaCheckedStateMismatch_description'
|
|
57
|
+
},
|
|
58
|
+
helpUrl: null,
|
|
59
|
+
tags: ['wcag2a', 'wcag412', 'aria', 'forms', 'atomic', 'manual'],
|
|
60
|
+
wcagSc: ['4.1.2'],
|
|
61
|
+
normativeMappings: [
|
|
62
|
+
{ standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
|
|
63
|
+
],
|
|
64
|
+
defaultSeverity: 'moderate',
|
|
65
|
+
category: 'robust',
|
|
66
|
+
type: 'manual',
|
|
67
|
+
defaultConfidence: 'medium',
|
|
68
|
+
coverage: { facetsBySc: { '4.1.2': ['aria-checked-state-mismatch'] } }
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function runInPage(ctx) {
|
|
72
|
+
const { document, root, helpers, rule } = ctx;
|
|
73
|
+
const safeRoot = root || document;
|
|
74
|
+
|
|
75
|
+
function trim(v) { return (v == null ? '' : String(v)).trim(); }
|
|
76
|
+
|
|
77
|
+
const nodes = helpers.queryAllSmart
|
|
78
|
+
? helpers.queryAllSmart('input[type="checkbox"][aria-checked], input[type="radio"][aria-checked]', safeRoot)
|
|
79
|
+
: helpers.queryAll('input[type="checkbox"][aria-checked], input[type="radio"][aria-checked]', safeRoot);
|
|
80
|
+
|
|
81
|
+
const occurrences = [];
|
|
82
|
+
let applicableCount = 0;
|
|
83
|
+
|
|
84
|
+
for (const el of nodes) {
|
|
85
|
+
if (!el || !el.getAttribute) continue;
|
|
86
|
+
|
|
87
|
+
const type = trim(el.getAttribute('type')).toLowerCase();
|
|
88
|
+
const isCheckbox = type === 'checkbox';
|
|
89
|
+
const isRadio = type === 'radio';
|
|
90
|
+
if (!isCheckbox && !isRadio) continue;
|
|
91
|
+
|
|
92
|
+
const rawAriaChecked = trim(el.getAttribute('aria-checked')).toLowerCase();
|
|
93
|
+
if (!rawAriaChecked) continue;
|
|
94
|
+
|
|
95
|
+
applicableCount += 1;
|
|
96
|
+
|
|
97
|
+
let normalizedAriaChecked;
|
|
98
|
+
if (isCheckbox) {
|
|
99
|
+
normalizedAriaChecked = (rawAriaChecked === 'mixed' || rawAriaChecked === 'true') ? rawAriaChecked : 'false';
|
|
100
|
+
} else {
|
|
101
|
+
normalizedAriaChecked = (rawAriaChecked === 'true') ? 'true' : 'false';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let actualState;
|
|
105
|
+
if (isCheckbox && el.indeterminate) {
|
|
106
|
+
actualState = 'mixed';
|
|
107
|
+
} else {
|
|
108
|
+
actualState = el.checked ? 'true' : 'false';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (normalizedAriaChecked === actualState) continue;
|
|
112
|
+
|
|
113
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
114
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
115
|
+
|
|
116
|
+
occurrences.push({
|
|
117
|
+
selector: stableSelector,
|
|
118
|
+
html,
|
|
119
|
+
summary: 'This element’s aria-checked value does not match its actual checked/indeterminate state.',
|
|
120
|
+
hint: 'Set aria-checked to match the element’s real state, or remove it — a native checkbox/radio already exposes this state without it.',
|
|
121
|
+
i18n: {
|
|
122
|
+
summaryKey: 'ariaCheckedStateMismatch_summary_cantTell',
|
|
123
|
+
hintKey: 'ariaCheckedStateMismatch_hint_cantTell',
|
|
124
|
+
params: { ariaChecked: normalizedAriaChecked, actualState, type }
|
|
125
|
+
},
|
|
126
|
+
data: {
|
|
127
|
+
details: { reasonCode: 'ARIA_CHECKED_STATE_MISMATCH', ariaChecked: normalizedAriaChecked, actualState, type }
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (applicableCount === 0) {
|
|
133
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
134
|
+
}
|
|
135
|
+
if (occurrences.length) {
|
|
136
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'moderate', occurrences };
|
|
137
|
+
}
|
|
138
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { id, meta, runInPage };
|