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(implicit-roles): add proper implicit role calculation #2242

Merged
merged 8 commits into from
Jun 5, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 11 additions & 72 deletions lib/commons/aria/implicit-role.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import allowedAttr from './allowed-attr';
import lookupTable from './lookup-table';
import { getNodeFromTree } from '../../core/utils';

/**
* Get the implicit role for a given node
Expand All @@ -9,86 +9,25 @@ import lookupTable from './lookup-table';
* @param {HTMLElement} node The node to test
* @return {Mixed} Either the role or `null` if there is none
*/
// TODO: axe 4.0 - rename to `getImplicitRole` so we can use
// a variable named `implicitRole` when assigning the output of
// the function
function implicitRole(node) {
'use strict';
const vNode = getNodeFromTree(node);
straker marked this conversation as resolved.
Show resolved Hide resolved

/*
* Filter function to reduce a list of roles to a valid list of roles for a nodetype
*/
const isValidImplicitRole = function(set, role) {
const validForNodeType = function(implicitNodeTypeSelector) {
// TODO: es-module-utils.matchesSelector
return axe.utils.matchesSelector(node, implicitNodeTypeSelector);
};

if (role.implicit && role.implicit.some(validForNodeType)) {
set.push(role.name);
}

return set;
};

/*
* Score a set of roles and aria-attributes by its optimal score
* E.g. [{score: 2, name: button}, {score: 1, name: main}]
*/
const sortRolesByOptimalAriaContext = function(roles, ariaAttributes) {
const getScore = function(role) {
const allowedAriaAttributes = allowedAttr(role);
return allowedAriaAttributes.reduce(function(score, attribute) {
return score + (ariaAttributes.indexOf(attribute) > -1 ? 1 : 0);
}, 0);
};

const scored = roles.map(function(role) {
return { score: getScore(role), name: role };
});

const sorted = scored.sort(function(scoredRoleA, scoredRoleB) {
return scoredRoleB.score - scoredRoleA.score;
});

return sorted.map(function(sortedRole) {
return sortedRole.name;
});
};

/*
* Create a list of { name / implicit } role mappings to filter on
*/
const roles = Object.keys(lookupTable.role).map(function(role) {
const lookup = lookupTable.role[role];
return { name: role, implicit: lookup && lookup.implicit };
});
if (!vNode) {
return null;
straker marked this conversation as resolved.
Show resolved Hide resolved
}

/* Build a list of valid implicit roles for this node */
const availableImplicitRoles = roles.reduce(isValidImplicitRole, []);
const nodeName = vNode.props.nodeName.toLowerCase();
straker marked this conversation as resolved.
Show resolved Hide resolved
const role = lookupTable.implicitRole[nodeName];
straker marked this conversation as resolved.
Show resolved Hide resolved
straker marked this conversation as resolved.
Show resolved Hide resolved

if (!availableImplicitRoles.length) {
if (!role) {
return null;
}

// TODO: es-module-utils.getNodeAttributes
const nodeAttributes = axe.utils.getNodeAttributes(node);
const ariaAttributes = [];

/* Get all aria-attributes defined for this node */
/* Should be a helper function somewhere */
for (let i = 0, j = nodeAttributes.length; i < j; i++) {
const attr = nodeAttributes[i];
if (attr.name.match(/^aria-/)) {
ariaAttributes.push(attr.name);
}
if (typeof role === 'function') {
return role(vNode);
}

/* Sort roles by highest score, return the first */
return sortRolesByOptimalAriaContext(
availableImplicitRoles,
ariaAttributes
).shift();
return role;
}

export default implicitRole;
143 changes: 142 additions & 1 deletion lib/commons/aria/lookup-table.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import arialabelledbyText from './arialabelledby-text';
import arialabelText from './arialabel-text';
import titleText from '../text/title-text';
import idrefs from '../dom/idrefs';
import isColumnHeader from '../table/is-column-header';
import isRowHeader from '../table/is-row-header';
import { closest } from '../../core/utils';

const isNull = value => value === null;
const isNotNull = value => value !== null;

Expand Down Expand Up @@ -1012,6 +1020,13 @@ lookupTable.role = {
},
figure: {
type: 'structure',
attributes: {
allowed: ['aria-expanded', 'aria-errormessage']
},
owned: null,
nameFrom: ['author', 'contents'],
context: null,
implicit: ['figure'],
unsupported: false
},
form: {
Expand Down Expand Up @@ -1131,7 +1146,7 @@ lookupTable.role = {
owned: null,
nameFrom: ['author', 'contents'],
context: null,
implicit: ['a[href]'],
implicit: ['a[href]', 'area[href]'],
unsupported: false,
allowedElements: [
'button',
Expand Down Expand Up @@ -2079,6 +2094,132 @@ lookupTable.role = {
}
};

// Source: https://www.w3.org/TR/html-aam-1.0/#element-mapping-table
// Source: https://www.w3.org/TR/html-aria/
lookupTable.implicitRole = {
straker marked this conversation as resolved.
Show resolved Hide resolved
straker marked this conversation as resolved.
Show resolved Hide resolved
a: vNode => {
return vNode.hasAttr('href') ? 'link' : null;
},
area: vNode => {
return vNode.hasAttr('href') ? 'link' : null;
},
article: 'article',
aside: 'complementary',
body: 'document',
button: 'button',
datalist: 'listbox',
dd: 'definition',
dfn: 'term',
details: 'group',
dialog: 'dialog',
dt: 'term',
fieldset: 'group',
figure: 'figure',
footer: vNode => {
const sectioningElement = closest(
vNode,
'article, aside, main, nav, section, [role=article], [role=complementary], [role=main], [role=navigation], [role=region]'
);

return !sectioningElement ? 'contentinfo' : null;
},
form: vNode => {
// accessible-text-virtual uses getRole, so we have to only
// use the name calculation steps we know apply to a form
// element
straker marked this conversation as resolved.
Show resolved Hide resolved
return arialabelledbyText(vNode) || arialabelText(vNode) || titleText(vNode)
straker marked this conversation as resolved.
Show resolved Hide resolved
? 'form'
: null;
},
h1: 'heading',
h2: 'heading',
h3: 'heading',
h4: 'heading',
h5: 'heading',
h6: 'heading',
header: vNode => {
const sectioningElement = closest(
vNode,
'article, aside, main, nav, section, [role=article], [role=complementary], [role=main], [role=navigation], [role=region]'
);

return !sectioningElement ? 'banner' : null;
},
hr: 'separator',
img: vNode => {
return vNode.hasAttr('alt') && !vNode.attr('alt') ? null : 'img';
},
input: vNode => {
// Source: https://www.w3.org/TR/html52/sec-forms.html#suggestions-source-element
const listElement = idrefs(vNode.actualNode, 'list').filter(
node => !!node
)[0];
const suggestionsSourceElement =
listElement && listElement.nodeName.toLowerCase() === 'datalist';

switch (vNode.attr('type')) {
straker marked this conversation as resolved.
Show resolved Hide resolved
case 'button':
case 'image':
case 'reset':
case 'submit':
return 'button';
case 'checkbox':
return 'checkbox';
case 'email':
case 'tel':
case 'text':
case 'url':
case null: // text is the default value
return !suggestionsSourceElement ? 'textbox' : 'combobox';
case 'number':
return 'spinbutton';
case 'radio':
return 'radio';
case 'range':
return 'slider';
case 'search':
return !suggestionsSourceElement ? 'searchbox' : 'combobox';
}
straker marked this conversation as resolved.
Show resolved Hide resolved
},
li: 'listitem',
main: 'main',
math: 'math',
menu: 'list',
nav: 'navigation',
ol: 'list',
optgroup: 'group',
option: 'option',
output: 'status',
progress: 'progressbar',
section: vNode => {
return arialabelledbyText(vNode) || arialabelText(vNode) || titleText(vNode)
? 'region'
: null;
},
select: vNode => {
return vNode.hasAttr('multiple') || parseInt(vNode.attr('size')) > 1
? 'listbox'
: 'combobox';
},
summary: 'button',
table: 'table',
tbody: 'rowgroup',
td: 'cell',
textarea: 'textbox',
tfoot: 'rowgroup',
th: vNode => {
if (isColumnHeader(vNode.actualNode)) {
return 'columnheader';
}
if (isRowHeader(vNode.actualNode)) {
return 'rowheader';
}
},
thead: 'rowgroup',
tr: 'row',
ul: 'list'
};

// Source: https://www.w3.org/TR/html-aria/
lookupTable.elementsAllowedNoRole = [
{
Expand Down
9 changes: 9 additions & 0 deletions test/checks/aria/allowed-attr.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ describe('aria-allowed-attr', function() {
'use strict';

var fixture = document.getElementById('fixture');
var flatTreeSetup = axe.testUtils.flatTreeSetup;
var checkContext = axe.testUtils.MockCheckContext();

afterEach(function() {
Expand All @@ -16,6 +17,7 @@ describe('aria-allowed-attr', function() {
node.tabIndex = 1;
node.setAttribute('aria-selected', 'true');
fixture.appendChild(node);
flatTreeSetup(fixture);

assert.isFalse(
axe.testUtils
Expand All @@ -32,6 +34,7 @@ describe('aria-allowed-attr', function() {
node.tabIndex = 1;
node.setAttribute('aria-checked', 'true');
fixture.appendChild(node);
flatTreeSetup(fixture);

assert.isTrue(
axe.testUtils
Expand All @@ -47,6 +50,7 @@ describe('aria-allowed-attr', function() {
node.tabIndex = 1;
node.setAttribute('aria-selected', 'true');
fixture.appendChild(node);
flatTreeSetup(fixture);

assert.isFalse(
axe.testUtils
Expand All @@ -63,6 +67,7 @@ describe('aria-allowed-attr', function() {
node.setAttribute('aria-selected', 'true');
node.setAttribute('aria-checked', 'true');
fixture.appendChild(node);
flatTreeSetup(fixture);

assert.isTrue(
axe.testUtils
Expand All @@ -79,6 +84,7 @@ describe('aria-allowed-attr', function() {
node.setAttribute('aria-cats', 'true');
node.setAttribute('role', 'dialog');
fixture.appendChild(node);
flatTreeSetup(fixture);

assert.isTrue(
axe.testUtils
Expand All @@ -96,6 +102,7 @@ describe('aria-allowed-attr', function() {
node.setAttribute('aria-required', 'true');
node.setAttribute('aria-checked', 'true');
fixture.appendChild(node);
flatTreeSetup(fixture);

assert.isTrue(
axe.testUtils
Expand All @@ -119,6 +126,7 @@ describe('aria-allowed-attr', function() {
fixture.innerHTML =
'<div role="mccheddarton" id="target" aria-checked="true" aria-snuggles="true"></div>';
var target = fixture.children[0];
flatTreeSetup(fixture);
assert.isTrue(
axe.testUtils
.getCheckEvaluate('aria-allowed-attr')
Expand Down Expand Up @@ -155,6 +163,7 @@ describe('aria-allowed-attr', function() {
mccheddarton: ['aria-snuggles'],
bagley: ['aria-snuggles2']
};
flatTreeSetup(fixture);
assert.isTrue(
axe.testUtils
.getCheckEvaluate('aria-allowed-attr')
Expand Down
7 changes: 7 additions & 0 deletions test/checks/aria/aria-roledescription.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ describe('aria-roledescription', function() {
'use strict';

var fixture = document.getElementById('fixture');
var flatTreeSetup = axe.testUtils.flatTreeSetup;
var checkContext = axe.testUtils.MockCheckContext();

afterEach(function() {
Expand All @@ -12,6 +13,7 @@ describe('aria-roledescription', function() {
it('returns true for elements with an implicit supported role', function() {
fixture.innerHTML =
'<button aria-roledescription="Awesome Button">Click</button>';
flatTreeSetup(fixture);
var actual = axe.testUtils
.getCheckEvaluate('aria-roledescription')
.call(checkContext, fixture.firstChild, {
Expand All @@ -24,6 +26,7 @@ describe('aria-roledescription', function() {
it('returns true for elements with an explicit supported role', function() {
fixture.innerHTML =
'<div role="radio" aria-roledescription="Awesome Radio">Click</div>';
flatTreeSetup(fixture);
var actual = axe.testUtils
.getCheckEvaluate('aria-roledescription')
.call(checkContext, fixture.firstChild, {
Expand All @@ -36,6 +39,7 @@ describe('aria-roledescription', function() {
it('returns undefined for elements with an unsupported role', function() {
fixture.innerHTML =
'<div role="main" aria-roledescription="Awesome Main">The main element</div>';
flatTreeSetup(fixture);
var actual = axe.testUtils
.getCheckEvaluate('aria-roledescription')
.call(checkContext, fixture.firstChild);
Expand All @@ -46,6 +50,7 @@ describe('aria-roledescription', function() {
it('returns false for elements without role', function() {
fixture.innerHTML =
'<div aria-roledescription="Awesome Main">The main element</div>';
flatTreeSetup(fixture);
var actual = axe.testUtils
.getCheckEvaluate('aria-roledescription')
.call(checkContext, fixture.firstChild);
Expand All @@ -56,6 +61,7 @@ describe('aria-roledescription', function() {
it('returns false for elements with role=presentation', function() {
fixture.innerHTML =
'<div role="presentation" aria-roledescription="Awesome Main">The main element</div>';
flatTreeSetup(fixture);
var actual = axe.testUtils
.getCheckEvaluate('aria-roledescription')
.call(checkContext, fixture.firstChild);
Expand All @@ -66,6 +72,7 @@ describe('aria-roledescription', function() {
it('returns false for elements with role=none', function() {
fixture.innerHTML =
'<div role="none" aria-roledescription="Awesome Main">The main element</div>';
flatTreeSetup(fixture);
var actual = axe.testUtils
.getCheckEvaluate('aria-roledescription')
.call(checkContext, fixture.firstChild);
Expand Down
Loading