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: rule aria-hidden-focus #1166

Merged
merged 37 commits into from
Jan 9, 2019
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
624ddbd
feat: new rule aria-hidden-focus
jeeyyy Sep 27, 2018
212d0da
fix: revert changes to isFocusable. To be tackled by issue #1208
jeeyyy Oct 28, 2018
8fa937b
Merge branch 'develop' into new-rule-aria-hidden-focus
jeeyyy Oct 28, 2018
35816f5
refactor: based on review comments
jeeyyy Nov 8, 2018
a60921c
Merge branch 'develop' into new-rule-aria-hidden-focus
jeeyyy Nov 8, 2018
861428a
fix: update aria-hidden focus check and add tests
jeeyyy Nov 8, 2018
1525e77
test: add shadowDOM tests
jeeyyy Nov 8, 2018
e3e644b
Merge branch 'develop' into new-rule-aria-hidden-focus
jeeyyy Nov 13, 2018
e37f902
fix: update check and tests based on code review comments
jeeyyy Nov 14, 2018
4aae0ae
Merge branch 'develop' into new-rule-aria-hidden-focus
jeeyyy Nov 14, 2018
addaa32
fix: update var name to retrigger build
jeeyyy Nov 14, 2018
16fc190
fix: add matches checks and tests based on review
jeeyyy Nov 19, 2018
1fe6929
Merge branch 'develop' into new-rule-aria-hidden-focus
jeeyyy Nov 20, 2018
57d1488
Merge branch 'develop' into new-rule-aria-hidden-focus
WilcoFiers Nov 20, 2018
34798ef
Merge branch 'develop' into new-rule-aria-hidden-focus
jeeyyy Nov 20, 2018
24998c1
fix: split check to focusable and tabbable
jeeyyy Nov 22, 2018
1bfd9d3
Merge branch 'develop' into new-rule-aria-hidden-focus
jeeyyy Nov 22, 2018
1a6a9b7
test: update assertions and fix breaking tests
jeeyyy Nov 22, 2018
edbf77f
fix: update based on review
jeeyyy Nov 27, 2018
d230af9
refactor: update based on code review
jeeyyy Nov 27, 2018
8ade68d
Merge branch 'develop' into new-rule-aria-hidden-focus
jeeyyy Nov 27, 2018
3a86bc6
fix: matches comparison check
jeeyyy Nov 27, 2018
0ae71a3
Merge branch 'develop' into new-rule-aria-hidden-focus
jeeyyy Nov 27, 2018
507e43e
Merge branch 'develop' into new-rule-aria-hidden-focus
jeeyyy Dec 4, 2018
8c3b1ae
fix: update messages for checks
jeeyyy Dec 4, 2018
6a4bc5d
Merge branch 'develop' into new-rule-aria-hidden-focus
jeeyyy Dec 7, 2018
aacd0c7
docs: update rule descriptions
jeeyyy Dec 7, 2018
7ea18ce
Merge branch 'develop' into new-rule-aria-hidden-focus
jeeyyy Dec 11, 2018
19c29fe
Merge branch 'develop' into new-rule-aria-hidden-focus
jeeyyy Dec 12, 2018
0f8565f
Merge branch 'develop' into new-rule-aria-hidden-focus
jeeyyy Jan 2, 2019
fa7ce5d
fix: cache values in virtualNode for improved performance
jeeyyy Jan 2, 2019
983d747
Merge branch 'develop' into new-rule-aria-hidden-focus
jeeyyy Jan 3, 2019
8a5da97
refactor: enhance vNode with getters
jeeyyy Jan 3, 2019
787b22a
Merge branch 'develop' into new-rule-aria-hidden-focus
jeeyyy Jan 3, 2019
c7e8142
Merge branch 'develop' into new-rule-aria-hidden-focus
jeeyyy Jan 7, 2019
eaebf1b
refactor: change checks to have no relatedNodes for aria-hidden-focus
jeeyyy Jan 8, 2019
ca109d5
Merge branch 'develop' into new-rule-aria-hidden-focus
WilcoFiers Jan 9, 2019
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 @@ -6,6 +6,7 @@
| aria-allowed-role | Ensures role attribute has an appropriate value for the element | Minor | cat.aria, best-practice | true |
| aria-dpub-role-fallback | Ensures unsupported DPUB roles are only used on elements with implicit fallback roles | Moderate | cat.aria, wcag2a, wcag131 | true |
| aria-hidden-body | Ensures aria-hidden='true' is not present on the document body. | Critical | cat.aria, wcag2a, wcag412 | true |
| aria-hidden-focus | Ensures aria-hidden elements do not contain focusable elements | Serious | cat.name-role-value, wcag2a, wcag412 | true |
| aria-required-attr | Ensures elements with ARIA roles have all required ARIA attributes | Critical | cat.aria, wcag2a, wcag412 | true |
| aria-required-children | Ensures elements with an ARIA role that require child roles contain them | Critical | cat.aria, wcag2a, wcag131 | true |
| aria-required-parent | Ensures elements with an ARIA role that require parent roles are contained by them | Critical | cat.aria, wcag2a, wcag131 | true |
Expand Down
27 changes: 27 additions & 0 deletions lib/checks/aria/aria-hidden-focus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const domTree = axe.utils.querySelectorAll(virtualNode, '*');
jeeyyy marked this conversation as resolved.
Show resolved Hide resolved

const relatedNodes = [];
jeeyyy marked this conversation as resolved.
Show resolved Hide resolved

const result = domTree.every(({ actualNode: el }) => {
const isElFocusable = axe.commons.dom.isFocusable(el);
let tabIndex = el.getAttribute('tabindex');
// dom.isFocusable does not check for tabindex
jeeyyy marked this conversation as resolved.
Show resolved Hide resolved
// this checks if a given `el` has been taken out of tab order
tabIndex =
tabIndex && !isNaN(parseInt(tabIndex, 10)) ? parseInt(tabIndex) : null;
if (isElFocusable && (tabIndex && tabIndex < 0)) {
return true;
}
if (isElFocusable === false) {
return true;
}
// add to related nodes
jeeyyy marked this conversation as resolved.
Show resolved Hide resolved
relatedNodes.push(el);
return false;
});

if (relatedNodes.length) {
this.relatedNodes(relatedNodes);
}

return result;
11 changes: 11 additions & 0 deletions lib/checks/aria/aria-hidden-focus.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"id": "aria-hidden-focus",
jeeyyy marked this conversation as resolved.
Show resolved Hide resolved
"evaluate": "aria-hidden-focus.js",
"metadata": {
"impact": "serious",
"messages": {
"pass": "No focusable elements contained within aria-hidden element",
"fail": "Focusable elements in aria-hidden='true' must have tabindex='-1'"
}
}
}
19 changes: 19 additions & 0 deletions lib/rules/aria-hidden-focus.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"id": "aria-hidden-focus",
"selector": "[aria-hidden=\"true\"]",
jeeyyy marked this conversation as resolved.
Show resolved Hide resolved
"excludeHidden": false,
"tags": [
"cat.name-role-value",
"wcag2a",
"wcag412"
],
"metadata": {
"description": "Ensures aria-hidden elements do not contain focusable elements",
"help": "ARIA hidden element must not contain focusable elements"
},
"all": [],
"any": [
"aria-hidden-focus"
],
"none": []
}
225 changes: 225 additions & 0 deletions test/checks/aria/aria-hidden-focus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
describe('aria-hidden-focus', function() {
'use strict';

var fixture = document.getElementById('fixture');
var fixtureSetup = axe.testUtils.fixtureSetup;
var check;
var shadowSupported = axe.testUtils.shadowSupport.v1;
var checkContext = axe.testUtils.MockCheckContext();

before(function() {
check = checks['aria-hidden-focus'];
});

afterEach(function() {
fixture.innerHTML = '';
axe._tree = undefined;
axe._selectorData = undefined;
checkContext.reset();
});

// pass
it('returns true when content not focusable by default', function() {
fixtureSetup('<p id="target" aria-hidden="true">Some text</p>');
var node = fixture.querySelector('#target');
var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node);
var actual = check.evaluate.call(checkContext, node, {}, virtualNode);
assert.isTrue(actual);
jeeyyy marked this conversation as resolved.
Show resolved Hide resolved
});

it('returns true when content hidden through CSS', function() {
fixtureSetup(
'<div id="target" aria-hidden="true"><a href="/" style="display:none">Link</a></div>'
);
var node = fixture.querySelector('#target');
var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node);
var actual = check.evaluate.call(checkContext, node, {}, virtualNode);
assert.isTrue(actual);
});

it('returns true when BUTTON removed from tab order through tabindex', function() {
fixtureSetup(
'<div id="target" aria-hidden="true">' +
'<button tabindex="-1">Some button</button>' +
'</div>'
);
var node = fixture.querySelector('#target');
var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node);
var actual = check.evaluate.call(checkContext, node, {}, virtualNode);
assert.isTrue(actual);
});

it('returns true when TEXTAREA removed from tab order through tabindex', function() {
fixtureSetup(
'<div id="target" aria-hidden="true">' +
'<label>Enter your comments:' +
'<textarea tabindex="-1"></textarea>' +
'</label>' +
'</div>'
);
var node = fixture.querySelector('#target');
var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node);
var actual = check.evaluate.call(checkContext, node, {}, virtualNode);
assert.isTrue(actual);
});

it('returns true when content made unfocusable through disabled', function() {
fixtureSetup('<input id="target" disabled aria-hidden="true" />');
var node = fixture.querySelector('#target');
var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node);
var actual = check.evaluate.call(checkContext, node, {}, virtualNode);
assert.isTrue(actual);
});

it('returns true when aria-hidden=false does not negate aria-hidden true', function() {
// Note: aria-hidden can't be reset once you've set it to true on an ancestor
fixtureSetup(
'<div id="target" aria-hidden="true"><div aria-hidden="false"><button tabindex="-1">Some button</button></div></div>'
);
var node = fixture.querySelector('#target');
var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node);
var actual = check.evaluate.call(checkContext, node, {}, virtualNode);
assert.isTrue(actual);
});

jeeyyy marked this conversation as resolved.
Show resolved Hide resolved
(shadowSupported ? it : xit)(
'returns true when content hidden through CSS inside shadowDOM',
function() {
fixtureSetup('<div id="target"></div>');
var node = fixture.querySelector('#target');
var shadow = node.attachShadow({ mode: 'open' });
shadow.innerHTML =
'<div aria-hidden="true"><a href="/" style="display:none">Link</a></div>';
axe._tree = axe.utils.getFlattenedTree(fixture);
axe._selectorData = axe.utils.getSelectorData(axe._tree);
var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node);
var actual = check.evaluate.call(checkContext, node, {}, virtualNode);
jeeyyy marked this conversation as resolved.
Show resolved Hide resolved
assert.isTrue(actual);
}
);

(shadowSupported ? it : xit)(
'returns true when BUTTON is removed from tab order through tabindex which coexists with plain text in shadowDOM',
function() {
fixtureSetup(
'<div aria-hidden="true" id="target"><button tabindex="-1">btn</button></div>`'
);
var node = fixture.querySelector('#target');
var shadow = node.attachShadow({ mode: 'open' });
shadow.innerHTML = 'plain text';
axe._tree = axe.utils.getFlattenedTree(fixture);
axe._selectorData = axe.utils.getSelectorData(axe._tree);
var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node);
var actual = check.evaluate.call(checkContext, node, {}, virtualNode);
assert.isTrue(actual);
}
);

// fail
it('returns false when focusable off screen link', function() {
fixtureSetup(
'<div id="target" aria-hidden="true"><a href="/" style="position:absolute; top:-999em">Link</a></div>'
);
var node = fixture.querySelector('#target');
var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node);
var actual = check.evaluate.call(checkContext, node, {}, virtualNode);
assert.isFalse(actual);
assert.deepEqual(
checkContext._relatedNodes,
Array.from(fixture.querySelectorAll('a'))
);
});

it('returns false when focusable form field only disabled through ARIA', function() {
fixtureSetup(
'<div id="target" aria-hidden="true"><input type="text" aria-disabled="true"/></div>'
);
var node = fixture.querySelector('#target');
var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node);
var actual = check.evaluate.call(checkContext, node, {}, virtualNode);
assert.isFalse(actual);
assert.deepEqual(
checkContext._relatedNodes,
Array.from(fixture.querySelectorAll('input'))
);
});

it('returns false when focusable content through tabindex', function() {
fixtureSetup(
'<p id="target" tabindex="0" aria-hidden="true">Some text</p>'
);
var node = fixture.querySelector('#target');
var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node);
var actual = check.evaluate.call(checkContext, node, {}, virtualNode);
assert.isFalse(actual);
});

it('returns false when focusable SUMMARY element', function() {
fixtureSetup(
'<details id="target" aria-hidden="true"><summary>Some button</summary><p>Some details</p></details>'
);
var node = fixture.querySelector('#target');
var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node);
var actual = check.evaluate.call(checkContext, node, {}, virtualNode);
assert.isFalse(actual);
assert.deepEqual(
checkContext._relatedNodes,
Array.from(fixture.querySelectorAll('details'))
);
});

it('returns false when focusable SELECT element', function() {
fixtureSetup(
'<div id="target" aria-hidden="true">' +
'<label>Choose:' +
'<select>' +
'<option selected="selected">Chosen</option>' +
'<option>Not Selected</option>' +
'</select>' +
'</label>' +
'</div>'
);
var node = fixture.querySelector('#target');
var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node);
var actual = check.evaluate.call(checkContext, node, {}, virtualNode);
assert.isFalse(actual);
assert.deepEqual(
checkContext._relatedNodes,
Array.from(fixture.querySelectorAll('select'))
);
});

it('returns false when focusable AREA element', function() {
fixtureSetup(
'<main id="target" aria-hidden="true">' +
'<map name="infographic">' +
'<area shape="rect" coords="184,6,253,27" href="https://mozilla.org"' +
'target="_blank" alt="Mozilla" />' +
'</map>' +
'</main>'
);
var node = fixture.querySelector('#target');
var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node);
var actual = check.evaluate.call(checkContext, node, {}, virtualNode);
assert.isFalse(actual);
assert.deepEqual(
checkContext._relatedNodes,
Array.from(fixture.querySelectorAll('area'))
);
});

(shadowSupported ? it : xit)(
'returns false when focusable content through tabindex inside shadowDOM',
function() {
fixtureSetup('<div id="target"></div>');
var node = fixture.querySelector('#target');
var shadow = node.attachShadow({ mode: 'open' });
shadow.innerHTML = '<button>Some text</button>';
axe._tree = axe.utils.getFlattenedTree(fixture);
axe._selectorData = axe.utils.getSelectorData(axe._tree);
var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node);
var actual = check.evaluate.call(checkContext, node, {}, virtualNode);
assert.isFalse(actual);
}
);
});
65 changes: 65 additions & 0 deletions test/integration/rules/aria-hidden-focus/aria-hidden-focus.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<!-- ///////////////// -->
<!-- Pass -->
<!-- ///////////////// -->
<p id='pass1' aria-hidden="true">
Some text
</p>

<div id='pass2' aria-hidden="true">
<a href="/" style="display:none">Link</a>
</div>

<div id='pass3' aria-hidden="true">
<button tabindex="-1">Some button</button>
</div>

<input id='pass4' disabled aria-hidden="true" />

<div id='pass5' aria-hidden="true">
<!-- aria-hidden=false does not negate aria-hidden true -->
<div aria-hidden="false">
<button tabindex="-1">Some button</button>
</div>
</div>

<div id='pass6' aria-hidden="true">
<label>
Enter your comments:
<textarea tabindex="-1"></textarea>
</label>
</div>


<!-- ///////////////// -->
<!-- Fail -->
<!-- ///////////////// -->
<div id='violation1' aria-hidden="true">
<a href="/" style="position:absolute; top:-999em">Link</a>
</div>

<div id='violation2' aria-hidden="true">
<input aria-disabled="true" />
</div>

<p id='violation3' tabindex="0" aria-hidden="true">Some text</p>

<details id='violation4' aria-hidden="true">
<summary>Some button</summary>
<p>Some details</p>
</details>

<div id='violation5' aria-hidden="true">
<label>
Choose:
<select>
<option selected="selected">Chosen</option>
<option>Not Selected</option>
</select>
</label>
</div>

<main id='violation6' aria-hidden='true'>
<map name="infographic">
<area shape="rect" coords="184,6,253,27" href="https://mozilla.org" target="_blank" alt="Mozilla" />
</map>
</main>
Loading