Skip to content

Commit

Permalink
fix(implicit-roles): add proper implicit role calculation (#2242)
Browse files Browse the repository at this point in the history
* fix(implicit-roles): add proper implicit role calculation

* fixes

* fixes

* typo

* fix test

* Update lib/commons/aria/implicit-role.js

Co-authored-by: Wilco Fiers <WilcoFiers@users.noreply.github.com>

* Update lib/commons/aria/lookup-table.js

Co-authored-by: Wilco Fiers <WilcoFiers@users.noreply.github.com>

Co-authored-by: Wilco Fiers <WilcoFiers@users.noreply.github.com>
  • Loading branch information
straker and WilcoFiers authored Jun 5, 2020
1 parent 4e0bf65 commit e9dd259
Show file tree
Hide file tree
Showing 13 changed files with 666 additions and 152 deletions.
95 changes: 23 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,37 @@ 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';

/*
* 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;
});
};
const vNode = getNodeFromTree(node);

// this error is only thrown if the virtual tree is not a
// complete tree, which only happens in linting and if a
// user used `getFlattenedTree` manually on a subset of the
// DOM tree
if (!vNode) {
throw new ReferenceError(
'Cannot get implicit role of a node outside the current scope.'
);
}

/*
* 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 };
});
// until we have proper implicit role lookups for svgs we will
// avoid giving them one
if (node.namespaceURI === 'http://www.w3.org/2000/svg') {
return null;
}

/* Build a list of valid implicit roles for this node */
const availableImplicitRoles = roles.reduce(isValidImplicitRole, []);
const nodeName = vNode.props.nodeName;
const role = lookupTable.implicitHtmlRole[nodeName];

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;
162 changes: 161 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,151 @@ 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 !!(
// testing for when browsers give a <section> a region role:
// chrome - always a region role
// firefox - if non-empty aria-labelledby, aria-label, or title
// safari - if non-empty aria-lablledby or aria-label
//
// we will go with safaris implantation as it is the least common
// denominator
(
(vNode.hasAttr('aria-labelledby') &&
sanitize(arialabelledbyText(vNode))) ||
(vNode.hasAttr('aria-label') && sanitize(arialabelText(vNode)))
)
);
}

// Source: https://www.w3.org/TR/html-aam-1.0/#element-mapping-table
// Source: https://www.w3.org/TR/html-aria/
lookupTable.implicitHtmlRole = {
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';
}
},
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

0 comments on commit e9dd259

Please sign in to comment.