Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: better unsupported attribute support for aria-roledescription #1382

Merged
merged 11 commits into from
Feb 28, 2019
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 |
straker marked this conversation as resolved.
Show resolved Hide resolved
| 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