Skip to content

Commit 93f721e

Browse files
strakerWilcoFiers
authored andcommitted
fix: better unsupported attribute support for aria-roledescription (#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
1 parent 98bf49b commit 93f721e

File tree

11 files changed

+313
-105
lines changed

11 files changed

+313
-105
lines changed

build/tasks/aria-supported.js

Lines changed: 164 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -10,108 +10,188 @@ module.exports = function(grunt) {
1010
'aria-supported',
1111
'Task for generating a diff of supported aria roles and properties.',
1212
function() {
13-
const entry = this.data.entry;
14-
const destFile = this.data.destFile;
15-
const listType = this.data.listType.toLowerCase();
16-
1713
/**
18-
* `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.
14+
* NOTE:
15+
* `axe` has to be dynamically required at this stage,
16+
* as `axe` does not exist until grunt task `build:uglify` is complete,
17+
* hence cannot be required at the top of the file.
1918
*/
2019
const axe = require('../../axe');
20+
const listType = this.data.listType.toLowerCase();
21+
const headings = {
22+
main:
23+
`# ARIA Roles and Attributes ${
24+
listType === 'all' ? 'available' : listType
25+
} in axe-core.\n\n` +
26+
'It can be difficult to know which features of web technologies are accessible across ' +
27+
'different platforms, and with different screen readers and other assistive technologies. ' +
28+
'Axe-core does some of this work for you, by raising issues when accessibility features are ' +
29+
'used that are known to cause problems.\n\n' +
30+
'This page contains a list of ARIA 1.1 features that axe-core raises as unsupported. ' +
31+
'For more information, read [We’ve got your back with “Accessibility Supported” in axe]' +
32+
'(https://www.deque.com/blog/weve-got-your-back-with-accessibility-supported-in-axe/).\n\n' +
33+
'For a detailed description about how accessibility support is decided, see [How we make ' +
34+
'decisions on rules](accessibility-supported.md).',
35+
rolesMdTableHeader: ['aria-role', 'axe-core support'],
36+
attributesMdTableHeader: ['aria-attribute', 'axe-core support']
37+
};
38+
39+
const { diff: rolesTable, notes: rolesFootnotes } = getDiff(
40+
roles,
41+
axe.commons.aria.lookupTable.role,
42+
listType
43+
);
44+
const rolesTableMarkdown = mdTable([
45+
headings.rolesMdTableHeader,
46+
...rolesTable
47+
]);
48+
49+
const ariaQueryAriaAttributes = getAriaQueryAttributes();
50+
const { diff: attributesTable, notes: attributesFootnotes } = getDiff(
51+
ariaQueryAriaAttributes,
52+
axe.commons.aria.lookupTable.attributes,
53+
listType
54+
);
55+
const attributesTableMarkdown = mdTable([
56+
headings.attributesMdTableHeader,
57+
...attributesTable
58+
]);
59+
60+
const footnotes = [...rolesFootnotes, ...attributesFootnotes].map(
61+
(footnote, index) => `[^${index + 1}]: ${footnote}`
62+
);
63+
64+
const content = `${
65+
headings.main
66+
}\n\n## Roles\n\n${rolesTableMarkdown}\n\n## Attributes\n\n${attributesTableMarkdown}\n\n${footnotes}`;
67+
68+
const destFile = this.data.destFile;
69+
// Format the content so Prettier doesn't create a diff after running.
70+
// See https://github.com/dequelabs/axe-core/issues/1310.
71+
const formattedContent = format(content, destFile);
72+
73+
// write `aria supported` file contents
74+
grunt.file.write(destFile, formattedContent);
2175

2276
/**
23-
* As `aria-query` roles map, does not list all aria attributes in its props,
24-
* the below reduce function aims to concatanate and unique the below two,
25-
* - list from props with in roles map
26-
* - list from aria map
27-
*
28-
* @return {Map} `aQaria` - This gives a composite list of aria attributes, which is later used to diff against axe-core supported attributes.
77+
* Get list of aria attributes, from `aria-query`
78+
* @returns {Set|Object} collection of aria attributes from `aria-query` module
2979
*/
30-
const ariaKeys = Array.from(props).map(([key]) => key);
31-
const roleAriaKeys = Array.from(roles).reduce((out, [name, rule]) => {
32-
return [...out, ...Object.keys(rule.props)];
33-
}, []);
34-
const aQaria = new Set(axe.utils.uniqueArray(roleAriaKeys, ariaKeys));
80+
function getAriaQueryAttributes() {
81+
const ariaKeys = Array.from(props).map(([key]) => key);
82+
const roleAriaKeys = Array.from(roles).reduce((out, [name, rule]) => {
83+
return [...out, ...Object.keys(rule.props)];
84+
}, []);
85+
return new Set(axe.utils.uniqueArray(roleAriaKeys, ariaKeys));
86+
}
3587

3688
/**
3789
* Given a `base` Map and `subject` Map object,
3890
* The function converts the `base` Map entries to an array which is sorted then enumerated to compare each entry against the `subject` Map
39-
* The function constructs a `string` to represent a `markdown table` to
91+
* The function constructs a `string` to represent a `markdown table`, as well as returns notes to append to footnote
4092
* @param {Map} base Base Map Object
4193
* @param {Map} subject Subject Map Object
42-
* @return {Array[]} Example Output: [ [ 'alert', 'No' ], [ 'figure', 'Yes' ] ]
94+
* @param {String} type type to compare
95+
* @returns {Array<Object>[]}
96+
* @example Example Output: [ [ 'alert', 'No' ], [ 'figure', 'Yes' ] ]
4397
*/
44-
const getDiff = (base, subject) => {
45-
return Array.from(base.entries())
46-
.sort()
47-
.reduce((out, [key] = item) => {
48-
switch (listType) {
49-
case 'supported':
50-
if (
51-
subject.hasOwnProperty(key) &&
52-
subject[key].unsupported === false
53-
) {
54-
out.push([`${key}`, 'Yes']);
55-
}
56-
break;
57-
case 'unsupported':
58-
if (
59-
(subject[key] && subject[key].unsupported === true) ||
60-
!subject.hasOwnProperty(key)
61-
) {
62-
out.push([`${key}`, 'No']);
98+
function getDiff(base, subject, type) {
99+
const diff = [];
100+
const notes = [];
101+
102+
const sortedBase = Array.from(base.entries()).sort();
103+
104+
sortedBase.forEach(([key] = item) => {
105+
switch (type) {
106+
case 'supported':
107+
if (
108+
subject.hasOwnProperty(key) &&
109+
subject[key].unsupported === false
110+
) {
111+
diff.push([`${key}`, 'Yes']);
112+
}
113+
break;
114+
case 'unsupported':
115+
if (
116+
(subject[key] && subject[key].unsupported === true) ||
117+
!subject.hasOwnProperty(key)
118+
) {
119+
diff.push([`${key}`, 'No']);
120+
} else if (
121+
subject[key] &&
122+
subject[key].unsupported &&
123+
subject[key].unsupported.exceptions
124+
) {
125+
diff.push([`${key}`, `Mixed[^${notes.length + 1}]`]);
126+
notes.push(
127+
getSupportedElementsAsFootnote(
128+
subject[key].unsupported.exceptions
129+
)
130+
);
131+
}
132+
break;
133+
case 'all':
134+
default:
135+
diff.push([
136+
`${key}`,
137+
subject.hasOwnProperty(key) &&
138+
subject[key].unsupported === false
139+
? 'Yes'
140+
: 'No'
141+
]);
142+
break;
143+
}
144+
});
145+
146+
return {
147+
diff,
148+
notes
149+
};
150+
}
151+
152+
/**
153+
* Parse a list of unsupported exception elements and add a footnote
154+
* detailing which HTML elements are supported.
155+
*
156+
* @param {Array<String|Object>} elements List of supported elements
157+
* @returns {Array<String|Object>} notes
158+
*/
159+
function getSupportedElementsAsFootnote(elements) {
160+
const notes = [];
161+
162+
const supportedElements = elements.map(element => {
163+
if (typeof element === 'string') {
164+
return `\`<${element}>\``;
165+
}
166+
167+
/**
168+
* if element is not a string it will be an object with structure:
169+
{
170+
nodeName: string,
171+
properties: {
172+
type: {string|string[]}
63173
}
64-
break;
65-
case 'all':
66-
default:
67-
out.push([
68-
`${key}`,
69-
subject.hasOwnProperty(key) &&
70-
subject[key].unsupported === false
71-
? 'Yes'
72-
: 'No'
73-
]);
74-
break;
174+
}
175+
*/
176+
return Object.keys(element.properties).map(prop => {
177+
const value = element.properties[prop];
178+
179+
// the 'type' property can be a string or an array
180+
if (typeof value === 'string') {
181+
return `\`<${element.nodeName} ${prop}="${value}">\``;
75182
}
76-
return out;
77-
}, []);
78-
};
79183

80-
const getMdContent = (heading, rolesTable, attributesTable) => {
81-
return `${heading}\n\n## Roles\n\n${rolesTable}\n\n## Attributes\n\n${attributesTable}`;
82-
};
184+
// output format for an array of types:
185+
// <input type="button" | "checkbox">
186+
const values = value.map(v => `"${v}"`).join(' | ');
187+
return `\`<${element.nodeName} ${prop}=${values}>\``;
188+
});
189+
});
83190

84-
const generateDoc = () => {
85-
const content = getMdContent(
86-
`# ARIA Roles and Attributes ${
87-
listType === 'all' ? 'available' : listType
88-
} in axe-core.\n\n` +
89-
'It can be difficult to know which features of web technologies are accessible across ' +
90-
'different platforms, and with different screen readers and other assistive technologies. ' +
91-
'Axe-core does some of this work for you, by raising issues when accessibility features are ' +
92-
'used that are known to cause problems.\n\n' +
93-
'This page contains a list of ARIA 1.1 features that axe-core raises as unsupported. ' +
94-
'For more information, read [We’ve got your back with “Accessibility Supported” in axe]' +
95-
'(https://www.deque.com/blog/weve-got-your-back-with-accessibility-supported-in-axe/).\n\n' +
96-
'For a detailed description about how accessibility support is decided, see [How we make ' +
97-
'decisions on rules](accessibility-supported.md).',
98-
mdTable([
99-
['aria-role', 'axe-core support'],
100-
...getDiff(roles, axe.commons.aria.lookupTable.role)
101-
]),
102-
mdTable([
103-
['aria-attribute', 'axe-core support'],
104-
...getDiff(aQaria, axe.commons.aria.lookupTable.attributes)
105-
])
106-
);
107-
108-
// Format the content so Prettier doesn't create a diff after running.
109-
// See https://github.com/dequelabs/axe-core/issues/1310.
110-
const formattedContent = format(content, destFile);
111-
grunt.file.write(destFile, formattedContent);
112-
};
191+
notes.push('Supported on elements: ' + supportedElements.join(', '));
113192

114-
generateDoc();
193+
return notes;
194+
}
115195
}
116196
);
117197
};

doc/aria-supported.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,6 @@ For a detailed description about how accessibility support is decided, see [How
1818
| -------------------- | ---------------- |
1919
| aria-describedat | No |
2020
| aria-details | No |
21-
| aria-roledescription | No |
21+
| aria-roledescription | Mixed[^1] |
22+
23+
[^1]: Supported on elements: `<button>`, `<input type="button" | "checkbox" | "image" | "radio" | "reset" | "submit">`, `<img>`, `<select>`, `<summary>`

lib/checks/aria/unsupportedattr.js

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,39 @@
1-
let unsupported = Array.from(node.attributes)
2-
.filter(candidate => {
3-
// filter out unsupported attributes
4-
return axe.commons.aria.validateAttr(candidate.name, {
5-
flagUnsupported: true
1+
const nodeName = node.nodeName.toUpperCase();
2+
const lookupTable = axe.commons.aria.lookupTable;
3+
const role = axe.commons.aria.getRole(node);
4+
5+
const unsupportedAttrs = Array.from(node.attributes)
6+
.filter(({ name }) => {
7+
const attribute = lookupTable.attributes[name];
8+
9+
if (!axe.commons.aria.validateAttr(name)) {
10+
return false;
11+
}
12+
13+
const { unsupported } = attribute;
14+
15+
if (typeof unsupported !== 'object') {
16+
return !!unsupported;
17+
}
18+
19+
// validate attributes and conditions (if any) from allowedElement to given node
20+
const isException = axe.commons.matches(node, unsupported.exceptions);
21+
22+
if (!Object.keys(lookupTable.evaluateRoleForElement).includes(nodeName)) {
23+
return !isException;
24+
}
25+
26+
// evaluate a given aria-role, execute the same
27+
return !lookupTable.evaluateRoleForElement[nodeName]({
28+
node,
29+
role,
30+
out: isException
631
});
732
})
8-
.map(candidate => {
9-
return candidate.name.toString();
10-
});
33+
.map(candidate => candidate.name.toString());
1134

12-
if (unsupported.length) {
13-
this.data(unsupported);
35+
if (unsupportedAttrs.length) {
36+
this.data(unsupportedAttrs);
1437
return true;
1538
}
1639
return false;

lib/commons/aria/attributes.js

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,9 @@ aria.allowedAttr = function(role) {
3737
* @memberof axe.commons.aria
3838
* @instance
3939
* @param {String} att The attribute name
40-
* @param {Object} options Use `flagUnsupported: true` to report unsupported attributes
4140
* @return {Boolean}
4241
*/
43-
aria.validateAttr = function(att, { flagUnsupported = false } = {}) {
42+
aria.validateAttr = function validateAttr(att) {
4443
const attrDefinition = aria.lookupTable.attributes[att];
45-
if (flagUnsupported && attrDefinition) {
46-
return !!attrDefinition.unsupported;
47-
}
4844
return !!attrDefinition;
4945
};

lib/commons/aria/index.js

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,22 @@ lookupTable.attributes = {
196196
unsupported: false
197197
},
198198
'aria-roledescription': {
199-
unsupported: true
199+
type: 'string',
200+
allowEmpty: true,
201+
unsupported: {
202+
exceptions: [
203+
'button',
204+
{
205+
nodeName: 'input',
206+
properties: {
207+
type: ['button', 'checkbox', 'image', 'radio', 'reset', 'submit']
208+
}
209+
},
210+
'img',
211+
'select',
212+
'summary'
213+
]
214+
}
200215
},
201216
'aria-rowcount': {
202217
type: 'int',
@@ -260,7 +275,8 @@ lookupTable.globalAttributes = [
260275
'aria-labelledby',
261276
'aria-live',
262277
'aria-owns',
263-
'aria-relevant'
278+
'aria-relevant',
279+
'aria-roledescription'
264280
];
265281

266282
lookupTable.role = {

0 commit comments

Comments
 (0)