@surea11y/core 1.2.0 → 1.4.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 +81 -7
- package/LICENSE +373 -21
- package/README.md +175 -35
- package/bin/surea11y-core.js +20 -0
- package/docs/API_STABILITY.md +27 -1
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/ENGINE_OPTIONS.md +2 -0
- package/docs/I18N.md +12 -9
- package/docs/INTEGRATION.md +19 -1
- package/docs/LIMITATIONS.md +1 -1
- package/docs/OUTPUT_SCHEMA.md +1 -1
- package/docs/REPORT.md +1 -1
- package/docs/RULE_CATALOG.md +1 -1
- package/docs/SARIF.md +59 -0
- package/package.json +63 -18
- package/src/baseline.js +0 -0
- package/src/checks/automatic/area-alt-present.js +63 -31
- package/src/checks/automatic/aria-allowed-attr.js +204 -80
- package/src/checks/automatic/aria-allowed-role.js +23 -7
- package/src/checks/automatic/aria-braille-equivalent.js +34 -10
- package/src/checks/automatic/aria-conditional-attr.js +32 -14
- package/src/checks/automatic/aria-deprecated-role.js +26 -11
- package/src/checks/automatic/aria-hidden-body.js +48 -23
- package/src/checks/automatic/aria-hidden-focus.js +420 -66
- package/src/checks/automatic/aria-prohibited-attr.js +327 -60
- package/src/checks/automatic/aria-prohibited-children.js +111 -103
- package/src/checks/automatic/aria-required-attr.js +29 -15
- package/src/checks/automatic/aria-required-children.js +44 -24
- package/src/checks/automatic/aria-required-parent.js +64 -35
- package/src/checks/automatic/aria-role-name-present.js +49 -21
- package/src/checks/automatic/aria-roles-valid.js +24 -12
- package/src/checks/automatic/aria-valid-attr-value.js +46 -22
- package/src/checks/automatic/aria-valid-attr.js +19 -5
- package/src/checks/automatic/autocomplete-valid.js +76 -16
- package/src/checks/automatic/avoid-inline-spacing.js +23 -8
- package/src/checks/automatic/binary-control-name-present.js +62 -50
- package/src/checks/automatic/button-name-present.js +54 -24
- package/src/checks/automatic/bypass-blocks-present.js +51 -32
- package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
- package/src/checks/automatic/combobox-name-present.js +40 -45
- package/src/checks/automatic/contrast-computable.js +363 -341
- package/src/checks/automatic/contrast-enhanced.js +489 -466
- package/src/checks/automatic/contrast-minimum.js +488 -465
- package/src/checks/automatic/css-orientation-lock.js +51 -35
- package/src/checks/automatic/definition-list-children-valid.js +46 -25
- package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
- package/src/checks/automatic/dialog-name-present.js +47 -85
- package/src/checks/automatic/dlitem-parent-valid.js +25 -8
- package/src/checks/automatic/duplicate-id-aria.js +28 -9
- package/src/checks/automatic/embed-text-alternative-present.js +88 -35
- package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
- package/src/checks/automatic/form-control-single-label.js +50 -14
- package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
- package/src/checks/automatic/iframe-focusable-content.js +265 -22
- package/src/checks/automatic/iframe-name-present.js +33 -9
- package/src/checks/automatic/iframe-title-unique.js +32 -9
- package/src/checks/automatic/img-alt-present.js +54 -52
- package/src/checks/automatic/input-image-alt-present.js +141 -112
- package/src/checks/automatic/label-in-name.js +65 -41
- package/src/checks/automatic/language-page-present.js +111 -109
- package/src/checks/automatic/link-in-text-block.js +61 -19
- package/src/checks/automatic/link-name-present.js +47 -14
- package/src/checks/automatic/list-children-valid.js +40 -33
- package/src/checks/automatic/listbox-name-present.js +41 -19
- package/src/checks/automatic/listitem-parent-valid.js +48 -13
- package/src/checks/automatic/menuitem-name-present.js +41 -61
- package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
- package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
- package/src/checks/automatic/meter-name-present.js +40 -36
- package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
- package/src/checks/automatic/object-text-alternative-present.js +93 -39
- package/src/checks/automatic/option-name-present.js +40 -21
- package/src/checks/automatic/page-title-present.js +19 -6
- package/src/checks/automatic/progressbar-name-present.js +49 -44
- package/src/checks/automatic/role-img-alt-present.js +211 -159
- package/src/checks/automatic/searchbox-name-present.js +41 -19
- package/src/checks/automatic/server-side-image-map-absent.js +27 -11
- package/src/checks/automatic/slider-name-present.js +42 -47
- package/src/checks/automatic/spinbutton-name-present.js +41 -19
- package/src/checks/automatic/summary-name-present.js +39 -17
- package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
- package/src/checks/automatic/svg-text-alternative-present.js +262 -230
- package/src/checks/automatic/tab-name-present.js +39 -60
- package/src/checks/automatic/table-headers-attr-valid.js +27 -10
- package/src/checks/automatic/table-th-has-data-cells.js +24 -8
- package/src/checks/automatic/target-size-minimum.js +123 -48
- package/src/checks/automatic/td-has-header.js +53 -12
- package/src/checks/automatic/textbox-name-present.js +41 -19
- package/src/checks/automatic/tooltip-name-present.js +39 -18
- package/src/checks/automatic/treeitem-name-present.js +40 -21
- package/src/checks/automatic/valid-lang.js +22 -6
- package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
- package/src/checks/manual/accesskeys-manual.js +17 -6
- package/src/checks/manual/area-alt-decorative-manual.js +194 -193
- package/src/checks/manual/area-alt-quality-manual.js +184 -141
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
- package/src/checks/manual/aria-text-manual.js +20 -11
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
- package/src/checks/manual/css-hidden-focus.js +375 -169
- package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
- package/src/checks/manual/empty-heading-manual.js +41 -24
- package/src/checks/manual/empty-table-header-manual.js +69 -31
- package/src/checks/manual/focus-order-semantics-manual.js +60 -13
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
- package/src/checks/manual/heading-order-manual.js +50 -8
- package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
- package/src/checks/manual/image-redundant-alt-manual.js +38 -8
- package/src/checks/manual/img-alt-decorative-manual.js +133 -96
- package/src/checks/manual/img-alt-quality-manual.js +178 -127
- package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
- package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
- package/src/checks/manual/label-title-only-manual.js +44 -28
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
- package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
- package/src/checks/manual/landmark-one-main-manual.js +38 -43
- package/src/checks/manual/landmark-unique-manual.js +78 -67
- package/src/checks/manual/link-name-quality-manual.js +45 -12
- package/src/checks/manual/media-transcript-present-manual.js +37 -22
- package/src/checks/manual/meta-viewport-large-manual.js +19 -6
- package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
- package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
- package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
- package/src/checks/manual/p-as-heading-manual.js +24 -7
- package/src/checks/manual/page-has-heading-one-manual.js +42 -32
- package/src/checks/manual/page-title-patterns-manual.js +80 -50
- package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
- package/src/checks/manual/region-manual.js +244 -60
- package/src/checks/manual/scope-attr-valid-manual.js +13 -4
- package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
- package/src/checks/manual/skip-link-manual.js +42 -18
- package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
- package/src/checks/manual/tabindex-manual.js +13 -4
- package/src/checks/manual/table-duplicate-name-manual.js +22 -11
- package/src/checks/manual/table-fake-caption-manual.js +48 -10
- package/src/checks/manual/video-caption-manual.js +17 -4
- package/src/checks/manual-review.js +58 -12
- package/src/core.js +41705 -29650
- package/src/index.js +2 -0
- package/src/report.js +109 -47
- package/src/sarif.js +190 -0
- package/surea11y.browser.js +37774 -0
- package/bin/core.js +0 -348
- package/docs/CLI.md +0 -75
- package/src/catalogs/composites.wcag.js +0 -490
- package/src/checks/rules-and-tags.full.csv +0 -19
- package/src/checks/rules-and-tags.full.json +0 -259
- package/src/core/aria-helpers.js +0 -970
- package/src/core/contrast-helpers.js +0 -1147
- package/src/core/dom-helpers.js +0 -4235
- package/src/core/dom-runner.js +0 -671
- package/src/core/frame-messaging.js +0 -210
- package/src/core/frame-scan.js +0 -178
- package/src/core/rollup-composites.js +0 -135
- package/src/core/rule-meta.js +0 -159
- package/src/coverage/wcag-facets.js +0 -1079
- package/src/coverage/wcag-version-map.js +0 -84
- package/src/i18n/en.js +0 -923
- package/src/i18n/fr.js +0 -844
- package/src/policy/contracts.js +0 -18
- package/src/policy/resolvePolicy.js +0 -55
- package/src/policy/schemas/engine-options.schema.json +0 -103
- package/src/policy/schemas/policy-contract.schema.json +0 -40
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -11,20 +13,26 @@
|
|
|
11
13
|
* Human review is required to confirm that the provided text alternative is accurate and appropriate.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
|
-
const id =
|
|
16
|
+
const id = 'area-alt-quality';
|
|
15
17
|
|
|
16
18
|
const meta = {
|
|
17
|
-
title:
|
|
18
|
-
description:
|
|
19
|
+
title: '<area> alt text must be appropriate (manual review)',
|
|
20
|
+
description: 'Flags <area> elements with non-empty alt text for human review of appropriateness.',
|
|
19
21
|
i18n: {
|
|
20
|
-
titleKey:
|
|
21
|
-
descriptionKey:
|
|
22
|
+
titleKey: 'area_altQuality_title',
|
|
23
|
+
descriptionKey: 'area_altQuality_description'
|
|
22
24
|
},
|
|
23
25
|
helpUrl: null,
|
|
24
|
-
tags: [
|
|
26
|
+
tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'imagemap', 'manual', 'atomic'],
|
|
25
27
|
wcagSc: ['1.1.1'],
|
|
26
28
|
normativeMappings: [
|
|
27
|
-
{
|
|
29
|
+
{
|
|
30
|
+
standard: 'WCAG',
|
|
31
|
+
version: '2.2',
|
|
32
|
+
requirement: '1.1.1',
|
|
33
|
+
title: 'Non-text Content',
|
|
34
|
+
conformanceLevel: 'A'
|
|
35
|
+
}
|
|
28
36
|
],
|
|
29
37
|
defaultSeverity: 'minor',
|
|
30
38
|
category: 'perceivable',
|
|
@@ -38,167 +46,202 @@ const meta = {
|
|
|
38
46
|
};
|
|
39
47
|
|
|
40
48
|
function runInPage(ctx) {
|
|
49
|
+
const { document, root, helpers, rule } = ctx;
|
|
50
|
+
const safeRoot = root || document;
|
|
51
|
+
|
|
52
|
+
const queryAllSmart =
|
|
53
|
+
helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
54
|
+
const queryAll =
|
|
55
|
+
helpers && typeof helpers.queryAll === 'function'
|
|
56
|
+
? helpers.queryAll
|
|
57
|
+
: (sel) => {
|
|
58
|
+
try {
|
|
59
|
+
return safeRoot && safeRoot.querySelectorAll
|
|
60
|
+
? Array.from(safeRoot.querySelectorAll(sel))
|
|
61
|
+
: [];
|
|
62
|
+
} catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
};
|
|
41
66
|
|
|
42
|
-
|
|
43
|
-
|
|
67
|
+
const getEligibilityInfo =
|
|
68
|
+
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
44
69
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
? helpers.queryAll
|
|
48
|
-
: (sel) => {
|
|
49
|
-
try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
|
|
50
|
-
catch { return []; }
|
|
51
|
-
};
|
|
70
|
+
const isAccTreeEligible =
|
|
71
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
52
72
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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;
|
|
73
|
+
const __accEligCache = new WeakMap();
|
|
74
|
+
function accEligibleCached(node) {
|
|
75
|
+
if (!isAccTreeEligible) return { eligible: true, reasons: [] };
|
|
76
|
+
if (!node || typeof node !== 'object') return { eligible: true, reasons: [] };
|
|
77
|
+
const c = __accEligCache.get(node);
|
|
78
|
+
if (c) return c;
|
|
79
|
+
let r;
|
|
80
|
+
try {
|
|
81
|
+
r = isAccTreeEligible(node, ctx);
|
|
82
|
+
} catch {
|
|
83
|
+
r = { eligible: true, reasons: [] };
|
|
72
84
|
}
|
|
85
|
+
r = r && typeof r === 'object' ? r : { eligible: !!r, reasons: [] };
|
|
86
|
+
__accEligCache.set(node, r);
|
|
87
|
+
return r;
|
|
88
|
+
}
|
|
73
89
|
|
|
74
|
-
// --- image-map semantics (rule-local; match automatic <area> applicability) ---
|
|
75
|
-
function normUsemap(val) {
|
|
90
|
+
// --- image-map semantics (rule-local; match automatic <area> applicability) ---
|
|
91
|
+
function normUsemap(val) {
|
|
76
92
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
} catch {
|
|
81
|
-
|
|
82
|
-
|
|
93
|
+
const t = String(val || '').trim();
|
|
94
|
+
if (!t) return '';
|
|
95
|
+
return t[0] === '#' ? t.slice(1).trim().toLowerCase() : t.toLowerCase();
|
|
96
|
+
} catch {
|
|
97
|
+
return '';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function getMapName(mapEl) {
|
|
83
101
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
} catch {
|
|
88
|
-
|
|
102
|
+
if (!mapEl || !mapEl.getAttribute) return '';
|
|
103
|
+
const n = String(mapEl.getAttribute('name') || mapEl.getAttribute('id') || '').trim();
|
|
104
|
+
return n ? n.toLowerCase() : '';
|
|
105
|
+
} catch {
|
|
106
|
+
return '';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
89
109
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
const getFocusableInfo =
|
|
111
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
112
|
+
|
|
113
|
+
function isRolePresentationExcluded(el) {
|
|
114
|
+
const role = (() => {
|
|
115
|
+
try {
|
|
116
|
+
return String(el.getAttribute('role') || '')
|
|
117
|
+
.trim()
|
|
118
|
+
.toLowerCase();
|
|
119
|
+
} catch {
|
|
120
|
+
return '';
|
|
121
|
+
}
|
|
122
|
+
})();
|
|
123
|
+
if (role !== 'presentation' && role !== 'none') return false;
|
|
124
|
+
|
|
125
|
+
// Exclude only when NOT focusable (mirrors img-alt-present policy)
|
|
126
|
+
let focusable;
|
|
127
|
+
if (getFocusableInfo) {
|
|
128
|
+
const fi = (() => {
|
|
129
|
+
try {
|
|
130
|
+
return getFocusableInfo(el, ctx);
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
109
133
|
}
|
|
110
|
-
|
|
134
|
+
})();
|
|
135
|
+
focusable = !!(fi && fi.focusable);
|
|
136
|
+
} else {
|
|
137
|
+
const tabindex = el.getAttribute('tabindex');
|
|
138
|
+
focusable =
|
|
139
|
+
tabindex != null &&
|
|
140
|
+
String(tabindex).trim() !== '' &&
|
|
141
|
+
!Number.isNaN(Number(String(tabindex).trim()));
|
|
111
142
|
}
|
|
143
|
+
return !focusable;
|
|
144
|
+
}
|
|
112
145
|
|
|
146
|
+
const els = (() => {
|
|
147
|
+
try {
|
|
148
|
+
return Array.from((queryAllSmart ? queryAllSmart('area') : queryAll('area')) || []);
|
|
149
|
+
} catch {
|
|
150
|
+
return queryAll('area');
|
|
151
|
+
}
|
|
152
|
+
})();
|
|
113
153
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
154
|
+
if (!els.length) {
|
|
155
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const occurrences = [];
|
|
159
|
+
let applicableCount = 0;
|
|
118
160
|
|
|
119
|
-
|
|
120
|
-
|
|
161
|
+
const __usemapIndex = new Map(); // mapName -> img (first in document order)
|
|
162
|
+
try {
|
|
163
|
+
const imgs = Array.from(document.querySelectorAll('img[usemap]'));
|
|
164
|
+
for (const img of imgs) {
|
|
165
|
+
const u = normUsemap(img.getAttribute('usemap'));
|
|
166
|
+
if (!u) continue;
|
|
167
|
+
if (!__usemapIndex.has(u)) __usemapIndex.set(u, img);
|
|
121
168
|
}
|
|
169
|
+
} catch {}
|
|
122
170
|
|
|
123
|
-
|
|
124
|
-
|
|
171
|
+
for (const el of els) {
|
|
172
|
+
if (!el || !el.getAttribute) continue;
|
|
125
173
|
|
|
126
|
-
|
|
174
|
+
// Must belong to a *used* image map (referenced by an <img usemap>). If unused, not applicable.
|
|
175
|
+
let img;
|
|
127
176
|
try {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
177
|
+
const map = el.closest && el.closest('map');
|
|
178
|
+
const mapName = map ? getMapName(map) : '';
|
|
179
|
+
img = mapName ? __usemapIndex.get(mapName) || null : null;
|
|
180
|
+
} catch {
|
|
181
|
+
img = null;
|
|
182
|
+
}
|
|
183
|
+
if (!img) continue;
|
|
135
184
|
|
|
136
|
-
|
|
137
|
-
|
|
185
|
+
// The referencing <img> must be eligible in the accessibility tree.
|
|
186
|
+
if (isAccTreeEligible) {
|
|
187
|
+
const imgElig = accEligibleCached(img);
|
|
188
|
+
if (imgElig && imgElig.eligible === false) continue;
|
|
189
|
+
}
|
|
138
190
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const mapName = map ? getMapName(map) : '';
|
|
144
|
-
img = mapName ? (__usemapIndex.get(mapName) || null) : null;
|
|
145
|
-
} catch {
|
|
146
|
-
img = null;
|
|
147
|
-
}
|
|
148
|
-
if (!img) continue;
|
|
191
|
+
if (isAccTreeEligible) {
|
|
192
|
+
const elig = accEligibleCached(el);
|
|
193
|
+
if (elig && elig.eligible === false) continue;
|
|
194
|
+
}
|
|
149
195
|
|
|
150
|
-
|
|
151
|
-
if (isAccTreeEligible) {
|
|
152
|
-
const imgElig = accEligibleCached(img);
|
|
153
|
-
if (imgElig && imgElig.eligible === false) continue;
|
|
154
|
-
}
|
|
196
|
+
if (isRolePresentationExcluded(el)) continue;
|
|
155
197
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
198
|
+
// Rule-specific applicability (only elements that already have a text alternative mechanism)
|
|
199
|
+
let alt;
|
|
200
|
+
try {
|
|
201
|
+
alt = el.getAttribute('alt');
|
|
202
|
+
} catch {
|
|
203
|
+
alt = null;
|
|
204
|
+
}
|
|
205
|
+
if (alt === null) continue;
|
|
206
|
+
if (String(alt).trim() === '') continue; // only non-empty alt is applicable here
|
|
160
207
|
|
|
161
|
-
|
|
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
|
-
};
|
|
208
|
+
applicableCount += 1;
|
|
189
209
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
210
|
+
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
211
|
+
|
|
212
|
+
let altVal;
|
|
213
|
+
try {
|
|
214
|
+
altVal = String(el.getAttribute('alt') || '');
|
|
215
|
+
} catch {
|
|
216
|
+
altVal = '';
|
|
195
217
|
}
|
|
196
218
|
|
|
197
|
-
|
|
198
|
-
|
|
219
|
+
const baseOccurrence = {
|
|
220
|
+
summary: 'Review alt text on <area> for accuracy and appropriateness.',
|
|
221
|
+
hint: 'Ensure the alt text identifies the destination/action of the image map area in context.',
|
|
222
|
+
i18n: {
|
|
223
|
+
summaryKey: 'area_altQuality_summary_cantTell',
|
|
224
|
+
hintKey: 'area_altQuality_hint_cantTell',
|
|
225
|
+
params: { element: (el.tagName || '').toLowerCase() }
|
|
226
|
+
},
|
|
227
|
+
data: {
|
|
228
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
229
|
+
details: { alt: altVal.trim() } // optional but useful for manual review
|
|
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 });
|
|
199
237
|
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (applicableCount === 0) {
|
|
241
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
242
|
+
}
|
|
200
243
|
|
|
201
|
-
|
|
244
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences };
|
|
202
245
|
}
|
|
203
246
|
|
|
204
247
|
module.exports = { id, meta, runInPage };
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -14,34 +16,23 @@
|
|
|
14
16
|
* checked state is already exposed natively), but when an author sets
|
|
15
17
|
* it explicitly it should agree with the element's actual state —
|
|
16
18
|
* otherwise assistive technology is told something different from what
|
|
17
|
-
* a sighted user perceives.
|
|
18
|
-
* a widely-used reference engine's own `aria-conditional-attr` rule
|
|
19
|
-
* performs (verified directly against its `ariaConditionalCheckboxAttr`/
|
|
20
|
-
* `ariaConditionalRadioAttr` source).
|
|
19
|
+
* a sighted user perceives.
|
|
21
20
|
* @implementation-notes
|
|
22
21
|
* - Deliberately authored as `type: 'manual'` (cantTell-capped, never
|
|
23
|
-
* fail), unlike most ARIA-validity rules in this file family.
|
|
24
|
-
*
|
|
25
|
-
* mismatch it reports is a genuine, current-state fact. This engine
|
|
26
|
-
* analyzes STATIC markup only (no script execution) — `.checked`
|
|
22
|
+
* fail), unlike most ARIA-validity rules in this file family. This
|
|
23
|
+
* engine analyzes STATIC markup only (no script execution) — `.checked`
|
|
27
24
|
* reliably reflects the static `checked` attribute for freshly-parsed
|
|
28
|
-
* markup
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* `
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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.
|
|
25
|
+
* markup, but a very common, entirely legitimate real-world pattern is a
|
|
26
|
+
* JS-hydrated widget whose server-rendered HTML intentionally ships
|
|
27
|
+
* `aria-checked="true"` (or `"mixed"`) BEFORE client JS sets the native
|
|
28
|
+
* `checked`/`indeterminate` property to match on hydration. A hard
|
|
29
|
+
* `fail` here would misfire on that pattern constantly. Flagging for
|
|
30
|
+
* human review (cantTell) captures the same signal without the
|
|
31
|
+
* false-positive risk of asserting it as a confirmed violation.
|
|
38
32
|
* - `aria-checked="mixed"` is only meaningful for checkboxes (radio
|
|
39
|
-
* buttons have no indeterminate state)
|
|
40
|
-
* own `normalizeAriaChecked` (radio) vs `normalizeAriaChecked2` (checkbox)
|
|
41
|
-
* split, verified directly against its source.
|
|
33
|
+
* buttons have no indeterminate state).
|
|
42
34
|
* - Any `aria-checked` value other than "true"/"false"/"mixed" (checkbox)
|
|
43
|
-
* or "true"/"false" (radio) is treated as equivalent to "false"
|
|
44
|
-
* matches that reference engine's own normalization exactly.
|
|
35
|
+
* or "true"/"false" (radio) is treated as equivalent to "false".
|
|
45
36
|
* - Not rule-gated on isAccTreeEligible: whether the accessible state
|
|
46
37
|
* matches is still a markup-correctness property, while engine-level
|
|
47
38
|
* hidden-subtree filtering applies unless engineOptions.includeHiddenElements
|
|
@@ -52,7 +43,8 @@ const id = 'aria-checked-state-mismatch';
|
|
|
52
43
|
|
|
53
44
|
const meta = {
|
|
54
45
|
title: 'Native checkbox/radio aria-checked should match its actual state',
|
|
55
|
-
description:
|
|
46
|
+
description:
|
|
47
|
+
'Flags a native <input type="checkbox">/<input type="radio"> whose explicit aria-checked value disagrees with its actual checked/indeterminate state, for manual review.',
|
|
56
48
|
i18n: {
|
|
57
49
|
titleKey: 'ariaCheckedStateMismatch_title',
|
|
58
50
|
descriptionKey: 'ariaCheckedStateMismatch_description'
|
|
@@ -61,7 +53,13 @@ const meta = {
|
|
|
61
53
|
tags: ['wcag2a', 'wcag412', 'aria', 'forms', 'atomic', 'manual'],
|
|
62
54
|
wcagSc: ['4.1.2'],
|
|
63
55
|
normativeMappings: [
|
|
64
|
-
{
|
|
56
|
+
{
|
|
57
|
+
standard: 'WCAG',
|
|
58
|
+
version: '2.2',
|
|
59
|
+
requirement: '4.1.2',
|
|
60
|
+
title: 'Name, Role, Value',
|
|
61
|
+
conformanceLevel: 'A'
|
|
62
|
+
}
|
|
65
63
|
],
|
|
66
64
|
defaultSeverity: 'moderate',
|
|
67
65
|
category: 'robust',
|
|
@@ -71,12 +69,16 @@ const meta = {
|
|
|
71
69
|
};
|
|
72
70
|
|
|
73
71
|
function runInPage(ctx) {
|
|
74
|
-
const {
|
|
72
|
+
const { helpers, rule } = ctx;
|
|
75
73
|
|
|
76
|
-
function trim(v) {
|
|
74
|
+
function trim(v) {
|
|
75
|
+
return (v == null ? '' : String(v)).trim();
|
|
76
|
+
}
|
|
77
77
|
|
|
78
78
|
const nodes = helpers.queryAllSmart
|
|
79
|
-
? helpers.queryAllSmart(
|
|
79
|
+
? helpers.queryAllSmart(
|
|
80
|
+
'input[type="checkbox"][aria-checked], input[type="radio"][aria-checked]'
|
|
81
|
+
)
|
|
80
82
|
: helpers.queryAll('input[type="checkbox"][aria-checked], input[type="radio"][aria-checked]');
|
|
81
83
|
|
|
82
84
|
const occurrences = [];
|
|
@@ -97,9 +99,10 @@ function runInPage(ctx) {
|
|
|
97
99
|
|
|
98
100
|
let normalizedAriaChecked;
|
|
99
101
|
if (isCheckbox) {
|
|
100
|
-
normalizedAriaChecked =
|
|
102
|
+
normalizedAriaChecked =
|
|
103
|
+
rawAriaChecked === 'mixed' || rawAriaChecked === 'true' ? rawAriaChecked : 'false';
|
|
101
104
|
} else {
|
|
102
|
-
normalizedAriaChecked =
|
|
105
|
+
normalizedAriaChecked = rawAriaChecked === 'true' ? 'true' : 'false';
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
let actualState;
|
|
@@ -112,12 +115,13 @@ function runInPage(ctx) {
|
|
|
112
115
|
if (normalizedAriaChecked === actualState) continue;
|
|
113
116
|
|
|
114
117
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
115
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
118
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
116
119
|
|
|
117
120
|
occurrences.push({
|
|
118
121
|
selector: stableSelector,
|
|
119
122
|
html,
|
|
120
|
-
summary:
|
|
123
|
+
summary:
|
|
124
|
+
'This element’s aria-checked value does not match its actual checked/indeterminate state.',
|
|
121
125
|
hint: 'Set aria-checked to match the element’s real state, or remove it — a native checkbox/radio already exposes this state without it.',
|
|
122
126
|
i18n: {
|
|
123
127
|
summaryKey: 'ariaCheckedStateMismatch_summary_cantTell',
|
|
@@ -125,7 +129,12 @@ function runInPage(ctx) {
|
|
|
125
129
|
params: { ariaChecked: normalizedAriaChecked, actualState, type }
|
|
126
130
|
},
|
|
127
131
|
data: {
|
|
128
|
-
details: {
|
|
132
|
+
details: {
|
|
133
|
+
reasonCode: 'ARIA_CHECKED_STATE_MISMATCH',
|
|
134
|
+
ariaChecked: normalizedAriaChecked,
|
|
135
|
+
actualState,
|
|
136
|
+
type
|
|
137
|
+
}
|
|
129
138
|
}
|
|
130
139
|
});
|
|
131
140
|
}
|
|
@@ -134,7 +143,12 @@ function runInPage(ctx) {
|
|
|
134
143
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
135
144
|
}
|
|
136
145
|
if (occurrences.length) {
|
|
137
|
-
return {
|
|
146
|
+
return {
|
|
147
|
+
ruleId: rule.ruleId,
|
|
148
|
+
outcome: 'cantTell',
|
|
149
|
+
severity: rule.defaultSeverity || 'moderate',
|
|
150
|
+
occurrences
|
|
151
|
+
};
|
|
138
152
|
}
|
|
139
153
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
140
154
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check aria-text
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary role="text" elements should have no focusable descendants
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Elements with an explicit `role="text"`.
|
|
10
12
|
* @expectation
|
|
@@ -15,10 +17,9 @@
|
|
|
15
17
|
* focusable content — a focusable descendant inside a "this is just
|
|
16
18
|
* text" region is unreachable or confusing for keyboard/AT users.
|
|
17
19
|
* @implementation-notes
|
|
18
|
-
* - Not WCAG-normative
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* rationale).
|
|
20
|
+
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
21
|
+
* `type: 'manual'` rule, matching the Tier 1b precedent (see
|
|
22
|
+
* `landmark-unique`'s header comment for the shared rationale).
|
|
22
23
|
* - "Focusable descendant" is a presence check (link/button/form
|
|
23
24
|
* control/`[tabindex]`/`iframe`/`[contenteditable]`), the same
|
|
24
25
|
* conservative selector `scrollable-region-focusable` uses, not a full
|
|
@@ -29,7 +30,8 @@ const id = 'aria-text';
|
|
|
29
30
|
|
|
30
31
|
const meta = {
|
|
31
32
|
title: 'role="text" elements should have no focusable descendants',
|
|
32
|
-
description:
|
|
33
|
+
description:
|
|
34
|
+
'Checks that elements with role="text" contain no focusable descendant (link, button, form control, tabindex, iframe, or contenteditable).',
|
|
33
35
|
i18n: {
|
|
34
36
|
titleKey: 'ariaText_title',
|
|
35
37
|
descriptionKey: 'ariaText_description'
|
|
@@ -46,12 +48,14 @@ const meta = {
|
|
|
46
48
|
};
|
|
47
49
|
|
|
48
50
|
function runInPage(ctx) {
|
|
49
|
-
const {
|
|
51
|
+
const { helpers, rule } = ctx;
|
|
50
52
|
|
|
51
53
|
const FOCUSABLE_DESCENDANT_SELECTOR =
|
|
52
54
|
'a[href], button, input, select, textarea, [tabindex], iframe, [contenteditable]:not([contenteditable="false"])';
|
|
53
55
|
|
|
54
|
-
const nodes = helpers.queryAllSmart
|
|
56
|
+
const nodes = helpers.queryAllSmart
|
|
57
|
+
? helpers.queryAllSmart('[role="text"]')
|
|
58
|
+
: helpers.queryAll('[role="text"]');
|
|
55
59
|
|
|
56
60
|
const occurrences = [];
|
|
57
61
|
let applicableCount = 0;
|
|
@@ -61,7 +65,7 @@ function runInPage(ctx) {
|
|
|
61
65
|
|
|
62
66
|
applicableCount += 1;
|
|
63
67
|
|
|
64
|
-
let focusableDescendant
|
|
68
|
+
let focusableDescendant;
|
|
65
69
|
try {
|
|
66
70
|
focusableDescendant = el.querySelector(FOCUSABLE_DESCENDANT_SELECTOR);
|
|
67
71
|
} catch {
|
|
@@ -70,7 +74,7 @@ function runInPage(ctx) {
|
|
|
70
74
|
if (!focusableDescendant) continue;
|
|
71
75
|
|
|
72
76
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
73
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
77
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
74
78
|
|
|
75
79
|
const baseOccurrence = {
|
|
76
80
|
selector: stableSelector,
|
|
@@ -99,7 +103,12 @@ function runInPage(ctx) {
|
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
if (occurrences.length) {
|
|
102
|
-
return {
|
|
106
|
+
return {
|
|
107
|
+
ruleId: rule.ruleId,
|
|
108
|
+
outcome: 'cantTell',
|
|
109
|
+
severity: rule.defaultSeverity || 'minor',
|
|
110
|
+
occurrences
|
|
111
|
+
};
|
|
103
112
|
}
|
|
104
113
|
|
|
105
114
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|