diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md index 4f143435d6..46068d5126 100644 --- a/doc/rule-descriptions.md +++ b/doc/rule-descriptions.md @@ -100,6 +100,7 @@ Rules that do not necessarily conform to WCAG success criterion but are industry | [landmark-unique](https://dequeuniversity.com/rules/axe/4.1/landmark-unique?application=RuleDescription) | Landmarks must have a unique role or role/label/title (i.e. accessible name) combination | Moderate | cat.semantics, best-practice | failure | | [meta-viewport-large](https://dequeuniversity.com/rules/axe/4.1/meta-viewport-large?application=RuleDescription) | Ensures <meta name="viewport"> can scale a significant amount | Minor | cat.sensory-and-visual-cues, best-practice | failure | | [meta-viewport](https://dequeuniversity.com/rules/axe/4.1/meta-viewport?application=RuleDescription) | Ensures <meta name="viewport"> does not disable text scaling and zooming | Critical | cat.sensory-and-visual-cues, best-practice, ACT | failure | +| [nested-interactive](https://dequeuniversity.com/rules/axe/4.1/nested-interactive?application=RuleDescription) | Ensure interactive controls are not nested | Serious | cat.keyboard, best-practice | failure, needs review | | [page-has-heading-one](https://dequeuniversity.com/rules/axe/4.1/page-has-heading-one?application=RuleDescription) | Ensure that the page, or at least one of its frames contains a level-one heading | Moderate | cat.semantics, best-practice | failure | | [presentation-role-conflict](https://dequeuniversity.com/rules/axe/4.1/presentation-role-conflict?application=RuleDescription) | Flags elements whose role is none or presentation and which cause the role conflict resolution to trigger. | Minor | cat.aria, best-practice | failure | | [region](https://dequeuniversity.com/rules/axe/4.1/region?application=RuleDescription) | Ensures all page content is contained by landmarks | Moderate | cat.keyboard, best-practice | failure | diff --git a/lib/checks/keyboard/no-focusable-content-evaluate.js b/lib/checks/keyboard/no-focusable-content-evaluate.js new file mode 100644 index 0000000000..e45bdfde9f --- /dev/null +++ b/lib/checks/keyboard/no-focusable-content-evaluate.js @@ -0,0 +1,35 @@ +import isFocusable from '../../commons/dom/is-focusable'; + +function focusableDescendants(vNode) { + if (isFocusable(vNode)) { + return true; + } + + if (!vNode.children) { + if (vNode.props.nodeType === 1) { + throw new Error('Cannot determine children'); + } + + return false; + } + + return vNode.children.some(child => { + return focusableDescendants(child); + }); +} + +function noFocusbleContentEvaluate(node, options, virtualNode) { + if (!virtualNode.children) { + return undefined; + } + + try { + return !virtualNode.children.some(child => { + return focusableDescendants(child); + }); + } catch (e) { + return undefined; + } +} + +export default noFocusbleContentEvaluate; diff --git a/lib/checks/keyboard/no-focusable-content.json b/lib/checks/keyboard/no-focusable-content.json new file mode 100644 index 0000000000..a67554e6a8 --- /dev/null +++ b/lib/checks/keyboard/no-focusable-content.json @@ -0,0 +1,12 @@ +{ + "id": "no-focusable-content", + "evaluate": "no-focusable-content-evaluate", + "metadata": { + "impact": "serious", + "messages": { + "pass": "Element does not have focusable descendants", + "fail": "Element has focusable descendants", + "incomplete": "Could not determine if element has descendants" + } + } +} diff --git a/lib/commons/dom/is-focusable.js b/lib/commons/dom/is-focusable.js index dae0c92d5f..d5900b4e27 100644 --- a/lib/commons/dom/is-focusable.js +++ b/lib/commons/dom/is-focusable.js @@ -15,6 +15,10 @@ import { getNodeFromTree } from '../../core/utils'; function isFocusable(el) { const vNode = el instanceof AbstractVirtualNode ? el : getNodeFromTree(el); + if (vNode.props.nodeType !== 1) { + return false; + } + if (focusDisabled(vNode)) { return false; } else if (isNativelyFocusable(vNode)) { diff --git a/lib/core/base/metadata-function-map.js b/lib/core/base/metadata-function-map.js index a69f0b7e9f..6e40a4a1af 100644 --- a/lib/core/base/metadata-function-map.js +++ b/lib/core/base/metadata-function-map.js @@ -92,6 +92,7 @@ import focusableModalOpenEvaluate from '../../checks/keyboard/focusable-modal-op import focusableNoNameEvaluate from '../../checks/keyboard/focusable-no-name-evaluate'; import focusableNotTabbableEvaluate from '../../checks/keyboard/focusable-not-tabbable-evaluate'; import landmarkIsTopLevelEvaluate from '../../checks/keyboard/landmark-is-top-level-evaluate'; +import noFocusableContentEvaluate from '../../checks/keyboard/no-focusable-content-evaluate'; import tabindexEvaluate from '../../checks/keyboard/tabindex-evaluate'; // label @@ -151,6 +152,7 @@ import landmarkHasBodyContextMatches from '../../rules/landmark-has-body-context import landmarkUniqueMatches from '../../rules/landmark-unique-matches'; import layoutTableMatches from '../../rules/layout-table-matches'; import linkInTextBlockMatches from '../../rules/link-in-text-block-matches'; +import nestedInteractiveMatches from '../../rules/nested-interactive-matches'; import noAutoplayAudioMatches from '../../rules/no-autoplay-audio-matches'; import noEmptyRoleMatches from '../../rules/no-empty-role-matches'; import noExplicitNameRequiredMatches from '../../rules/no-explicit-name-required-matches'; @@ -259,6 +261,7 @@ const metadataFunctionMap = { 'focusable-no-name-evaluate': focusableNoNameEvaluate, 'focusable-not-tabbable-evaluate': focusableNotTabbableEvaluate, 'landmark-is-top-level-evaluate': landmarkIsTopLevelEvaluate, + 'no-focusable-content-evaluate': noFocusableContentEvaluate, 'tabindex-evaluate': tabindexEvaluate, // label @@ -320,6 +323,7 @@ const metadataFunctionMap = { 'landmark-unique-matches': landmarkUniqueMatches, 'layout-table-matches': layoutTableMatches, 'link-in-text-block-matches': linkInTextBlockMatches, + 'nested-interactive-matches': nestedInteractiveMatches, 'no-autoplay-audio-matches': noAutoplayAudioMatches, 'no-empty-role-matches': noEmptyRoleMatches, 'no-explicit-name-required-matches': noExplicitNameRequiredMatches, diff --git a/lib/rules/nested-interactive-matches.js b/lib/rules/nested-interactive-matches.js new file mode 100644 index 0000000000..3128c85cdc --- /dev/null +++ b/lib/rules/nested-interactive-matches.js @@ -0,0 +1,13 @@ +import { getRole } from '../commons/aria'; +import standards from '../standards'; + +function nestedInteractiveMatches(node, virtualNode) { + const role = getRole(virtualNode); + if (!role) { + return false; + } + + return !!standards.ariaRoles[role].childrenPresentational; +} + +export default nestedInteractiveMatches; diff --git a/lib/rules/nested-interactive.json b/lib/rules/nested-interactive.json new file mode 100644 index 0000000000..f61690cd2b --- /dev/null +++ b/lib/rules/nested-interactive.json @@ -0,0 +1,12 @@ +{ + "id": "nested-interactive", + "matches": "nested-interactive-matches", + "tags": ["cat.keyboard", "wcag2a", "wcag412"], + "metadata": { + "description": "Nested interactive controls are not announced by screen readers", + "help": "Ensure interactive controls are not nested" + }, + "all": [], + "any": ["no-focusable-content"], + "none": [] +} diff --git a/test/checks/keyboard/no-focusable-content.js b/test/checks/keyboard/no-focusable-content.js new file mode 100644 index 0000000000..1e1a3a8474 --- /dev/null +++ b/test/checks/keyboard/no-focusable-content.js @@ -0,0 +1,40 @@ +describe('no-focusable-content tests', function() { + var fixture = document.querySelector('#fixture'); + var queryFixture = axe.testUtils.queryFixture; + var noFocusableContent = axe.testUtils.getCheckEvaluate( + 'no-focusable-content' + ); + + afterEach(function() { + fixture.innerHTML = ''; + }); + + it('should return true if element has no focusable content', function() { + var vNode = queryFixture(''); + assert.isTrue(noFocusableContent(null, null, vNode)); + }); + + it('should return true if element is empty', function() { + var vNode = queryFixture(''); + assert.isTrue(noFocusableContent(null, null, vNode)); + }); + + it('should return true if element only has text content', function() { + var vNode = queryFixture(''); + assert.isTrue(noFocusableContent(null, null, vNode)); + }); + + it('should return false if element has focusable content', function() { + var vNode = queryFixture( + '' + ); + assert.isFalse(noFocusableContent(null, null, vNode)); + }); + + it('should return false if element has natively focusable content', function() { + var vNode = queryFixture( + '' + ); + assert.isFalse(noFocusableContent(null, null, vNode)); + }); +}); diff --git a/test/commons/dom/is-focusable.js b/test/commons/dom/is-focusable.js index ddcce16b11..ee75953083 100644 --- a/test/commons/dom/is-focusable.js +++ b/test/commons/dom/is-focusable.js @@ -60,6 +60,14 @@ describe('is-focusable', function() { assert.isTrue(axe.commons.dom.isFocusable(el)); }); + it('should return false for non-element nodes', function() { + fixture.innerHTML = 'Hello World'; + flatTreeSetup(fixture); + var el = document.getElementById('target').childNodes[0]; + + assert.isFalse(axe.commons.dom.isFocusable(el)); + }); + it('should return false for disabled elements', function() { fixture.innerHTML = ''; var el = document.getElementById('target'); diff --git a/test/integration/rules/nested-interactive/nested-interactive.html b/test/integration/rules/nested-interactive/nested-interactive.html new file mode 100644 index 0000000000..beb7fbdf37 --- /dev/null +++ b/test/integration/rules/nested-interactive/nested-interactive.html @@ -0,0 +1,14 @@ + +
pass
+ + + + + +
+ + + + +ignored +ignored diff --git a/test/integration/rules/nested-interactive/nested-interactive.json b/test/integration/rules/nested-interactive/nested-interactive.json new file mode 100644 index 0000000000..78d3b43176 --- /dev/null +++ b/test/integration/rules/nested-interactive/nested-interactive.json @@ -0,0 +1,13 @@ +{ + "description": "nested-interactive tests", + "rule": "nested-interactive", + "violations": [["#fail1"], ["#fail2"], ["#fail3"], ["#fail4"], ["#fail5"]], + "passes": [ + ["#pass1"], + ["#pass2"], + ["#pass3"], + ["#pass4"], + ["#pass5"], + ["#pass6"] + ] +} diff --git a/test/integration/virtual-rules/nested-interactive.js b/test/integration/virtual-rules/nested-interactive.js new file mode 100644 index 0000000000..ba963b99b5 --- /dev/null +++ b/test/integration/virtual-rules/nested-interactive.js @@ -0,0 +1,124 @@ +describe('nested-interactive virtual-rule', function() { + it('should pass for element without focusable content', function() { + var node = new axe.SerialVirtualNode({ + nodeName: 'button' + }); + var child = new axe.SerialVirtualNode({ + nodeName: '#text', + nodeType: 3, + nodeValue: 'Hello World' + }); + node.children = [child]; + + var results = axe.runVirtualRule('nested-interactive', node); + + assert.lengthOf(results.passes, 1); + assert.lengthOf(results.violations, 0); + assert.lengthOf(results.incomplete, 0); + }); + + it('should pass for aria element without focusable content', function() { + var node = new axe.SerialVirtualNode({ + nodeName: 'div', + attributes: { + role: 'button' + } + }); + var child = new axe.SerialVirtualNode({ + nodeName: '#text', + nodeType: 3, + nodeValue: 'Hello World' + }); + node.children = [child]; + + var results = axe.runVirtualRule('nested-interactive', node); + + assert.lengthOf(results.passes, 1); + assert.lengthOf(results.violations, 0); + assert.lengthOf(results.incomplete, 0); + }); + + it('should pass for empty element without', function() { + var node = new axe.SerialVirtualNode({ + nodeName: 'div', + attributes: { + role: 'button' + } + }); + node.children = []; + + var results = axe.runVirtualRule('nested-interactive', node); + + assert.lengthOf(results.passes, 1); + assert.lengthOf(results.violations, 0); + assert.lengthOf(results.incomplete, 0); + }); + + it('should fail for element with focusable content', function() { + var node = new axe.SerialVirtualNode({ + nodeName: 'button' + }); + var child = new axe.SerialVirtualNode({ + nodeName: 'span', + attributes: { + tabindex: 1 + } + }); + child.children = []; + node.children = [child]; + + var results = axe.runVirtualRule('nested-interactive', node); + + assert.lengthOf(results.passes, 0); + assert.lengthOf(results.violations, 1); + assert.lengthOf(results.incomplete, 0); + }); + + it('should fail for element with natively focusable content', function() { + var node = new axe.SerialVirtualNode({ + nodeName: 'div', + attributes: { + role: 'button' + } + }); + var child = new axe.SerialVirtualNode({ + nodeName: 'button' + }); + child.children = []; + node.children = [child]; + + var results = axe.runVirtualRule('nested-interactive', node); + + assert.lengthOf(results.passes, 0); + assert.lengthOf(results.violations, 1); + assert.lengthOf(results.incomplete, 0); + }); + + it('should return incomplete if element has undefined children', function() { + var node = new axe.SerialVirtualNode({ + nodeName: 'button' + }); + + var results = axe.runVirtualRule('nested-interactive', node); + + assert.lengthOf(results.passes, 0); + assert.lengthOf(results.violations, 0); + assert.lengthOf(results.incomplete, 1); + }); + + it('should return incomplete if descendant has undefined children', function() { + var node = new axe.SerialVirtualNode({ + nodeName: 'button' + }); + var child = new axe.SerialVirtualNode({ + nodeName: 'span' + }); + node.children = [child]; + + var results = axe.runVirtualRule('nested-interactive', node); + + assert.lengthOf(results.passes, 0); + assert.lengthOf(results.violations, 0); + assert.lengthOf(results.incomplete, 1); + }); +}); diff --git a/test/rule-matches/nested-interactive-matches.js b/test/rule-matches/nested-interactive-matches.js new file mode 100644 index 0000000000..243418851d --- /dev/null +++ b/test/rule-matches/nested-interactive-matches.js @@ -0,0 +1,35 @@ +describe('nested-interactive-matches', function() { + var fixture = document.querySelector('#fixture'); + var queryFixture = axe.testUtils.queryFixture; + var rule; + + beforeEach(function() { + rule = axe._audit.rules.find(function(rule) { + return rule.id === 'nested-interactive'; + }); + }); + + afterEach(function() { + fixture.innerHTML = ''; + }); + + it('should match if element has children presentational', function() { + var vNode = queryFixture(''); + assert.isTrue(rule.matches(null, vNode)); + }); + + it('should match if aria element has children presentational', function() { + var vNode = queryFixture('
'); + assert.isTrue(rule.matches(null, vNode)); + }); + + it('should not match if element does not have children presentational', function() { + var vNode = queryFixture(''); + assert.isFalse(rule.matches(null, vNode)); + }); + + it('should not match if element has no role', function() { + var vNode = queryFixture(''); + assert.isFalse(rule.matches(null, vNode)); + }); +});