Skip to content

Commit

Permalink
feat(nested-interactive): new rule to flag nested interactive elements (
Browse files Browse the repository at this point in the history
#2691)

* feat(nested-interactive): new rule to flag nested interactive elements

* Update lib/rules/nested-interactive.json

Co-authored-by: Wilco Fiers <WilcoFiers@users.noreply.github.com>

* Update lib/rules/nested-interactive.json

Co-authored-by: Wilco Fiers <WilcoFiers@users.noreply.github.com>

* fixes

* remove only

* remove log

Co-authored-by: Wilco Fiers <WilcoFiers@users.noreply.github.com>
  • Loading branch information
straker and WilcoFiers committed Dec 18, 2020
1 parent f2a2ff6 commit 13a7cf1
Show file tree
Hide file tree
Showing 13 changed files with 315 additions and 0 deletions.
1 change: 1 addition & 0 deletions doc/rule-descriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 &lt;meta name=&quot;viewport&quot;&gt; 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 &lt;meta name=&quot;viewport&quot;&gt; 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&nbsp;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 |
Expand Down
35 changes: 35 additions & 0 deletions lib/checks/keyboard/no-focusable-content-evaluate.js
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 12 additions & 0 deletions lib/checks/keyboard/no-focusable-content.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
4 changes: 4 additions & 0 deletions lib/commons/dom/is-focusable.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
4 changes: 4 additions & 0 deletions lib/core/base/metadata-function-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions lib/rules/nested-interactive-matches.js
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 12 additions & 0 deletions lib/rules/nested-interactive.json
Original file line number Diff line number Diff line change
@@ -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": []
}
40 changes: 40 additions & 0 deletions test/checks/keyboard/no-focusable-content.js
Original file line number Diff line number Diff line change
@@ -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('<button id="target"><span>Hello</span></button>');
assert.isTrue(noFocusableContent(null, null, vNode));
});

it('should return true if element is empty', function() {
var vNode = queryFixture('<button id="target"></button>');
assert.isTrue(noFocusableContent(null, null, vNode));
});

it('should return true if element only has text content', function() {
var vNode = queryFixture('<button id="target">Hello</button>');
assert.isTrue(noFocusableContent(null, null, vNode));
});

it('should return false if element has focusable content', function() {
var vNode = queryFixture(
'<button id="target"><span tabindex="0">Hello</span></button>'
);
assert.isFalse(noFocusableContent(null, null, vNode));
});

it('should return false if element has natively focusable content', function() {
var vNode = queryFixture(
'<button id="target"><a href="foo.html">Hello</a></button>'
);
assert.isFalse(noFocusableContent(null, null, vNode));
});
});
8 changes: 8 additions & 0 deletions test/commons/dom/is-focusable.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<span id="target">Hello World</span>';
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 = '<input type="text" id="target" disabled>';
var el = document.getElementById('target');
Expand Down
14 changes: 14 additions & 0 deletions test/integration/rules/nested-interactive/nested-interactive.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<button id="pass1">pass</button>
<div role="button" id="pass2">pass</div>
<div role="tab" id="pass3">pass</div>
<div role="checkbox" id="pass4">pass</div>
<div role="radio" id="pass5"><span>pass</span></div>

<button id="fail1"><span tabindex="0">fail</span></button>
<div role="button" id="fail2"><input /></div>
<div role="tab" id="fail3"><button id="pass6">fail</button></div>
<div role="checkbox" id="fail4"><a href="foo.html">fail</a></div>
<div role="radio" id="fail5"><span tabindex="0">fail</span></div>

<a id="ignored1" href="foo.html">ignored</a>
<span id="ignored2">ignored</span>
13 changes: 13 additions & 0 deletions test/integration/rules/nested-interactive/nested-interactive.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"description": "nested-interactive tests",
"rule": "nested-interactive",
"violations": [["#fail1"], ["#fail2"], ["#fail3"], ["#fail4"], ["#fail5"]],
"passes": [
["#pass1"],
["#pass2"],
["#pass3"],
["#pass4"],
["#pass5"],
["#pass6"]
]
}
124 changes: 124 additions & 0 deletions test/integration/virtual-rules/nested-interactive.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
35 changes: 35 additions & 0 deletions test/rule-matches/nested-interactive-matches.js
Original file line number Diff line number Diff line change
@@ -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('<button id="target"></button>');
assert.isTrue(rule.matches(null, vNode));
});

it('should match if aria element has children presentational', function() {
var vNode = queryFixture('<div role="button" id="target"></div>');
assert.isTrue(rule.matches(null, vNode));
});

it('should not match if element does not have children presentational', function() {
var vNode = queryFixture('<a href="foo.html" id="target"></a>');
assert.isFalse(rule.matches(null, vNode));
});

it('should not match if element has no role', function() {
var vNode = queryFixture('<span id="target"></span>');
assert.isFalse(rule.matches(null, vNode));
});
});

0 comments on commit 13a7cf1

Please sign in to comment.