@surea11y/core 1.6.0 → 1.7.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 +47 -0
- package/README.md +24 -38
- package/docs/ACT_RULE_MAPPING.md +8 -6
- package/docs/API_STABILITY.md +51 -3
- package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
- package/docs/DESIGN_CHALLENGES.md +66 -0
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +28 -2
- package/docs/INTEGRATION.md +4 -2
- package/docs/LIMITATIONS.md +3 -1
- package/docs/OUTPUT_SCHEMA.md +44 -6
- package/docs/POLICY.md +1 -1
- package/docs/RULE_AUTHORING.md +11 -12
- package/docs/RULE_CATALOG.md +76 -26
- package/docs/RULE_HELPERS.md +333 -0
- package/docs/RULE_TAXONOMY.md +25 -4
- package/docs/SARIF.md +21 -2
- package/docs/WCAG_CONFORMANCE.md +9 -1
- package/package.json +9 -3
- package/src/checks/automatic/aria-allowed-attr.js +6 -0
- package/src/checks/automatic/aria-allowed-role.js +32 -23
- package/src/checks/automatic/aria-braille-equivalent.js +18 -10
- package/src/checks/automatic/aria-conditional-attr.js +17 -10
- package/src/checks/automatic/aria-deprecated-role.js +12 -0
- package/src/checks/automatic/aria-hidden-body.js +1 -1
- package/src/checks/automatic/aria-prohibited-attr.js +5 -0
- package/src/checks/automatic/aria-prohibited-children.js +6 -6
- package/src/checks/automatic/aria-required-attr.js +59 -12
- package/src/checks/automatic/aria-required-children.js +33 -16
- package/src/checks/automatic/aria-required-parent.js +32 -6
- package/src/checks/automatic/aria-role-name-present.js +1 -1
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +74 -21
- package/src/checks/automatic/aria-valid-attr.js +14 -9
- package/src/checks/automatic/avoid-inline-spacing.js +133 -6
- package/src/checks/automatic/contrast-computable.js +10 -0
- package/src/checks/automatic/contrast-enhanced.js +12 -0
- package/src/checks/automatic/contrast-minimum.js +12 -0
- package/src/checks/automatic/css-orientation-lock.js +42 -5
- package/src/checks/automatic/duplicate-id-aria.js +5 -0
- package/src/checks/automatic/duplicate-id.js +13 -8
- package/src/checks/automatic/form-control-single-label.js +9 -0
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +5 -0
- package/src/checks/automatic/label-in-name.js +38 -56
- package/src/checks/automatic/link-in-text-block.js +279 -23
- package/src/checks/automatic/target-size-minimum.js +84 -5
- package/src/checks/automatic/td-has-header.js +19 -18
- package/src/checks/manual/form-control-label-quality-manual.js +134 -24
- package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
- package/src/checks/manual/password-paste-enabled-manual.js +255 -0
- package/src/core.js +3863 -44184
- package/src/earl.js +144 -0
- package/src/sarif.js +22 -2
- package/surea11y.browser.js +10 -41039
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- /package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +0 -0
|
@@ -240,6 +240,7 @@ function runInPage(ctx) {
|
|
|
240
240
|
|
|
241
241
|
const findings = [];
|
|
242
242
|
let sheetCount = 0;
|
|
243
|
+
let unreadableSheetCount = 0;
|
|
243
244
|
|
|
244
245
|
try {
|
|
245
246
|
const sheets = document.styleSheets || [];
|
|
@@ -248,7 +249,10 @@ function runInPage(ctx) {
|
|
|
248
249
|
try {
|
|
249
250
|
rules = sheet && sheet.cssRules ? sheet.cssRules : null;
|
|
250
251
|
} catch {
|
|
251
|
-
|
|
252
|
+
// Cross-origin, not inspectable. Counted, since a lock could be
|
|
253
|
+
// declared there and a `pass` would claim more than was checked.
|
|
254
|
+
unreadableSheetCount += 1;
|
|
255
|
+
continue;
|
|
252
256
|
}
|
|
253
257
|
if (!rules) continue;
|
|
254
258
|
sheetCount += 1;
|
|
@@ -264,15 +268,48 @@ function runInPage(ctx) {
|
|
|
264
268
|
// no-throw: treat as no accessible stylesheets
|
|
265
269
|
}
|
|
266
270
|
|
|
267
|
-
|
|
268
|
-
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
269
|
-
}
|
|
271
|
+
const scanTarget = document.documentElement || document.body || null;
|
|
270
272
|
|
|
273
|
+
// A lock found in a readable sheet is still a lock, so `fail` outranks the
|
|
274
|
+
// uncertainty below.
|
|
271
275
|
if (!findings.length) {
|
|
276
|
+
if (unreadableSheetCount > 0) {
|
|
277
|
+
return {
|
|
278
|
+
ruleId: rule.ruleId,
|
|
279
|
+
outcome: 'cantTell',
|
|
280
|
+
severity: rule.defaultSeverity || 'serious',
|
|
281
|
+
confidence: 'low',
|
|
282
|
+
occurrences: [
|
|
283
|
+
helpers.reportOccurrence(scanTarget, {
|
|
284
|
+
summary: `${unreadableSheetCount} stylesheet(s) could not be read, so whether this page locks its orientation could not be determined.`,
|
|
285
|
+
hint: 'Cross-origin stylesheets are not inspectable from the page. Check any third-party CSS for an orientation media query containing a rotate() transform, or re-run the scan with those stylesheets served same-origin.',
|
|
286
|
+
i18n: {
|
|
287
|
+
summaryKey: 'cssOrientationLock_summary_cantTell_unreadableSheets',
|
|
288
|
+
hintKey: 'cssOrientationLock_hint_cantTell_unreadableSheets',
|
|
289
|
+
params: { count: String(unreadableSheetCount) }
|
|
290
|
+
},
|
|
291
|
+
uncertainty: {
|
|
292
|
+
code: 'not-computable',
|
|
293
|
+
needed: 'The contents of the stylesheets this scan could not read.',
|
|
294
|
+
evidence: { unreadableSheetCount, reasonCode: 'STYLESHEETS_NOT_READABLE' }
|
|
295
|
+
},
|
|
296
|
+
data: {
|
|
297
|
+
details: {
|
|
298
|
+
reasonCode: 'STYLESHEETS_NOT_READABLE',
|
|
299
|
+
unreadableSheetCount
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
]
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (sheetCount === 0) {
|
|
307
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
308
|
+
}
|
|
272
309
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
273
310
|
}
|
|
274
311
|
|
|
275
|
-
const target =
|
|
312
|
+
const target = scanTarget;
|
|
276
313
|
const occurrences = findings.map((f) =>
|
|
277
314
|
helpers.reportOccurrence(target, {
|
|
278
315
|
summary: f.selectorText
|
|
@@ -128,6 +128,11 @@ function runInPage(ctx) {
|
|
|
128
128
|
hintKey: 'duplicateIdAria_hint_cantTell',
|
|
129
129
|
params: { id: refId, duplicateCount: String(els.length) }
|
|
130
130
|
},
|
|
131
|
+
uncertainty: {
|
|
132
|
+
code: 'judgement-required',
|
|
133
|
+
needed: 'Whether the first element carrying this id is the intended target.',
|
|
134
|
+
evidence: { id: refId, duplicateCount: els.length, resolvesTo: 'first' }
|
|
135
|
+
},
|
|
131
136
|
data: {
|
|
132
137
|
details: {
|
|
133
138
|
reasonCode: 'DUPLICATE_ID_ARIA_REFERENCED',
|
|
@@ -19,14 +19,19 @@
|
|
|
19
19
|
* two different shadow roots is not a duplicate.
|
|
20
20
|
* @implementation-notes
|
|
21
21
|
* - WCAG-VERSION SCOPED. SC 4.1.1 Parsing was removed in WCAG 2.2, so this
|
|
22
|
-
* rule is tagged `wcag2a` (its 2.0/2.1 origin) plus `wcag22-removed`.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* `
|
|
29
|
-
*
|
|
22
|
+
* rule is tagged `wcag2a` (its 2.0/2.1 origin) plus `wcag22-removed`.
|
|
23
|
+
* The engine acts on that tag itself: under a 2.2 target, which is the
|
|
24
|
+
* default, this rule still runs and still reports every duplicate it
|
|
25
|
+
* finds, but its fail is coerced to `cantTell` with a `wcagVersionScope`
|
|
26
|
+
* field saying why (see scopeOutcomeToWcagVersion in
|
|
27
|
+
* `src/core/dom-runner.js`). A consumer targeting 2.0 or 2.1
|
|
28
|
+
* (`engineOptions.wcagVersion`, or a tag set that implies it) gets the
|
|
29
|
+
* real 4.1.1 failure; one that would rather not see the rule at all
|
|
30
|
+
* under 2.2 still excludes it with `excludeTags: ['wcag22-removed']`.
|
|
31
|
+
* The alternative, dropping the SC mapping entirely, would have made a
|
|
32
|
+
* genuine 2.0/2.1 failure invisible to anyone conformance-testing
|
|
33
|
+
* against those versions. See `docs/ENGINE_OPTIONS.md` for the option
|
|
34
|
+
* and the tag, and `docs/DESIGN_CHALLENGES.md` for the decision history.
|
|
30
35
|
* - The defect outlives its Success Criterion: a duplicate id breaks
|
|
31
36
|
* `<label for>` association, fragment navigation, `getElementById`, and
|
|
32
37
|
* every ID-reference attribute, none of which stopped mattering when
|
|
@@ -173,6 +173,15 @@ function runInPage(ctx) {
|
|
|
173
173
|
hintKey: 'formControlSingleLabel_hint_cantTell',
|
|
174
174
|
params: { element: tag, labelCount: String(eligibleLabels.size) }
|
|
175
175
|
},
|
|
176
|
+
uncertainty: {
|
|
177
|
+
code: 'spec-only',
|
|
178
|
+
needed: 'Whether the empty label is filled in at runtime or is simply redundant.',
|
|
179
|
+
evidence: {
|
|
180
|
+
element: tag,
|
|
181
|
+
labelCount: eligibleLabels.size,
|
|
182
|
+
contributingLabelCount: contributing.length
|
|
183
|
+
}
|
|
184
|
+
},
|
|
176
185
|
data: {
|
|
177
186
|
details: {
|
|
178
187
|
reasonCode: 'FORM_FIELD_EXTRA_EMPTY_LABEL',
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @check identical-iframes-same-purpose
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary Frames sharing an accessible name must embed the same resource
|
|
9
|
+
* @standard WCAG 2.2
|
|
10
|
+
* @sc 4.1.2
|
|
11
|
+
* @applicability
|
|
12
|
+
* Applies to each set of two or more <iframe>/<frame> elements that are
|
|
13
|
+
* included in the accessibility tree and share the same non-empty
|
|
14
|
+
* accessible name, compared with whitespace collapsed. A frame named
|
|
15
|
+
* only by a mechanism that names nothing, or hidden from the
|
|
16
|
+
* accessibility tree, is not part of a set; a set needs two surviving
|
|
17
|
+
* members to exist at all.
|
|
18
|
+
* @expectation
|
|
19
|
+
* Every frame in a set resolves to the same resource. A shared name
|
|
20
|
+
* describes one resource, so two frames answering to it must embed the
|
|
21
|
+
* same one.
|
|
22
|
+
* @implementation-notes
|
|
23
|
+
* - Distinct from iframe-title-unique, which asks the stricter question of
|
|
24
|
+
* whether the title ATTRIBUTE repeats at all, and answers it from static
|
|
25
|
+
* markup. This rule keys on the computed accessible name and judges the
|
|
26
|
+
* resource behind it.
|
|
27
|
+
* - src values are compared as resolved absolute URLs with the fragment
|
|
28
|
+
* removed and a trailing slash normalised away, so a directory written
|
|
29
|
+
* both with and without one is a single resource.
|
|
30
|
+
* - Frames that resolve to different URLs are reported cantTell, never
|
|
31
|
+
* fail. Different resources can still be equivalent — differently worded
|
|
32
|
+
* copies of one page, or two adverts serving the same purpose — and
|
|
33
|
+
* nothing in the markup settles it. Comparing the embedded documents
|
|
34
|
+
* would not settle it either, since content differing is exactly what
|
|
35
|
+
* those equivalent cases look like.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const id = 'identical-iframes-same-purpose';
|
|
39
|
+
|
|
40
|
+
const meta = {
|
|
41
|
+
title: 'Frames with the same name embed the same resource',
|
|
42
|
+
description:
|
|
43
|
+
'Checks that <iframe>/<frame> elements sharing an accessible name embed the same resource, since one name can only describe one resource.',
|
|
44
|
+
i18n: {
|
|
45
|
+
titleKey: 'identicalIframesSamePurpose_title',
|
|
46
|
+
descriptionKey: 'identicalIframesSamePurpose_description'
|
|
47
|
+
},
|
|
48
|
+
helpUrl: null,
|
|
49
|
+
tags: ['wcag2a', 'wcag412', 'structure', 'atomic', 'automatic', 'name', 'iframe'],
|
|
50
|
+
wcagSc: ['4.1.2'],
|
|
51
|
+
normativeMappings: [
|
|
52
|
+
{
|
|
53
|
+
standard: 'WCAG',
|
|
54
|
+
version: '2.2',
|
|
55
|
+
requirement: '4.1.2',
|
|
56
|
+
title: 'Name, Role, Value',
|
|
57
|
+
conformanceLevel: 'A'
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
defaultSeverity: 'moderate',
|
|
61
|
+
category: 'robust',
|
|
62
|
+
type: 'automatic',
|
|
63
|
+
defaultConfidence: 'medium',
|
|
64
|
+
coverage: { facetsBySc: { '4.1.2': ['identical-iframes-same-purpose'] } }
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function runInPage(ctx) {
|
|
68
|
+
const { helpers, rule } = ctx;
|
|
69
|
+
|
|
70
|
+
const nodes = helpers.queryAllSmart
|
|
71
|
+
? helpers.queryAllSmart('iframe, frame')
|
|
72
|
+
: helpers.queryAll('iframe, frame');
|
|
73
|
+
|
|
74
|
+
function normalizedName(el) {
|
|
75
|
+
if (!helpers.getAccessibleNameInfo) return '';
|
|
76
|
+
let info;
|
|
77
|
+
try {
|
|
78
|
+
info = helpers.getAccessibleNameInfo(el, ctx, { maxRefs: 8 });
|
|
79
|
+
} catch {
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
if (!info || !info.present || !info.value) return '';
|
|
83
|
+
return String(info.value).replace(/\s+/g, ' ').trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A light-DOM child of a shadow host with no slot to land in is absent from
|
|
87
|
+
// the flat tree and so renders nowhere, which the shared eligibility helper
|
|
88
|
+
// does not model.
|
|
89
|
+
function isUnslotted(el) {
|
|
90
|
+
try {
|
|
91
|
+
let cur = el;
|
|
92
|
+
let guard = 0;
|
|
93
|
+
while (cur && cur.nodeType === 1 && guard++ < 100) {
|
|
94
|
+
const parent = cur.parentNode;
|
|
95
|
+
if (!parent || parent.nodeType !== 1) return false;
|
|
96
|
+
if (parent.shadowRoot && cur.assignedSlot == null) return true;
|
|
97
|
+
cur = parent;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function inAccessibilityTree(el) {
|
|
106
|
+
if (isUnslotted(el)) return false;
|
|
107
|
+
if (!helpers.isIncludedInAccessibilityTree) return true;
|
|
108
|
+
try {
|
|
109
|
+
return !!helpers.isIncludedInAccessibilityTree(el);
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A directory written with and without its trailing slash is one resource,
|
|
116
|
+
// and a fragment selects within a resource rather than naming another.
|
|
117
|
+
function resourceKey(el) {
|
|
118
|
+
let raw;
|
|
119
|
+
try {
|
|
120
|
+
raw = el.getAttribute('src');
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (raw == null || !String(raw).trim()) return null;
|
|
125
|
+
|
|
126
|
+
const doc = (ctx && ctx.document) || (el.ownerDocument ? el.ownerDocument : null);
|
|
127
|
+
const base = doc && doc.baseURI ? doc.baseURI : undefined;
|
|
128
|
+
try {
|
|
129
|
+
const u = new URL(String(raw).trim(), base);
|
|
130
|
+
let pathname = u.pathname;
|
|
131
|
+
if (pathname.length > 1 && pathname.charAt(pathname.length - 1) === '/') {
|
|
132
|
+
pathname = pathname.slice(0, -1);
|
|
133
|
+
}
|
|
134
|
+
return u.protocol + '//' + u.host + pathname + u.search;
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const groups = new Map();
|
|
141
|
+
|
|
142
|
+
for (const el of nodes) {
|
|
143
|
+
if (!el || !el.tagName) continue;
|
|
144
|
+
if (!inAccessibilityTree(el)) continue;
|
|
145
|
+
|
|
146
|
+
const name = normalizedName(el);
|
|
147
|
+
if (!name) continue;
|
|
148
|
+
|
|
149
|
+
const list = groups.get(name);
|
|
150
|
+
if (list) list.push(el);
|
|
151
|
+
else groups.set(name, [el]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const sets = [];
|
|
155
|
+
for (const [name, els] of groups) {
|
|
156
|
+
if (els.length >= 2) sets.push([name, els]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!sets.length) {
|
|
160
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const occurrences = [];
|
|
164
|
+
|
|
165
|
+
for (const [name, els] of sets) {
|
|
166
|
+
const keys = els.map(resourceKey);
|
|
167
|
+
const resolved = keys.filter((k) => k != null);
|
|
168
|
+
const allResolved = resolved.length === keys.length;
|
|
169
|
+
const allSame = allResolved && resolved.every((k) => k === resolved[0]);
|
|
170
|
+
if (allSame) continue;
|
|
171
|
+
|
|
172
|
+
for (let i = 0; i < els.length; i++) {
|
|
173
|
+
const el = els[i];
|
|
174
|
+
const tag = el.tagName.toLowerCase();
|
|
175
|
+
occurrences.push(
|
|
176
|
+
helpers.reportOccurrence(el, {
|
|
177
|
+
summary:
|
|
178
|
+
'This frame shares its accessible name with another frame that embeds a different resource.',
|
|
179
|
+
hint: 'Give each frame a name describing the resource it embeds, or point them at the same resource.',
|
|
180
|
+
i18n: {
|
|
181
|
+
summaryKey: 'identicalIframesSamePurpose_summary_cantTell',
|
|
182
|
+
hintKey: 'identicalIframesSamePurpose_hint_cantTell',
|
|
183
|
+
params: { element: tag, name }
|
|
184
|
+
},
|
|
185
|
+
uncertainty:
|
|
186
|
+
keys[i] == null
|
|
187
|
+
? {
|
|
188
|
+
code: 'not-computable',
|
|
189
|
+
needed: 'A resolvable src for this frame.',
|
|
190
|
+
evidence: { element: tag, name, setSize: els.length }
|
|
191
|
+
}
|
|
192
|
+
: {
|
|
193
|
+
code: 'equivalence-unknown',
|
|
194
|
+
needed: 'Whether the two resources serve the same purpose despite differing.',
|
|
195
|
+
evidence: {
|
|
196
|
+
element: tag,
|
|
197
|
+
name,
|
|
198
|
+
resource: keys[i],
|
|
199
|
+
otherResources: resolved.filter((k) => k !== keys[i]),
|
|
200
|
+
setSize: els.length
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
data: {
|
|
204
|
+
details: {
|
|
205
|
+
reasonCode:
|
|
206
|
+
keys[i] == null ? 'IFRAME_RESOURCE_UNRESOLVED' : 'IFRAME_RESOURCE_DIFFERS',
|
|
207
|
+
element: tag,
|
|
208
|
+
name,
|
|
209
|
+
resource: keys[i],
|
|
210
|
+
setSize: els.length
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (occurrences.length) {
|
|
219
|
+
return {
|
|
220
|
+
ruleId: rule.ruleId,
|
|
221
|
+
outcome: 'cantTell',
|
|
222
|
+
severity: rule.defaultSeverity || 'moderate',
|
|
223
|
+
occurrences
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -390,6 +390,11 @@ function runInPage(ctx) {
|
|
|
390
390
|
'This frame has tabindex="-1" and a focusable candidate, but focus moves immediately to another target. Verify keyboard reachability in a real browser.',
|
|
391
391
|
hint: 'If this is an intentional focus handoff, ensure keyboard users cannot remain on hidden/intermediate frame content.',
|
|
392
392
|
i18n: null,
|
|
393
|
+
uncertainty: {
|
|
394
|
+
code: 'runtime-dependent',
|
|
395
|
+
needed: 'Whether a keyboard user can reach this frame’s content in a real browser.',
|
|
396
|
+
evidence: { element: tag, focusRedirected: true }
|
|
397
|
+
},
|
|
393
398
|
data: {
|
|
394
399
|
details: {
|
|
395
400
|
reasonCode: 'IFRAME_TABINDEX_NEGATIVE_CONTENT_RUNTIME_REDIRECT',
|
|
@@ -254,55 +254,22 @@ function runInPage(ctx) {
|
|
|
254
254
|
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
// Real <label> elements associated with a native form control -- the
|
|
258
|
+
// shared dom-helpers.js implementation (a `for`-attribute index plus a
|
|
259
|
+
// bounded closest('label') walk), not the native `.labels`/`.control`
|
|
260
|
+
// pair: jsdom implements those as a whole-document walk on every access
|
|
261
|
+
// (see that function's own header comment for the full explanation),
|
|
262
|
+
// real cost this engine's Node/jsdom runtime pays, not just a browser
|
|
263
|
+
// realm this code happens to also run in.
|
|
257
264
|
function getAssociatedLabelElements(control) {
|
|
258
|
-
|
|
259
|
-
try {
|
|
260
|
-
if (control && control.labels && typeof control.labels.length === 'number') {
|
|
261
|
-
for (const l of control.labels) labels.push(l);
|
|
262
|
-
}
|
|
263
|
-
} catch {
|
|
264
|
-
// ignore
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Fallback: wrapped label
|
|
268
|
-
try {
|
|
269
|
-
const w = control && control.closest ? control.closest('label') : null;
|
|
270
|
-
if (w) labels.push(w);
|
|
271
|
-
} catch {
|
|
272
|
-
// ignore
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Fallback: label[for=id]. Uses `document` directly rather than
|
|
276
|
-
// ctx.root -- label[for] association is a document-wide relationship
|
|
277
|
-
// (IDs are document-unique), not bounded by whatever contextSelector
|
|
278
|
-
// region happens to be scanned, and ctx.root is an array (multi-region
|
|
279
|
-
// contextSelector support), not a single element with its own
|
|
280
|
-
// .querySelector to call directly.
|
|
281
|
-
try {
|
|
282
|
-
const idAttribute = control && control.getAttribute ? control.getAttribute('id') || '' : '';
|
|
283
|
-
const key = String(idAttribute || '').trim();
|
|
284
|
-
if (key && document && document.querySelector) {
|
|
285
|
-
const l = document.querySelector('label[for="' + CSS.escape(key) + '"]');
|
|
286
|
-
if (l) labels.push(l);
|
|
287
|
-
}
|
|
288
|
-
} catch {
|
|
289
|
-
// ignore
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// De-dupe in document order
|
|
293
|
-
const seen = new Set();
|
|
294
|
-
const out = [];
|
|
295
|
-
for (const l of labels) {
|
|
265
|
+
if (helpers && typeof helpers.getAssociatedLabelElements === 'function') {
|
|
296
266
|
try {
|
|
297
|
-
|
|
298
|
-
if (seen.has(l)) continue;
|
|
299
|
-
seen.add(l);
|
|
300
|
-
out.push(l);
|
|
267
|
+
return helpers.getAssociatedLabelElements(control) || [];
|
|
301
268
|
} catch {
|
|
302
|
-
|
|
269
|
+
return [];
|
|
303
270
|
}
|
|
304
271
|
}
|
|
305
|
-
return
|
|
272
|
+
return [];
|
|
306
273
|
}
|
|
307
274
|
|
|
308
275
|
function getVisibleTextLabelInfo(el) {
|
|
@@ -490,44 +457,45 @@ function runInPage(ctx) {
|
|
|
490
457
|
// character) is not something markup settles: the author may have meant
|
|
491
458
|
// either. Report without asserting a defect instead of failing or
|
|
492
459
|
// staying silent.
|
|
493
|
-
let
|
|
460
|
+
let uncertainReason = '';
|
|
494
461
|
if (!contains) {
|
|
495
462
|
if (containsWordRun(tokenize(visibleLabel, true), tokenize(accName, true), null)) {
|
|
496
|
-
|
|
463
|
+
uncertainReason = 'HYPHENATION_DIFFERS';
|
|
497
464
|
} else {
|
|
498
465
|
const abbreviated = abbreviatedWords(visibleLabel);
|
|
499
466
|
if (abbreviated.size && containsWordRun(labelTokens, nameTokens, abbreviated)) {
|
|
500
|
-
|
|
467
|
+
uncertainReason = 'POSSIBLE_ABBREVIATION';
|
|
501
468
|
} else if ((labelInfo.sourceElements || []).some(isIconFontElement)) {
|
|
502
|
-
|
|
469
|
+
uncertainReason = 'POSSIBLE_ICON_FONT_GLYPH';
|
|
503
470
|
} else if (isSingleSymbolicCharacter(visibleLabel, accNorm)) {
|
|
504
|
-
|
|
471
|
+
uncertainReason = 'POSSIBLE_SYMBOLIC_CHARACTER';
|
|
505
472
|
}
|
|
506
473
|
}
|
|
507
474
|
}
|
|
508
475
|
|
|
509
476
|
const isSymbolicUncertainty =
|
|
510
|
-
|
|
477
|
+
uncertainReason === 'POSSIBLE_ICON_FONT_GLYPH' ||
|
|
478
|
+
uncertainReason === 'POSSIBLE_SYMBOLIC_CHARACTER';
|
|
511
479
|
|
|
512
480
|
if (!contains) {
|
|
513
481
|
occurrences.push(
|
|
514
482
|
helpers.reportOccurrence(el, {
|
|
515
|
-
...(
|
|
516
|
-
summary:
|
|
483
|
+
...(uncertainReason ? { outcome: 'cantTell' } : null),
|
|
484
|
+
summary: uncertainReason
|
|
517
485
|
? 'Accessible name may not contain the visible label text.'
|
|
518
486
|
: 'Accessible name does not contain the visible label text.',
|
|
519
|
-
hint: !
|
|
487
|
+
hint: !uncertainReason
|
|
520
488
|
? 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).'
|
|
521
489
|
: isSymbolicUncertainty
|
|
522
490
|
? 'Check by hand: the visible text may render as an icon or symbol rather than literal words, which markup cannot settle.'
|
|
523
491
|
: 'Check by hand: the two differ only by an abbreviation or by hyphenation, which markup cannot settle.',
|
|
524
492
|
i18n: {
|
|
525
|
-
summaryKey: !
|
|
493
|
+
summaryKey: !uncertainReason
|
|
526
494
|
? 'labelInName_summary_fail'
|
|
527
495
|
: isSymbolicUncertainty
|
|
528
496
|
? 'labelInName_summary_cantTell_symbolic'
|
|
529
497
|
: 'labelInName_summary_cantTell',
|
|
530
|
-
hintKey: !
|
|
498
|
+
hintKey: !uncertainReason
|
|
531
499
|
? 'labelInName_hint_fail'
|
|
532
500
|
: isSymbolicUncertainty
|
|
533
501
|
? 'labelInName_hint_cantTell_symbolic'
|
|
@@ -539,9 +507,23 @@ function runInPage(ctx) {
|
|
|
539
507
|
nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
|
|
540
508
|
}
|
|
541
509
|
},
|
|
510
|
+
...(uncertainReason
|
|
511
|
+
? {
|
|
512
|
+
uncertainty: {
|
|
513
|
+
code: 'equivalence-unknown',
|
|
514
|
+
needed:
|
|
515
|
+
'Whether the accessible name and the visible label say the same thing to a user.',
|
|
516
|
+
evidence: {
|
|
517
|
+
visibleLabel,
|
|
518
|
+
accessibleName: accName,
|
|
519
|
+
difference: uncertainReason
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
: null),
|
|
542
524
|
data: {
|
|
543
525
|
details: {
|
|
544
|
-
reasonCode:
|
|
526
|
+
reasonCode: uncertainReason || 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
|
|
545
527
|
visibleLabel,
|
|
546
528
|
accessibleName: accName,
|
|
547
529
|
normalized: { visibleLabel: visibleNorm, accessibleName: accNorm },
|