Skip to content

Commit

Permalink
fix(aria-allowed-role): allow title, aria-label and aria-labelledby o…
Browse files Browse the repository at this point in the history
…n a img element with a supported role (#3224)
  • Loading branch information
Zidious authored Nov 10, 2021
1 parent ed9ef99 commit 006a681
Show file tree
Hide file tree
Showing 16 changed files with 307 additions and 13 deletions.
1 change: 0 additions & 1 deletion lib/commons/aria/get-element-unallowed-roles.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ function getElementUnallowedRoles(node, allowImplicit = true) {
) {
return true;
}

// check if role is allowed on element
return !isAriaRoleAllowedOnElement(vNode, role);
});
Expand Down
2 changes: 2 additions & 0 deletions lib/commons/matches/from-definition.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import hasAccessibleName from './has-accessible-name';
import attributes from './attributes';
import condition from './condition';
import explicitRole from './explicit-role';
Expand All @@ -9,6 +10,7 @@ import AbstractVirtualNode from '../../core/base/virtual-node/abstract-virtual-n
import { getNodeFromTree, matches } from '../../core/utils';

const matchers = {
hasAccessibleName,
attributes,
condition,
explicitRole,
Expand Down
25 changes: 25 additions & 0 deletions lib/commons/matches/has-accessible-name.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import accessibleTextVirtual from '../text/accessible-text-virtual';
import fromPrimative from './from-primative';

/**
* Check if a virtual node has a non-empty accessible name
*``
* Note: matches.hasAccessibleName(vNode, true) can be indirectly used through
* matches(vNode, { hasAccessibleName: boolean })
*
* Example:
* ```js
* matches.hasAccessibleName(vNode, true);
* matches.hasAccessibleName(vNode, false);
*
* ```
*
* @param {VirtualNode} vNode
* @param {Object} matcher
* @returns {Boolean}
*/
function hasAccessibleName(vNode, matcher) {
return fromPrimative(!!accessibleTextVirtual(vNode), matcher);
}

export default hasAccessibleName;
2 changes: 2 additions & 0 deletions lib/commons/matches/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* @namespace commons.matches
* @memberof axe
*/
import hasAccessibleName from './has-accessible-name';
import attributes from './attributes';
import condition from './condition';
import explicitRole from './explicit-role';
Expand All @@ -15,6 +16,7 @@ import nodeName from './node-name';
import properties from './properties';
import semanticRole from './semantic-role';

matches.hasAccessibleName = hasAccessibleName;
matches.attributes = attributes;
matches.condition = condition;
matches.explicitRole = explicitRole;
Expand Down
9 changes: 8 additions & 1 deletion lib/commons/standards/get-element-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import matchesFn from '../../commons/matches';
* @param {VirtualNode} vNode The VirtualNode to get the spec for.
* @return {Object} The standard spec object
*/
function getElementSpec(vNode) {
function getElementSpec(vNode, { noMatchAccessibleName = false } = {}) {
const standard = standards.htmlElms[vNode.props.nodeName];

// invalid element name (could be an svg or custom element name)
Expand All @@ -29,6 +29,13 @@ function getElementSpec(vNode) {
}

const { matches, ...props } = variant[variantName];
const matchProperties = Array.isArray(matches) ? matches : [matches];
for (let i = 0; i < matchProperties.length && noMatchAccessibleName; i++) {
if (matchProperties[i].hasOwnProperty('hasAccessibleName')) {
return standard;
}
}

if (matchesFn(vNode, matches)) {
for (const propName in props) {
if (props.hasOwnProperty(propName)) {
Expand Down
2 changes: 1 addition & 1 deletion lib/commons/text/native-text-alternative.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function nativeTextAlternative(virtualNode, context = {}) {
* @return {Function[]} Array of native accessible name computation methods
*/
function findTextMethods(virtualNode) {
const elmSpec = getElementSpec(virtualNode);
const elmSpec = getElementSpec(virtualNode, { noMatchAccessibleName: true });
const methods = elmSpec.namingMethods || [];

return methods.map(methodName => nativeTextMethods[methodName]);
Expand Down
6 changes: 4 additions & 2 deletions lib/commons/text/subtree-text.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import accessibleTextVirtual from './accessible-text-virtual';
import namedFromContents from '../aria/named-from-contents';
import getOwnedVirtual from '../aria/get-owned-virtual';
import getElementsByContentType from '../standards/get-elements-by-content-type';
import getElementSpec from '../standards/get-element-spec'
import getElementSpec from '../standards/get-element-spec';

/**
* Get the accessible text for an element that can get its name from content
Expand All @@ -16,7 +16,9 @@ function subtreeText(virtualNode, context = {}) {
const { alreadyProcessed } = accessibleTextVirtual;
context.startNode = context.startNode || virtualNode;
const { strict, inControlContext, inLabelledByContext } = context;
const { contentTypes } = getElementSpec(virtualNode);
const { contentTypes } = getElementSpec(virtualNode, {
noMatchAccessibleName: true
});
if (
alreadyProcessed(virtualNode, context) ||
virtualNode.props.nodeType !== 1 ||
Expand Down
14 changes: 10 additions & 4 deletions lib/standards/html-elms.js
Original file line number Diff line number Diff line change
Expand Up @@ -330,11 +330,17 @@ const htmlElms = {
img: {
variant: {
nonEmptyAlt: {
matches: {
attributes: {
alt: '/.+/'
matches: [
{
// Because <img role="none" alt="foo" /> has no accessible name:
attributes: {
alt: '/.+/'
}
},
{
hasAccessibleName: true
}
},
],
allowedRoles: [
'button',
'checkbox',
Expand Down
76 changes: 76 additions & 0 deletions test/checks/aria/aria-allowed-role.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,82 @@ describe('aria-allowed-role', function() {
assert.deepEqual(checkContext._data, ['none']);
});

it('returns true when img has aria-label and a valid role, role="button"', function() {
var vNode = queryFixture(
'<img id="target" aria-label="foo" role="button"/>'
);
assert.isTrue(
axe.testUtils
.getCheckEvaluate('aria-allowed-role')
.call(checkContext, null, null, vNode)
);
assert.isNull(checkContext._data, null);
});

it('returns false when img has aria-label and a invalid role, role="alert"', function() {
var vNode = queryFixture(
'<img id="target" aria-label="foo" role="alert"/>'
);
assert.isFalse(
axe.testUtils
.getCheckEvaluate('aria-allowed-role')
.call(checkContext, null, null, vNode)
);
assert.deepEqual(checkContext._data, ['alert']);
});

it('returns true when img has aria-labelledby and a valid role, role="menuitem"', function() {
var vNode = queryFixture(
'<div id="foo">hello world</div>' +
'<img id="target" aria-labelledby="foo" role="menuitem"/>'
);
assert.isTrue(
axe.testUtils
.getCheckEvaluate('aria-allowed-role')
.call(checkContext, null, null, vNode)
);
assert.isNull(checkContext._data, null);
});

it('returns false when img has aria-labelledby and a invalid role, role="rowgroup"', function() {
var vNode = queryFixture(
'<div id="foo">hello world</div>' +
'<img id="target" aria-labelledby="foo" role="rowgroup"/>'
);
assert.isFalse(
axe.testUtils
.getCheckEvaluate('aria-allowed-role')
.call(checkContext, null, null, vNode)
);
assert.deepEqual(checkContext._data, ['rowgroup']);
});

it('returns true when img has title and a valid role, role="link"', function() {
var vNode = queryFixture(
'<div id="foo">hello world</div>' +
'<img id="target" title="foo" role="link"/>'
);
assert.isTrue(
axe.testUtils
.getCheckEvaluate('aria-allowed-role')
.call(checkContext, null, null, vNode)
);
assert.isNull(checkContext._data, null);
});

it('returns false when img has title and a invalid role, role="radiogroup"', function() {
var vNode = queryFixture(
'<div id="foo">hello world</div>' +
'<img id="target" title="foo" role="radiogroup"/>'
);
assert.isFalse(
axe.testUtils
.getCheckEvaluate('aria-allowed-role')
.call(checkContext, null, null, vNode)
);
assert.deepEqual(checkContext._data, ['radiogroup']);
});

it('returns true when input of type image and no role', function() {
var vNode = queryFixture('<input id="target" type="image"/>');
assert.isTrue(
Expand Down
24 changes: 24 additions & 0 deletions test/checks/aria/valid-attr-value.js
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,30 @@ describe('aria-valid-attr-value', function() {
);
});

it('should return true on valid aria-labelledby value within img elm', function() {
var vNode = queryFixture(
'<div id="foo">hello world</div>' +
'<img id="target" role="button" aria-labelledby="foo"/>'
);
assert.isTrue(
axe.testUtils
.getCheckEvaluate('aria-valid-attr-value')
.call(checkContext, null, null, vNode)
);
});

it('should return undefined on invalid aria-labelledby value within img elm', function() {
var vNode = queryFixture(
'<div id="foo">hello world</div>' +
'<img id="target" role="button" aria-labelledby="hazaar"/>'
);
assert.isUndefined(
axe.testUtils
.getCheckEvaluate('aria-valid-attr-value')
.call(checkContext, null, null, vNode)
);
});

describe('options', function() {
it('should exclude supplied attributes', function() {
var vNode = queryFixture(
Expand Down
14 changes: 14 additions & 0 deletions test/commons/matches/from-definition.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,20 @@ describe('matches.fromDefinition', function() {
);
});

it('matches a definition with an `accessibleName` property', function() {
var virtualNode = queryFixture('<input id="target" aria-label="foo">');
assert.isTrue(
fromDefinition(virtualNode, {
hasAccessibleName: true
})
);
assert.isFalse(
fromDefinition(virtualNode, {
hasAccessibleName: false
})
);
});

it('returns true when all matching properties return true', function() {
var virtualNode = queryFixture(
'<input id="target" value="bar" aria-disabled="true" />'
Expand Down
96 changes: 96 additions & 0 deletions test/commons/matches/has-accessible-name.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
describe('matches.accessibleName', function() {
var hasAccessibleName = axe.commons.matches.hasAccessibleName;
var fixture = document.querySelector('#fixture');
var queryFixture = axe.testUtils.queryFixture;

beforeEach(function() {
fixture.innerHTML = '';
});

it('should return true when text has an accessible name', function() {
var virtualNode = queryFixture('<button id="target">hello world</button>');
assert.isTrue(hasAccessibleName(virtualNode, true));
});

it('should return true when aria-label has an accessible name', function() {
var virtualNode = queryFixture(
'<button id="target" aria-label="hello world"></button>'
);
assert.isTrue(hasAccessibleName(virtualNode, true));
});

it('should return true when aria-labelledby has an accessible name', function() {
var virtualNode = queryFixture(
'<button id="target" aria-labelledby="foo"></button><div id="foo">hello world</div'
);
assert.isTrue(hasAccessibleName(virtualNode, true));
});

it('should return true when title has an accessible name', function() {
var virtualNode = queryFixture(
'<button id="target" title="hello world"></button>'
);
assert.isTrue(hasAccessibleName(virtualNode, true));
});

it('should return true when label has an accessible name', function() {
var virtualNode = queryFixture(
'<label>hello world<input id="target"></label>'
);
assert.isTrue(hasAccessibleName(virtualNode, true));
});

it('should return false when text does not have an accessible name', function() {
var virtualNode = queryFixture('<button id="target"></button>');
assert.isFalse(hasAccessibleName(virtualNode, true));
});

it('should return false when aria-label does not have an accessible name', function() {
var virtualNode = queryFixture(
'<button id="target" aria-label=""></button>'
);
assert.isFalse(hasAccessibleName(virtualNode, true));
});

it('should return false when aria-labelledby does not have an accessible name', function() {
var virtualNode = queryFixture(
'<button id="target" aria-labelledby=""></button><div id="foo">hello world</div'
);
assert.isFalse(hasAccessibleName(virtualNode, true));
});

it('should return false when aria-labelledby references an element that does not exist', function() {
var virtualNode = queryFixture(
'<button id="target" aria-labelledby="bar"></button><div id="foo">hello world</div'
);
assert.isFalse(hasAccessibleName(virtualNode, true));
});

it('should return false when aria-labelledby references an elm that does not exist', function() {
var virtualNode = queryFixture(
'<button id="target" aria-labelledby="bar"></button><div id="foo">hello world</div'
);
assert.isFalse(hasAccessibleName(virtualNode, true));
});

it('should return false when title does not have an accessible name', function() {
var virtualNode = queryFixture('<button id="target" title=""></button>');
assert.isFalse(hasAccessibleName(virtualNode, true));
});

it('should return false when label does not have an accessible name', function() {
var virtualNode = queryFixture('<label><input id="target"></label>');
assert.isFalse(hasAccessibleName(virtualNode, true));
});

it('works with SerialVirtualNode', function() {
var serialNode = new axe.SerialVirtualNode({
nodeName: 'button',
attributes: {
role: 'button',
'aria-label': 'hello world'
}
});
assert.isTrue(hasAccessibleName(serialNode, true));
});
});
17 changes: 17 additions & 0 deletions test/integration/rules/aria-allowed-role/aria-allowed-role.html
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,23 @@ <h1 id="pass-h1-valid-role" role="none"></h1>
<div id="fail-dpub-4" role="doc-noteref">ok</div>
<!-- images -->
<div id="fail-dpub-5" role="doc-cover">ok</div>
<img role="doc-cover" aria-label="foo" id="pass-img-valid-role-aria-label" />
<img role="menuitem" title="bar" id="pass-img-valid-role-title" />
<div id="image-baz">hazaar</div>
<img
role="switch"
aria-labelledby="image-baz"
id="pass-img-valid-role-aria-labelledby"
/>
<img role="text" aria-label="foo" id="fail-img-invalid-role-aria-label" />
<img role="spinbutton" title="bar" id="fail-img-invalid-role-title" />
<img
role="combobox"
aria-labelledby="image-baz"
id="fail-img-invalid-role-aria-labelledby"
/>
<img role="button" id="fail-img-no-accessible-name-present" />

<!-- listitems -->
<div id="fail-dpub-6" role="doc-biblioentry">ok</div>
<div id="fail-dpub-7" role="doc-endnote">ok</div>
Expand Down
Loading

0 comments on commit 006a681

Please sign in to comment.