diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md
index c3e54fb914..4ecd46a484 100644
--- a/doc/rule-descriptions.md
+++ b/doc/rule-descriptions.md
@@ -12,6 +12,7 @@
| aria-valid-attr-value | Ensures all ARIA attributes have valid values | Critical | cat.aria, wcag2a, wcag412 | true |
| aria-valid-attr | Ensures attributes that begin with aria- are valid ARIA attributes | Critical | cat.aria, wcag2a, wcag412 | true |
| audio-caption | Ensures <audio> elements have captions | Critical | cat.time-and-media, wcag2a, wcag121, section508, section508.22.a | true |
+| autocomplete-valid | Ensure the autocomplete attribute is correct and suitable for the form field | Serious | cat.forms, wcag21aa, wcag135 | true |
| blink | Ensures <blink> elements are not used | Serious | cat.time-and-media, wcag2a, wcag222, section508, section508.22.j | true |
| button-name | Ensures buttons have discernible text | Serious, Critical | cat.name-role-value, wcag2a, wcag412, section508, section508.22.a | true |
| 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 |
diff --git a/lib/checks/forms/autocomplete-appropriate.js b/lib/checks/forms/autocomplete-appropriate.js
new file mode 100644
index 0000000000..259dea94dd
--- /dev/null
+++ b/lib/checks/forms/autocomplete-appropriate.js
@@ -0,0 +1,48 @@
+// Select and textarea is always allowed
+if (node.nodeName.toUpperCase() !== 'INPUT') {
+ return true;
+}
+
+const number = ['text', 'search', 'number'];
+const url = ['text', 'search', 'url'];
+const allowedTypesMap = {
+ bday: ['text', 'search', 'date'],
+ email: ['text', 'search', 'email'],
+ 'cc-exp': ['text', 'search', 'month'],
+ 'street-address': [], // Not even the default
+ tel: ['text', 'search', 'tel'],
+ 'cc-exp-month': number,
+ 'cc-exp-year': number,
+ 'transaction-amount': number,
+ 'bday-day': number,
+ 'bday-month': number,
+ 'bday-year': number,
+ 'new-password': ['text', 'search', 'password'],
+ 'current-password': ['text', 'search', 'password'],
+ url: url,
+ photo: url,
+ impp: url
+};
+
+if (typeof options === 'object') {
+ // Merge in options
+ Object.keys(options).forEach(key => {
+ if (!allowedTypesMap[key]) {
+ allowedTypesMap[key] = [];
+ }
+ allowedTypesMap[key] = allowedTypesMap[key].concat(options[key]);
+ });
+}
+
+const autocomplete = node.getAttribute('autocomplete');
+const autocompleteTerms = autocomplete
+ .split(/\s+/g)
+ .map(term => term.toLowerCase());
+const purposeTerm = autocompleteTerms[autocompleteTerms.length - 1];
+const allowedTypes = allowedTypesMap[purposeTerm];
+
+if (typeof allowedTypes === 'undefined') {
+ return node.type === 'text';
+}
+
+return allowedTypes.includes(node.type);
diff --git a/lib/checks/forms/autocomplete-appropriate.json b/lib/checks/forms/autocomplete-appropriate.json
new file mode 100644
index 0000000000..30bc0ecf5d
--- /dev/null
+++ b/lib/checks/forms/autocomplete-appropriate.json
@@ -0,0 +1,11 @@
+{
+ "id": "autocomplete-appropriate",
+ "evaluate": "autocomplete-appropriate.js",
+ "metadata": {
+ "impact": "serious",
+ "messages": {
+ "pass": "the autocomplete value is on an appropriate element",
+ "fail": "the autocomplete value is inappropriate for this type of input"
+ }
+ }
+}
diff --git a/lib/checks/forms/autocomplete-valid.js b/lib/checks/forms/autocomplete-valid.js
new file mode 100644
index 0000000000..3d6c5da16d
--- /dev/null
+++ b/lib/checks/forms/autocomplete-valid.js
@@ -0,0 +1,102 @@
+let {
+ standaloneTerms = [],
+ qualifiedTerms = [],
+ qualifiers = [],
+ locations = [],
+ looseTyped = false
+} =
+ options || {};
+
+qualifiers = qualifiers.concat(['home', 'work', 'mobile', 'fax', 'pager']);
+locations = locations.concat(['billing', 'shipping']);
+standaloneTerms = standaloneTerms.concat([
+ 'name',
+ 'honorific-prefix',
+ 'given-name',
+ 'additional-name',
+ 'family-name',
+ 'honorific-suffix',
+ 'nickname',
+ 'username',
+ 'new-password',
+ 'current-password',
+ 'organization-title',
+ 'organization',
+ 'street-address',
+ 'address-line1',
+ 'address-line2',
+ 'address-line3',
+ 'address-level4',
+ 'address-level3',
+ 'address-level2',
+ 'address-level1',
+ 'country',
+ 'country-name',
+ 'postal-code',
+ 'cc-name',
+ 'cc-given-name',
+ 'cc-additional-name',
+ 'cc-family-name',
+ 'cc-number',
+ 'cc-exp',
+ 'cc-exp-month',
+ 'cc-exp-year',
+ 'cc-csc',
+ 'cc-type',
+ 'transaction-currency',
+ 'transaction-amount',
+ 'language',
+ 'bday',
+ 'bday-day',
+ 'bday-month',
+ 'bday-year',
+ 'sex',
+ 'url',
+ 'photo'
+]);
+
+qualifiedTerms = qualifiedTerms.concat([
+ 'tel',
+ 'tel-country-code',
+ 'tel-national',
+ 'tel-area-code',
+ 'tel-local',
+ 'tel-local-prefix',
+ 'tel-local-suffix',
+ 'tel-extension',
+ 'email',
+ 'impp'
+]);
+
+const autocomplete = node.getAttribute('autocomplete');
+const autocompleteTerms = autocomplete.split(/\s+/g).map(term => {
+ return term.toLowerCase();
+});
+
+if (!looseTyped) {
+ if (
+ autocompleteTerms[0].length > 8 &&
+ autocompleteTerms[0].substr(0, 8) === 'section-'
+ ) {
+ autocompleteTerms.shift();
+ }
+
+ if (locations.includes(autocompleteTerms[0])) {
+ autocompleteTerms.shift();
+ }
+
+ if (qualifiers.includes(autocompleteTerms[0])) {
+ autocompleteTerms.shift();
+ // only quantifiers allowed at this point
+ standaloneTerms = [];
+ }
+
+ if (autocompleteTerms.length !== 1) {
+ return false;
+ }
+}
+
+const purposeTerm = autocompleteTerms[autocompleteTerms.length - 1];
+return (
+ standaloneTerms.includes(purposeTerm) || qualifiedTerms.includes(purposeTerm)
+);
diff --git a/lib/checks/forms/autocomplete-valid.json b/lib/checks/forms/autocomplete-valid.json
new file mode 100644
index 0000000000..428d49c876
--- /dev/null
+++ b/lib/checks/forms/autocomplete-valid.json
@@ -0,0 +1,11 @@
+{
+ "id": "autocomplete-valid",
+ "evaluate": "autocomplete-valid.js",
+ "metadata": {
+ "impact": "serious",
+ "messages": {
+ "pass": "the autocomplete attribute is correctly formatted",
+ "fail": "the autocomplete attribute is incorrectly formatted"
+ }
+ }
+}
diff --git a/lib/rules/autocomplete-matches.js b/lib/rules/autocomplete-matches.js
new file mode 100644
index 0000000000..ace7eec2d2
--- /dev/null
+++ b/lib/rules/autocomplete-matches.js
@@ -0,0 +1,45 @@
+const { text, aria, dom } = axe.commons;
+
+const autocomplete = node.getAttribute('autocomplete');
+if (!autocomplete || text.sanitize(autocomplete) === '') {
+ return false;
+}
+
+const nodeName = node.nodeName.toUpperCase();
+if (['TEXTAREA', 'INPUT', 'SELECT'].includes(nodeName) === false) {
+ return false;
+}
+
+// The element is an `input` element a `type` of `hidden`, `button`, `submit` or `reset`
+const excludedInputTypes = ['submit', 'reset', 'button', 'hidden'];
+if (nodeName === 'INPUT' && excludedInputTypes.includes(node.type)) {
+ return false;
+}
+
+// The element has a `disabled` or `aria-disabled="true"` attribute
+const ariaDisabled = node.getAttribute('aria-disabled') || 'false';
+if (node.disabled || ariaDisabled.toLowerCase() === 'true') {
+ return false;
+}
+
+// The element has `tabindex="-1"` and has a [[semantic role]] that is
+// not a [widget](https://www.w3.org/TR/wai-aria-1.1/#widget_roles)
+const role = node.getAttribute('role');
+const tabIndex = node.getAttribute('tabindex');
+if (tabIndex === '-1' && role) {
+ const roleDef = aria.lookupTable.role[role];
+ if (roleDef === undefined || roleDef.type !== 'widget') {
+ return false;
+ }
+}
+
+// The element is **not** visible on the page or exposed to assistive technologies
+if (
+ tabIndex === '-1' &&
+ !dom.isVisible(node, false) &&
+ !dom.isVisible(node, true)
+) {
+ return false;
+}
+
+return true;
diff --git a/lib/rules/autocomplete-valid.json b/lib/rules/autocomplete-valid.json
new file mode 100644
index 0000000000..20e909e584
--- /dev/null
+++ b/lib/rules/autocomplete-valid.json
@@ -0,0 +1,19 @@
+{
+ "id": "autocomplete-valid",
+ "matches": "autocomplete-matches.js",
+ "tags": [
+ "cat.forms",
+ "wcag21aa",
+ "wcag135"
+ ],
+ "metadata": {
+ "description": "Ensure the autocomplete attribute is correct and suitable for the form field",
+ "help": "autocomplete attribute must be used correctly"
+ },
+ "all": [
+ "autocomplete-valid",
+ "autocomplete-appropriate"
+ ],
+ "any": [],
+ "none": []
+}
diff --git a/test/checks/forms/autocomplete-appropriate.js b/test/checks/forms/autocomplete-appropriate.js
new file mode 100644
index 0000000000..aae35f69be
--- /dev/null
+++ b/test/checks/forms/autocomplete-appropriate.js
@@ -0,0 +1,62 @@
+describe('autocomplete-appropriate', function() {
+ 'use strict';
+
+ var fixture = document.getElementById('fixture');
+ var checkSetup = axe.testUtils.checkSetup;
+ var checkContext = axe.testUtils.MockCheckContext();
+ var evaluate = checks['autocomplete-appropriate'].evaluate;
+
+ beforeEach(function() {
+ axe._tree = undefined;
+ });
+
+ afterEach(function() {
+ fixture.innerHTML = '';
+ checkContext.reset();
+ });
+
+ function autocompleteCheckParams(term, type, options) {
+ return checkSetup(
+ '',
+ options
+ );
+ }
+
+ it('returns true for non-select elements', function() {
+ ['div', 'button', 'select', 'textarea'].forEach(function(tagName) {
+ var elm = document.createElement(tagName);
+ elm.setAttribute('autocomplete', 'foo');
+ elm.setAttribute('type', 'email');
+ var params = checkSetup(elm);
+
+ assert.isTrue(
+ evaluate.apply(checkContext, params),
+ 'failed for ' + tagName
+ );
+ });
+ });
+
+ it('returns true if the input type is in the map', function() {
+ var options = { foo: ['url'] };
+ var params = autocompleteCheckParams('foo', 'url', options);
+ assert.isTrue(evaluate.apply(checkContext, params));
+ });
+
+ it('returns false if the input type is not in the map', function() {
+ var options = { foo: ['url'] };
+ var params = autocompleteCheckParams('foo', 'email', options);
+ assert.isFalse(evaluate.apply(checkContext, params));
+ });
+
+ it('returns true if the input type is text and the term is undefined', function() {
+ var options = {};
+ var params = autocompleteCheckParams('foo', 'text', options);
+ assert.isTrue(evaluate.apply(checkContext, params));
+ });
+
+ it('returns false if the input type is text and the term maps to an empty array', function() {
+ var options = { foo: [] };
+ var params = autocompleteCheckParams('foo', 'text', options);
+ assert.isFalse(evaluate.apply(checkContext, params));
+ });
+});
diff --git a/test/checks/forms/autocomplete-valid.js b/test/checks/forms/autocomplete-valid.js
new file mode 100644
index 0000000000..1dbf0fa2fb
--- /dev/null
+++ b/test/checks/forms/autocomplete-valid.js
@@ -0,0 +1,104 @@
+describe('autocomplete-valid', function() {
+ 'use strict';
+
+ var fixture = document.getElementById('fixture');
+ var checkSetup = axe.testUtils.checkSetup;
+ var checkContext = axe.testUtils.MockCheckContext();
+ var evaluate = checks['autocomplete-valid'].evaluate;
+
+ var options = {
+ standaloneTerms: ['standalone-term'],
+ qualifiedTerms: ['qualified-term']
+ };
+
+ beforeEach(function() {
+ axe._tree = undefined;
+ });
+
+ afterEach(function() {
+ fixture.innerHTML = '';
+ checkContext.reset();
+ });
+
+ function autocompleteCheckParams(arg, opt) {
+ return checkSetup(
+ '',
+ opt || options
+ );
+ }
+
+ it('returns true the only term is a valid autocomplete term', function() {
+ var params = autocompleteCheckParams('standalone-term');
+ assert.isTrue(evaluate.apply(checkContext, params));
+ });
+
+ it('returns false the only term is an invalid autocomplete term', function() {
+ var params = autocompleteCheckParams('bad-term');
+ assert.isFalse(evaluate.apply(checkContext, params));
+ });
+
+ it('returns true if section-* is used as the first term', function() {
+ var params = autocompleteCheckParams('section-foo standalone-term');
+ assert.isTrue(evaluate.apply(checkContext, params));
+ });
+
+ it('returns true if `shipping` or `billing` is used as the first term', function() {
+ var params1 = autocompleteCheckParams('shipping standalone-term');
+ assert.isTrue(evaluate.apply(checkContext, params1));
+
+ var params2 = autocompleteCheckParams('billing standalone-term');
+ assert.isTrue(evaluate.apply(checkContext, params2));
+ });
+
+ it('returns true if section-* is used before `shipping` or `billing`', function() {
+ var params = autocompleteCheckParams(
+ 'section-foo shipping standalone-term'
+ );
+ assert.isTrue(evaluate.apply(checkContext, params));
+ });
+
+ it('returns false if `shipping` or `billing` is used before section-*', function() {
+ var params = autocompleteCheckParams(
+ 'shipping section-foo standalone-term'
+ );
+ assert.isFalse(evaluate.apply(checkContext, params));
+ });
+
+ it('returns true if "home", "work", "mobile", "fax" or "pager" is used before aqualifier', function() {
+ ['home', 'work', 'mobile', 'fax', 'pager'].forEach(function(qualifier) {
+ var params = autocompleteCheckParams(qualifier + ' qualified-term');
+ assert.isTrue(
+ evaluate.apply(checkContext, params),
+ 'failed for ' + qualifier
+ );
+ });
+ });
+
+ it('returns false if "home", "work", "mobile", "fax" or "pager" is used before an inappropriate term', function() {
+ ['home', 'work', 'mobile', 'fax', 'pager'].forEach(function(qualifier) {
+ var params = autocompleteCheckParams(qualifier + ' standalone-term');
+ assert.isFalse(
+ evaluate.apply(checkContext, params),
+ 'failed for ' + qualifier
+ );
+ });
+ });
+
+ describe('options.strictMode:false', function() {
+ it('returns true if the last term is a valid autocomplete term', function() {
+ var params = autocompleteCheckParams('do not care! valid-term', {
+ looseTyped: true,
+ standaloneTerms: ['valid-term']
+ });
+ assert.isTrue(evaluate.apply(checkContext, params));
+ });
+
+ it('returns false if the last term is an invalid autocomplete term', function() {
+ var params = autocompleteCheckParams('shipping invalid', {
+ looseTyped: true,
+ standaloneTerms: ['valid-term']
+ });
+ assert.isFalse(evaluate.apply(checkContext, params));
+ });
+ });
+});
diff --git a/test/integration/rules/autocomplete-valid/autocomplete-valid.html b/test/integration/rules/autocomplete-valid/autocomplete-valid.html
new file mode 100644
index 0000000000..c0a8ee92ff
--- /dev/null
+++ b/test/integration/rules/autocomplete-valid/autocomplete-valid.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/integration/rules/autocomplete-valid/autocomplete-valid.json b/test/integration/rules/autocomplete-valid/autocomplete-valid.json
new file mode 100644
index 0000000000..3c58f69d0e
--- /dev/null
+++ b/test/integration/rules/autocomplete-valid/autocomplete-valid.json
@@ -0,0 +1,19 @@
+{
+ "description": "autocomplete-valid tests",
+ "rule": "autocomplete-valid",
+ "violations": [
+ ["#fail1"],
+ ["#fail2"],
+ ["#fail3"],
+ ["#fail4"],
+ ["#fail5"]
+ ],
+ "passes": [
+ ["#pass1"],
+ ["#pass2"],
+ ["#pass3"],
+ ["#pass4"],
+ ["#pass5"],
+ ["#pass6"]
+ ]
+}
diff --git a/test/rule-matches/autocomplete-matches.js b/test/rule-matches/autocomplete-matches.js
new file mode 100644
index 0000000000..61309512bc
--- /dev/null
+++ b/test/rule-matches/autocomplete-matches.js
@@ -0,0 +1,156 @@
+describe('autocomplete-matches', function() {
+ 'use strict';
+ var fixture = document.getElementById('fixture');
+ var rule = axe._audit.rules.find(function(rule) {
+ return rule.id === 'autocomplete-valid';
+ });
+
+ afterEach(function() {
+ fixture.innerHTML = '';
+ });
+
+ it('is a function', function() {
+ assert.isFunction(rule.matches);
+ });
+
+ it('returns true for input elements', function() {
+ var elm = document.createElement('input');
+ elm.setAttribute('autocomplete', 'foo');
+ fixture.appendChild(elm);
+ assert.isTrue(rule.matches(elm));
+ });
+
+ it('returns true for select elements', function() {
+ var elm = document.createElement('select');
+ elm.setAttribute('autocomplete', 'foo');
+ fixture.appendChild(elm);
+ assert.isTrue(rule.matches(elm));
+ });
+
+ it('returns true for textarea elements', function() {
+ var elm = document.createElement('textarea');
+ elm.setAttribute('autocomplete', 'foo');
+ fixture.appendChild(elm);
+ assert.isTrue(rule.matches(elm));
+ });
+
+ it('returns false for buttons elements', function() {
+ var elm = document.createElement('button');
+ elm.setAttribute('autocomplete', 'foo');
+ fixture.appendChild(elm);
+ assert.isFalse(rule.matches(elm));
+ });
+
+ it('should return false for non-form field elements', function() {
+ var elm = document.createElement('div');
+ elm.setAttribute('autocomplete', 'foo');
+ fixture.appendChild(elm);
+ assert.isFalse(rule.matches(elm));
+ });
+
+ it('returns false for input buttons', function() {
+ ['reset', 'submit', 'button'].forEach(function(type) {
+ var elm = document.createElement('input');
+ elm.setAttribute('autocomplete', 'foo');
+ elm.type = type;
+ fixture.appendChild(elm);
+ assert.isFalse(rule.matches(elm));
+ });
+ });
+
+ it('returns false for elements with an empty autocomplete', function() {
+ var elm = document.createElement('input');
+ elm.setAttribute('autocomplete', ' ');
+ fixture.appendChild(elm);
+ assert.isFalse(rule.matches(elm));
+ });
+
+ it('returns false for intput[type=hidden]', function() {
+ var elm = document.createElement('input');
+ elm.setAttribute('autocomplete', 'foo');
+ elm.type = 'hidden';
+ fixture.appendChild(elm);
+ assert.isFalse(rule.matches(elm));
+ });
+
+ it('returns false for disabled fields', function() {
+ ['input', 'select', 'textarea'].forEach(function(tagName) {
+ var elm = document.createElement(tagName);
+ elm.setAttribute('autocomplete', 'foo');
+ elm.disabled = true;
+ fixture.appendChild(elm);
+ assert.isFalse(rule.matches(elm));
+ });
+ });
+
+ it('returns false for aria-disabled=true fields', function() {
+ ['input', 'select', 'textarea'].forEach(function(tagName) {
+ var elm = document.createElement(tagName);
+ elm.setAttribute('autocomplete', 'foo');
+ elm.setAttribute('aria-disabled', 'true');
+ fixture.appendChild(elm);
+ assert.isFalse(rule.matches(elm));
+ });
+ });
+
+ it('returns true for aria-disabled=false fields', function() {
+ ['input', 'select', 'textarea'].forEach(function(tagName) {
+ var elm = document.createElement(tagName);
+ elm.setAttribute('autocomplete', 'foo');
+ elm.setAttribute('aria-disabled', 'false');
+ fixture.appendChild(elm);
+ assert.isTrue(rule.matches(elm));
+ });
+ });
+
+ it('returns false for non-widget roles with tabindex=-1', function() {
+ var nonWidgetRoles = ['application', 'fakerole', 'main'];
+ nonWidgetRoles.forEach(function(role) {
+ var elm = document.createElement('input');
+ elm.setAttribute('autocomplete', 'foo');
+ elm.setAttribute('role', role);
+ elm.setAttribute('tabindex', '-1');
+ fixture.appendChild(elm);
+ assert.isFalse(
+ rule.matches(elm),
+ 'Expect role=' + role + ' to be ignored when it has tabindex=-1'
+ );
+ });
+ });
+
+ it('returns true for form fields with a widget role with tabindex=-1', function() {
+ var nonWidgetRoles = ['button', 'menuitem', 'slider'];
+ nonWidgetRoles.forEach(function(role) {
+ var elm = document.createElement('input');
+ elm.setAttribute('autocomplete', 'foo');
+ elm.setAttribute('role', role);
+ elm.setAttribute('tabindex', '-1');
+ fixture.appendChild(elm);
+ assert.isTrue(rule.matches(elm));
+ });
+ });
+
+ it('returns true for form fields with tabindex=-1', function() {
+ ['input', 'select', 'textarea'].forEach(function(tagName) {
+ var elm = document.createElement(tagName);
+ elm.setAttribute('autocomplete', 'foo');
+ elm.setAttribute('tabindex', -1);
+ fixture.appendChild(elm);
+ assert.isTrue(rule.matches(elm));
+ });
+ });
+
+ it('returns false for off screen and hidden form fields with tabindex=-1', function() {
+ var elm = document.createElement('input');
+ elm.setAttribute('autocomplete', 'foo');
+ elm.setAttribute('tabindex', -1);
+ elm.setAttribute('style', 'position:absolute; top:-9999em');
+
+ parent = document.createElement('div');
+ parent.appendChild(elm);
+ parent.setAttribute('aria-hidden', 'true');
+
+ fixture.appendChild(parent);
+ assert.isFalse(rule.matches(elm));
+ });
+});