@surea11y/core 1.6.0 → 1.8.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 +140 -0
- package/README.md +179 -90
- package/docs/ACT_RULE_MAPPING.md +10 -8
- package/docs/API_STABILITY.md +67 -6
- package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +162 -2
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +109 -5
- package/docs/I18N.md +62 -20
- package/docs/INTEGRATION.md +4 -2
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +4 -1
- package/docs/OUTPUT_SCHEMA.md +62 -11
- package/docs/POLICY.md +1 -1
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +83 -17
- package/docs/RULE_CATALOG.md +212 -139
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +390 -0
- package/docs/RULE_TAXONOMY.md +27 -6
- package/docs/SARIF.md +23 -3
- package/docs/WCAG_CONFORMANCE.md +64 -3
- package/package.json +41 -12
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- 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 +43 -17
- 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-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +22 -4
- package/src/checks/automatic/aria-prohibited-children.js +6 -6
- package/src/checks/automatic/aria-required-attr.js +88 -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 +20 -3
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +89 -24
- package/src/checks/automatic/aria-valid-attr.js +14 -9
- package/src/checks/automatic/autocomplete-valid.js +152 -26
- package/src/checks/automatic/avoid-inline-spacing.js +207 -15
- package/src/checks/automatic/button-name-present.js +2 -1
- package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
- package/src/checks/automatic/combobox-name-present.js +34 -51
- package/src/checks/automatic/contrast-computable.js +45 -4
- package/src/checks/automatic/contrast-enhanced.js +16 -4
- package/src/checks/automatic/contrast-minimum.js +57 -11
- package/src/checks/automatic/css-orientation-lock.js +171 -12
- package/src/checks/automatic/definition-list-children-valid.js +67 -23
- package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
- package/src/checks/automatic/dialog-name-present.js +28 -9
- package/src/checks/automatic/duplicate-id-aria.js +5 -0
- package/src/checks/automatic/duplicate-id.js +19 -10
- 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 +12 -4
- package/src/checks/automatic/iframe-title-unique.js +36 -81
- package/src/checks/automatic/input-image-alt-present.js +32 -20
- package/src/checks/automatic/label-in-name.js +78 -69
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +512 -44
- package/src/checks/automatic/link-name-present.js +13 -5
- package/src/checks/automatic/list-children-valid.js +18 -1
- package/src/checks/automatic/listbox-name-present.js +19 -49
- package/src/checks/automatic/listitem-parent-valid.js +4 -3
- package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
- package/src/checks/automatic/page-title-present.js +16 -4
- package/src/checks/automatic/progressbar-name-present.js +11 -1
- package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
- package/src/checks/automatic/searchbox-name-present.js +32 -49
- package/src/checks/automatic/server-side-image-map-absent.js +48 -28
- package/src/checks/automatic/slider-name-present.js +38 -52
- package/src/checks/automatic/spinbutton-name-present.js +32 -49
- package/src/checks/automatic/target-size-minimum.js +84 -16
- package/src/checks/automatic/td-has-header.js +60 -23
- package/src/checks/automatic/text-spacing-content-loss.js +548 -0
- package/src/checks/automatic/textbox-name-present.js +32 -49
- package/src/checks/automatic/valid-lang.js +15 -10
- package/src/checks/manual/area-alt-quality-manual.js +113 -31
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
- package/src/checks/manual/css-hidden-focus.js +215 -7
- package/src/checks/manual/form-control-label-quality-manual.js +243 -29
- package/src/checks/manual/heading-order-manual.js +9 -1
- package/src/checks/manual/heading-quality-manual.js +143 -9
- package/src/checks/manual/img-alt-decorative-manual.js +6 -3
- package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
- package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
- package/src/checks/manual/link-name-quality-manual.js +130 -4
- package/src/checks/manual/media-transcript-present-manual.js +65 -8
- package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
- package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
- package/src/checks/manual/p-as-heading-manual.js +89 -44
- package/src/checks/manual/page-title-patterns-manual.js +77 -8
- package/src/checks/manual/password-paste-enabled-manual.js +255 -0
- package/src/checks/manual/skip-link-manual.js +42 -14
- package/src/checks/manual/table-fake-caption-manual.js +32 -1
- package/src/checks/manual/video-caption-manual.js +47 -24
- package/src/checks/manual-review.js +0 -4
- package/src/core.js +18061 -46194
- package/src/coverage/en301549-map.js +187 -0
- package/src/coverage/standards.js +279 -0
- package/src/coverage/wcag-facets.js +1119 -0
- package/src/coverage/wcag-version-map.js +101 -0
- package/src/earl.js +144 -0
- package/src/en301549.js +33 -0
- package/src/junit.js +321 -0
- package/src/profile-kit.js +163 -0
- package/src/report.js +343 -74
- package/src/sarif.js +56 -5
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +11 -41039
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- package/surea11y.i18n.ja.js +3 -0
- package/src/checks/manual/area-alt-decorative-manual.js +0 -255
|
@@ -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 };
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* @expectation
|
|
16
16
|
* Each attribute's value conforms to its WAI-ARIA-declared value type:
|
|
17
17
|
* boolean ("true"/"false"), tristate ("true"/"false"/"mixed"), a token
|
|
18
|
-
* from a fixed enumerated set, an integer
|
|
19
|
-
* reference (list) that resolves to an
|
|
18
|
+
* from a fixed enumerated set, an integer (within the range WAI-ARIA sets
|
|
19
|
+
* for it), a real number, or an ID reference (list) that resolves to an
|
|
20
|
+
* existing element in the document.
|
|
20
21
|
* Per ACT 6a7281's own applicability ("any state or property that is
|
|
21
22
|
* NOT empty"), an explicitly empty value, including a bare boolean-style
|
|
22
23
|
* attribute with no "=value" at all, e.g. `aria-checked` alone, is out
|
|
@@ -27,7 +28,7 @@
|
|
|
27
28
|
* - Not rule-gated on isAccTreeEligible: this remains a static-markup
|
|
28
29
|
* property, while engine-level hidden-subtree filtering still applies
|
|
29
30
|
* unless engineOptions.includeHiddenElements is true.
|
|
30
|
-
* - ID-reference resolution (see aria-helpers.js's idExists) only
|
|
31
|
+
* - ID-reference resolution (see aria-helpers.js's idExists) only considers
|
|
31
32
|
* idref-list attributes (aria-labelledby, aria-describedby,
|
|
32
33
|
* aria-controls, aria-owns, etc.) when NONE of the space-separated ids
|
|
33
34
|
* resolve, a partially-dangling list (some ids exist, some don't) is
|
|
@@ -38,6 +39,23 @@
|
|
|
38
39
|
* text names it as a non-required property whose target "may be created
|
|
39
40
|
* in response to an event that may or may not happen" (a validation
|
|
40
41
|
* error message rendered only once the error actually occurs).
|
|
42
|
+
* - An idref list that resolves to nothing is a `cantTell`, never a fail.
|
|
43
|
+
* The element falls back to its other name and description sources (a
|
|
44
|
+
* <button aria-describedby="nope">Save</button> is still named "Save"),
|
|
45
|
+
* so whether anything was lost depends on what the reference was meant
|
|
46
|
+
* to add, and the target may be created later. aria-controls goes one
|
|
47
|
+
* step further: a collapsed widget (aria-expanded="false" or
|
|
48
|
+
* aria-selected="false") passes outright, since the absence of the
|
|
49
|
+
* controlled element is exactly what that state means. A name that goes
|
|
50
|
+
* missing because of a dangling aria-labelledby is reported by the name
|
|
51
|
+
* rules.
|
|
52
|
+
* - Integers are also checked against the lower bounds WAI-ARIA 1.2 sets:
|
|
53
|
+
* aria-level, aria-posinset, aria-colindex, aria-rowindex and
|
|
54
|
+
* aria-colspan at least 1, aria-rowspan at least 0, aria-setsize at
|
|
55
|
+
* least 1 or exactly -1.
|
|
56
|
+
* - Two tiers in one run means helpers.resolveTieredOutcome decides the
|
|
57
|
+
* aggregate: a real fail elsewhere on the page still reports fail, and
|
|
58
|
+
* the aria-controls occurrences ride along rather than being dropped.
|
|
41
59
|
*/
|
|
42
60
|
|
|
43
61
|
const id = 'aria-valid-attr-value';
|
|
@@ -79,13 +97,15 @@ function runInPage(ctx) {
|
|
|
79
97
|
|
|
80
98
|
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*') : helpers.queryAll('*');
|
|
81
99
|
|
|
82
|
-
const
|
|
100
|
+
const failOccurrences = [];
|
|
101
|
+
const cantTellOccurrences = [];
|
|
83
102
|
let applicableCount = 0;
|
|
84
103
|
|
|
85
104
|
for (const el of nodes) {
|
|
86
105
|
if (!el || !el.attributes || !el.getAttribute) continue;
|
|
87
106
|
|
|
88
107
|
let invalid = null;
|
|
108
|
+
let review = null;
|
|
89
109
|
const attrs = el.attributes;
|
|
90
110
|
for (let i = 0; i < attrs.length; i++) {
|
|
91
111
|
const name = String(attrs[i].name || '').toLowerCase();
|
|
@@ -95,21 +115,26 @@ function runInPage(ctx) {
|
|
|
95
115
|
applicableCount += 1;
|
|
96
116
|
|
|
97
117
|
const rawValue = el.getAttribute(name);
|
|
98
|
-
const result = ariaHelpers.validateAttrValue(name, rawValue);
|
|
99
|
-
if (
|
|
118
|
+
const result = ariaHelpers.validateAttrValue(name, rawValue, el);
|
|
119
|
+
if (result.valid) continue;
|
|
120
|
+
|
|
121
|
+
const item = {
|
|
122
|
+
name,
|
|
123
|
+
value: rawValue == null ? '' : String(rawValue),
|
|
124
|
+
reason: result.reason
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
if (result.review) {
|
|
128
|
+
if (!review) review = [];
|
|
129
|
+
review.push(item);
|
|
130
|
+
} else {
|
|
100
131
|
if (!invalid) invalid = [];
|
|
101
|
-
invalid.push(
|
|
102
|
-
name,
|
|
103
|
-
value: rawValue == null ? '' : String(rawValue),
|
|
104
|
-
reason: result.reason
|
|
105
|
-
});
|
|
132
|
+
invalid.push(item);
|
|
106
133
|
}
|
|
107
134
|
}
|
|
108
135
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
for (const item of invalid) {
|
|
112
|
-
occurrences.push(
|
|
136
|
+
for (const item of invalid || []) {
|
|
137
|
+
failOccurrences.push(
|
|
113
138
|
helpers.reportOccurrence(el, {
|
|
114
139
|
summary: 'This element has an ARIA attribute with an invalid value.',
|
|
115
140
|
hint: 'Use a value that matches the attribute’s expected type (see the WAI-ARIA specification for this attribute).',
|
|
@@ -129,20 +154,60 @@ function runInPage(ctx) {
|
|
|
129
154
|
})
|
|
130
155
|
);
|
|
131
156
|
}
|
|
157
|
+
|
|
158
|
+
for (const item of review || []) {
|
|
159
|
+
const controls = item.name === 'aria-controls';
|
|
160
|
+
cantTellOccurrences.push(
|
|
161
|
+
helpers.reportOccurrence(el, {
|
|
162
|
+
summary:
|
|
163
|
+
'No element with this id exists right now, so the engine cannot tell whether this reference is wrong.',
|
|
164
|
+
hint: controls
|
|
165
|
+
? 'Confirm the controlled element is created when the widget opens; if it never exists, remove or correct the reference.'
|
|
166
|
+
: 'Check whether an element with this id is added later. If not, correct or remove the reference; until then the element uses its other name or description sources.',
|
|
167
|
+
i18n: {
|
|
168
|
+
summaryKey: 'ariaValidAttrValue_summary_cantTell_idref',
|
|
169
|
+
hintKey: controls
|
|
170
|
+
? 'ariaValidAttrValue_hint_cantTell_idref'
|
|
171
|
+
: 'ariaValidAttrValue_hint_cantTell_idrefList',
|
|
172
|
+
params: { attr: item.name, value: item.value }
|
|
173
|
+
},
|
|
174
|
+
uncertainty: {
|
|
175
|
+
code: 'runtime-dependent',
|
|
176
|
+
needed: controls
|
|
177
|
+
? 'Whether the widget creates the referenced element when it opens.'
|
|
178
|
+
: 'Whether the referenced element is added later, and whether its absence loses a name, description or relationship.',
|
|
179
|
+
evidence: {
|
|
180
|
+
attribute: item.name,
|
|
181
|
+
referencedId: item.value,
|
|
182
|
+
resolvedAtScanTime: false
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
data: {
|
|
186
|
+
details: {
|
|
187
|
+
reasonCode: 'ARIA_ATTR_VALUE_TARGET_ABSENT',
|
|
188
|
+
attr: item.name,
|
|
189
|
+
value: item.value,
|
|
190
|
+
valueReason: item.reason
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
);
|
|
195
|
+
}
|
|
132
196
|
}
|
|
133
197
|
|
|
134
198
|
if (applicableCount === 0) {
|
|
135
199
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
136
200
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
201
|
+
|
|
202
|
+
// See helpers.resolveTieredOutcome's own header comment
|
|
203
|
+
// (src/core/dom-helpers.js): a fail-tier finding never silently discards
|
|
204
|
+
// cantTell-tier findings from the same run.
|
|
205
|
+
const resolved = helpers.resolveTieredOutcome(
|
|
206
|
+
failOccurrences,
|
|
207
|
+
cantTellOccurrences,
|
|
208
|
+
rule.defaultSeverity || 'serious'
|
|
209
|
+
);
|
|
210
|
+
return { ruleId: rule.ruleId, ...resolved };
|
|
146
211
|
}
|
|
147
212
|
|
|
148
213
|
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 };
|
|
@@ -10,25 +10,40 @@
|
|
|
10
10
|
* @sc 1.3.5
|
|
11
11
|
* @applicability
|
|
12
12
|
* Applies to form controls (input, select, textarea) with a non-empty
|
|
13
|
-
* autocomplete attribute.
|
|
13
|
+
* autocomplete attribute. Disabled controls (the disabled attribute,
|
|
14
|
+
* including a control disabled by a disabled fieldset ancestor, or
|
|
15
|
+
* aria-disabled="true") and input types with a fixed value are exempt,
|
|
16
|
+
* as in ACT 73f2c2.
|
|
14
17
|
* @expectation
|
|
15
18
|
* The value is "on"/"off" alone, or a well-formed autofill detail
|
|
16
19
|
* token list: an optional "section-*" token, then an optional
|
|
17
20
|
* "shipping"/"billing" token, then an optional contact-modality token
|
|
18
|
-
* (home/work/mobile/fax/pager
|
|
21
|
+
* (home/work/mobile/fax/pager), then exactly one recognized
|
|
19
22
|
* field-name token (name, email, street-address, cc-number, tel, ...),
|
|
20
|
-
* optionally followed by "webauthn".
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
+
* optionally followed by "webauthn". The field name must also suit the
|
|
24
|
+
* control: the HTML Standard gives each field name a control group, and
|
|
25
|
+
* each group is allowed only on some input types (street-address only on
|
|
26
|
+
* textarea or select; email only on text, search or email inputs; and so
|
|
27
|
+
* on). A malformed or unsuitable value means the field is not reliably
|
|
28
|
+
* identified for assistive technology that relies on autocomplete to
|
|
29
|
+
* describe the expected input purpose.
|
|
23
30
|
* @implementation-notes
|
|
24
31
|
* - Implements the structural shape of the WHATWG autofill grammar
|
|
25
32
|
* (section/mode/contact-modality prefixes + one field-name token, in
|
|
26
|
-
* order) with the
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
33
|
+
* order) with the fixed field-name vocabulary from the HTML Standard,
|
|
34
|
+
* rather than validating every field-specific constraint (e.g. which
|
|
35
|
+
* contact-modality tokens are legal for which field names), matches
|
|
36
|
+
* this engine's established "scoped" precedent (see aria-helpers.js)
|
|
37
|
+
* for keeping high-confidence fail without reimplementing the entire
|
|
38
|
+
* spec.
|
|
39
|
+
* - Control groups: textarea, select and input type=hidden accept every
|
|
40
|
+
* group. input types text and search (and a missing or unknown type)
|
|
41
|
+
* accept every group except Multiline (street-address). password, email,
|
|
42
|
+
* url, tel, number, month and date inputs accept only their own group
|
|
43
|
+
* (email also accepts username). Other input types (time, week,
|
|
44
|
+
* datetime-local, range, color) are not checked for the group, as the
|
|
45
|
+
* W3C validator does not check them either. A mismatch is reported with
|
|
46
|
+
* reasonCode AUTOCOMPLETE_FIELD_CONTROL_MISMATCH.
|
|
32
47
|
*/
|
|
33
48
|
|
|
34
49
|
const id = 'autocomplete-valid';
|
|
@@ -113,18 +128,78 @@ function runInPage(ctx) {
|
|
|
113
128
|
'tel-national',
|
|
114
129
|
'tel-area-code',
|
|
115
130
|
'tel-local',
|
|
131
|
+
'tel-local-prefix',
|
|
132
|
+
'tel-local-suffix',
|
|
116
133
|
'tel-extension',
|
|
117
134
|
'email',
|
|
118
135
|
'impp',
|
|
119
136
|
'url',
|
|
120
137
|
'photo'
|
|
121
138
|
]);
|
|
122
|
-
const CONTACT_MODALITY = new Set(['home', 'work', 'mobile', 'fax', 'pager'
|
|
139
|
+
const CONTACT_MODALITY = new Set(['home', 'work', 'mobile', 'fax', 'pager']);
|
|
123
140
|
|
|
124
|
-
|
|
141
|
+
// Control group of each field name that is not in the Text group (HTML
|
|
142
|
+
// Standard, autofill field table).
|
|
143
|
+
const FIELD_GROUP = {
|
|
144
|
+
username: 'username',
|
|
145
|
+
'new-password': 'password',
|
|
146
|
+
'current-password': 'password',
|
|
147
|
+
'one-time-code': 'password',
|
|
148
|
+
'street-address': 'multiline',
|
|
149
|
+
'cc-exp': 'month',
|
|
150
|
+
'cc-exp-month': 'numeric',
|
|
151
|
+
'cc-exp-year': 'numeric',
|
|
152
|
+
'transaction-amount': 'numeric',
|
|
153
|
+
bday: 'date',
|
|
154
|
+
'bday-day': 'numeric',
|
|
155
|
+
'bday-month': 'numeric',
|
|
156
|
+
'bday-year': 'numeric',
|
|
157
|
+
url: 'url',
|
|
158
|
+
photo: 'url',
|
|
159
|
+
impp: 'url',
|
|
160
|
+
tel: 'tel',
|
|
161
|
+
email: 'email'
|
|
162
|
+
};
|
|
163
|
+
// Groups accepted by input types other than text and search. text and
|
|
164
|
+
// search accept every group except multiline.
|
|
165
|
+
const GROUPS_BY_INPUT_TYPE = {
|
|
166
|
+
password: ['password'],
|
|
167
|
+
email: ['email', 'username'],
|
|
168
|
+
url: ['url'],
|
|
169
|
+
tel: ['tel'],
|
|
170
|
+
number: ['numeric'],
|
|
171
|
+
month: ['month'],
|
|
172
|
+
date: ['date']
|
|
173
|
+
};
|
|
174
|
+
const KNOWN_INPUT_TYPES = new Set([
|
|
175
|
+
'hidden',
|
|
176
|
+
'text',
|
|
177
|
+
'search',
|
|
178
|
+
'tel',
|
|
179
|
+
'url',
|
|
180
|
+
'email',
|
|
181
|
+
'password',
|
|
182
|
+
'date',
|
|
183
|
+
'month',
|
|
184
|
+
'week',
|
|
185
|
+
'time',
|
|
186
|
+
'datetime-local',
|
|
187
|
+
'number',
|
|
188
|
+
'range',
|
|
189
|
+
'color',
|
|
190
|
+
'checkbox',
|
|
191
|
+
'radio',
|
|
192
|
+
'file',
|
|
193
|
+
'submit',
|
|
194
|
+
'image',
|
|
195
|
+
'reset',
|
|
196
|
+
'button'
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
// Returns the field-name token of a well-formed value, or null.
|
|
200
|
+
function getFieldName(raw) {
|
|
125
201
|
const tokens = raw.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
126
|
-
if (!tokens.length) return
|
|
127
|
-
if (tokens.length === 1 && (tokens[0] === 'on' || tokens[0] === 'off')) return true;
|
|
202
|
+
if (!tokens.length) return null;
|
|
128
203
|
|
|
129
204
|
let i = 0;
|
|
130
205
|
if (tokens[i] && tokens[i].startsWith('section-') && tokens[i].length > 'section-'.length)
|
|
@@ -136,7 +211,7 @@ function runInPage(ctx) {
|
|
|
136
211
|
const next = tokens[i + 1];
|
|
137
212
|
const isContactField =
|
|
138
213
|
next === 'email' || next === 'impp' || next === 'tel' || (next || '').startsWith('tel-');
|
|
139
|
-
if (!isContactField) return
|
|
214
|
+
if (!isContactField) return null;
|
|
140
215
|
i += 1;
|
|
141
216
|
}
|
|
142
217
|
|
|
@@ -144,8 +219,24 @@ function runInPage(ctx) {
|
|
|
144
219
|
if (tokens[end - 1] === 'webauthn') end -= 1;
|
|
145
220
|
|
|
146
221
|
const remaining = tokens.slice(i, end);
|
|
147
|
-
if (remaining.length !== 1) return
|
|
148
|
-
return FIELD_NAMES.has(remaining[0]);
|
|
222
|
+
if (remaining.length !== 1) return null;
|
|
223
|
+
return FIELD_NAMES.has(remaining[0]) ? remaining[0] : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// True when the field name's control group is allowed on this control.
|
|
227
|
+
function fieldSuitsControl(el, fieldName) {
|
|
228
|
+
const tag = String(el.tagName || '').toLowerCase();
|
|
229
|
+
if (tag !== 'input') return true;
|
|
230
|
+
let type = String(el.getAttribute('type') || 'text')
|
|
231
|
+
.trim()
|
|
232
|
+
.toLowerCase();
|
|
233
|
+
if (!KNOWN_INPUT_TYPES.has(type)) type = 'text';
|
|
234
|
+
if (type === 'hidden') return true;
|
|
235
|
+
const group = FIELD_GROUP[fieldName] || 'text';
|
|
236
|
+
if (type === 'text' || type === 'search') return group !== 'multiline';
|
|
237
|
+
const allowed = GROUPS_BY_INPUT_TYPE[type];
|
|
238
|
+
if (!allowed) return true;
|
|
239
|
+
return allowed.includes(group);
|
|
149
240
|
}
|
|
150
241
|
|
|
151
242
|
const nodes = helpers.queryAllSmart
|
|
@@ -175,6 +266,13 @@ function runInPage(ctx) {
|
|
|
175
266
|
if (FIXED_VALUE_TYPES.has(type)) return true;
|
|
176
267
|
}
|
|
177
268
|
if (el.hasAttribute && el.hasAttribute('disabled')) return true;
|
|
269
|
+
// A control inside a disabled fieldset (outside its first legend) is
|
|
270
|
+
// disabled too.
|
|
271
|
+
try {
|
|
272
|
+
if (el.matches && el.matches(':disabled')) return true;
|
|
273
|
+
} catch {
|
|
274
|
+
/* selector unsupported */
|
|
275
|
+
}
|
|
178
276
|
if (String(el.getAttribute('aria-disabled') || '').toLowerCase() === 'true') return true;
|
|
179
277
|
return false;
|
|
180
278
|
}
|
|
@@ -190,21 +288,49 @@ function runInPage(ctx) {
|
|
|
190
288
|
|
|
191
289
|
applicableCount += 1;
|
|
192
290
|
|
|
193
|
-
|
|
194
|
-
|
|
291
|
+
const fieldName = getFieldName(raw);
|
|
195
292
|
const tag = el.tagName.toLowerCase();
|
|
196
293
|
|
|
294
|
+
if (!fieldName) {
|
|
295
|
+
occurrences.push(
|
|
296
|
+
helpers.reportOccurrence(el, {
|
|
297
|
+
summary: 'This autocomplete attribute value is not a valid autofill value.',
|
|
298
|
+
hint: 'Use "on"/"off", or a valid autofill token list (e.g. "shipping postal-code", "cc-number").',
|
|
299
|
+
i18n: {
|
|
300
|
+
summaryKey: 'autocompleteValid_summary_fail',
|
|
301
|
+
hintKey: 'autocompleteValid_hint_fail',
|
|
302
|
+
params: { element: tag, value: raw }
|
|
303
|
+
},
|
|
304
|
+
data: {
|
|
305
|
+
details: { reasonCode: 'AUTOCOMPLETE_VALUE_INVALID', element: tag, value: raw }
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (fieldSuitsControl(el, fieldName)) continue;
|
|
313
|
+
|
|
314
|
+
const inputType = String(el.getAttribute('type') || 'text')
|
|
315
|
+
.trim()
|
|
316
|
+
.toLowerCase();
|
|
197
317
|
occurrences.push(
|
|
198
318
|
helpers.reportOccurrence(el, {
|
|
199
|
-
summary:
|
|
200
|
-
hint: 'Use
|
|
319
|
+
summary: `The autofill field name "${fieldName}" is not allowed on an input of type "${inputType}".`,
|
|
320
|
+
hint: 'Use a field name that suits this type of control, or change the control (street-address needs a textarea; email needs a text, search or email input; bday-day needs a text, search or number input).',
|
|
201
321
|
i18n: {
|
|
202
|
-
summaryKey: '
|
|
203
|
-
hintKey: '
|
|
204
|
-
params: { element: tag, value: raw }
|
|
322
|
+
summaryKey: 'autocompleteValid_summary_mismatch',
|
|
323
|
+
hintKey: 'autocompleteValid_hint_mismatch',
|
|
324
|
+
params: { element: tag, value: raw, fieldName, inputType }
|
|
205
325
|
},
|
|
206
326
|
data: {
|
|
207
|
-
details: {
|
|
327
|
+
details: {
|
|
328
|
+
reasonCode: 'AUTOCOMPLETE_FIELD_CONTROL_MISMATCH',
|
|
329
|
+
element: tag,
|
|
330
|
+
value: raw,
|
|
331
|
+
fieldName,
|
|
332
|
+
inputType
|
|
333
|
+
}
|
|
208
334
|
}
|
|
209
335
|
})
|
|
210
336
|
);
|