Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 8 commits into from
Dec 18, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 <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 |
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
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>
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));
});
});