Skip to content

Commit eee05ae

Browse files
committed
feat(aria): support role=image as a synonym for role=img
Add a reusable synonym mechanism: the image role is defined as a synonym of img (ARIA 1.3, w3c/aria#1370) and getExplicitRole/getRole resolve synonym roles to their canonical role by default, with a noSynonym opt out. The role-img-alt selector and aria-allowed-role role resolution are updated so role=image behaves like role=img. Closes #4656
1 parent 19aacea commit eee05ae

16 files changed

Lines changed: 140 additions & 8 deletions

lib/commons/aria/get-element-unallowed-roles.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import isValidRole from './is-valid-role';
22
import getImplicitRole from './implicit-role';
33
import getRoleType from './get-role-type';
44
import isAriaRoleAllowedOnElement from './is-aria-role-allowed-on-element';
5+
import standards from '../../standards';
56
import { tokenList, isHtmlElement, nodeLookup } from '../../core/utils';
67

78
// dpub roles which are subclassing roles that are implicit on some native
@@ -41,8 +42,11 @@ function getRoleSegments(vNode) {
4142
roles = roles.concat(nodeRoles);
4243
}
4344

44-
// filter invalid roles
45-
return roles.filter(role => isValidRole(role));
45+
// filter invalid roles and resolve synonym roles (e.g. `image` -> `img`)
46+
// so the allowed-role check compares against the canonical role
47+
return roles
48+
.filter(role => isValidRole(role))
49+
.map(role => standards.ariaRoles[role]?.synonym || role);
4650
}
4751

4852
/**

lib/commons/aria/get-explicit-role.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import isValidRole from './is-valid-role';
2+
import standards from '../../standards';
23
import { getNodeFromTree, tokenList } from '../../core/utils';
34
import AbstractVirtuaNode from '../../core/base/virtual-node/abstract-virtual-node';
45

5-
function getExplicitRole(vNode, { fallback, abstracts, dpub } = {}) {
6+
function getExplicitRole(vNode, { fallback, abstracts, dpub, noSynonym } = {}) {
67
vNode = vNode instanceof AbstractVirtuaNode ? vNode : getNodeFromTree(vNode);
78

89
if (vNode.props.nodeType !== 1) {
@@ -20,7 +21,18 @@ function getExplicitRole(vNode, { fallback, abstracts, dpub } = {}) {
2021
return isValidRole(role, { allowAbstract: abstracts });
2122
});
2223

23-
return firstValidRole || null;
24+
if (!firstValidRole) {
25+
return null;
26+
}
27+
28+
// Resolve synonym roles (e.g. `image` -> `img`) to their canonical role
29+
// unless the caller explicitly wants the literal role.
30+
const synonym = standards.ariaRoles[firstValidRole]?.synonym;
31+
if (!noSynonym && synonym) {
32+
return synonym;
33+
}
34+
35+
return firstValidRole;
2436
}
2537

2638
export default getExplicitRole;

lib/commons/aria/get-role.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ function resolveRole(node, { noImplicit, ...roleOptions } = {}) {
171171
* @param {boolean} options.dpub Allow role to be any (valid) doc-* roles
172172
* @param {boolean} options.noPresentational return null if role is presentation or none
173173
* @param {boolean} options.chromium Include implicit roles from chromium-based browsers in role result
174+
* @param {boolean} options.noSynonym Return the literal role rather than resolving a synonym role (e.g. `image`) to its canonical role (`img`)
174175
* @returns {string|null} Role or null
175176
*
176177
* @deprecated noImplicit option is deprecated. Use aria.getExplicitRole instead.

lib/rules/role-img-alt.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"id": "role-img-alt",
33
"impact": "serious",
4-
"selector": "[role='img']:not(img, area, input, object)",
4+
"selector": "[role='img']:not(img, area, input, object), [role='image']:not(img, area, input, object)",
55
"matches": "html-namespace-matches",
66
"tags": [
77
"cat.text-alternatives",

lib/standards/aria-roles.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,18 @@ const ariaRoles = {
267267
accessibleNameRequired: false,
268268
nameFromContent: true
269269
},
270+
image: {
271+
type: 'structure',
272+
// Synonym of img, added in ARIA 1.3 (w3c/aria#1370). Kept as a full
273+
// definition so code paths that read the role directly (without
274+
// normalizing the synonym) still behave identically to img.
275+
synonym: 'img',
276+
// Spec difference: Aria-expanded removed in 1.2
277+
allowedAttrs: ['aria-expanded'],
278+
superclassRole: ['section'],
279+
accessibleNameRequired: true,
280+
childrenPresentational: true
281+
},
270282
img: {
271283
type: 'structure',
272284
// Spec difference: Aria-expanded removed in 1.2

test/commons/aria/get-element-unallowed-roles.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,4 +214,18 @@ describe('aria.getElementUnallowedRoles', () => {
214214
assert.isNotEmpty(actual);
215215
assert.include(actual, 'application');
216216
});
217+
218+
it('returns empty for the synonym role=image on an svg elm', () => {
219+
const node = document.createElement('svg');
220+
node.setAttribute('role', 'image');
221+
flatTreeSetup(node);
222+
assert.isEmpty(getElementUnallowedRoles(node));
223+
});
224+
225+
it('returns empty for the synonym role=image on an embed elm', () => {
226+
const node = document.createElement('embed');
227+
node.setAttribute('role', 'image');
228+
flatTreeSetup(node);
229+
assert.isEmpty(getElementUnallowedRoles(node));
230+
});
217231
});

test/commons/aria/get-explicit-role.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,34 @@ describe('aria.getExplicitRole', () => {
141141
);
142142
});
143143
});
144+
145+
describe('synonym roles', () => {
146+
it('resolves the synonym role `image` to `img`', () => {
147+
const node = document.createElement('div');
148+
node.setAttribute('role', 'image');
149+
const vNode = flatTreeSetup(node)[0];
150+
assert.equal(aria.getExplicitRole(vNode), 'img');
151+
});
152+
153+
it('returns the literal role with the `noSynonym` option', () => {
154+
const node = document.createElement('div');
155+
node.setAttribute('role', 'image');
156+
const vNode = flatTreeSetup(node)[0];
157+
assert.equal(aria.getExplicitRole(vNode, { noSynonym: true }), 'image');
158+
});
159+
160+
it('leaves the canonical role `img` unchanged', () => {
161+
const node = document.createElement('div');
162+
node.setAttribute('role', 'img');
163+
const vNode = flatTreeSetup(node)[0];
164+
assert.equal(aria.getExplicitRole(vNode), 'img');
165+
});
166+
167+
it('resolves the synonym when it is the first valid fallback role', () => {
168+
const node = document.createElement('div');
169+
node.setAttribute('role', 'foo image');
170+
const vNode = flatTreeSetup(node)[0];
171+
assert.equal(aria.getExplicitRole(vNode, { fallback: true }), 'img');
172+
});
173+
});
144174
});

test/commons/aria/get-role.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,4 +465,20 @@ describe('aria.getRole', () => {
465465
});
466466
});
467467
});
468+
469+
describe('synonym roles', () => {
470+
it('resolves the synonym role `image` to `img`', () => {
471+
const node = document.createElement('div');
472+
node.setAttribute('role', 'image');
473+
const vNode = flatTreeSetup(node)[0];
474+
assert.equal(aria.getRole(vNode), 'img');
475+
});
476+
477+
it('returns the literal role with the `noSynonym` option', () => {
478+
const node = document.createElement('div');
479+
node.setAttribute('role', 'image');
480+
const vNode = flatTreeSetup(node)[0];
481+
assert.equal(aria.getRole(vNode, { noSynonym: true }), 'image');
482+
});
483+
});
468484
});

test/commons/aria/is-valid-role.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,8 @@ describe('aria.isValidRole', () => {
2727
axe.commons.aria.isValidRole('input', { allowAbstract: true })
2828
);
2929
});
30+
31+
it('returns true for the synonym role `image`', () => {
32+
assert.isTrue(axe.commons.aria.isValidRole('image'));
33+
});
3034
});

test/integration/rules/aria-allowed-role/aria-allowed-role.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ <h1 id="pass-h1-role-doc-subtitle" role="doc-subtitle"></h1>
3838
<header id="pass-header-valid-role" role="group"></header>
3939
<footer id="pass-footer-valid-role" role="group"></footer>
4040
<embed id="pass-embed-valid-role" role="img" />
41+
<embed id="pass-embed-synonym-role-image" role="image" />
4142
<input type="text" role="textbox" id="pass-input-text-redundant-role" />
4243
<input type="text" role="textbox combobox" id="pass-input-multiple-roles" />
4344
<input

0 commit comments

Comments
 (0)