diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md index 88dfac245c..bf9bf912a3 100644 --- a/doc/rule-descriptions.md +++ b/doc/rule-descriptions.md @@ -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 element 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 | diff --git a/lib/checks/aria/aria-hidden-focus.js b/lib/checks/aria/aria-hidden-focus.js new file mode 100755 index 0000000000..05dddf943e --- /dev/null +++ b/lib/checks/aria/aria-hidden-focus.js @@ -0,0 +1,15 @@ +const { dom } = axe.commons; + +let elements = [node]; + +if (node.children.length) { + const children = Array.prototype.slice.call(node.querySelectorAll('*')); + elements = elements.concat(children); +} + +const result = elements.every(element => { + const output = dom.isFocusable(element, false) === false; + return output; +}); + +return result; diff --git a/lib/checks/aria/aria-hidden-focus.json b/lib/checks/aria/aria-hidden-focus.json new file mode 100755 index 0000000000..0bb501ae90 --- /dev/null +++ b/lib/checks/aria/aria-hidden-focus.json @@ -0,0 +1,11 @@ +{ + "id": "aria-hidden-focus", + "evaluate": "aria-hidden-focus.js", + "metadata": { + "impact": "serious", + "messages": { + "pass": "No focusable elements under aria-hidden element", + "fail": "aria-hidden=true element must not contain focusable elements" + } + } +} diff --git a/lib/commons/dom/is-focusable.js b/lib/commons/dom/is-focusable.js index 060a9a5eb6..19348f6326 100644 --- a/lib/commons/dom/is-focusable.js +++ b/lib/commons/dom/is-focusable.js @@ -4,12 +4,13 @@ /** * Determines if focusing has been disabled on an element. * @param {HTMLElement} el The HTMLElement + * @param {Boolean} screenReader Default(true), When provided, will evaluate visibility from the perspective of a screen reader * @return {Boolean} Whether focusing has been disabled on an element. */ -function focusDisabled(el) { +function focusDisabled(el, screenReader = true) { return ( el.disabled || - (!dom.isVisible(el, true) && el.nodeName.toUpperCase() !== 'AREA') + (!dom.isVisible(el, screenReader) && el.nodeName.toUpperCase() !== 'AREA') ); } @@ -19,12 +20,12 @@ function focusDisabled(el) { * @memberof axe.commons.dom * @instance * @param {HTMLElement} el The HTMLElement + * @param {Boolean} screenReader Default(true), When provided, will evaluate visibility from the perspective of a screen reader * @return {Boolean} The element's focusability status */ -dom.isFocusable = function(el) { - 'use strict'; - if (focusDisabled(el)) { +dom.isFocusable = function(el, screenReader = true) { + if (focusDisabled(el, screenReader)) { return false; } else if (dom.isNativelyFocusable(el)) { return true; diff --git a/lib/rules/aria-hidden-focus.json b/lib/rules/aria-hidden-focus.json new file mode 100755 index 0000000000..d6dc731e5c --- /dev/null +++ b/lib/rules/aria-hidden-focus.json @@ -0,0 +1,19 @@ +{ + "id": "aria-hidden-focus", + "selector": "[aria-hidden=\"true\"]", + "excludeHidden": false, + "tags": [ + "cat.name-role-value", + "wcag2a", + "wcag412" + ], + "metadata": { + "description": "Ensures aria-hidden element do not contain focusable elements", + "help": "ARIA hidden element must not contain focusable elements" + }, + "all": [], + "any": [ + "aria-hidden-focus" + ], + "none": [] +} diff --git a/test/checks/aria/aria-hidden-focus.js b/test/checks/aria/aria-hidden-focus.js new file mode 100755 index 0000000000..a7193fe595 --- /dev/null +++ b/test/checks/aria/aria-hidden-focus.js @@ -0,0 +1,86 @@ +describe('aria-hidden-focus', function() { + 'use strict'; + + var fixture = document.getElementById('fixture'); + var check = undefined; + var checkContext = axe.testUtils.MockCheckContext(); + + before(function() { + check = checks['aria-hidden-focus']; + checkContext._data = null; + }); + + afterEach(function() { + fixture.innerHTML = ''; + }); + + it('returns true when content not focusable by default', function() { + fixture.innerHTML = '
'; + var node = fixture.querySelector('#target'); + var actual = check.evaluate.call(checkContext, node); + assert.isTrue(actual); + }); + + it('returns true when content hidden through CSS', function() { + fixture.innerHTML = + ' '; + var node = fixture.querySelector('#target'); + var actual = check.evaluate.call(checkContext, node); + assert.isTrue(actual); + }); + + it('returns true when content made unfocusable through tabindex', function() { + fixture.innerHTML = + ''; + var node = fixture.querySelector('#target'); + var actual = check.evaluate.call(checkContext, node); + assert.isTrue(actual); + }); + + it('returns true when content made unfocusable through disabled', function() { + fixture.innerHTML = ''; + var node = fixture.querySelector('#target'); + var actual = check.evaluate.call(checkContext, node); + assert.isTrue(actual); + }); + + it('returns false when focusable off screen link', function() { + fixture.innerHTML = + ' '; + var node = fixture.querySelector('#target'); + var actual = check.evaluate.call(checkContext, node); + assert.isFalse(actual); + }); + + it('returns false when focusable form field, incorrectly disabled', function() { + fixture.innerHTML = + ''; + var node = fixture.querySelector('#target'); + var actual = check.evaluate.call(checkContext, node); + assert.isFalse(actual); + }); + + it('returns false when aria-hidden=false does not negate aria-hidden true', function() { + fixture.innerHTML = + ' '; + var node = fixture.querySelector('#target'); + var actual = check.evaluate.call(checkContext, node); + assert.isFalse(actual); + }); + + it('returns false when focusable content through tabindex', function() { + fixture.innerHTML = + ' '; + var node = fixture.querySelector('#target'); + var actual = check.evaluate.call(checkContext, node); + assert.isFalse(actual); + }); + + it('returns false when Focusable summary element', function() { + fixture.innerHTML = + ' '; + var node = fixture.querySelector('#target'); + var actual = check.evaluate.call(checkContext, node); + assert.isFalse(actual); + }); +}); diff --git a/test/integration/rules/aria-hidden-focus/aria-hidden-focus.html b/test/integration/rules/aria-hidden-focus/aria-hidden-focus.html new file mode 100644 index 0000000000..29aa3d70db --- /dev/null +++ b/test/integration/rules/aria-hidden-focus/aria-hidden-focus.html @@ -0,0 +1,24 @@ + + + + + + + + + + + + + diff --git a/test/integration/rules/aria-hidden-focus/aria-hidden-focus.json b/test/integration/rules/aria-hidden-focus/aria-hidden-focus.json new file mode 100644 index 0000000000..d5c0afd605 --- /dev/null +++ b/test/integration/rules/aria-hidden-focus/aria-hidden-focus.json @@ -0,0 +1,38 @@ +{ + "description": "aria-hidden-focus tests", + "rule": "aria-hidden-focus", + "violations": [ + [ + "#violation1" + ], + [ + "#violation2" + ], + [ + "#violation3" + ], + [ + "#violation4" + ], + [ + "#violation5" + ] + ], + "passes": [ + [ + "#pass1" + ], + [ + "#pass2" + ], + [ + "#pass3" + ], + [ + "#pass4" + ], + [ + "#pass5" + ] + ] +}