Skip to content

Commit

Permalink
feat: Add allowEmpty option for aria-valid-attr-value (#1154)
Browse files Browse the repository at this point in the history
Allow specifying per ARIA attribute which ones are allowed to be empty. Changes include:
- aria-haspopup: Now allowed to be empty
- aria-invalid: Now allowed to be empty
- aria-current: Now allowed to be empty
- aria-valuetext: No longer allowed to be empty

Closes #994

## Reviewer checks

**Required fields, to be filled out by PR reviewer(s)**
- [x] Follows the commit message policy, appropriate for next version
- [x] Has documentation updated, a DU ticket, or requires no documentation change
- [x] Includes new tests, or was unnecessary
- [x] Code is reviewed for security by: Jey
  • Loading branch information
WilcoFiers authored Nov 14, 2018
1 parent bdff141 commit 89d18d0
Show file tree
Hide file tree
Showing 9 changed files with 578 additions and 380 deletions.
16 changes: 10 additions & 6 deletions lib/checks/aria/errormessage.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
const { aria, dom } = axe.commons;
options = Array.isArray(options) ? options : [];

var attr = node.getAttribute('aria-errormessage'),
hasAttr = node.hasAttribute('aria-errormessage');
const attr = node.getAttribute('aria-errormessage');
const hasAttr = node.hasAttribute('aria-errormessage');

var doc = axe.commons.dom.getRootNode(node);
const doc = dom.getRootNode(node);

function validateAttrValue() {
var idref = attr && doc.getElementById(attr);
function validateAttrValue(attr) {
if (attr.trim() === '') {
return aria.lookupTable.attributes['aria-errormessage'].allowEmpty;
}
const idref = attr && doc.getElementById(attr);
if (idref) {
return (
idref.getAttribute('role') === 'alert' ||
Expand All @@ -20,7 +24,7 @@ function validateAttrValue() {

// limit results to elements that actually have this attribute
if (options.indexOf(attr) === -1 && hasAttr) {
if (!validateAttrValue()) {
if (!validateAttrValue(attr)) {
this.data(axe.utils.tokenList(attr));
return false;
}
Expand Down
69 changes: 1 addition & 68 deletions lib/commons/aria/attributes.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* global aria, axe, dom */
/* global aria, axe */

/**
* Get required attributes for a given role
Expand Down Expand Up @@ -45,70 +45,3 @@ aria.validateAttr = function(att) {
'use strict';
return !!aria.lookupTable.attributes[att];
};

/**
* Validate the value of an ARIA attribute
* @method validateAttrValue
* @memberof axe.commons.aria
* @instance
* @param {HTMLElement} node The element to check
* @param {String} attr The name of the attribute
* @return {Boolean}
*/
aria.validateAttrValue = function validateAttrValue(node, attr) {
/*eslint complexity: ["error",17]*/
'use strict';
var matches,
list,
value = node.getAttribute(attr),
attrInfo = aria.lookupTable.attributes[attr];

var doc = dom.getRootNode(node);

if (!attrInfo) {
return true;
}

switch (attrInfo.type) {
case 'boolean':
case 'nmtoken':
return (
typeof value === 'string' &&
attrInfo.values.includes(value.toLowerCase())
);

case 'nmtokens':
list = axe.utils.tokenList(value);
// Check if any value isn't in the list of values
return list.reduce(function(result, token) {
return result && attrInfo.values.includes(token);
// Initial state, fail if the list is empty
}, list.length !== 0);

case 'idref':
// idref is allowed to be empty
if (value.trim().length === 0) {
return true;
}
return !!(value && doc.getElementById(value));

case 'idrefs':
// idrefs are allowed to be empty
if (value.trim().length === 0) {
return true;
}
list = axe.utils.tokenList(value);
return list.some(token => doc.getElementById(token));

case 'string':
// anything goes
return true;

case 'decimal':
matches = value.match(/^[-+]?([0-9]*)\.?([0-9]*)$/);
return !!(matches && (matches[1] || matches[2]));

case 'int':
return /^[-+]?[0-9]+$/.test(value);
}
};
33 changes: 23 additions & 10 deletions lib/commons/aria/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ var aria = (commons.aria = {}),

lookupTable.attributes = {
'aria-activedescendant': {
type: 'idref'
type: 'idref',
allowEmpty: true
},
'aria-atomic': {
type: 'boolean',
Expand Down Expand Up @@ -37,14 +38,17 @@ lookupTable.attributes = {
type: 'int'
},
'aria-controls': {
type: 'idrefs'
type: 'idrefs',
allowEmpty: true
},
'aria-current': {
type: 'nmtoken',
allowEmpty: true,
values: ['page', 'step', 'location', 'date', 'time', 'true', 'false']
},
'aria-describedby': {
type: 'idrefs'
type: 'idrefs',
allowEmpty: true
},
'aria-disabled': {
type: 'boolean',
Expand All @@ -55,21 +59,24 @@ lookupTable.attributes = {
values: ['copy', 'move', 'reference', 'execute', 'popup', 'none']
},
'aria-errormessage': {
type: 'idref'
type: 'idref',
allowEmpty: true
},
'aria-expanded': {
type: 'nmtoken',
values: ['true', 'false', 'undefined']
},
'aria-flowto': {
type: 'idrefs'
type: 'idrefs',
allowEmpty: true
},
'aria-grabbed': {
type: 'nmtoken',
values: ['true', 'false', 'undefined']
},
'aria-haspopup': {
type: 'nmtoken',
allowEmpty: true,
values: ['true', 'false', 'menu', 'listbox', 'tree', 'grid', 'dialog']
},
'aria-hidden': {
Expand All @@ -78,16 +85,20 @@ lookupTable.attributes = {
},
'aria-invalid': {
type: 'nmtoken',
allowEmpty: true,
values: ['true', 'false', 'spelling', 'grammar']
},
'aria-keyshortcuts': {
type: 'string'
type: 'string',
allowEmpty: true
},
'aria-label': {
type: 'string'
type: 'string',
allowEmpty: true
},
'aria-labelledby': {
type: 'idrefs'
type: 'idrefs',
allowEmpty: true
},
'aria-level': {
type: 'int'
Expand All @@ -113,10 +124,12 @@ lookupTable.attributes = {
values: ['horizontal', 'vertical']
},
'aria-owns': {
type: 'idrefs'
type: 'idrefs',
allowEmpty: true
},
'aria-placeholder': {
type: 'string'
type: 'string',
allowEmpty: true
},
'aria-posinset': {
type: 'int'
Expand Down
63 changes: 63 additions & 0 deletions lib/commons/aria/validate-attr-value.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/* global aria, dom */

/**
* Validate the value of an ARIA attribute
* @method validateAttrValue
* @memberof axe.commons.aria
* @instance
* @param {HTMLElement} node The element to check
* @param {String} attr The name of the attribute
* @return {Boolean}
*/
aria.validateAttrValue = function validateAttrValue(node, attr) {
/*eslint complexity: ["error",17]*/
'use strict';
var matches,
list,
value = node.getAttribute(attr),
attrInfo = aria.lookupTable.attributes[attr];

var doc = dom.getRootNode(node);

if (!attrInfo) {
return true;
}
if (attrInfo.allowEmpty && (!value || value.trim() === '')) {
return true;
}

switch (attrInfo.type) {
case 'boolean':
case 'nmtoken':
return (
typeof value === 'string' &&
attrInfo.values.includes(value.toLowerCase())
);

case 'nmtokens':
list = axe.utils.tokenList(value);
// Check if any value isn't in the list of values
return list.reduce(function(result, token) {
return result && attrInfo.values.includes(token);
// Initial state, fail if the list is empty
}, list.length !== 0);

case 'idref':
return !!(value && doc.getElementById(value));

case 'idrefs':
list = axe.utils.tokenList(value);
return list.some(token => doc.getElementById(token));

case 'string':
// Not allowed empty except with allowEmpty: true
return value.trim() !== '';

case 'decimal':
matches = value.match(/^[-+]?([0-9]*)\.?([0-9]*)$/);
return !!(matches && (matches[1] || matches[2]));

case 'int':
return /^[-+]?[0-9]+$/.test(value);
}
};
33 changes: 33 additions & 0 deletions test/checks/aria/errormessage.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,15 @@ describe('aria-errormessage', function() {
var shadowSupported = axe.testUtils.shadowSupport.v1;
var shadowCheckSetup = axe.testUtils.shadowCheckSetup;
var checkContext = axe.testUtils.MockCheckContext();
var attrData = Object.assign(
{},
axe.commons.aria.lookupTable.attributes['aria-errormessage']
);

afterEach(function() {
axe.commons.aria.lookupTable.attributes[
'aria-errormessage'
] = Object.assign({}, attrData);
fixture.innerHTML = '';
checkContext.reset();
});
Expand Down Expand Up @@ -66,6 +73,32 @@ describe('aria-errormessage', function() {
assert.deepEqual(checkContext._data, ['foo', 'bar', 'baz']);
});

it('returns true when aria-errormessage is empty, if that is allowed', function() {
axe.commons.aria.lookupTable.attributes[
'aria-errormessage'
].allowEmpty = true;
fixture.innerHTML = '<div aria-errormessage=" "></div>';
assert.isTrue(
checks['aria-errormessage'].evaluate.call(
checkContext,
fixture.children[0]
)
);
});

it('returns false when aria-errormessage is empty, if that is not allowed', function() {
axe.commons.aria.lookupTable.attributes[
'aria-errormessage'
].allowEmpty = false;
fixture.innerHTML = '<div aria-errormessage=" "></div>';
assert.isFalse(
checks['aria-errormessage'].evaluate.call(
checkContext,
fixture.children[0]
)
);
});

(shadowSupported ? it : xit)(
'should return false if aria-errormessage value crosses shadow boundary',
function() {
Expand Down
Loading

0 comments on commit 89d18d0

Please sign in to comment.