@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
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @atomic true
|
|
8
8
|
* @summary Roles that require a specific ancestor/owner context role must have one
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
|
-
* @sc
|
|
10
|
+
* @sc 1.3.1
|
|
11
11
|
* @applicability
|
|
12
12
|
* Applies to elements with an explicit, valid, non-abstract role that is
|
|
13
13
|
* also one of the roles with a documented, non-empty "required context
|
|
@@ -43,6 +43,16 @@
|
|
|
43
43
|
* aria-required-children/aria-prohibited-children, applied for
|
|
44
44
|
* consistency; an element that isn't currently exposed to the
|
|
45
45
|
* accessibility tree is skipped (notApplicable), not failed.
|
|
46
|
+
* - Also honors WAI-ARIA's escape hatch for a widget script has not
|
|
47
|
+
* finished assembling: "When a widget is missing required owned elements due to
|
|
48
|
+
* script execution or loading, authors MUST mark a containing element
|
|
49
|
+
* with aria-busy equal to true." aria-required-children reads that from
|
|
50
|
+
* the container it is checking; read from the item's side it is an
|
|
51
|
+
* ancestor, so the walk looks up rather than at the element itself, and
|
|
52
|
+
* only the exact string "true" counts. It also outranks the
|
|
53
|
+
* roleless-generic-parent rule below, since aria-busy is itself a global
|
|
54
|
+
* ARIA attribute and would otherwise block the context search and fail
|
|
55
|
+
* the very markup the spec says to mark.
|
|
46
56
|
*/
|
|
47
57
|
|
|
48
58
|
const id = 'aria-required-parent';
|
|
@@ -56,14 +66,14 @@ const meta = {
|
|
|
56
66
|
descriptionKey: 'ariaRequiredParent_description'
|
|
57
67
|
},
|
|
58
68
|
helpUrl: null,
|
|
59
|
-
tags: ['wcag2a', '
|
|
60
|
-
wcagSc: ['
|
|
69
|
+
tags: ['wcag2a', 'wcag131', 'aria', 'structure', 'atomic', 'automatic'],
|
|
70
|
+
wcagSc: ['1.3.1'],
|
|
61
71
|
normativeMappings: [
|
|
62
72
|
{
|
|
63
73
|
standard: 'WCAG',
|
|
64
74
|
version: '2.2',
|
|
65
|
-
requirement: '
|
|
66
|
-
title: '
|
|
75
|
+
requirement: '1.3.1',
|
|
76
|
+
title: 'Info and Relationships',
|
|
67
77
|
conformanceLevel: 'A'
|
|
68
78
|
}
|
|
69
79
|
],
|
|
@@ -71,7 +81,7 @@ const meta = {
|
|
|
71
81
|
category: 'robust',
|
|
72
82
|
type: 'automatic',
|
|
73
83
|
defaultConfidence: 'medium',
|
|
74
|
-
coverage: { facetsBySc: { '
|
|
84
|
+
coverage: { facetsBySc: { '1.3.1': ['aria-role-required-context-parent'] } }
|
|
75
85
|
};
|
|
76
86
|
|
|
77
87
|
function runInPage(ctx) {
|
|
@@ -183,6 +193,21 @@ function runInPage(ctx) {
|
|
|
183
193
|
return n && n.parentElement ? n.parentElement : null;
|
|
184
194
|
};
|
|
185
195
|
|
|
196
|
+
// The escape hatch marks the container being assembled, not the item inside
|
|
197
|
+
// it, so this walks up instead of reading the element's own attribute.
|
|
198
|
+
function hasBusyAncestor(el) {
|
|
199
|
+
let cur = getComposedParent(el);
|
|
200
|
+
let guard = 0;
|
|
201
|
+
while (cur && guard++ < 200) {
|
|
202
|
+
if (cur.nodeType === 1 && cur.getAttribute) {
|
|
203
|
+
const v = cur.getAttribute('aria-busy');
|
|
204
|
+
if (v != null && String(v).trim().toLowerCase() === 'true') return true;
|
|
205
|
+
}
|
|
206
|
+
cur = getComposedParent(cur);
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
186
211
|
function hasAcceptableAncestorContext(el, acceptableRoles, ownRole) {
|
|
187
212
|
const allowsGroup = acceptableRoles.has('group');
|
|
188
213
|
let cur = getComposedParent(el);
|
|
@@ -252,6 +277,7 @@ function runInPage(ctx) {
|
|
|
252
277
|
if (!requiredContext || !requiredContext.length) continue; // no entry, or explicitly unconstrained
|
|
253
278
|
|
|
254
279
|
if (!isEligibleAcc(el)) continue; // not currently exposed to the accessibility tree
|
|
280
|
+
if (hasBusyAncestor(el)) continue; // author has signaled transient incompleteness per WAI-ARIA
|
|
255
281
|
|
|
256
282
|
applicableCount += 1;
|
|
257
283
|
|
|
@@ -45,7 +45,7 @@ const meta = {
|
|
|
45
45
|
descriptionKey: 'ariaRoleNamePresent_description'
|
|
46
46
|
},
|
|
47
47
|
helpUrl: null,
|
|
48
|
-
tags: ['wcag2a', 'wcag412', 'navigation', 'atomic', 'automatic', 'name'],
|
|
48
|
+
tags: ['wcag2a', 'wcag412', 'aria', 'navigation', 'atomic', 'automatic', 'name'],
|
|
49
49
|
wcagSc: ['4.1.2'],
|
|
50
50
|
normativeMappings: [
|
|
51
51
|
{
|
|
@@ -11,16 +11,15 @@
|
|
|
11
11
|
* @applicability
|
|
12
12
|
* Applies to any element with a non-empty role="" attribute in the composed DOM.
|
|
13
13
|
* @expectation
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* element happens to be hidden right now.
|
|
14
|
+
* At least one role token names a concrete, non-abstract ARIA role.
|
|
15
|
+
* Graded by what the element falls back to when none does:
|
|
16
|
+
* - FAIL on a roleless host (div, span, custom element), which is left
|
|
17
|
+
* exposed as generic, so the role the author meant reaches no one.
|
|
18
|
+
* - CANTTELL where the element has a native role (a <button>, <nav>,
|
|
19
|
+
* <a href>), which the accessibility tree keeps using. ACT 674b10 lists
|
|
20
|
+
* 4.1.2 as a secondary requirement only, "satisfied through the implicit
|
|
21
|
+
* role," so the bad token is worth reporting but is not itself the
|
|
22
|
+
* criterion failing.
|
|
24
23
|
*/
|
|
25
24
|
|
|
26
25
|
const id = 'aria-roles-valid';
|
|
@@ -64,7 +63,8 @@ function runInPage(ctx) {
|
|
|
64
63
|
? helpers.queryAllSmart('[role]')
|
|
65
64
|
: helpers.queryAll('[role]');
|
|
66
65
|
|
|
67
|
-
const
|
|
66
|
+
const failOccurrences = [];
|
|
67
|
+
const cantTellOccurrences = [];
|
|
68
68
|
let applicableCount = 0;
|
|
69
69
|
|
|
70
70
|
// Programmatically hidden per the ACT glossary: display:none, visibility not
|
|
@@ -121,8 +121,42 @@ function runInPage(ctx) {
|
|
|
121
121
|
const isKnown = tokens.some((t) => ariaHelpers.isKnownRole(t));
|
|
122
122
|
const reasonCode = !isKnown ? 'ARIA_ROLE_INVALID' : 'ARIA_ROLE_ABSTRACT';
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
// An unusable role token leaves the element on its native role, when it
|
|
125
|
+
// has one: ACT 674b10 lists 4.1.2 as only a secondary requirement for
|
|
126
|
+
// exactly that reason. A roleless host (div, span, custom element) has
|
|
127
|
+
// nothing to fall back to and is exposed as generic instead.
|
|
128
|
+
const nativeRole =
|
|
129
|
+
typeof ariaHelpers.getNativeRoleForElement === 'function'
|
|
130
|
+
? ariaHelpers.getNativeRoleForElement(el) || ''
|
|
131
|
+
: '';
|
|
132
|
+
|
|
133
|
+
if (nativeRole) {
|
|
134
|
+
cantTellOccurrences.push(
|
|
135
|
+
helpers.reportOccurrence(el, {
|
|
136
|
+
occurrenceOutcome: 'cantTell',
|
|
137
|
+
summary: `The role attribute value is not usable, so this element is still exposed as its native role="${nativeRole}".`,
|
|
138
|
+
hint: 'Fix or remove the role token; assistive technology is using the native role in the meantime.',
|
|
139
|
+
i18n: {
|
|
140
|
+
summaryKey: 'ariaRolesValid_summary_cantTell',
|
|
141
|
+
hintKey: 'ariaRolesValid_hint_cantTell',
|
|
142
|
+
params: { role, nativeRole }
|
|
143
|
+
},
|
|
144
|
+
uncertainty: {
|
|
145
|
+
code: 'spec-only',
|
|
146
|
+
needed: 'Whether the native role this element falls back to is the one intended.',
|
|
147
|
+
evidence: { role, nativeRole, fallbackExposed: true }
|
|
148
|
+
},
|
|
149
|
+
data: {
|
|
150
|
+
details: { reasonCode, role, nativeRole }
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
failOccurrences.push(
|
|
125
158
|
helpers.reportOccurrence(el, {
|
|
159
|
+
occurrenceOutcome: 'fail',
|
|
126
160
|
summary: !isKnown
|
|
127
161
|
? 'The role attribute value is not a recognized ARIA role.'
|
|
128
162
|
: 'The role attribute value is an abstract ARIA role, which must not be used directly.',
|
|
@@ -146,15 +180,12 @@ function runInPage(ctx) {
|
|
|
146
180
|
if (applicableCount === 0) {
|
|
147
181
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
148
182
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
183
|
+
const resolved = helpers.resolveTieredOutcome(
|
|
184
|
+
failOccurrences,
|
|
185
|
+
cantTellOccurrences,
|
|
186
|
+
rule.defaultSeverity || 'serious'
|
|
187
|
+
);
|
|
188
|
+
return { ruleId: rule.ruleId, ...resolved };
|
|
158
189
|
}
|
|
159
190
|
|
|
160
191
|
module.exports = { id, meta, runInPage };
|
|
@@ -38,6 +38,19 @@
|
|
|
38
38
|
* text names it as a non-required property whose target "may be created
|
|
39
39
|
* in response to an event that may or may not happen" (a validation
|
|
40
40
|
* error message rendered only once the error actually occurs).
|
|
41
|
+
* - aria-controls is never a fail on a target that doesn't resolve, and
|
|
42
|
+
* this is the one place the rule reports two tiers. The controlled
|
|
43
|
+
* element is routinely built when the widget opens, so a static scan
|
|
44
|
+
* that cannot find it has not found a defect; it has found markup it
|
|
45
|
+
* cannot decide. A collapsed widget (aria-expanded="false" or
|
|
46
|
+
* aria-selected="false") passes outright, since the absence is exactly
|
|
47
|
+
* what that state means; anything else is a `cantTell` for human
|
|
48
|
+
* review. Every other idref/idref-list attribute keeps its fail: a
|
|
49
|
+
* dangling aria-labelledby or aria-owns names content that was supposed
|
|
50
|
+
* to be there already.
|
|
51
|
+
* - Two tiers in one run means helpers.resolveTieredOutcome decides the
|
|
52
|
+
* aggregate: a real fail elsewhere on the page still reports fail, and
|
|
53
|
+
* the aria-controls occurrences ride along rather than being dropped.
|
|
41
54
|
*/
|
|
42
55
|
|
|
43
56
|
const id = 'aria-valid-attr-value';
|
|
@@ -79,13 +92,15 @@ function runInPage(ctx) {
|
|
|
79
92
|
|
|
80
93
|
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*') : helpers.queryAll('*');
|
|
81
94
|
|
|
82
|
-
const
|
|
95
|
+
const failOccurrences = [];
|
|
96
|
+
const cantTellOccurrences = [];
|
|
83
97
|
let applicableCount = 0;
|
|
84
98
|
|
|
85
99
|
for (const el of nodes) {
|
|
86
100
|
if (!el || !el.attributes || !el.getAttribute) continue;
|
|
87
101
|
|
|
88
102
|
let invalid = null;
|
|
103
|
+
let review = null;
|
|
89
104
|
const attrs = el.attributes;
|
|
90
105
|
for (let i = 0; i < attrs.length; i++) {
|
|
91
106
|
const name = String(attrs[i].name || '').toLowerCase();
|
|
@@ -95,21 +110,26 @@ function runInPage(ctx) {
|
|
|
95
110
|
applicableCount += 1;
|
|
96
111
|
|
|
97
112
|
const rawValue = el.getAttribute(name);
|
|
98
|
-
const result = ariaHelpers.validateAttrValue(name, rawValue);
|
|
99
|
-
if (
|
|
113
|
+
const result = ariaHelpers.validateAttrValue(name, rawValue, el);
|
|
114
|
+
if (result.valid) continue;
|
|
115
|
+
|
|
116
|
+
const item = {
|
|
117
|
+
name,
|
|
118
|
+
value: rawValue == null ? '' : String(rawValue),
|
|
119
|
+
reason: result.reason
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (result.review) {
|
|
123
|
+
if (!review) review = [];
|
|
124
|
+
review.push(item);
|
|
125
|
+
} else {
|
|
100
126
|
if (!invalid) invalid = [];
|
|
101
|
-
invalid.push(
|
|
102
|
-
name,
|
|
103
|
-
value: rawValue == null ? '' : String(rawValue),
|
|
104
|
-
reason: result.reason
|
|
105
|
-
});
|
|
127
|
+
invalid.push(item);
|
|
106
128
|
}
|
|
107
129
|
}
|
|
108
130
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
for (const item of invalid) {
|
|
112
|
-
occurrences.push(
|
|
131
|
+
for (const item of invalid || []) {
|
|
132
|
+
failOccurrences.push(
|
|
113
133
|
helpers.reportOccurrence(el, {
|
|
114
134
|
summary: 'This element has an ARIA attribute with an invalid value.',
|
|
115
135
|
hint: 'Use a value that matches the attribute’s expected type (see the WAI-ARIA specification for this attribute).',
|
|
@@ -129,20 +149,53 @@ function runInPage(ctx) {
|
|
|
129
149
|
})
|
|
130
150
|
);
|
|
131
151
|
}
|
|
152
|
+
|
|
153
|
+
for (const item of review || []) {
|
|
154
|
+
cantTellOccurrences.push(
|
|
155
|
+
helpers.reportOccurrence(el, {
|
|
156
|
+
summary:
|
|
157
|
+
'No element with this id exists right now, so the engine cannot tell whether this reference is wrong.',
|
|
158
|
+
hint: 'Confirm the controlled element is created when the widget opens; if it never exists, remove or correct the reference.',
|
|
159
|
+
i18n: {
|
|
160
|
+
summaryKey: 'ariaValidAttrValue_summary_cantTell_idref',
|
|
161
|
+
hintKey: 'ariaValidAttrValue_hint_cantTell_idref',
|
|
162
|
+
params: { attr: item.name, value: item.value }
|
|
163
|
+
},
|
|
164
|
+
uncertainty: {
|
|
165
|
+
code: 'runtime-dependent',
|
|
166
|
+
needed: 'Whether the widget creates the referenced element when it opens.',
|
|
167
|
+
evidence: {
|
|
168
|
+
attribute: item.name,
|
|
169
|
+
referencedId: item.value,
|
|
170
|
+
resolvedAtScanTime: false
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
data: {
|
|
174
|
+
details: {
|
|
175
|
+
reasonCode: 'ARIA_ATTR_VALUE_TARGET_ABSENT',
|
|
176
|
+
attr: item.name,
|
|
177
|
+
value: item.value,
|
|
178
|
+
valueReason: item.reason
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
);
|
|
183
|
+
}
|
|
132
184
|
}
|
|
133
185
|
|
|
134
186
|
if (applicableCount === 0) {
|
|
135
187
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
136
188
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
189
|
+
|
|
190
|
+
// See helpers.resolveTieredOutcome's own header comment
|
|
191
|
+
// (src/core/dom-helpers.js): a fail-tier finding never silently discards
|
|
192
|
+
// cantTell-tier findings from the same run.
|
|
193
|
+
const resolved = helpers.resolveTieredOutcome(
|
|
194
|
+
failOccurrences,
|
|
195
|
+
cantTellOccurrences,
|
|
196
|
+
rule.defaultSeverity || 'serious'
|
|
197
|
+
);
|
|
198
|
+
return { ruleId: rule.ruleId, ...resolved };
|
|
146
199
|
}
|
|
147
200
|
|
|
148
201
|
module.exports = { id, meta, runInPage };
|
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
* specification (catches typos / made-up attribute names, which are
|
|
17
17
|
* silently ignored by assistive technology and therefore a real,
|
|
18
18
|
* deterministic defect).
|
|
19
|
+
* Reported at CANTTELL rather than FAIL: an aria-* attribute the spec
|
|
20
|
+
* does not define is inert, so nothing about the element's exposed name,
|
|
21
|
+
* role or value changes because it is there. Where the author meant a real
|
|
22
|
+
* attribute and the element ends up without a name, that absence is the
|
|
23
|
+
* naming rules' decision, not this one's. ACT 5f99a7 maps 1.3.1/4.1.2 as
|
|
24
|
+
* secondary requirements, "less strict" than the rule itself.
|
|
19
25
|
* @implementation-notes
|
|
20
26
|
* - Distinct from aria-valid-attr-value (which validates the VALUE
|
|
21
27
|
* of a recognized attribute), this rule only validates the attribute
|
|
@@ -96,6 +102,12 @@ function runInPage(ctx) {
|
|
|
96
102
|
hintKey: 'ariaValidAttr_hint_fail',
|
|
97
103
|
params: { attr: name }
|
|
98
104
|
},
|
|
105
|
+
uncertainty: {
|
|
106
|
+
code: 'spec-only',
|
|
107
|
+
needed:
|
|
108
|
+
'Whether the misspelling cost the element a name or state it has no other source for.',
|
|
109
|
+
evidence: { attribute: name, definedInAria: false, exposedEffect: 'none' }
|
|
110
|
+
},
|
|
99
111
|
data: {
|
|
100
112
|
details: { reasonCode: 'ARIA_ATTR_INVALID', attr: name }
|
|
101
113
|
}
|
|
@@ -107,15 +119,8 @@ function runInPage(ctx) {
|
|
|
107
119
|
if (applicableCount === 0) {
|
|
108
120
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
109
121
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
ruleId: rule.ruleId,
|
|
113
|
-
outcome: 'fail',
|
|
114
|
-
severity: rule.defaultSeverity || 'serious',
|
|
115
|
-
occurrences
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
122
|
+
const resolved = helpers.resolveTieredOutcome([], occurrences, rule.defaultSeverity || 'serious');
|
|
123
|
+
return { ruleId: rule.ruleId, ...resolved };
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
module.exports = { id, meta, runInPage };
|
|
@@ -25,8 +25,12 @@
|
|
|
25
25
|
* - Within one declaration block, importance outranks order, so the effective
|
|
26
26
|
* declaration is the last `!important` one for that property.
|
|
27
27
|
* - ACT 78fd32/24afc2/9e45ec additionally require the text to contain a soft
|
|
28
|
-
* wrap break, which
|
|
29
|
-
*
|
|
28
|
+
* wrap break, which layout would settle and this cannot. Two shapes do
|
|
29
|
+
* establish that no wrap is possible -- text not allowed to wrap, and a
|
|
30
|
+
* fixed-width element inside a horizontally scrolling ancestor -- and those
|
|
31
|
+
* are reported for review rather than failed. Anything else is treated as
|
|
32
|
+
* wrapping, so a forced value on text that never wraps for some other reason
|
|
33
|
+
* is still reported.
|
|
30
34
|
*/
|
|
31
35
|
|
|
32
36
|
const id = 'avoid-inline-spacing';
|
|
@@ -83,6 +87,8 @@ function runInPage(ctx) {
|
|
|
83
87
|
|
|
84
88
|
const occurrences = [];
|
|
85
89
|
let applicableCount = 0;
|
|
90
|
+
const undecided = [];
|
|
91
|
+
const noWrap = [];
|
|
86
92
|
|
|
87
93
|
// Within one declaration block, importance wins over order, so the last
|
|
88
94
|
// important declaration is the one that takes effect. Passed Example 5 of ACT
|
|
@@ -148,6 +154,36 @@ function runInPage(ctx) {
|
|
|
148
154
|
return null;
|
|
149
155
|
}
|
|
150
156
|
|
|
157
|
+
// ACT 78fd32/24afc2/9e45ec apply only to text that contains a soft wrap
|
|
158
|
+
// break. Layout settles that; short of it, two shapes establish that no wrap
|
|
159
|
+
// can happen -- text not allowed to wrap, and a fixed-width element inside a
|
|
160
|
+
// horizontally scrolling ancestor, which narrowing the viewport cannot reach.
|
|
161
|
+
function cannotSoftWrap(el) {
|
|
162
|
+
const cs = computedStyleOf(el);
|
|
163
|
+
const whiteSpace = cs ? String(cs.whiteSpace || '').toLowerCase() : '';
|
|
164
|
+
if (whiteSpace === 'nowrap' || whiteSpace === 'pre') return true;
|
|
165
|
+
|
|
166
|
+
if (!/(^|;)\s*width\s*:/i.test(String(el.getAttribute('style') || ''))) return false;
|
|
167
|
+
|
|
168
|
+
const chain =
|
|
169
|
+
helpers && typeof helpers.ancestorsIncludingSelf === 'function'
|
|
170
|
+
? helpers.ancestorsIncludingSelf(el)
|
|
171
|
+
: null;
|
|
172
|
+
const ancestors = chain || [];
|
|
173
|
+
if (!chain) {
|
|
174
|
+
for (let a = el.parentElement; a; a = a.parentElement) ancestors.push(a);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const ancestor of ancestors) {
|
|
178
|
+
if (ancestor === el) continue;
|
|
179
|
+
const acs = computedStyleOf(ancestor);
|
|
180
|
+
if (!acs) continue;
|
|
181
|
+
const overflowX = String(acs.overflowX || acs.overflow || '').toLowerCase();
|
|
182
|
+
if (overflowX === 'scroll' || overflowX === 'auto') return true;
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
151
187
|
// ACT scopes these rules to text visible on screen, and text pushed far off
|
|
152
188
|
// canvas is the one hidden shape the shared eligibility check keeps eligible.
|
|
153
189
|
function isOffScreen(el) {
|
|
@@ -228,6 +264,7 @@ function runInPage(ctx) {
|
|
|
228
264
|
if (!hasVisibleTextChild(el) || !isRendered(el) || isOffScreen(el)) continue;
|
|
229
265
|
|
|
230
266
|
const flagged = [];
|
|
267
|
+
const unresolved = [];
|
|
231
268
|
let inScope = false;
|
|
232
269
|
for (const prop of SPACING_PROPS) {
|
|
233
270
|
const decl = effectiveDeclaration(raw, prop);
|
|
@@ -235,14 +272,28 @@ function runInPage(ctx) {
|
|
|
235
272
|
if (INHERITED_KEYWORDS.indexOf(decl.value.toLowerCase()) !== -1) continue;
|
|
236
273
|
inScope = true;
|
|
237
274
|
const ratio = spacingRatio(el, prop, decl.value);
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
if (ratio === null)
|
|
275
|
+
// Not flagged, since `fail` needs a measured value -- but recorded, so
|
|
276
|
+
// the element does not fall through to `pass` unmeasured.
|
|
277
|
+
if (ratio === null) {
|
|
278
|
+
unresolved.push(prop);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
241
281
|
if (ratio < MIN_RATIO[prop]) flagged.push(prop);
|
|
242
282
|
}
|
|
243
283
|
|
|
244
284
|
if (inScope) applicableCount += 1;
|
|
245
|
-
if (!flagged.length)
|
|
285
|
+
if (!flagged.length) {
|
|
286
|
+
if (unresolved.length) undecided.push({ el, props: unresolved.slice() });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// A forced value on text that cannot wrap is outside these ACT rules'
|
|
291
|
+
// applicability, and whether it wraps is not decidable here, so it is
|
|
292
|
+
// reported for review rather than failed.
|
|
293
|
+
if (cannotSoftWrap(el)) {
|
|
294
|
+
noWrap.push({ el, props: flagged.slice() });
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
246
297
|
|
|
247
298
|
const tag = el.tagName.toLowerCase();
|
|
248
299
|
|
|
@@ -273,6 +324,82 @@ function runInPage(ctx) {
|
|
|
273
324
|
occurrences
|
|
274
325
|
};
|
|
275
326
|
}
|
|
327
|
+
if (noWrap.length) {
|
|
328
|
+
return {
|
|
329
|
+
ruleId: rule.ruleId,
|
|
330
|
+
outcome: 'cantTell',
|
|
331
|
+
severity: rule.defaultSeverity || 'moderate',
|
|
332
|
+
confidence: 'low',
|
|
333
|
+
occurrences: noWrap.map(({ el, props }) =>
|
|
334
|
+
helpers.reportOccurrence(el, {
|
|
335
|
+
summary: `This element's inline style forces ${props.join(', ')} with !important, but its text does not appear able to wrap, so the text-spacing criterion may not apply to it.`,
|
|
336
|
+
hint: 'Confirm whether this text ever wraps. If it cannot, the criterion does not apply; if it can, remove !important or set a value that already meets the metric.',
|
|
337
|
+
i18n: {
|
|
338
|
+
summaryKey: 'avoidInlineSpacing_summary_cantTell_noSoftWrap',
|
|
339
|
+
hintKey: 'avoidInlineSpacing_hint_cantTell_noSoftWrap',
|
|
340
|
+
params: {
|
|
341
|
+
element: (el.tagName || '').toLowerCase(),
|
|
342
|
+
properties: props.join(', ')
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
uncertainty: {
|
|
346
|
+
code: 'not-computable',
|
|
347
|
+
needed: 'Whether this text ever contains a soft wrap break, which needs layout.',
|
|
348
|
+
evidence: {
|
|
349
|
+
element: (el.tagName || '').toLowerCase(),
|
|
350
|
+
properties: props,
|
|
351
|
+
reasonCode: 'INLINE_SPACING_NO_SOFT_WRAP'
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
data: {
|
|
355
|
+
details: {
|
|
356
|
+
reasonCode: 'INLINE_SPACING_NO_SOFT_WRAP',
|
|
357
|
+
element: (el.tagName || '').toLowerCase(),
|
|
358
|
+
properties: props
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
})
|
|
362
|
+
)
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (undecided.length) {
|
|
366
|
+
return {
|
|
367
|
+
ruleId: rule.ruleId,
|
|
368
|
+
outcome: 'cantTell',
|
|
369
|
+
severity: rule.defaultSeverity || 'moderate',
|
|
370
|
+
confidence: 'low',
|
|
371
|
+
occurrences: undecided.map(({ el, props }) =>
|
|
372
|
+
helpers.reportOccurrence(el, {
|
|
373
|
+
summary: `This element's inline style sets ${props.join(', ')} with !important, but the value could not be resolved, so whether it meets the WCAG text-spacing metric could not be determined.`,
|
|
374
|
+
hint: 'Check this value by hand against the metric (line-height 1.5, letter-spacing 0.12em, word-spacing 0.16em), or express it in a unit the engine can resolve against the element’s computed font size.',
|
|
375
|
+
i18n: {
|
|
376
|
+
summaryKey: 'avoidInlineSpacing_summary_cantTell',
|
|
377
|
+
hintKey: 'avoidInlineSpacing_hint_cantTell',
|
|
378
|
+
params: {
|
|
379
|
+
element: (el.tagName || '').toLowerCase(),
|
|
380
|
+
properties: props.join(', ')
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
uncertainty: {
|
|
384
|
+
code: 'not-computable',
|
|
385
|
+
needed: 'A resolved value for the spacing declarations marked !important.',
|
|
386
|
+
evidence: {
|
|
387
|
+
element: (el.tagName || '').toLowerCase(),
|
|
388
|
+
properties: props,
|
|
389
|
+
reasonCode: 'INLINE_SPACING_NOT_RESOLVABLE'
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
data: {
|
|
393
|
+
details: {
|
|
394
|
+
reasonCode: 'INLINE_SPACING_NOT_RESOLVABLE',
|
|
395
|
+
element: (el.tagName || '').toLowerCase(),
|
|
396
|
+
properties: props
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
})
|
|
400
|
+
)
|
|
401
|
+
};
|
|
402
|
+
}
|
|
276
403
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
277
404
|
}
|
|
278
405
|
|
|
@@ -178,6 +178,16 @@ function runInPage(ctx) {
|
|
|
178
178
|
: {}
|
|
179
179
|
)
|
|
180
180
|
},
|
|
181
|
+
uncertainty: {
|
|
182
|
+
code: 'not-computable',
|
|
183
|
+
needed: 'A contrast ratio for this text, which this page composition blocks.',
|
|
184
|
+
evidence: {
|
|
185
|
+
reasonCode: rc,
|
|
186
|
+
blockerProperty: details.blockerProperty || null,
|
|
187
|
+
blockerValue: details.blockerValue || null,
|
|
188
|
+
backgroundAlpha: details.backgroundAlpha === undefined ? null : details.backgroundAlpha
|
|
189
|
+
}
|
|
190
|
+
},
|
|
181
191
|
data: { details }
|
|
182
192
|
};
|
|
183
193
|
|
|
@@ -216,6 +216,17 @@ function runInPage(ctx) {
|
|
|
216
216
|
|
|
217
217
|
const det = details && typeof details === 'object' ? details : { reasonCode: 'UNKNOWN' };
|
|
218
218
|
|
|
219
|
+
// The background is the only input this rule can fail to resolve; every other
|
|
220
|
+
// reason code here describes a ratio it did compute.
|
|
221
|
+
const uncertainty =
|
|
222
|
+
det.reasonCode === 'BACKGROUND_NOT_COMPUTABLE'
|
|
223
|
+
? {
|
|
224
|
+
code: 'not-computable',
|
|
225
|
+
needed: 'The effective background colour behind this text.',
|
|
226
|
+
evidence: { reasonCode: det.reasonCode, foreground: det.fg || null }
|
|
227
|
+
}
|
|
228
|
+
: null;
|
|
229
|
+
|
|
219
230
|
const occBase = {
|
|
220
231
|
selector: '',
|
|
221
232
|
html: '',
|
|
@@ -226,6 +237,7 @@ function runInPage(ctx) {
|
|
|
226
237
|
hintKey: '',
|
|
227
238
|
params: params && typeof params === 'object' ? params : {}
|
|
228
239
|
},
|
|
240
|
+
...(uncertainty ? { uncertainty } : {}),
|
|
229
241
|
data: { details: det }
|
|
230
242
|
};
|
|
231
243
|
|
|
@@ -213,6 +213,17 @@ function runInPage(ctx) {
|
|
|
213
213
|
|
|
214
214
|
const det = details && typeof details === 'object' ? details : { reasonCode: 'UNKNOWN' };
|
|
215
215
|
|
|
216
|
+
// The background is the only input this rule can fail to resolve; every other
|
|
217
|
+
// reason code here describes a ratio it did compute.
|
|
218
|
+
const uncertainty =
|
|
219
|
+
det.reasonCode === 'BACKGROUND_NOT_COMPUTABLE'
|
|
220
|
+
? {
|
|
221
|
+
code: 'not-computable',
|
|
222
|
+
needed: 'The effective background colour behind this text.',
|
|
223
|
+
evidence: { reasonCode: det.reasonCode, foreground: det.fg || null }
|
|
224
|
+
}
|
|
225
|
+
: null;
|
|
226
|
+
|
|
216
227
|
const occBase = {
|
|
217
228
|
selector: '',
|
|
218
229
|
html: '',
|
|
@@ -223,6 +234,7 @@ function runInPage(ctx) {
|
|
|
223
234
|
hintKey: '',
|
|
224
235
|
params: params && typeof params === 'object' ? params : {}
|
|
225
236
|
},
|
|
237
|
+
...(uncertainty ? { uncertainty } : {}),
|
|
226
238
|
data: { details: det }
|
|
227
239
|
};
|
|
228
240
|
|