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)); + }); +});