diff --git a/lib/checks/aria/aria-required-children-evaluate.js b/lib/checks/aria/aria-required-children-evaluate.js
index e4d2c845c9..bebe639da2 100644
--- a/lib/checks/aria/aria-required-children-evaluate.js
+++ b/lib/checks/aria/aria-required-children-evaluate.js
@@ -4,7 +4,8 @@ import {
getExplicitRole,
getOwnedVirtual
} from '../../commons/aria';
-import { hasContentVirtual, idrefs } from '../../commons/dom';
+import { getGlobalAriaAttrs } from '../../commons/standards';
+import { hasContentVirtual, idrefs, isFocusable } from '../../commons/dom';
/**
* Get all owned roles of an element
@@ -16,18 +17,24 @@ function getOwnedRoles(virtualNode, required) {
const ownedElement = ownedElements[i];
const role = getRole(ownedElement, { noPresentational: true });
+ const hasGlobalAria = getGlobalAriaAttrs().some(attr =>
+ ownedElement.hasAttr(attr)
+ );
+ const hasGlobalAriaOrFocusable =
+ hasGlobalAria || isFocusable(ownedElement);
+
// if owned node has no role or is presentational, or if role
// allows group or rowgroup, we keep parsing the descendant tree.
// this means intermediate roles between a required parent and
// child will fail the check
if (
- !role ||
+ (!role && ! hasGlobalAriaOrFocusable) ||
(['group', 'rowgroup'].includes(role) &&
required.some(requiredRole => requiredRole === role))
) {
ownedElements.push(...ownedElement.children);
- } else if (role) {
- ownedRoles.push(role);
+ } else if (role || hasGlobalAriaOrFocusable) {
+ ownedRoles.push({ role, ownedElement });
}
}
@@ -39,10 +46,10 @@ function getOwnedRoles(virtualNode, required) {
*/
function missingRequiredChildren(virtualNode, role, required, ownedRoles) {
for (let i = 0; i < ownedRoles.length; i++) {
- var ownedRole = ownedRoles[i];
+ const { role } = ownedRoles[i];
- if (required.includes(ownedRole)) {
- required = required.filter(requiredRole => requiredRole !== ownedRole);
+ if (required.includes(role)) {
+ required = required.filter(requiredRole => requiredRole !== role);
return null;
}
}
@@ -74,6 +81,16 @@ function ariaRequiredChildrenEvaluate(node, options, virtualNode) {
}
const ownedRoles = getOwnedRoles(virtualNode, required);
+ const unallowed = ownedRoles.filter(({ role }) => !required.includes(role));
+
+ if (unallowed.length) {
+ this.relatedNodes(unallowed.map(({ ownedElement }) => ownedElement));
+ this.data({
+ messageKey: 'unallowed'
+ });
+ return false;
+ }
+
const missing = missingRequiredChildren(
virtualNode,
role,
diff --git a/lib/checks/aria/aria-required-children.json b/lib/checks/aria/aria-required-children.json
index 6191d6361f..7f89cbeca6 100644
--- a/lib/checks/aria/aria-required-children.json
+++ b/lib/checks/aria/aria-required-children.json
@@ -21,7 +21,8 @@
"pass": "Required ARIA children are present",
"fail": {
"singular": "Required ARIA child role not present: ${data.values}",
- "plural": "Required ARIA children role not present: ${data.values}"
+ "plural": "Required ARIA children role not present: ${data.values}",
+ "unallowed": "Element has children which are not allowed (see related nodes)"
},
"incomplete": {
"singular": "Expecting ARIA child role to be added: ${data.values}",
diff --git a/lib/standards/aria-roles.js b/lib/standards/aria-roles.js
index f2f76fff3c..adbaaccfed 100644
--- a/lib/standards/aria-roles.js
+++ b/lib/standards/aria-roles.js
@@ -342,7 +342,14 @@ const ariaRoles = {
},
menubar: {
type: 'composite',
- requiredOwned: ['group', 'menuitemradio', 'menuitem', 'menuitemcheckbox'],
+ // Note: spec difference (menu as required owned)
+ requiredOwned: [
+ 'group',
+ 'menuitemradio',
+ 'menuitem',
+ 'menuitemcheckbox',
+ 'menu'
+ ],
allowedAttrs: [
'aria-activedescendant',
'aria-expanded',
@@ -477,7 +484,7 @@ const ariaRoles = {
},
radiogroup: {
type: 'composite',
- requiredOwned: ['radio'],
+ // Note: spec difference (no required owned)
allowedAttrs: [
'aria-readonly',
'aria-required',
diff --git a/test/checks/aria/required-children.js b/test/checks/aria/required-children.js
index c5fba9de19..126426370d 100644
--- a/test/checks/aria/required-children.js
+++ b/test/checks/aria/required-children.js
@@ -1,4 +1,4 @@
-describe('aria-required-children', function() {
+describe('aria-required-children', function () {
'use strict';
var fixture = document.getElementById('fixture');
@@ -6,13 +6,13 @@ describe('aria-required-children', function() {
var checkContext = axe.testUtils.MockCheckContext();
var checkSetup = axe.testUtils.checkSetup;
- afterEach(function() {
+ afterEach(function () {
fixture.innerHTML = '';
axe._tree = undefined;
checkContext.reset();
});
- it('should detect missing sole required child', function() {
+ it('should detect missing sole required child', function () {
var params = checkSetup(
'
'
);
@@ -27,7 +27,7 @@ describe('aria-required-children', function() {
(shadowSupported ? it : xit)(
'should detect missing sole required child in shadow tree',
- function() {
+ function () {
fixture.innerHTML = '';
var target = document.querySelector('#target');
@@ -47,7 +47,7 @@ describe('aria-required-children', function() {
}
);
- it('should detect multiple missing required children when one required', function() {
+ it('should detect multiple missing required children when one required', function () {
var params = checkSetup(
''
);
@@ -62,7 +62,7 @@ describe('aria-required-children', function() {
(shadowSupported ? it : xit)(
'should detect missing multiple required children in shadow tree when one required',
- function() {
+ function () {
fixture.innerHTML = '';
var target = document.querySelector('#target');
@@ -82,7 +82,7 @@ describe('aria-required-children', function() {
}
);
- it('should pass all existing required children when all required', function() {
+ it('should pass all existing required children when all required', function () {
var params = checkSetup(
''
);
@@ -93,7 +93,7 @@ describe('aria-required-children', function() {
);
});
- it('should return undefined when element is empty and is in reviewEmpty options', function() {
+ it('should return undefined when element is empty and is in reviewEmpty options', function () {
var params = checkSetup('', {
reviewEmpty: ['list']
});
@@ -104,7 +104,7 @@ describe('aria-required-children', function() {
);
});
- it('should return false when children do not have correct role and is in reviewEmpty options', function() {
+ it('should return false when children do not have correct role and is in reviewEmpty options', function () {
var params = checkSetup(
'',
{ reviewEmpty: ['list'] }
@@ -116,7 +116,7 @@ describe('aria-required-children', function() {
);
});
- it('should return false when owned children do not have correct role and is in reviewEmpty options', function() {
+ it('should return false when owned children do not have correct role and is in reviewEmpty options', function () {
var params = checkSetup(
'',
{ reviewEmpty: ['list'] }
@@ -128,7 +128,7 @@ describe('aria-required-children', function() {
);
});
- it('should fail when list does not have required children listitem', function() {
+ it('should fail when list does not have required children listitem', function () {
var params = checkSetup(
'Item 1
'
);
@@ -141,7 +141,7 @@ describe('aria-required-children', function() {
assert.deepEqual(checkContext._data, ['group', 'listitem']);
});
- it('should fail when list has intermediate child with role that is not a required role', function() {
+ it('should fail when list has intermediate child with role that is not a required role', function () {
var params = checkSetup(
''
);
@@ -151,10 +151,48 @@ describe('aria-required-children', function() {
.apply(checkContext, params)
);
- assert.deepEqual(checkContext._data, ['group', 'listitem']);
+ var unallowed = axe.utils.querySelectorAll(
+ axe._tree,
+ '[role="tabpanel"]'
+ )[0];
+ assert.deepEqual(checkContext._data, { messageKey: 'unallowed' });
+ assert.deepEqual(checkContext._relatedNodes, [unallowed]);
+ });
+
+ it('should fail when list has child with global aria attribute but no role', function () {
+ var params = checkSetup(
+ ''
+ );
+ assert.isFalse(
+ axe.testUtils
+ .getCheckEvaluate('aria-required-children')
+ .apply(checkContext, params)
+ );
+
+ var unallowed = axe.utils.querySelectorAll(
+ axe._tree,
+ '[aria-live="polite"]'
+ )[0];
+ assert.deepEqual(checkContext._data, { messageKey: 'unallowed' });
+ assert.deepEqual(checkContext._relatedNodes, [unallowed]);
+ });
+
+ it('should fail when list has child with tabindex but no role', function () {
+ var params = checkSetup(
+ ''
+ );
+ assert.isFalse(
+ axe.testUtils
+ .getCheckEvaluate('aria-required-children')
+ .apply(checkContext, params)
+ );
+
+ var unallowed = axe.utils.querySelectorAll(axe._tree, '[tabindex="0"]')[0];
+ assert.deepEqual(checkContext._data, { messageKey: 'unallowed' });
+ assert.deepEqual(checkContext._relatedNodes, [unallowed]);
});
- it('should fail when nested child with role row does not have required child role cell', function() {
+ it('should fail when nested child with role row does not have required child role cell', function () {
var params = checkSetup(
''
);
@@ -167,7 +205,7 @@ describe('aria-required-children', function() {
assert.includeMembers(checkContext._data, ['cell']);
});
- it('should pass one indirectly aria-owned child when one required', function() {
+ it('should pass one indirectly aria-owned child when one required', function () {
var params = checkSetup(
''
);
@@ -178,7 +216,7 @@ describe('aria-required-children', function() {
);
});
- it('should not break if aria-owns points to non-existent node', function() {
+ it('should not break if aria-owns points to non-existent node', function () {
var params = checkSetup(
''
);
@@ -189,7 +227,7 @@ describe('aria-required-children', function() {
);
});
- it('should pass one existing aria-owned child when one required', function() {
+ it('should pass one existing aria-owned child when one required', function () {
var params = checkSetup(
'Nothing here.
'
);
@@ -200,7 +238,7 @@ describe('aria-required-children', function() {
);
});
- it('should fail one existing aria-owned child when an intermediate child with role that is not a required role exists', function() {
+ it('should fail one existing aria-owned child when an intermediate child with role that is not a required role exists', function () {
var params = checkSetup(
''
);
@@ -211,7 +249,7 @@ describe('aria-required-children', function() {
);
});
- it('should pass one existing required child when one required (has explicit role of tab)', function() {
+ it('should pass one existing required child when one required (has explicit role of tab)', function () {
var params = checkSetup(
''
);
@@ -222,7 +260,7 @@ describe('aria-required-children', function() {
);
});
- it('should pass required child roles (grid contains row, which contains cell)', function() {
+ it('should pass required child roles (grid contains row, which contains cell)', function () {
var params = checkSetup(
''
);
@@ -233,7 +271,7 @@ describe('aria-required-children', function() {
);
});
- it('should pass one existing required child when one required', function() {
+ it('should pass one existing required child when one required', function () {
var params = checkSetup(
''
);
@@ -244,7 +282,7 @@ describe('aria-required-children', function() {
);
});
- it('should pass one existing required child when one required because of implicit role', function() {
+ it('should pass one existing required child when one required because of implicit role', function () {
var params = checkSetup(
''
);
@@ -255,7 +293,7 @@ describe('aria-required-children', function() {
);
});
- it('should pass when a child with an implicit role is present', function() {
+ it('should pass when a child with an implicit role is present', function () {
var params = checkSetup(
''
);
@@ -266,7 +304,7 @@ describe('aria-required-children', function() {
);
});
- it('should pass direct existing required children', function() {
+ it('should pass direct existing required children', function () {
var params = checkSetup(
''
);
@@ -277,7 +315,7 @@ describe('aria-required-children', function() {
);
});
- it('should pass indirect required children', function() {
+ it('should pass indirect required children', function () {
var params = checkSetup(
'Just a regular ol p that contains a...
Nothing here.
'
);
@@ -288,7 +326,7 @@ describe('aria-required-children', function() {
);
});
- it('should return true when a role has no required owned', function() {
+ it('should return true when a role has no required owned', function () {
var params = checkSetup(
''
);
@@ -299,7 +337,7 @@ describe('aria-required-children', function() {
);
});
- it('should pass when role allows group and group has required child', function() {
+ it('should pass when role allows group and group has required child', function () {
var params = checkSetup(
''
);
@@ -310,7 +348,7 @@ describe('aria-required-children', function() {
);
});
- it('should fail when role allows group and group does not have required child', function() {
+ it('should fail when role allows group and group does not have required child', function () {
var params = checkSetup(
''
);
@@ -321,7 +359,7 @@ describe('aria-required-children', function() {
);
});
- it('should fail when role does not allow group', function() {
+ it('should fail when role does not allow group', function () {
var params = checkSetup(
''
);
@@ -332,7 +370,7 @@ describe('aria-required-children', function() {
);
});
- it('should pass when role allows rowgroup and rowgroup has required child', function() {
+ it('should pass when role allows rowgroup and rowgroup has required child', function () {
var params = checkSetup(
''
);
@@ -343,7 +381,7 @@ describe('aria-required-children', function() {
);
});
- it('should fail when role allows rowgroup and rowgroup does not have required child', function() {
+ it('should fail when role allows rowgroup and rowgroup does not have required child', function () {
var params = checkSetup(
''
);
@@ -354,7 +392,7 @@ describe('aria-required-children', function() {
);
});
- it('should fail when role does not allow rowgroup', function() {
+ it('should fail when role does not allow rowgroup', function () {
var params = checkSetup(
''
);
@@ -365,8 +403,8 @@ describe('aria-required-children', function() {
);
});
- describe('options', function() {
- it('should return undefined instead of false when the role is in options.reviewEmpty', function() {
+ describe('options', function () {
+ it('should return undefined instead of false when the role is in options.reviewEmpty', function () {
var params = checkSetup('', {
reviewEmpty: []
});
@@ -387,7 +425,7 @@ describe('aria-required-children', function() {
);
});
- it('should not throw when options is incorrect', function() {
+ it('should not throw when options is incorrect', function () {
var params = checkSetup('');
// Options: (incorrect)
@@ -415,7 +453,7 @@ describe('aria-required-children', function() {
);
});
- it('should return undefined when the element has empty children', function() {
+ it('should return undefined when the element has empty children', function () {
var params = checkSetup(
''
);
@@ -429,7 +467,7 @@ describe('aria-required-children', function() {
);
});
- it('should return false when the element has empty child with role', function() {
+ it('should return false when the element has empty child with role', function () {
var params = checkSetup(
''
);
@@ -443,7 +481,7 @@ describe('aria-required-children', function() {
);
});
- it('should return undefined when the element has empty child with role=presentation', function() {
+ it('should return undefined when the element has empty child with role=presentation', function () {
var params = checkSetup(
''
);
@@ -457,7 +495,7 @@ describe('aria-required-children', function() {
);
});
- it('should return undefined when the element has empty child with role=none', function() {
+ it('should return undefined when the element has empty child with role=none', function () {
var params = checkSetup(
''
);
@@ -471,7 +509,7 @@ describe('aria-required-children', function() {
);
});
- it('should return undefined when the element has empty child and aria-label', function() {
+ it('should return undefined when the element has empty child and aria-label', function () {
var params = checkSetup(
''
);
diff --git a/test/integration/rules/aria-required-children/aria-required-children.html b/test/integration/rules/aria-required-children/aria-required-children.html
index 8d0305a492..565cfce232 100644
--- a/test/integration/rules/aria-required-children/aria-required-children.html
+++ b/test/integration/rules/aria-required-children/aria-required-children.html
@@ -72,14 +72,47 @@
+
+
+
+
Item 1
+ Item 2
+
+
+
+
List item 1
+
List item 2
+
+
+
+
+
List item 1
+
List item 2
+
+
-
+
+
diff --git a/test/integration/rules/aria-required-children/aria-required-children.json b/test/integration/rules/aria-required-children/aria-required-children.json
index c2f736160e..6af207cf7e 100644
--- a/test/integration/rules/aria-required-children/aria-required-children.json
+++ b/test/integration/rules/aria-required-children/aria-required-children.json
@@ -11,7 +11,11 @@
["#fail7"],
["#fail8"],
["#fail9"],
- ["#fail10"]
+ ["#fail10"],
+ ["#fail11"],
+ ["#fail12"],
+ ["#fail13"],
+ ["#fail14"]
],
"passes": [
["#pass1"],
@@ -26,7 +30,8 @@
["#pass10"],
["#pass11"],
["#pass12"],
- ["#pass13"]
+ ["#pass13"],
+ ["#pass14"]
],
"incomplete": [
["#incomplete1"],
@@ -37,7 +42,6 @@
["#incomplete6"],
["#incomplete7"],
["#incomplete8"],
- ["#incomplete9"],
- ["#incomplete10"]
+ ["#incomplete9"]
]
}