Skip to content

Commit e6189ce

Browse files
committed
feat: Add WCAG 2.1 autocomplete-valid rule
1 parent 4117331 commit e6189ce

12 files changed

+635
-0
lines changed

doc/rule-descriptions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
| aria-valid-attr-value | Ensures all ARIA attributes have valid values | Critical | cat.aria, wcag2a, wcag412 | true |
1313
| aria-valid-attr | Ensures attributes that begin with aria- are valid ARIA attributes | Critical | cat.aria, wcag2a, wcag412 | true |
1414
| audio-caption | Ensures <audio> elements have captions | Critical | cat.time-and-media, wcag2a, wcag121, section508, section508.22.a | true |
15+
| autocomplete-valid | Ensure the autocomplete attribute is correct and suitable for the form field | Serious | cat.forms, wcag21aa, wcag135 | true |
1516
| blink | Ensures <blink> elements are not used | Serious | cat.time-and-media, wcag2a, wcag222, section508, section508.22.j | true |
1617
| button-name | Ensures buttons have discernible text | Serious, Critical | cat.name-role-value, wcag2a, wcag412, section508, section508.22.a | true |
1718
| bypass | Ensures each page has at least one mechanism for a user to bypass navigation and jump straight to the content | Serious | cat.keyboard, wcag2a, wcag241, section508, section508.22.o | true |
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Select and textarea is always allowed
2+
if (node.nodeName.toUpperCase() !== 'INPUT') {
3+
return true;
4+
}
5+
6+
const number = ['text', 'search', 'number'];
7+
const url = ['text', 'search', 'url'];
8+
const allowedTypesMap = {
9+
bday: ['text', 'search', 'date'],
10+
email: ['text', 'search', 'email'],
11+
'cc-exp': ['text', 'search', 'month'],
12+
'street-address': [], // Not even the default
13+
tel: ['text', 'search', 'tel'],
14+
'cc-exp-month': number,
15+
'cc-exp-year': number,
16+
'transaction-amount': number,
17+
'bday-day': number,
18+
'bday-month': number,
19+
'bday-year': number,
20+
'new-password': ['text', 'search', 'password'],
21+
'current-password': ['text', 'search', 'password'],
22+
url: url,
23+
photo: url,
24+
impp: url
25+
};
26+
27+
if (typeof options === 'object') {
28+
// Merge in options
29+
Object.keys(options).forEach(key => {
30+
if (!allowedTypesMap[key]) {
31+
allowedTypesMap[key] = [];
32+
}
33+
allowedTypesMap[key] = allowedTypesMap[key].concat(options[key]);
34+
});
35+
}
36+
37+
const autocomplete = node.getAttribute('autocomplete');
38+
const autocompleteTerms = autocomplete
39+
.split(/\s+/g)
40+
.map(term => term.toLowerCase());
41+
const purposeTerm = autocompleteTerms[autocompleteTerms.length - 1];
42+
const allowedTypes = allowedTypesMap[purposeTerm];
43+
44+
if (typeof allowedTypes === 'undefined') {
45+
return node.type === 'text';
46+
}
47+
48+
return allowedTypes.includes(node.type);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"id": "autocomplete-appropriate",
3+
"evaluate": "autocomplete-appropriate.js",
4+
"metadata": {
5+
"impact": "serious",
6+
"messages": {
7+
"pass": "the autocomplete value is on an appropriate element",
8+
"fail": "the autocomplete value is inappropriate for this type of input"
9+
}
10+
}
11+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
let {
2+
standaloneTerms = [],
3+
qualifiedTerms = [],
4+
qualifiers = [],
5+
locations = [],
6+
looseTyped = false
7+
} =
8+
options || {};
9+
10+
qualifiers = qualifiers.concat(['home', 'work', 'mobile', 'fax', 'pager']);
11+
locations = locations.concat(['billing', 'shipping']);
12+
standaloneTerms = standaloneTerms.concat([
13+
'name',
14+
'honorific-prefix',
15+
'given-name',
16+
'additional-name',
17+
'family-name',
18+
'honorific-suffix',
19+
'nickname',
20+
'username',
21+
'new-password',
22+
'current-password',
23+
'organization-title',
24+
'organization',
25+
'street-address',
26+
'address-line1',
27+
'address-line2',
28+
'address-line3',
29+
'address-level4',
30+
'address-level3',
31+
'address-level2',
32+
'address-level1',
33+
'country',
34+
'country-name',
35+
'postal-code',
36+
'cc-name',
37+
'cc-given-name',
38+
'cc-additional-name',
39+
'cc-family-name',
40+
'cc-number',
41+
'cc-exp',
42+
'cc-exp-month',
43+
'cc-exp-year',
44+
'cc-csc',
45+
'cc-type',
46+
'transaction-currency',
47+
'transaction-amount',
48+
'language',
49+
'bday',
50+
'bday-day',
51+
'bday-month',
52+
'bday-year',
53+
'sex',
54+
'url',
55+
'photo'
56+
]);
57+
58+
qualifiedTerms = qualifiedTerms.concat([
59+
'tel',
60+
'tel-country-code',
61+
'tel-national',
62+
'tel-area-code',
63+
'tel-local',
64+
'tel-local-prefix',
65+
'tel-local-suffix',
66+
'tel-extension',
67+
'email',
68+
'impp'
69+
]);
70+
71+
const autocomplete = node.getAttribute('autocomplete');
72+
const autocompleteTerms = autocomplete.split(/\s+/g).map(term => {
73+
return term.toLowerCase();
74+
});
75+
76+
if (!looseTyped) {
77+
if (
78+
autocompleteTerms[0].length > 8 &&
79+
autocompleteTerms[0].substr(0, 8) === 'section-'
80+
) {
81+
autocompleteTerms.shift();
82+
}
83+
84+
if (locations.includes(autocompleteTerms[0])) {
85+
autocompleteTerms.shift();
86+
}
87+
88+
if (qualifiers.includes(autocompleteTerms[0])) {
89+
autocompleteTerms.shift();
90+
// only quantifiers allowed at this point
91+
standaloneTerms = [];
92+
}
93+
94+
if (autocompleteTerms.length !== 1) {
95+
return false;
96+
}
97+
}
98+
99+
const purposeTerm = autocompleteTerms[autocompleteTerms.length - 1];
100+
return (
101+
standaloneTerms.includes(purposeTerm) || qualifiedTerms.includes(purposeTerm)
102+
);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"id": "autocomplete-valid",
3+
"evaluate": "autocomplete-valid.js",
4+
"metadata": {
5+
"impact": "serious",
6+
"messages": {
7+
"pass": "the autocomplete attribute is correctly formatted",
8+
"fail": "the autocomplete attribute is incorrectly formatted"
9+
}
10+
}
11+
}

lib/rules/autocomplete-matches.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
const { text, aria, dom } = axe.commons;
2+
3+
const autocomplete = node.getAttribute('autocomplete');
4+
if (!autocomplete || text.sanitize(autocomplete) === '') {
5+
return false;
6+
}
7+
8+
const nodeName = node.nodeName.toUpperCase();
9+
if (['TEXTAREA', 'INPUT', 'SELECT'].includes(nodeName) === false) {
10+
return false;
11+
}
12+
13+
// The element is an `input` element a `type` of `hidden`, `button`, `submit` or `reset`
14+
const excludedInputTypes = ['submit', 'reset', 'button', 'hidden'];
15+
if (nodeName === 'INPUT' && excludedInputTypes.includes(node.type)) {
16+
return false;
17+
}
18+
19+
// The element has a `disabled` or `aria-disabled="true"` attribute
20+
const ariaDisabled = node.getAttribute('aria-disabled') || 'false';
21+
if (node.disabled || ariaDisabled.toLowerCase() === 'true') {
22+
return false;
23+
}
24+
25+
// The element has `tabindex="-1"` and has a [[semantic role]] that is
26+
// not a [widget](https://www.w3.org/TR/wai-aria-1.1/#widget_roles)
27+
const role = node.getAttribute('role');
28+
const tabIndex = node.getAttribute('tabindex');
29+
if (tabIndex === '-1' && role) {
30+
const roleDef = aria.lookupTable.role[role];
31+
if (roleDef === undefined || roleDef.type !== 'widget') {
32+
return false;
33+
}
34+
}
35+
36+
// The element is **not** visible on the page or exposed to assistive technologies
37+
if (
38+
tabIndex === '-1' &&
39+
!dom.isVisible(node, false) &&
40+
!dom.isVisible(node, true)
41+
) {
42+
return false;
43+
}
44+
45+
return true;

lib/rules/autocomplete-valid.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"id": "autocomplete-valid",
3+
"matches": "autocomplete-matches.js",
4+
"tags": [
5+
"cat.forms",
6+
"wcag21aa",
7+
"wcag135"
8+
],
9+
"metadata": {
10+
"description": "Ensure the autocomplete attribute is correct and suitable for the form field",
11+
"help": "autocomplete attribute must be used correctly"
12+
},
13+
"all": [
14+
"autocomplete-valid",
15+
"autocomplete-appropriate"
16+
],
17+
"any": [],
18+
"none": []
19+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
describe('autocomplete-appropriate', function() {
2+
'use strict';
3+
4+
var fixture = document.getElementById('fixture');
5+
var checkSetup = axe.testUtils.checkSetup;
6+
var checkContext = axe.testUtils.MockCheckContext();
7+
var evaluate = checks['autocomplete-appropriate'].evaluate;
8+
9+
beforeEach(function() {
10+
axe._tree = undefined;
11+
});
12+
13+
afterEach(function() {
14+
fixture.innerHTML = '';
15+
checkContext.reset();
16+
});
17+
18+
function autocompleteCheckParams(term, type, options) {
19+
return checkSetup(
20+
'<input autocomplete="' + term + '" type=' + type + ' id="target" />',
21+
options
22+
);
23+
}
24+
25+
it('returns true for non-select elements', function() {
26+
['div', 'button', 'select', 'textarea'].forEach(function(tagName) {
27+
var elm = document.createElement(tagName);
28+
elm.setAttribute('autocomplete', 'foo');
29+
elm.setAttribute('type', 'email');
30+
var params = checkSetup(elm);
31+
32+
assert.isTrue(
33+
evaluate.apply(checkContext, params),
34+
'failed for ' + tagName
35+
);
36+
});
37+
});
38+
39+
it('returns true if the input type is in the map', function() {
40+
var options = { foo: ['url'] };
41+
var params = autocompleteCheckParams('foo', 'url', options);
42+
assert.isTrue(evaluate.apply(checkContext, params));
43+
});
44+
45+
it('returns false if the input type is not in the map', function() {
46+
var options = { foo: ['url'] };
47+
var params = autocompleteCheckParams('foo', 'email', options);
48+
assert.isFalse(evaluate.apply(checkContext, params));
49+
});
50+
51+
it('returns true if the input type is text and the term is undefined', function() {
52+
var options = {};
53+
var params = autocompleteCheckParams('foo', 'text', options);
54+
assert.isTrue(evaluate.apply(checkContext, params));
55+
});
56+
57+
it('returns false if the input type is text and the term maps to an empty array', function() {
58+
var options = { foo: [] };
59+
var params = autocompleteCheckParams('foo', 'text', options);
60+
assert.isFalse(evaluate.apply(checkContext, params));
61+
});
62+
});

0 commit comments

Comments
 (0)