Skip to content

Commit

Permalink
feat(get-role): add presentation role resolution and inheritance (#2281)
Browse files Browse the repository at this point in the history
* feat(get-role): add presentation role resolution and inheritance

* comment

* add test

* fix all but test

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

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

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

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

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

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

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

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

* fixes

* throw error

* changes

Co-authored-by: Wilco Fiers <WilcoFiers@users.noreply.github.com>
  • Loading branch information
straker and WilcoFiers authored Jun 23, 2020
1 parent 61bac69 commit e207190
Show file tree
Hide file tree
Showing 8 changed files with 369 additions and 54 deletions.
133 changes: 126 additions & 7 deletions lib/commons/aria/get-role.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,118 @@
import getExplicitRole from './get-explicit-role';
import getImplicitRole from './implicit-role';
import lookupTable from './lookup-table';
import isFocusable from '../dom/is-focusable';
import { getNodeFromTree } from '../../core/utils';
import AbstractVirtuaNode from '../../core/base/virtual-node/abstract-virtual-node';

// when an element inherits the presentational role from a parent
// is not defined in the spec, but through testing it seems to be
// when a specific HTML parent relationship is required and that
// parent has `role=presentation`, then the child inherits the
// role (i.e. table, ul, dl). Further testing has shown that
// intermediate elements (such as divs) break this chain only in
// Chrome.
//
// Also, any nested structure chains reset the role (so two nested
// lists with the topmost list role=none will not cause the nested
// list to inherit the role=none).
//
// from Scott O'Hara:
//
// "the expectation for me, in standard html is that element
// structures that require specific parent/child relationships,
// if the parent is set to presentational that should set the
// children to presentational. ala, tables and lists."
// "but outside of those specific constructs, i would not expect
// role=presentation to do anything to child element roles"
const inheritsPresentationChain = {
// valid parent elements, any other element will prevent any
// children from inheriting a presentational role from a valid
// ancestor
td: ['tr'],
th: ['tr'],
tr: ['thead', 'tbody', 'tfoot', 'table'],
thead: ['table'],
tbody: ['table'],
tfoot: ['table'],
li: ['ol', 'ul'],
// dts and dds can be wrapped in divs and the div will pass through
// the presentation role
dt: ['dl', 'div'],
dd: ['dl', 'div'],
div: ['dl']
};

// role presentation inheritance.
// Source: https://www.w3.org/TR/wai-aria-1.1/#conflict_resolution_presentation_none
function getInheritedRole(vNode, explicitRoleOptions) {
const parentNodeNames = inheritsPresentationChain[vNode.props.nodeName];
if (!parentNodeNames) {
return null;
}

// if we can't look at the parent then we can't know if the node
// inherits the presentational role or not
if (!vNode.parent) {
throw new ReferenceError(
'Cannot determine role presentational inheritance of a required parent outside the current scope.'
);
}

// parent is not a valid ancestor that can inherit presentation
if (!parentNodeNames.includes(vNode.parent.props.nodeName)) {
return null;
}

const parentRole = getExplicitRole(vNode.parent, explicitRoleOptions);
if (
['none', 'presentation'].includes(parentRole) &&
!hasConflictResolution(vNode.parent)
) {
return parentRole;
}

// an explicit role of anything other than presentational will
// prevent any children from inheriting a presentational role
// from a valid ancestor
if (parentRole) {
return null;
}

return getInheritedRole(vNode.parent, explicitRoleOptions);
}

function resolveImplicitRole(vNode, explicitRoleOptions) {
const implicitRole = getImplicitRole(vNode);

if (!implicitRole) {
return null;
}

const presentationalRole = getInheritedRole(vNode, explicitRoleOptions);
if (presentationalRole) {
return presentationalRole;
}

return implicitRole;
}

// role conflict resolution
// note: Chrome returns a list with resolved role as "generic"
// instead of as a list
// (e.g. <ul role="none" aria-label><li>hello</li></ul>)
// we will return it as a list as that is the best option.
// Source: https://www.w3.org/TR/wai-aria-1.1/#conflict_resolution_presentation_none
// See also: https://github.com/w3c/aria/issues/1270
function hasConflictResolution(vNode) {
const hasGlobalAria = lookupTable.globalAttributes.some(attr =>
vNode.hasAttr(attr)
);
return hasGlobalAria || isFocusable(vNode.actualNode);
}

/**
* Return the semantic role of an element
* Return the semantic role of an element.
*
* @method getRole
* @memberof axe.commons.aria
Expand All @@ -19,20 +127,31 @@ import AbstractVirtuaNode from '../../core/base/virtual-node/abstract-virtual-no
*
* @deprecated noImplicit option is deprecated. Use aria.getExplicitRole instead.
*/
function getRole(node, { noImplicit, fallback, abstracts, dpub } = {}) {
function getRole(node, { noImplicit, ...explicitRoleOptions } = {}) {
const vNode =
node instanceof AbstractVirtuaNode ? node : getNodeFromTree(node);
if (vNode.props.nodeType !== 1) {
return null;
}
const explicitRole = getExplicitRole(vNode, { fallback, abstracts, dpub });

// Get the implicit role, if permitted
if (!explicitRole && !noImplicit) {
return getImplicitRole(vNode);
const explicitRole = getExplicitRole(vNode, explicitRoleOptions);

if (!explicitRole) {
return noImplicit ? null : resolveImplicitRole(vNode, explicitRoleOptions);
}

if (!['presentation', 'none'].includes(explicitRole)) {
return explicitRole;
}

if (hasConflictResolution(vNode)) {
// return null if there is a conflict resolution but no implicit
// has been set as the explicit role is not the true role
return noImplicit ? null : resolveImplicitRole(vNode, explicitRoleOptions);
}

return explicitRole || null;
// role presentation or none and no conflict resolution
return explicitRole;
}

export default getRole;
18 changes: 17 additions & 1 deletion lib/commons/aria/lookup-table.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 isFocusable from '../dom/is-focusable';
import { closest } from '../../core/utils';

const isNull = value => value === null;
Expand Down Expand Up @@ -2170,7 +2171,19 @@ lookupTable.implicitHtmlRole = {
},
hr: 'separator',
img: vNode => {
return vNode.hasAttr('alt') && !vNode.attr('alt') ? null : 'img';
// an images role is considered implicitly presentation if the
// alt attribute is empty. But that shouldn't be the case if it
// has global aria attributes or is focusable, so we need to
// override the role back to `img`
// e.g. <img alt="" aria-label="foo"></img>
const emptyAlt = vNode.hasAttr('alt') && !vNode.attr('alt');
const hasGlobalAria = lookupTable.globalAttributes.find(attr =>
vNode.hasAttr(attr)
);

return emptyAlt && !hasGlobalAria && !isFocusable(vNode.actualNode)
? 'presentation'
: 'img';
},
input: vNode => {
// Source: https://www.w3.org/TR/html52/sec-forms.html#suggestions-source-element
Expand Down Expand Up @@ -2204,6 +2217,9 @@ lookupTable.implicitHtmlRole = {
return !suggestionsSourceElement ? 'searchbox' : 'combobox';
}
},
// Note: if an li (or some other elms) do not have a required
// parent, Firefox ignores the implicit semantic role and treats
// it as a generic text.
li: 'listitem',
main: 'main',
math: 'math',
Expand Down
10 changes: 1 addition & 9 deletions lib/commons/text/title-text.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
import matches from '../matches/matches';
import getRole from '../aria/get-role';

const alwaysTitleElements = [
'button',
'iframe',
'a[href]',
{
nodeName: 'input',
properties: { type: 'button' }
}
];
const alwaysTitleElements = ['iframe'];

/**
* Get title text
Expand Down
17 changes: 7 additions & 10 deletions test/commons/aria/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,19 @@ describe('aria.allowedAttr', function() {
'use strict';

var orig;
var origGlobals;
beforeEach(function() {
orig = axe.commons.aria.lookupTable.role;
origGlobals = axe.commons.aria.lookupTable.globalAttributes;
});

afterEach(function() {
axe.commons.aria.lookupTable.role = orig;
axe.commons.aria.lookupTable.globalAttributes = origGlobals;
});

it('should returned the attributes property for the proper role', function() {
var orig = (axe.commons.aria.lookupTable.globalAttributes = ['world']);
axe.commons.aria.lookupTable.globalAttributes = ['world'];
axe.commons.aria.lookupTable.role = {
cats: {
attributes: {
Expand All @@ -52,11 +55,10 @@ describe('aria.allowedAttr', function() {
};

assert.deepEqual(axe.commons.aria.allowedAttr('cats'), ['hello', 'world']);
axe.commons.aria.lookupTable.globalAttributes = orig;
});

it('should also check required attributes', function() {
var orig = (axe.commons.aria.lookupTable.globalAttributes = ['world']);
axe.commons.aria.lookupTable.globalAttributes = ['world'];
axe.commons.aria.lookupTable.role = {
cats: {
attributes: {
Expand All @@ -71,18 +73,13 @@ describe('aria.allowedAttr', function() {
'world',
'hello'
]);
axe.commons.aria.lookupTable.globalAttributes = orig;
});

it('should return an array with globally allowed attributes', function() {
var result,
orig = (axe.commons.aria.lookupTable.globalAttributes = ['world']);

axe.commons.aria.lookupTable.globalAttributes = ['world'];
axe.commons.aria.lookupTable.role = {};
result = axe.commons.aria.allowedAttr('cats');

assert.deepEqual(result, ['world']);
axe.commons.aria.lookupTable.globalAttributes = orig;
assert.deepEqual(axe.commons.aria.allowedAttr('cats'), ['world']);
});
});

Expand Down
Loading

0 comments on commit e207190

Please sign in to comment.