Skip to content

Commit

Permalink
feat: Add WCAG 2.1 autocomplete-valid rule
Browse files Browse the repository at this point in the history
  • Loading branch information
WilcoFiers committed Jul 5, 2018
1 parent 4117331 commit e6189ce
Show file tree
Hide file tree
Showing 12 changed files with 635 additions and 0 deletions.
1 change: 1 addition & 0 deletions doc/rule-descriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
48 changes: 48 additions & 0 deletions lib/checks/forms/autocomplete-appropriate.js
Original file line number Diff line number Diff line change
@@ -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);
11 changes: 11 additions & 0 deletions lib/checks/forms/autocomplete-appropriate.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
102 changes: 102 additions & 0 deletions lib/checks/forms/autocomplete-valid.js
Original file line number Diff line number Diff line change
@@ -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)
);
11 changes: 11 additions & 0 deletions lib/checks/forms/autocomplete-valid.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
45 changes: 45 additions & 0 deletions lib/rules/autocomplete-matches.js
Original file line number Diff line number Diff line change
@@ -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;
19 changes: 19 additions & 0 deletions lib/rules/autocomplete-valid.json
Original file line number Diff line number Diff line change
@@ -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": []
}
62 changes: 62 additions & 0 deletions test/checks/forms/autocomplete-appropriate.js
Original file line number Diff line number Diff line change
@@ -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(
'<input autocomplete="' + term + '" type=' + type + ' id="target" />',
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));
});
});
Loading

1 comment on commit e6189ce

@jeankaplansky
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://dequeuniversity.com/rules/axe/3.1/autocomplete-valid created. Content is not complete. Resource ID set to autocomplete-valid.

Please sign in to comment.