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 all 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
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) {
jeeyyy marked this conversation as resolved.
Show resolved Hide resolved
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