Skip to content

Commit

Permalink
fix: better unsupported attribute support for aria-roledescription (#…
Browse files Browse the repository at this point in the history
…1382)

Expand the `unsupported` property of an attribute by allowing certain elements to use the attribute.

Closes: #1216

## 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: @WilcoFiers
  • Loading branch information
straker authored and WilcoFiers committed Feb 28, 2019
1 parent 98bf49b commit 93f721e
Show file tree
Hide file tree
Showing 11 changed files with 313 additions and 105 deletions.
248 changes: 164 additions & 84 deletions build/tasks/aria-supported.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,108 +10,188 @@ module.exports = function(grunt) {
'aria-supported',
'Task for generating a diff of supported aria roles and properties.',
function() {
const entry = this.data.entry;
const destFile = this.data.destFile;
const listType = this.data.listType.toLowerCase();

/**
* `axe` has to be dynamically required at this stage, as `axe` does not exist until grunt task `build:uglify` is complete, and hence cannot be required at the top of the file.
* NOTE:
* `axe` has to be dynamically required at this stage,
* as `axe` does not exist until grunt task `build:uglify` is complete,
* hence cannot be required at the top of the file.
*/
const axe = require('../../axe');
const listType = this.data.listType.toLowerCase();
const headings = {
main:
`# ARIA Roles and Attributes ${
listType === 'all' ? 'available' : listType
} in axe-core.\n\n` +
'It can be difficult to know which features of web technologies are accessible across ' +
'different platforms, and with different screen readers and other assistive technologies. ' +
'Axe-core does some of this work for you, by raising issues when accessibility features are ' +
'used that are known to cause problems.\n\n' +
'This page contains a list of ARIA 1.1 features that axe-core raises as unsupported. ' +
'For more information, read [We’ve got your back with “Accessibility Supported” in axe]' +
'(https://www.deque.com/blog/weve-got-your-back-with-accessibility-supported-in-axe/).\n\n' +
'For a detailed description about how accessibility support is decided, see [How we make ' +
'decisions on rules](accessibility-supported.md).',
rolesMdTableHeader: ['aria-role', 'axe-core support'],
attributesMdTableHeader: ['aria-attribute', 'axe-core support']
};

const { diff: rolesTable, notes: rolesFootnotes } = getDiff(
roles,
axe.commons.aria.lookupTable.role,
listType
);
const rolesTableMarkdown = mdTable([
headings.rolesMdTableHeader,
...rolesTable
]);

const ariaQueryAriaAttributes = getAriaQueryAttributes();
const { diff: attributesTable, notes: attributesFootnotes } = getDiff(
ariaQueryAriaAttributes,
axe.commons.aria.lookupTable.attributes,
listType
);
const attributesTableMarkdown = mdTable([
headings.attributesMdTableHeader,
...attributesTable
]);

const footnotes = [...rolesFootnotes, ...attributesFootnotes].map(
(footnote, index) => `[^${index + 1}]: ${footnote}`
);

const content = `${
headings.main
}\n\n## Roles\n\n${rolesTableMarkdown}\n\n## Attributes\n\n${attributesTableMarkdown}\n\n${footnotes}`;

const destFile = this.data.destFile;
// Format the content so Prettier doesn't create a diff after running.
// See https://github.com/dequelabs/axe-core/issues/1310.
const formattedContent = format(content, destFile);

// write `aria supported` file contents
grunt.file.write(destFile, formattedContent);

/**
* As `aria-query` roles map, does not list all aria attributes in its props,
* the below reduce function aims to concatanate and unique the below two,
* - list from props with in roles map
* - list from aria map
*
* @return {Map} `aQaria` - This gives a composite list of aria attributes, which is later used to diff against axe-core supported attributes.
* Get list of aria attributes, from `aria-query`
* @returns {Set|Object} collection of aria attributes from `aria-query` module
*/
const ariaKeys = Array.from(props).map(([key]) => key);
const roleAriaKeys = Array.from(roles).reduce((out, [name, rule]) => {
return [...out, ...Object.keys(rule.props)];
}, []);
const aQaria = new Set(axe.utils.uniqueArray(roleAriaKeys, ariaKeys));
function getAriaQueryAttributes() {
const ariaKeys = Array.from(props).map(([key]) => key);
const roleAriaKeys = Array.from(roles).reduce((out, [name, rule]) => {
return [...out, ...Object.keys(rule.props)];
}, []);
return new Set(axe.utils.uniqueArray(roleAriaKeys, ariaKeys));
}

/**
* Given a `base` Map and `subject` Map object,
* The function converts the `base` Map entries to an array which is sorted then enumerated to compare each entry against the `subject` Map
* The function constructs a `string` to represent a `markdown table` to
* The function constructs a `string` to represent a `markdown table`, as well as returns notes to append to footnote
* @param {Map} base Base Map Object
* @param {Map} subject Subject Map Object
* @return {Array[]} Example Output: [ [ 'alert', 'No' ], [ 'figure', 'Yes' ] ]
* @param {String} type type to compare
* @returns {Array<Object>[]}
* @example Example Output: [ [ 'alert', 'No' ], [ 'figure', 'Yes' ] ]
*/
const getDiff = (base, subject) => {
return Array.from(base.entries())
.sort()
.reduce((out, [key] = item) => {
switch (listType) {
case 'supported':
if (
subject.hasOwnProperty(key) &&
subject[key].unsupported === false
) {
out.push([`${key}`, 'Yes']);
}
break;
case 'unsupported':
if (
(subject[key] && subject[key].unsupported === true) ||
!subject.hasOwnProperty(key)
) {
out.push([`${key}`, 'No']);
function getDiff(base, subject, type) {
const diff = [];
const notes = [];

const sortedBase = Array.from(base.entries()).sort();

sortedBase.forEach(([key] = item) => {
switch (type) {
case 'supported':
if (
subject.hasOwnProperty(key) &&
subject[key].unsupported === false
) {
diff.push([`${key}`, 'Yes']);
}
break;
case 'unsupported':
if (
(subject[key] && subject[key].unsupported === true) ||
!subject.hasOwnProperty(key)
) {
diff.push([`${key}`, 'No']);
} else if (
subject[key] &&
subject[key].unsupported &&
subject[key].unsupported.exceptions
) {
diff.push([`${key}`, `Mixed[^${notes.length + 1}]`]);
notes.push(
getSupportedElementsAsFootnote(
subject[key].unsupported.exceptions
)
);
}
break;
case 'all':
default:
diff.push([
`${key}`,
subject.hasOwnProperty(key) &&
subject[key].unsupported === false
? 'Yes'
: 'No'
]);
break;
}
});

return {
diff,
notes
};
}

/**
* Parse a list of unsupported exception elements and add a footnote
* detailing which HTML elements are supported.
*
* @param {Array<String|Object>} elements List of supported elements
* @returns {Array<String|Object>} notes
*/
function getSupportedElementsAsFootnote(elements) {
const notes = [];

const supportedElements = elements.map(element => {
if (typeof element === 'string') {
return `\`<${element}>\``;
}

/**
* if element is not a string it will be an object with structure:
{
nodeName: string,
properties: {
type: {string|string[]}
}
break;
case 'all':
default:
out.push([
`${key}`,
subject.hasOwnProperty(key) &&
subject[key].unsupported === false
? 'Yes'
: 'No'
]);
break;
}
*/
return Object.keys(element.properties).map(prop => {
const value = element.properties[prop];

// the 'type' property can be a string or an array
if (typeof value === 'string') {
return `\`<${element.nodeName} ${prop}="${value}">\``;
}
return out;
}, []);
};

const getMdContent = (heading, rolesTable, attributesTable) => {
return `${heading}\n\n## Roles\n\n${rolesTable}\n\n## Attributes\n\n${attributesTable}`;
};
// output format for an array of types:
// <input type="button" | "checkbox">
const values = value.map(v => `"${v}"`).join(' | ');
return `\`<${element.nodeName} ${prop}=${values}>\``;
});
});

const generateDoc = () => {
const content = getMdContent(
`# ARIA Roles and Attributes ${
listType === 'all' ? 'available' : listType
} in axe-core.\n\n` +
'It can be difficult to know which features of web technologies are accessible across ' +
'different platforms, and with different screen readers and other assistive technologies. ' +
'Axe-core does some of this work for you, by raising issues when accessibility features are ' +
'used that are known to cause problems.\n\n' +
'This page contains a list of ARIA 1.1 features that axe-core raises as unsupported. ' +
'For more information, read [We’ve got your back with “Accessibility Supported” in axe]' +
'(https://www.deque.com/blog/weve-got-your-back-with-accessibility-supported-in-axe/).\n\n' +
'For a detailed description about how accessibility support is decided, see [How we make ' +
'decisions on rules](accessibility-supported.md).',
mdTable([
['aria-role', 'axe-core support'],
...getDiff(roles, axe.commons.aria.lookupTable.role)
]),
mdTable([
['aria-attribute', 'axe-core support'],
...getDiff(aQaria, axe.commons.aria.lookupTable.attributes)
])
);

// Format the content so Prettier doesn't create a diff after running.
// See https://github.com/dequelabs/axe-core/issues/1310.
const formattedContent = format(content, destFile);
grunt.file.write(destFile, formattedContent);
};
notes.push('Supported on elements: ' + supportedElements.join(', '));

generateDoc();
return notes;
}
}
);
};
4 changes: 3 additions & 1 deletion doc/aria-supported.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ For a detailed description about how accessibility support is decided, see [How
| -------------------- | ---------------- |
| aria-describedat | No |
| aria-details | No |
| aria-roledescription | No |
| aria-roledescription | Mixed[^1] |

[^1]: Supported on elements: `<button>`, `<input type="button" | "checkbox" | "image" | "radio" | "reset" | "submit">`, `<img>`, `<select>`, `<summary>`
43 changes: 33 additions & 10 deletions lib/checks/aria/unsupportedattr.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,39 @@
let unsupported = Array.from(node.attributes)
.filter(candidate => {
// filter out unsupported attributes
return axe.commons.aria.validateAttr(candidate.name, {
flagUnsupported: true
const nodeName = node.nodeName.toUpperCase();
const lookupTable = axe.commons.aria.lookupTable;
const role = axe.commons.aria.getRole(node);

const unsupportedAttrs = Array.from(node.attributes)
.filter(({ name }) => {
const attribute = lookupTable.attributes[name];

if (!axe.commons.aria.validateAttr(name)) {
return false;
}

const { unsupported } = attribute;

if (typeof unsupported !== 'object') {
return !!unsupported;
}

// validate attributes and conditions (if any) from allowedElement to given node
const isException = axe.commons.matches(node, unsupported.exceptions);

if (!Object.keys(lookupTable.evaluateRoleForElement).includes(nodeName)) {
return !isException;
}

// evaluate a given aria-role, execute the same
return !lookupTable.evaluateRoleForElement[nodeName]({
node,
role,
out: isException
});
})
.map(candidate => {
return candidate.name.toString();
});
.map(candidate => candidate.name.toString());

if (unsupported.length) {
this.data(unsupported);
if (unsupportedAttrs.length) {
this.data(unsupportedAttrs);
return true;
}
return false;
6 changes: 1 addition & 5 deletions lib/commons/aria/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,9 @@ aria.allowedAttr = function(role) {
* @memberof axe.commons.aria
* @instance
* @param {String} att The attribute name
* @param {Object} options Use `flagUnsupported: true` to report unsupported attributes
* @return {Boolean}
*/
aria.validateAttr = function(att, { flagUnsupported = false } = {}) {
aria.validateAttr = function validateAttr(att) {
const attrDefinition = aria.lookupTable.attributes[att];
if (flagUnsupported && attrDefinition) {
return !!attrDefinition.unsupported;
}
return !!attrDefinition;
};
20 changes: 18 additions & 2 deletions lib/commons/aria/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,22 @@ lookupTable.attributes = {
unsupported: false
},
'aria-roledescription': {
unsupported: true
type: 'string',
allowEmpty: true,
unsupported: {
exceptions: [
'button',
{
nodeName: 'input',
properties: {
type: ['button', 'checkbox', 'image', 'radio', 'reset', 'submit']
}
},
'img',
'select',
'summary'
]
}
},
'aria-rowcount': {
type: 'int',
Expand Down Expand Up @@ -260,7 +275,8 @@ lookupTable.globalAttributes = [
'aria-labelledby',
'aria-live',
'aria-owns',
'aria-relevant'
'aria-relevant',
'aria-roledescription'
];

lookupTable.role = {
Expand Down
Loading

0 comments on commit 93f721e

Please sign in to comment.