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 3 commits
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;
153 changes: 152 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 idrefs from '../dom/idrefs';
import isColumnHeader from '../table/is-column-header';
import isRowHeader from '../table/is-row-header';
import sanitize from '../text/sanitize';
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,142 @@ lookupTable.role = {
}
};

const sectioningElementSelector =
'article:not([role]), aside:not([role]), main:not([role]), nav:not([role]), section:not([role]), [role=article], [role=complementary], [role=main], [role=navigation], [role=region]';

// sectioning elements only have an accessible name if the
// aria-label, aria-labelledby, or title attribute has valid
// content.
// can't go through the normal accessible name computation
// as it leads into an infinite loop of asking for the role
// of the element while the implicit role needs the name.
// Source: https://www.w3.org/TR/html-aam-1.0/#section-and-grouping-element-accessible-name-computation
//
// form elements also follow this same pattern although not
// specifically called out in the spec like section elements
// (per Scott O'Hara)
// Source: https://web-a11y.slack.com/archives/C042TSFGN/p1590607895241100?thread_ts=1590602189.217800&cid=C042TSFGN
function hasAccessibleName(vNode) {
return !!(
(vNode.hasAttr('aria-labelledby') && sanitize(arialabelledbyText(vNode))) ||
(vNode.hasAttr('aria-label') && sanitize(arialabelText(vNode))) ||
(vNode.hasAttr('title') && sanitize(vNode.attr('title')))
);
}

// 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, sectioningElementSelector);

return !sectioningElement ? 'contentinfo' : null;
},
form: vNode => {
return hasAccessibleName(vNode) ? 'form' : null;
},
h1: 'heading',
h2: 'heading',
h3: 'heading',
h4: 'heading',
h5: 'heading',
h6: 'heading',
header: vNode => {
const sectioningElement = closest(vNode, sectioningElementSelector);

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') || '').toLowerCase()) {
case 'button':
case 'image':
case 'reset':
case 'submit':
return 'button';
case 'checkbox':
return 'checkbox';
case 'email':
case 'tel':
case 'text':
case 'url':
case '': // 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 hasAccessibleName(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
Loading