diff --git a/lib/checks/.eslintrc b/lib/checks/.eslintrc
index e22052a9b0..357031c0a9 100644
--- a/lib/checks/.eslintrc
+++ b/lib/checks/.eslintrc
@@ -36,7 +36,7 @@
"strict": 0,
"max-params": [
2,
- 5
+ 6
],
"max-depth": [
2,
diff --git a/lib/checks/aria/required-children.js b/lib/checks/aria/required-children.js
index 3341fd6ece..a566b215e4 100644
--- a/lib/checks/aria/required-children.js
+++ b/lib/checks/aria/required-children.js
@@ -1,58 +1,87 @@
-const requiredOwned = axe.commons.aria.requiredOwned;
-const implicitNodes = axe.commons.aria.implicitNodes;
-const matchesSelector = axe.utils.matchesSelector;
-const idrefs = axe.commons.dom.idrefs;
-const hasContentVirtual = axe.commons.dom.hasContentVirtual;
+const { aria, dom } = axe.commons;
+const { requiredOwned, implicitNodes, getRole } = aria;
+const { hasContentVirtual, idrefs } = dom;
+const { matchesSelector, querySelectorAll, getNodeFromTree } = axe.utils;
+
const reviewEmpty =
options && Array.isArray(options.reviewEmpty) ? options.reviewEmpty : [];
+const role = node.getAttribute('role');
+const required = requiredOwned(role);
+if (!required) {
+ return true;
+}
-function owns(node, virtualTree, role, ariaOwned) {
- if (node === null) {
- return false;
- }
- const implicit = implicitNodes(role);
- let selector = ['[role="' + role + '"]'];
-
- if (implicit) {
- selector = selector.concat(
- implicit.map(implicitSelector => implicitSelector + ':not([role])')
- );
- }
+let all = false;
+let childRoles = required.one;
+if (!childRoles) {
+ all = true;
+ childRoles = required.all;
+}
- selector = selector.join(',');
- return ariaOwned
- ? matchesSelector(node, selector) ||
- !!axe.utils.querySelectorAll(virtualTree, selector)[0]
- : !!axe.utils.querySelectorAll(virtualTree, selector)[0];
+const ownedElements = idrefs(node, 'aria-owns');
+const descendantRole = getDescendantRole(node, ownedElements);
+const missing = missingRequiredChildren(
+ node,
+ childRoles,
+ all,
+ role,
+ ownedElements,
+ descendantRole
+);
+if (!missing) {
+ return true;
}
-function ariaOwns(nodes, role) {
- for (let index = 0; index < nodes.length; index++) {
- const node = nodes[index];
- if (node === null) {
- continue;
- }
- const virtualTree = axe.utils.getNodeFromTree(node);
- if (owns(node, virtualTree, role, true)) {
- return true;
- }
- }
- return false;
+this.data(missing);
+
+// Only review empty nodes when a node is both empty and does not have an aria-owns relationship
+if (
+ reviewEmpty.includes(role) &&
+ !hasContentVirtual(virtualNode, false, true) &&
+ !descendantRole.length &&
+ idrefs(node, 'aria-owns').length === 0
+) {
+ return undefined;
}
-function missingRequiredChildren(node, childRoles, all, role) {
- const missing = [],
- ownedElements = idrefs(node, 'aria-owns');
+return false;
+
+/**
+ * Get missing children roles
+ * @param {HTMLElement} node node
+ * @param {String[]} childRoles expected children roles
+ * @param {Boolean} all should all child roles be present?
+ * @param {String} role role of given node
+ */
+function missingRequiredChildren(
+ node,
+ childRoles,
+ all,
+ role,
+ ownedEls,
+ descRole
+) {
+ let missing = [];
for (let index = 0; index < childRoles.length; index++) {
const childRole = childRoles[index];
- if (
- owns(node, virtualNode, childRole) ||
- ariaOwns(ownedElements, childRole)
- ) {
+ const ownsRole = owns(node, virtualNode, childRole);
+ const ariaOwnsRole = ariaOwns(ownedEls, childRole);
+ if (ownsRole || ariaOwnsRole) {
if (!all) {
return null;
}
+
+ /**
+ * Verify if descendants contain one of the requested child roles & that a requested child role is not nested within an overriding role
+ * Only handle when role is not `combobox`, given there is an exception/ different path for `combobox`
+ * Eg:
+ * `
`
+ * should fail because `listitem` role not under `list` but has `tabpanel` between them, so although `listitem` is owned by `list` this is a failure.
+ */
+ if (role !== 'combobox' && !descRole.includes(childRole)) {
+ missing.push(childRole);
+ }
} else {
if (all) {
missing.push(childRole);
@@ -70,7 +99,7 @@ function missingRequiredChildren(node, childRoles, all, role) {
node.nodeName.toUpperCase() === 'INPUT' &&
textTypeInputs.includes(node.type)) ||
owns(node, virtualNode, 'searchbox') ||
- ariaOwns(ownedElements, 'searchbox')
+ ariaOwns(ownedEls, 'searchbox')
) {
missing.splice(textboxIndex, 1);
}
@@ -106,49 +135,85 @@ function missingRequiredChildren(node, childRoles, all, role) {
return null;
}
-function hasDecendantWithRole(node) {
- return (
- node.children &&
- node.children.some(child => {
- const role = axe.commons.aria.getRole(child);
- return (
- !['presentation', 'none', null].includes(role) ||
- hasDecendantWithRole(child)
- );
- })
- );
-}
-
-const role = node.getAttribute('role');
-const required = requiredOwned(role);
+/**
+ * Helper to check if a given node owns an element with a given role
+ * @param {HTMLElement} node node
+ * @param {Object} virtualTree virtual node
+ * @param {String} role role
+ * @param {Boolean} ariaOwned
+ * @returns {Boolean}
+ */
+function owns(node, virtualTree, role, ariaOwned) {
+ if (node === null) {
+ return false;
+ }
+ const implicit = implicitNodes(role);
+ let selector = ['[role="' + role + '"]'];
-if (!required) {
- return true;
-}
+ if (implicit) {
+ selector = selector.concat(
+ implicit.map(implicitSelector => implicitSelector + ':not([role])')
+ );
+ }
-let all = false;
-let childRoles = required.one;
-if (!childRoles) {
- all = true;
- childRoles = required.all;
+ selector = selector.join(',');
+ return ariaOwned
+ ? matchesSelector(node, selector) ||
+ !!querySelectorAll(virtualTree, selector)[0]
+ : !!querySelectorAll(virtualTree, selector)[0];
}
-const missing = missingRequiredChildren(node, childRoles, all, role);
+/**
+ * Helper to check if a given node is `aria-owns` an element with a given role
+ * @param {HTMLElement[]} nodes nodes
+ * @param {String} role role
+ * @returns {Boolean}
+ */
+function ariaOwns(nodes, role) {
+ for (let index = 0; index < nodes.length; index++) {
+ const node = nodes[index];
+ if (node === null) {
+ continue;
+ }
-if (!missing) {
- return true;
+ const virtualTree = getNodeFromTree(node);
+ if (owns(node, virtualTree, role, true)) {
+ return true;
+ }
+ }
+ return false;
}
-this.data(missing);
+/**
+ * Get role (that is not presentation or none) of descendant
+ * @param {HTMLElement} node node
+ * @returns {String[]}
+ */
+function getDescendantRole(node, ownedEls) {
+ const isOwns = ownedEls && ownedEls.length > 0;
+ const el = isOwns ? ownedEls[0] : node;
+
+ if (!el) {
+ return [];
+ }
-// Only review empty nodes when a node is both empty and does not have an aria-owns relationship
-if (
- reviewEmpty.includes(role) &&
- !hasContentVirtual(virtualNode, false, true) &&
- !hasDecendantWithRole(virtualNode) &&
- idrefs(node, 'aria-owns').length === 0
-) {
- return undefined;
-} else {
- return false;
+ const items = isOwns
+ ? Array.from(el.children).reduce(
+ (out, child) => {
+ out.push(child);
+ return out;
+ },
+ [el]
+ )
+ : Array.from(el.children);
+
+ return items.reduce((out, child) => {
+ const role = getRole(child);
+ if (['presentation', 'none', null].includes(role)) {
+ out = out.concat(getDescendantRole(child));
+ } else {
+ out.push(role);
+ }
+ return out;
+ }, []);
}
diff --git a/test/checks/aria/required-children.js b/test/checks/aria/required-children.js
index a7132dd693..44460ed8a0 100644
--- a/test/checks/aria/required-children.js
+++ b/test/checks/aria/required-children.js
@@ -103,6 +103,15 @@ describe('aria-required-children', function() {
);
});
+ it('should pass all existing required children when all required', function() {
+ var params = checkSetup(
+ ''
+ );
+ assert.isTrue(
+ checks['aria-required-children'].evaluate.apply(checkContext, params)
+ );
+ });
+
it('should return undefined when element is empty and is in reviewEmpty options', function() {
var params = checkSetup('', {
reviewEmpty: ['list']
@@ -246,6 +255,39 @@ describe('aria-required-children', function() {
assert.deepEqual(checkContext._data, ['listbox']);
});
+ it('should fail when list does not have required children listitem', function() {
+ var params = checkSetup(
+ 'Item 1
'
+ );
+ assert.isFalse(
+ checks['aria-required-children'].evaluate.apply(checkContext, params)
+ );
+
+ assert.deepEqual(checkContext._data, ['listitem']);
+ });
+
+ it('should fail when list has intermediate child with role that is not a required role', function() {
+ var params = checkSetup(
+ ''
+ );
+ assert.isFalse(
+ checks['aria-required-children'].evaluate.apply(checkContext, params)
+ );
+
+ assert.deepEqual(checkContext._data, ['listitem']);
+ });
+
+ it('should fail when nested child with role row does not have required child role cell', function() {
+ var params = checkSetup(
+ ''
+ );
+ assert.isFalse(
+ checks['aria-required-children'].evaluate.apply(checkContext, params)
+ );
+
+ assert.includeMembers(checkContext._data, ['cell']);
+ });
+
it('should pass one indirectly aria-owned child when one required', function() {
var params = checkSetup(
''
@@ -273,6 +315,33 @@ 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() {
+ var params = checkSetup(
+ ''
+ );
+ assert.isFalse(
+ checks['aria-required-children'].evaluate.apply(checkContext, params)
+ );
+ });
+
+ it('should pass one existing required child when one required (has explicit role of tab)', function() {
+ var params = checkSetup(
+ ''
+ );
+ assert.isTrue(
+ checks['aria-required-children'].evaluate.apply(checkContext, params)
+ );
+ });
+
+ it('should pass required child roles (grid contains row, which contains cell)', function() {
+ var params = checkSetup(
+ ''
+ );
+ assert.isTrue(
+ checks['aria-required-children'].evaluate.apply(checkContext, params)
+ );
+ });
+
it('should pass one existing required child when one required', 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 e3e913eda5..25cca78769 100644
--- a/test/integration/rules/aria-required-children/aria-required-children.html
+++ b/test/integration/rules/aria-required-children/aria-required-children.html
@@ -1,14 +1,20 @@
-
+
-
+
-
+
@@ -19,4 +25,25 @@
-
+
+
+
+
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 7030e1cbcb..31500091ff 100644
--- a/test/integration/rules/aria-required-children/aria-required-children.json
+++ b/test/integration/rules/aria-required-children/aria-required-children.json
@@ -1,7 +1,15 @@
{
"description": "aria-required-children test",
"rule": "aria-required-children",
- "violations": [["#fail1"], ["#fail2"], ["#fail3"], ["#fail4"], ["#fail5"]],
+ "violations": [
+ ["#fail1"],
+ ["#fail2"],
+ ["#fail3"],
+ ["#fail4"],
+ ["#fail5"],
+ ["#fail6"],
+ ["#fail7"]
+ ],
"passes": [
["#pass1"],
["#pass2"],
@@ -10,7 +18,15 @@
["#pass5"],
["#pass6"],
["#pass7"],
- ["#pass8"]
+ ["#pass8"],
+ ["#pass9"],
+ ["#pass10"],
+ ["#pass11"],
+ ["#pass12"],
+ ["#pass13"],
+ ["#pass14"],
+ ["#pass15"],
+ ["#pass16"]
],
"incomplete": [
["#incomplete1"],