@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,225 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check area-alt-present
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Accessible <area> elements must have an alt attribute
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.1.1
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to <area> elements that:
|
|
11
|
+
* 1) are in a <map> that is referenced by an <img usemap>, AND
|
|
12
|
+
* 2) the referencing <img> is eligible in the accessibility tree (best-effort), AND
|
|
13
|
+
* 3) the <area> itself is eligible in the accessibility tree (with engine exceptions).
|
|
14
|
+
* @expectation
|
|
15
|
+
* Each applicable <area> element has an alt attribute.
|
|
16
|
+
* The alt attribute may be empty (alt="").
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const id = 'area-alt-present';
|
|
20
|
+
|
|
21
|
+
const meta = {
|
|
22
|
+
title: '<area> must have an alt attribute',
|
|
23
|
+
description: 'Checks that <area> elements provide an alt attribute to support a text alternative mechanism.',
|
|
24
|
+
i18n: {
|
|
25
|
+
titleKey: 'area_altPresent_title',
|
|
26
|
+
descriptionKey: 'area_altPresent_description'
|
|
27
|
+
},
|
|
28
|
+
helpUrl: null,
|
|
29
|
+
tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'imagemap', 'atomic', 'automatic'],
|
|
30
|
+
wcagSc: ['1.1.1'],
|
|
31
|
+
normativeMappings: [
|
|
32
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
|
|
33
|
+
],
|
|
34
|
+
defaultSeverity: 'serious',
|
|
35
|
+
category: 'perceivable',
|
|
36
|
+
type: 'automatic',
|
|
37
|
+
defaultConfidence: 'high',
|
|
38
|
+
coverage: {
|
|
39
|
+
facetsBySc: {
|
|
40
|
+
'1.1.1': ['area-alt-attr-present']
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function runInPage(ctx) {
|
|
46
|
+
const { document, root, helpers, rule } = ctx;
|
|
47
|
+
const safeRoot = root || document;
|
|
48
|
+
|
|
49
|
+
const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
50
|
+
const queryAll = helpers && typeof helpers.queryAll === 'function'
|
|
51
|
+
? helpers.queryAll
|
|
52
|
+
: (sel) => {
|
|
53
|
+
try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
|
|
54
|
+
catch { return []; }
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
|
|
58
|
+
? helpers.getEligibilityInfo
|
|
59
|
+
: null;
|
|
60
|
+
|
|
61
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
62
|
+
? helpers.isAccTreeEligible
|
|
63
|
+
: null;
|
|
64
|
+
|
|
65
|
+
const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function'
|
|
66
|
+
? helpers.getAriaNameInfo
|
|
67
|
+
: null;
|
|
68
|
+
|
|
69
|
+
// --- image-map semantics (rule-local) ---
|
|
70
|
+
|
|
71
|
+
function normUsemap(val) {
|
|
72
|
+
try {
|
|
73
|
+
const s = String(val || '').trim();
|
|
74
|
+
if (!s) return '';
|
|
75
|
+
return (s[0] === '#') ? s.slice(1).trim().toLowerCase() : s.toLowerCase();
|
|
76
|
+
} catch {
|
|
77
|
+
return '';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getMapName(mapEl) {
|
|
82
|
+
try {
|
|
83
|
+
if (!mapEl || !mapEl.getAttribute) return '';
|
|
84
|
+
const n = String(mapEl.getAttribute('name') || mapEl.getAttribute('id') || '').trim();
|
|
85
|
+
return n ? n.toLowerCase() : '';
|
|
86
|
+
} catch {
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Cache mapName -> first referencing <img> in document order (deterministic)
|
|
92
|
+
const __usemapIndex = (() => {
|
|
93
|
+
const idx = new Map();
|
|
94
|
+
try {
|
|
95
|
+
const imgs = document && document.querySelectorAll ? document.querySelectorAll('img[usemap]') : [];
|
|
96
|
+
for (const img of imgs) {
|
|
97
|
+
if (!img || !img.getAttribute) continue;
|
|
98
|
+
const u = normUsemap(img.getAttribute('usemap'));
|
|
99
|
+
if (!u) continue;
|
|
100
|
+
if (!idx.has(u)) idx.set(u, img); // first in document order wins
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// ignore
|
|
104
|
+
}
|
|
105
|
+
return idx;
|
|
106
|
+
})();
|
|
107
|
+
|
|
108
|
+
function getReferencingImgForArea(areaEl) {
|
|
109
|
+
try {
|
|
110
|
+
if (!areaEl || !areaEl.closest) return null;
|
|
111
|
+
const map = areaEl.closest('map');
|
|
112
|
+
if (!map) return null;
|
|
113
|
+
|
|
114
|
+
const mapName = getMapName(map);
|
|
115
|
+
if (!mapName) return null;
|
|
116
|
+
|
|
117
|
+
return __usemapIndex.get(mapName) || null;
|
|
118
|
+
} catch {}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const areas = (() => {
|
|
123
|
+
try { return Array.from((queryAllSmart ? queryAllSmart('area') : queryAll('area')) || []); }
|
|
124
|
+
catch { return queryAll('area'); }
|
|
125
|
+
})();
|
|
126
|
+
|
|
127
|
+
if (!areas.length) {
|
|
128
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const occurrences = [];
|
|
132
|
+
let applicableCount = 0;
|
|
133
|
+
|
|
134
|
+
for (const el of areas) {
|
|
135
|
+
if (!el || !el.getAttribute) continue;
|
|
136
|
+
|
|
137
|
+
// 0) Must belong to a *used* image map: an <img usemap> must reference its <map>.
|
|
138
|
+
// If not used, <area> is not applicable (matches your observed focus behavior).
|
|
139
|
+
const img = getReferencingImgForArea(el);
|
|
140
|
+
if (!img) continue;
|
|
141
|
+
|
|
142
|
+
// 1) The referencing <img> must itself be eligible in the acc tree.
|
|
143
|
+
// This is the "visibility of map/area doesn't matter; the image does" policy.
|
|
144
|
+
if (isAccTreeEligible) {
|
|
145
|
+
const imgElig = (() => {
|
|
146
|
+
try { return isAccTreeEligible(img, ctx); } catch { return { eligible: true, reasons: [] }; }
|
|
147
|
+
})();
|
|
148
|
+
if (imgElig && imgElig.eligible === false) continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 2) The <area> itself must be eligible (aria-hidden/inert exceptions handled by helper).
|
|
152
|
+
if (isAccTreeEligible) {
|
|
153
|
+
const elig = (() => {
|
|
154
|
+
try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
|
|
155
|
+
})();
|
|
156
|
+
if (elig && elig.eligible === false) continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// From here: applicable
|
|
160
|
+
applicableCount += 1;
|
|
161
|
+
|
|
162
|
+
const hasAlt = el.getAttribute('alt') !== null;
|
|
163
|
+
if (hasAlt) continue;
|
|
164
|
+
|
|
165
|
+
// aria-label / aria-labelledby is also a valid, standards-recognized
|
|
166
|
+
// text-alternative mechanism for <area> (HTML-AAM accessible name
|
|
167
|
+
// computation includes ARIA naming before falling back to alt).
|
|
168
|
+
if (getAriaNameInfo) {
|
|
169
|
+
let ariaName = null;
|
|
170
|
+
try {
|
|
171
|
+
ariaName = getAriaNameInfo(el, ctx);
|
|
172
|
+
} catch {
|
|
173
|
+
ariaName = null;
|
|
174
|
+
}
|
|
175
|
+
if (ariaName && ariaName.present) continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// A non-empty title attribute is HTML-AAM's own next fallback naming
|
|
179
|
+
// source once alt is entirely absent -- also accepted by a widely-used
|
|
180
|
+
// reference engine's equivalent area-alt rule (non-empty-title, same "any" list as
|
|
181
|
+
// non-empty-alt/aria-label/aria-labelledby). See img-alt-present's
|
|
182
|
+
// sibling fix (2026-07-23, AliExpress's title-only logo <img>) for
|
|
183
|
+
// the real page this was found via -- same gap, same fix, different
|
|
184
|
+
// element.
|
|
185
|
+
const titleRaw = (() => { try { return el.getAttribute('title'); } catch { return null; } })();
|
|
186
|
+
if (titleRaw !== null && String(titleRaw).trim()) continue;
|
|
187
|
+
|
|
188
|
+
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
189
|
+
|
|
190
|
+
const baseOccurrence = {
|
|
191
|
+
// Leave selector/html empty so the engine can fill them from __node.
|
|
192
|
+
selector: '',
|
|
193
|
+
html: '',
|
|
194
|
+
summary: 'Missing alt attribute on <area>.',
|
|
195
|
+
hint: 'Add an alt attribute (use alt="" only for decorative areas).',
|
|
196
|
+
i18n: {
|
|
197
|
+
summaryKey: 'area_altPresent_summary_fail',
|
|
198
|
+
hintKey: 'area_altPresent_hint_fail',
|
|
199
|
+
params: { element: 'area' }
|
|
200
|
+
},
|
|
201
|
+
data: {
|
|
202
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
207
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
208
|
+
} else {
|
|
209
|
+
// Never compute selector/snippet in the rule.
|
|
210
|
+
occurrences.push({ ...baseOccurrence });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (applicableCount === 0) {
|
|
215
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!occurrences.length) {
|
|
219
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check aria-allowed-attr
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary aria-* attributes present must be permitted for the element's role
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 4.1.2
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to elements with an explicit, valid, non-abstract role that
|
|
11
|
+
* also carry at least one recognized aria-* attribute.
|
|
12
|
+
* @expectation
|
|
13
|
+
* Every recognized aria-* attribute present is either: (a) globally
|
|
14
|
+
* supported on any element (the "global" ARIA states/properties, e.g.
|
|
15
|
+
* aria-label/aria-hidden/aria-describedby), or (b) explicitly listed as
|
|
16
|
+
* a required or supported state/property for the element's role.
|
|
17
|
+
* @implementation-notes
|
|
18
|
+
* - Only evaluated for elements with an explicit role, since the global
|
|
19
|
+
* set already covers implicit-role elements without asserting anything
|
|
20
|
+
* role-specific; scope kept deliberately narrow to avoid false positives
|
|
21
|
+
* on implicit-role ARIA-in-HTML edge cases not modeled here.
|
|
22
|
+
* - SUPPORTED_ATTRS_BY_ROLE widened 2026-07-21 (7 new roles), cross-checked
|
|
23
|
+
* against a widely-used reference engine's own per-role `allowedAttrs`
|
|
24
|
+
* table AND verified each addition is an unambiguous, well-established ARIA
|
|
25
|
+
* fact rather than a blind import — that engine's own table has ~68 roles,
|
|
26
|
+
* far more than were reconciled here; this pass deliberately took only the
|
|
27
|
+
* additions with clear, specific supported attributes (not just the
|
|
28
|
+
* near-universal, thin `aria-expanded` that engine allows on most roles), and
|
|
29
|
+
* left the rest for a dedicated future full-reconciliation pass rather
|
|
30
|
+
* than rushing all 68 through at lower confidence:
|
|
31
|
+
* - `searchbox`: identical to the already-covered `textbox` (ARIA
|
|
32
|
+
* explicitly defines searchbox as textbox's subclass, same supported
|
|
33
|
+
* set).
|
|
34
|
+
* - `meter`: valuenow/valuemax/valuemin/valuetext, matching the
|
|
35
|
+
* already-covered slider/progressbar/scrollbar/spinbutton pattern
|
|
36
|
+
* (valuenow is also separately *required* on meter — see
|
|
37
|
+
* `aria-required-attr`'s own 2026-07-21 widening — but the two tables
|
|
38
|
+
* aren't unioned automatically, so it needs listing in both places).
|
|
39
|
+
* - `columnheader`/`rowheader`: same table-role family already covered
|
|
40
|
+
* by `gridcell`/`row` — sort/col-row-index/span are standard WAI-ARIA
|
|
41
|
+
* grid-model properties.
|
|
42
|
+
* - `dialog`/`alertdialog`: `aria-modal` — the spec-defining property
|
|
43
|
+
* that distinguishes a modal from a non-modal dialog; unambiguous.
|
|
44
|
+
* - `listitem`: level/posinset/setsize — standard flat-representation-
|
|
45
|
+
* of-hierarchy properties, same family as the already-covered
|
|
46
|
+
* `treeitem`.
|
|
47
|
+
* - `menu`/`menubar`/`toolbar`: activedescendant/orientation — standard
|
|
48
|
+
* composite-widget properties, same family as the already-covered
|
|
49
|
+
* `tablist`.
|
|
50
|
+
* - Not gated on isAccTreeEligible: this is a static markup property.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
const id = 'aria-allowed-attr';
|
|
54
|
+
|
|
55
|
+
const meta = {
|
|
56
|
+
title: 'aria-* attributes must be permitted for the element’s role',
|
|
57
|
+
description: 'Checks that every recognized aria-* attribute present on an element with an explicit role is either globally supported or supported by that role.',
|
|
58
|
+
i18n: {
|
|
59
|
+
titleKey: 'ariaAllowedAttr_title',
|
|
60
|
+
descriptionKey: 'ariaAllowedAttr_description'
|
|
61
|
+
},
|
|
62
|
+
helpUrl: null,
|
|
63
|
+
tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
|
|
64
|
+
wcagSc: ['4.1.2'],
|
|
65
|
+
normativeMappings: [
|
|
66
|
+
{ standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
|
|
67
|
+
],
|
|
68
|
+
defaultSeverity: 'moderate',
|
|
69
|
+
category: 'robust',
|
|
70
|
+
type: 'automatic',
|
|
71
|
+
defaultConfidence: 'medium',
|
|
72
|
+
coverage: { facetsBySc: { '4.1.2': ['aria-attr-allowed-for-role'] } }
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function runInPage(ctx) {
|
|
76
|
+
const { document, root, helpers, rule } = ctx;
|
|
77
|
+
const safeRoot = root || document;
|
|
78
|
+
|
|
79
|
+
const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
|
|
80
|
+
if (!ariaHelpers) {
|
|
81
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Global ARIA states/properties supported on (almost) any element,
|
|
85
|
+
// regardless of role, per the WAI-ARIA "Global States and Properties" list.
|
|
86
|
+
// Declared inside runInPage (rather than at module scope) because the
|
|
87
|
+
// build inlines only this function's own source text — see
|
|
88
|
+
// scripts/build-core.js header ("runInPage MUST be self-contained").
|
|
89
|
+
const GLOBAL_ATTRS = [
|
|
90
|
+
'aria-atomic', 'aria-braillelabel', 'aria-brailleroledescription', 'aria-busy',
|
|
91
|
+
'aria-controls', 'aria-current', 'aria-describedby', 'aria-description',
|
|
92
|
+
'aria-details', 'aria-disabled', 'aria-dropeffect', 'aria-errormessage',
|
|
93
|
+
'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid',
|
|
94
|
+
'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-live', 'aria-owns',
|
|
95
|
+
'aria-relevant', 'aria-roledescription'
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
// Per-role supported (non-global) states/properties. Deliberately
|
|
99
|
+
// conservative — see src/core/aria-helpers.js file header for the same
|
|
100
|
+
// confidence-scoping rationale; only well-established, unambiguous
|
|
101
|
+
// role/attribute pairings from the WAI-ARIA role definitions are listed.
|
|
102
|
+
const SUPPORTED_ATTRS_BY_ROLE = {
|
|
103
|
+
alertdialog: ['aria-modal'],
|
|
104
|
+
checkbox: ['aria-checked', 'aria-readonly', 'aria-required'],
|
|
105
|
+
columnheader: ['aria-sort', 'aria-colindex', 'aria-colspan', 'aria-readonly', 'aria-required', 'aria-rowindex', 'aria-rowspan', 'aria-selected'],
|
|
106
|
+
combobox: ['aria-expanded', 'aria-autocomplete', 'aria-readonly', 'aria-required'],
|
|
107
|
+
dialog: ['aria-modal'],
|
|
108
|
+
grid: ['aria-multiselectable', 'aria-readonly', 'aria-colcount', 'aria-rowcount'],
|
|
109
|
+
gridcell: ['aria-selected', 'aria-readonly', 'aria-required', 'aria-colindex', 'aria-colspan', 'aria-rowindex', 'aria-rowspan'],
|
|
110
|
+
heading: ['aria-level'],
|
|
111
|
+
listbox: ['aria-multiselectable', 'aria-readonly', 'aria-required', 'aria-orientation'],
|
|
112
|
+
listitem: ['aria-level', 'aria-posinset', 'aria-setsize'],
|
|
113
|
+
menu: ['aria-activedescendant', 'aria-orientation'],
|
|
114
|
+
menubar: ['aria-activedescendant', 'aria-orientation'],
|
|
115
|
+
menuitemcheckbox: ['aria-checked'],
|
|
116
|
+
menuitemradio: ['aria-checked'],
|
|
117
|
+
meter: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext'],
|
|
118
|
+
option: ['aria-selected', 'aria-checked', 'aria-posinset', 'aria-setsize'],
|
|
119
|
+
progressbar: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext'],
|
|
120
|
+
radio: ['aria-checked'],
|
|
121
|
+
radiogroup: ['aria-readonly', 'aria-required', 'aria-orientation'],
|
|
122
|
+
row: ['aria-selected', 'aria-level', 'aria-posinset', 'aria-setsize', 'aria-colindex', 'aria-rowindex'],
|
|
123
|
+
rowheader: ['aria-sort', 'aria-colindex', 'aria-colspan', 'aria-readonly', 'aria-required', 'aria-rowindex', 'aria-rowspan', 'aria-selected'],
|
|
124
|
+
scrollbar: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext', 'aria-orientation', 'aria-controls'],
|
|
125
|
+
searchbox: ['aria-activedescendant', 'aria-autocomplete', 'aria-multiline', 'aria-placeholder', 'aria-readonly', 'aria-required'],
|
|
126
|
+
separator: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext', 'aria-orientation'],
|
|
127
|
+
slider: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext', 'aria-orientation', 'aria-readonly'],
|
|
128
|
+
spinbutton: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext', 'aria-readonly', 'aria-required'],
|
|
129
|
+
switch: ['aria-checked'],
|
|
130
|
+
tab: ['aria-selected'],
|
|
131
|
+
table: ['aria-colcount', 'aria-rowcount'],
|
|
132
|
+
tablist: ['aria-multiselectable', 'aria-orientation'],
|
|
133
|
+
textbox: ['aria-activedescendant', 'aria-autocomplete', 'aria-multiline', 'aria-placeholder', 'aria-readonly', 'aria-required'],
|
|
134
|
+
toolbar: ['aria-activedescendant', 'aria-orientation'],
|
|
135
|
+
tree: ['aria-multiselectable', 'aria-readonly', 'aria-required', 'aria-orientation'],
|
|
136
|
+
treegrid: ['aria-multiselectable', 'aria-readonly', 'aria-required', 'aria-orientation', 'aria-colcount', 'aria-rowcount'],
|
|
137
|
+
treeitem: ['aria-checked', 'aria-selected', 'aria-expanded', 'aria-level', 'aria-posinset', 'aria-setsize']
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const globalSet = new Set(GLOBAL_ATTRS);
|
|
141
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
|
|
142
|
+
|
|
143
|
+
const occurrences = [];
|
|
144
|
+
let applicableCount = 0;
|
|
145
|
+
|
|
146
|
+
for (const el of nodes) {
|
|
147
|
+
if (!el || !el.attributes) continue;
|
|
148
|
+
|
|
149
|
+
const role = ariaHelpers.getExplicitRole(el);
|
|
150
|
+
if (!role || !ariaHelpers.isValidConcreteRole(role)) continue; // aria-roles-valid's concern
|
|
151
|
+
|
|
152
|
+
// Deliberately scoped: only roles with an explicit supported-attrs
|
|
153
|
+
// entry are evaluated (see file header — kept narrow to avoid
|
|
154
|
+
// over-claiming constraints for roles not yet modeled here).
|
|
155
|
+
const roleSupported = SUPPORTED_ATTRS_BY_ROLE[role];
|
|
156
|
+
if (!roleSupported) continue;
|
|
157
|
+
const roleSupportedSet = new Set(roleSupported);
|
|
158
|
+
|
|
159
|
+
let disallowed = null;
|
|
160
|
+
const attrs = el.attributes;
|
|
161
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
162
|
+
const name = String(attrs[i].name || '').toLowerCase();
|
|
163
|
+
if (name.slice(0, 5) !== 'aria-') continue;
|
|
164
|
+
if (!ariaHelpers.isValidAriaAttrName(name)) continue; // aria-valid-attr's concern
|
|
165
|
+
|
|
166
|
+
applicableCount += 1;
|
|
167
|
+
|
|
168
|
+
if (globalSet.has(name) || roleSupportedSet.has(name)) continue;
|
|
169
|
+
|
|
170
|
+
if (!disallowed) disallowed = [];
|
|
171
|
+
disallowed.push(name);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!disallowed || !disallowed.length) continue;
|
|
175
|
+
|
|
176
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
177
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
178
|
+
|
|
179
|
+
for (const name of disallowed) {
|
|
180
|
+
occurrences.push({
|
|
181
|
+
selector: stableSelector,
|
|
182
|
+
html,
|
|
183
|
+
summary: 'This ARIA attribute is not permitted for this element’s role.',
|
|
184
|
+
hint: 'Remove this attribute, or use a role that supports it.',
|
|
185
|
+
i18n: {
|
|
186
|
+
summaryKey: 'ariaAllowedAttr_summary_fail',
|
|
187
|
+
hintKey: 'ariaAllowedAttr_hint_fail',
|
|
188
|
+
params: { attr: name, role }
|
|
189
|
+
},
|
|
190
|
+
data: {
|
|
191
|
+
details: { reasonCode: 'ARIA_ATTR_NOT_ALLOWED', attr: name, role }
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (applicableCount === 0) {
|
|
198
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
199
|
+
}
|
|
200
|
+
if (occurrences.length) {
|
|
201
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
|
|
202
|
+
}
|
|
203
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check aria-allowed-role
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Explicit role must be permitted by the ARIA-in-HTML spec for its host element
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 4.1.2
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to elements with an explicit, valid, non-abstract role, where
|
|
11
|
+
* the host element/attribute combination has an asserted permitted-roles
|
|
12
|
+
* constraint in the ARIA-in-HTML table (src/core/aria-helpers.js
|
|
13
|
+
* ALLOWED_ROLES_BY_ELEMENT).
|
|
14
|
+
* @expectation
|
|
15
|
+
* The explicit role is one of the roles the ARIA-in-HTML specification
|
|
16
|
+
* permits for that host element.
|
|
17
|
+
* @implementation-notes
|
|
18
|
+
* - Deliberately scoped to elements present in ALLOWED_ROLES_BY_ELEMENT;
|
|
19
|
+
* elements without an asserted constraint are treated as "no constraint"
|
|
20
|
+
* (not flagged) rather than guessed at — see that table's header comment.
|
|
21
|
+
* - Not gated on isAccTreeEligible: this is a static markup property.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const id = 'aria-allowed-role';
|
|
25
|
+
|
|
26
|
+
const meta = {
|
|
27
|
+
title: 'Explicit role must be permitted for its host element',
|
|
28
|
+
description: 'Checks that an explicit role="" attribute is one of the roles the ARIA-in-HTML specification permits for the host element (e.g. role="tab" is not permitted on <nav>).',
|
|
29
|
+
i18n: {
|
|
30
|
+
titleKey: 'ariaAllowedRole_title',
|
|
31
|
+
descriptionKey: 'ariaAllowedRole_description'
|
|
32
|
+
},
|
|
33
|
+
helpUrl: null,
|
|
34
|
+
tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
|
|
35
|
+
wcagSc: ['4.1.2'],
|
|
36
|
+
normativeMappings: [
|
|
37
|
+
{ standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
|
|
38
|
+
],
|
|
39
|
+
defaultSeverity: 'moderate',
|
|
40
|
+
category: 'robust',
|
|
41
|
+
type: 'automatic',
|
|
42
|
+
defaultConfidence: 'high',
|
|
43
|
+
coverage: { facetsBySc: { '4.1.2': ['aria-role-allowed-for-element'] } }
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function runInPage(ctx) {
|
|
47
|
+
const { document, root, helpers, rule } = ctx;
|
|
48
|
+
const safeRoot = root || document;
|
|
49
|
+
|
|
50
|
+
const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
|
|
51
|
+
if (!ariaHelpers) {
|
|
52
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
|
|
56
|
+
|
|
57
|
+
const occurrences = [];
|
|
58
|
+
let applicableCount = 0;
|
|
59
|
+
|
|
60
|
+
for (const el of nodes) {
|
|
61
|
+
if (!el || !el.getAttribute) continue;
|
|
62
|
+
|
|
63
|
+
const role = ariaHelpers.getExplicitRole(el);
|
|
64
|
+
if (!role || !ariaHelpers.isValidConcreteRole(role)) continue; // aria-roles-valid's concern
|
|
65
|
+
|
|
66
|
+
const info = ariaHelpers.isRoleAllowedOnElement(el, role);
|
|
67
|
+
if (!info.constrained) continue; // no asserted constraint for this element
|
|
68
|
+
|
|
69
|
+
applicableCount += 1;
|
|
70
|
+
|
|
71
|
+
if (info.allowed) continue;
|
|
72
|
+
|
|
73
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
74
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
75
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
76
|
+
|
|
77
|
+
occurrences.push({
|
|
78
|
+
selector: stableSelector,
|
|
79
|
+
html,
|
|
80
|
+
summary: 'This role is not permitted on this element.',
|
|
81
|
+
hint: 'Use a role permitted for this element, or change the host element.',
|
|
82
|
+
i18n: {
|
|
83
|
+
summaryKey: 'ariaAllowedRole_summary_fail',
|
|
84
|
+
hintKey: 'ariaAllowedRole_hint_fail',
|
|
85
|
+
params: { role, element: tag }
|
|
86
|
+
},
|
|
87
|
+
data: {
|
|
88
|
+
details: { reasonCode: 'ARIA_ROLE_NOT_ALLOWED_FOR_ELEMENT', role, element: tag }
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (applicableCount === 0) {
|
|
94
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
95
|
+
}
|
|
96
|
+
if (occurrences.length) {
|
|
97
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
|
|
98
|
+
}
|
|
99
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check aria-braille-equivalent
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary aria-braillelabel/aria-brailleroledescription must not be the only naming mechanism
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 4.1.2
|
|
9
|
+
* @applicability
|
|
10
|
+
* Elements with a non-empty `aria-braillelabel` and/or non-empty
|
|
11
|
+
* `aria-brailleroledescription` attribute.
|
|
12
|
+
* @expectation
|
|
13
|
+
* Per the ARIA specification, `aria-braillelabel` is a Braille-specific
|
|
14
|
+
* SUPPLEMENT to (not a replacement for) the element's regular
|
|
15
|
+
* accessible name, and `aria-brailleroledescription` is a supplement
|
|
16
|
+
* to `aria-roledescription`. An element must therefore also have:
|
|
17
|
+
* - a non-empty accessible name from a non-braille mechanism, if it
|
|
18
|
+
* declares `aria-braillelabel`;
|
|
19
|
+
* - a non-empty `aria-roledescription`, if it declares
|
|
20
|
+
* `aria-brailleroledescription`.
|
|
21
|
+
* Using either braille-specific attribute as the ONLY naming mechanism
|
|
22
|
+
* leaves non-braille assistive technology (most screen readers, voice
|
|
23
|
+
* control, etc.) with no accessible name/role description at all.
|
|
24
|
+
* @implementation-notes
|
|
25
|
+
* - `aria-braillelabel`/`aria-brailleroledescription` do not participate
|
|
26
|
+
* in the standard accessible-name computation, so
|
|
27
|
+
* `helpers.getAccessibleNameInfo` already reflects only the "regular"
|
|
28
|
+
* (non-braille) name — no special-casing needed there.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const id = 'aria-braille-equivalent';
|
|
32
|
+
|
|
33
|
+
const meta = {
|
|
34
|
+
title: 'aria-braillelabel/aria-brailleroledescription must have a non-braille equivalent',
|
|
35
|
+
description:
|
|
36
|
+
'Checks that elements using aria-braillelabel also have a regular accessible name, and elements using aria-brailleroledescription also have aria-roledescription.',
|
|
37
|
+
i18n: {
|
|
38
|
+
titleKey: 'ariaBrailleEquivalent_title',
|
|
39
|
+
descriptionKey: 'ariaBrailleEquivalent_description'
|
|
40
|
+
},
|
|
41
|
+
helpUrl: null,
|
|
42
|
+
tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
|
|
43
|
+
wcagSc: ['4.1.2'],
|
|
44
|
+
normativeMappings: [
|
|
45
|
+
{ standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
|
|
46
|
+
],
|
|
47
|
+
defaultSeverity: 'serious',
|
|
48
|
+
category: 'robust',
|
|
49
|
+
type: 'automatic',
|
|
50
|
+
defaultConfidence: 'high',
|
|
51
|
+
coverage: { facetsBySc: { '4.1.2': ['aria-braille-equivalent'] } }
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function runInPage(ctx) {
|
|
55
|
+
const { document, root, helpers, rule } = ctx;
|
|
56
|
+
const safeRoot = root || document;
|
|
57
|
+
|
|
58
|
+
function trim(v) { return (v == null ? '' : String(v)).trim(); }
|
|
59
|
+
|
|
60
|
+
function getConservativeSubtreeText(container) {
|
|
61
|
+
// "Name from content" — recurses into descendants and uses each one's
|
|
62
|
+
// own accessible name (img alt, aria-label/aria-labelledby, title) when
|
|
63
|
+
// it has one, not just literal text nodes. See getContentNameInfo's
|
|
64
|
+
// header comment in src/core/dom-helpers.js for the full rationale
|
|
65
|
+
// (this replaced a text-node-only TreeWalker that missed the common
|
|
66
|
+
// "<a><img alt='...'></a>" logo-link / "<button><img alt='...'></button>"
|
|
67
|
+
// icon-button pattern).
|
|
68
|
+
if (helpers.getContentNameInfo) {
|
|
69
|
+
const info = helpers.getContentNameInfo(container, ctx);
|
|
70
|
+
return info && info.present ? info.value : '';
|
|
71
|
+
}
|
|
72
|
+
const t = (container && container.textContent) ? String(container.textContent) : '';
|
|
73
|
+
return t.replace(/\s+/g, ' ').trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const nodes = helpers.queryAllSmart
|
|
77
|
+
? helpers.queryAllSmart('[aria-braillelabel], [aria-brailleroledescription]', safeRoot)
|
|
78
|
+
: helpers.queryAll('[aria-braillelabel], [aria-brailleroledescription]', safeRoot);
|
|
79
|
+
|
|
80
|
+
const occurrences = [];
|
|
81
|
+
let applicableCount = 0;
|
|
82
|
+
|
|
83
|
+
for (const el of nodes) {
|
|
84
|
+
if (!el || !el.getAttribute) continue;
|
|
85
|
+
|
|
86
|
+
const brailleLabel = trim(el.getAttribute('aria-braillelabel'));
|
|
87
|
+
const brailleRoleDesc = trim(el.getAttribute('aria-brailleroledescription'));
|
|
88
|
+
if (!brailleLabel && !brailleRoleDesc) continue;
|
|
89
|
+
|
|
90
|
+
applicableCount += 1;
|
|
91
|
+
|
|
92
|
+
const missing = [];
|
|
93
|
+
|
|
94
|
+
if (brailleLabel) {
|
|
95
|
+
const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
|
|
96
|
+
const programmaticName = trim(nameInfo && typeof nameInfo.value === 'string' ? nameInfo.value : '');
|
|
97
|
+
const name = programmaticName || getConservativeSubtreeText(el);
|
|
98
|
+
if (!name) missing.push({ attr: 'aria-braillelabel', requires: 'an accessible name' });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (brailleRoleDesc) {
|
|
102
|
+
const roleDesc = trim(el.getAttribute('aria-roledescription'));
|
|
103
|
+
if (!roleDesc) missing.push({ attr: 'aria-brailleroledescription', requires: 'aria-roledescription' });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!missing.length) continue;
|
|
107
|
+
|
|
108
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
109
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
110
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
111
|
+
|
|
112
|
+
for (const m of missing) {
|
|
113
|
+
occurrences.push({
|
|
114
|
+
selector: stableSelector,
|
|
115
|
+
html,
|
|
116
|
+
summary: `This element has ${m.attr} but no ${m.requires}, its non-braille equivalent.`,
|
|
117
|
+
hint: `${m.attr} is a Braille-specific supplement, not a replacement — also provide ${m.requires}.`,
|
|
118
|
+
i18n: {
|
|
119
|
+
summaryKey: 'ariaBrailleEquivalent_summary_fail',
|
|
120
|
+
hintKey: 'ariaBrailleEquivalent_hint_fail',
|
|
121
|
+
params: { element: tag, attr: m.attr, requires: m.requires }
|
|
122
|
+
},
|
|
123
|
+
data: {
|
|
124
|
+
details: { reasonCode: 'BRAILLE_ATTR_WITHOUT_EQUIVALENT', attr: m.attr, requires: m.requires }
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (applicableCount === 0) {
|
|
131
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
132
|
+
}
|
|
133
|
+
if (occurrences.length) {
|
|
134
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
|
|
135
|
+
}
|
|
136
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { id, meta, runInPage };
|