Skip to content

Commit

Permalink
feat(rule): New aria-hidden-focus rule (#1166)
Browse files Browse the repository at this point in the history
Rule: Aria Hidden Focus.
Spec for rule - Spec: [auto-wcag.github.io/auto-wcag/rules/SC4-1-2-aria-hidden-focus.html](https://auto-wcag.github.io/auto-wcag/rules/SC4-1-2-aria-hidden-focus.html)

Closes issue:
- #1150

## Reviewer checks

**Required fields, to be filled out by PR reviewer(s)**
- [x] Follows the commit message policy, appropriate for next version
- [x] Has documentation updated, a DU ticket, or requires no documentation change
- [x] Includes new tests, or was unnecessary
- [x] Code is reviewed for security by: @WilcoFiers
  • Loading branch information
jeeyyy authored and WilcoFiers committed Jan 9, 2019
1 parent 5f834ed commit 4489965
Show file tree
Hide file tree
Showing 17 changed files with 710 additions and 32 deletions.
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
25 changes: 25 additions & 0 deletions lib/checks/shared/focusable-disabled.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const elementsThatCanBeDisabled = [
'BUTTON',
'FIELDSET',
'INPUT',
'SELECT',
'TEXTAREA'
];

const tabbableElements = virtualNode.tabbableElements;

if (!tabbableElements || !tabbableElements.length) {
return true;
}

const relatedNodes = tabbableElements.reduce((out, { actualNode: el }) => {
const nodeName = el.nodeName.toUpperCase();
// populate nodes that can be disabled
if (elementsThatCanBeDisabled.includes(nodeName)) {
out.push(el);
}
return out;
}, []);
this.relatedNodes(relatedNodes);

return relatedNodes.length === 0;
11 changes: 11 additions & 0 deletions lib/checks/shared/focusable-disabled.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"id": "focusable-disabled",
"evaluate": "focusable-disabled.js",
"metadata": {
"impact": "serious",
"messages": {
"pass": "No focusable elements contained within element",
"fail": "Focusable content should be disabled or be removed from the DOM"
}
}
}
25 changes: 25 additions & 0 deletions lib/checks/shared/focusable-not-tabbable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const elementsThatCanBeDisabled = [
'BUTTON',
'FIELDSET',
'INPUT',
'SELECT',
'TEXTAREA'
];

const tabbableElements = virtualNode.tabbableElements;

if (!tabbableElements || !tabbableElements.length) {
return true;
}

const relatedNodes = tabbableElements.reduce((out, { actualNode: el }) => {
const nodeName = el.nodeName.toUpperCase();
// populate nodes that cannot be disabled
if (!elementsThatCanBeDisabled.includes(nodeName)) {
out.push(el);
}
return out;
}, []);
this.relatedNodes(relatedNodes);

return relatedNodes.length === 0;
11 changes: 11 additions & 0 deletions lib/checks/shared/focusable-not-tabbable.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"id": "focusable-not-tabbable",
"evaluate": "focusable-not-tabbable.js",
"metadata": {
"impact": "serious",
"messages": {
"pass": "No focusable elements contained within element",
"fail": "Focusable content should have tabindex='-1' or be removed from the DOM"
}
}
}
24 changes: 24 additions & 0 deletions lib/commons/dom/get-tabbable-elements.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/* global dom */

/**
* Get all elements (including given node) that are part if the tab order
* @method getTabbableElements
* @memberof axe.commons.dom
* @instance
* @param {Object} virtualNode The virtualNode to assess
* @return {Boolean}
*/
dom.getTabbableElements = function getTabbableElements(virtualNode) {
const nodeAndDescendents = axe.utils.querySelectorAll(virtualNode, '*');

const tabbableElements = nodeAndDescendents.filter(vNode => {
const isFocusable = vNode.isFocusable;
let tabIndex = vNode.actualNode.getAttribute('tabindex');
tabIndex =
tabIndex && !isNaN(parseInt(tabIndex, 10)) ? parseInt(tabIndex) : null;

return tabIndex ? isFocusable && tabIndex >= 0 : isFocusable;
});

return tabbableElements;
};
17 changes: 16 additions & 1 deletion lib/core/utils/flattened-tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,25 @@ var axe = axe || { utils: {} };
* @return {Object} - the wrapped node
*/
function virtualDOMfromNode(node, shadowId) {
const vNodeCache = {};
return {
shadowId: shadowId,
children: [],
actualNode: node
actualNode: node,
get isFocusable() {
if (!vNodeCache._isFocusable) {
vNodeCache._isFocusable = axe.commons.dom.isFocusable(node);
}
return vNodeCache._isFocusable;
},
get tabbableElements() {
if (!vNodeCache._tabbableElements) {
vNodeCache._tabbableElements = axe.commons.dom.getTabbableElements(
this
);
}
return vNodeCache._tabbableElements;
}
};
}

Expand Down
18 changes: 18 additions & 0 deletions lib/rules/aria-hidden-focus-matches.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const { getComposedParent } = axe.commons.dom;

/**
* Only match the outer-most `aria-hidden=true` element
* @param {HTMLElement} el the HTMLElement to verify
* @return {Boolean}
*/
function shouldMatchElement(el) {
if (!el) {
return true;
}
if (el.getAttribute('aria-hidden') === 'true') {
return false;
}
return shouldMatchElement(getComposedParent(el));
}

return shouldMatchElement(getComposedParent(node));
14 changes: 14 additions & 0 deletions lib/rules/aria-hidden-focus.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"id": "aria-hidden-focus",
"selector": "[aria-hidden=\"true\"]",
"matches": "aria-hidden-focus-matches.js",
"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": ["focusable-disabled", "focusable-not-tabbable"],
"any": [],
"none": []
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,9 @@
"eslint-config-prettier": "^3.0.0",
"execa": "^1.0.0",
"fs-extra": "^7.0.0",
"grunt": "^1.0.3",
"globby": "^8.0.1",
"grunt": "^1.0.2",
"grunt": "^1.0.3",
"grunt-babel": "^7.0.0",
"grunt-contrib-clean": "^2.0.0",
"grunt-contrib-concat": "^1.0.1",
Expand Down
132 changes: 132 additions & 0 deletions test/checks/shared/focusable-disabled.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
describe('focusable-disabled', function() {
'use strict';

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

before(function() {
check = checks['focusable-disabled'];
});

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

it('returns true when content not focusable by default (no tabbable elements)', function() {
var params = checkSetup('<p id="target" aria-hidden="true">Some text</p>');
var actual = check.evaluate.apply(checkContext, params);
assert.isTrue(actual);
});

it('returns true when content hidden through CSS (no tabbable elements)', function() {
var params = checkSetup(
'<div id="target" aria-hidden="true"><a href="/" style="display:none">Link</a></div>'
);
var actual = check.evaluate.apply(checkContext, params);
assert.isTrue(actual);
});

it('returns true when content made unfocusable through disabled (no tabbable elements)', function() {
var params = checkSetup(
'<input id="target" disabled aria-hidden="true" />'
);
var actual = check.evaluate.apply(checkContext, params);
assert.isTrue(actual);
});

it('returns true when focusable off screen link (cannot be disabled)', function() {
var params = checkSetup(
'<div id="target" aria-hidden="true"><a href="/" style="position:absolute; top:-999em">Link</a></div>'
);
var actual = check.evaluate.apply(checkContext, params);
assert.isTrue(actual);
assert.lengthOf(checkContext._relatedNodes, 0);
});

it('returns false when focusable form field only disabled through ARIA', function() {
var params = checkSetup(
'<div id="target" aria-hidden="true"><input type="text" aria-disabled="true"/></div>'
);
var actual = check.evaluate.apply(checkContext, params);
assert.isFalse(actual);
assert.lengthOf(checkContext._relatedNodes, 1);
assert.deepEqual(
checkContext._relatedNodes,
Array.from(fixture.querySelectorAll('input'))
);
});

it('returns false when focusable SELECT element that can be disabled', function() {
var params = checkSetup(
'<div id="target" aria-hidden="true">' +
'<label>Choose:' +
'<select>' +
'<option selected="selected">Chosen</option>' +
'<option>Not Selected</option>' +
'</select>' +
'</label>' +
'</div>'
);
var actual = check.evaluate.apply(checkContext, params);
assert.isFalse(actual);
assert.lengthOf(checkContext._relatedNodes, 1);
assert.deepEqual(
checkContext._relatedNodes,
Array.from(fixture.querySelectorAll('select'))
);
});

it('returns true when focusable AREA element (cannot be disabled)', function() {
var params = checkSetup(
'<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 actual = check.evaluate.apply(checkContext, params);
assert.isTrue(actual);
});

(shadowSupported ? it : xit)(
'returns false when focusable content inside shadowDOM, that can be disabled',
function() {
// Note:
// `testUtils.checkSetup` does not work for shadowDOM
// as `axe._tree` and `axe._selectorData` needs to be updated after shadowDOM construction
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);
}
);

it('returns true when focusable target that cannot be disabled', function() {
var params = checkSetup(
'<div aria-hidden="true"><a id="target" href="">foo</a><button>bar</button></div>'
);
var actual = check.evaluate.apply(checkContext, params);
assert.isTrue(actual);
});

it('returns false when focusable target that can be disabled', function() {
var params = checkSetup(
'<div aria-hidden="true"><a href="">foo</a><button id="target">bar</button></div>'
);
var actual = check.evaluate.apply(checkContext, params);
assert.isFalse(actual);
});
});
Loading

0 comments on commit 4489965

Please sign in to comment.