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): Scrollable region focusable #1396

Merged
merged 17 commits into from
May 14, 2019
Merged
Show file tree
Hide file tree
Changes from 10 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 @@ -66,6 +66,7 @@
| radiogroup | Ensures related <input type="radio"> elements have a group and that the group designation is consistent | Critical | cat.forms, best-practice | true |
| region | Ensures all page content is contained by landmarks | Moderate | cat.keyboard, best-practice | true |
| scope-attr-valid | Ensures the scope attribute is used correctly on tables | Moderate, Critical | cat.tables, best-practice | true |
| scrollable-region-focusable | Ensure that scrollable region has keyboard access | Moderate | wcag2a, wcag211 | true |
| server-side-image-map | Ensures that server-side image maps are not used | Minor | cat.text-alternatives, wcag2a, wcag211, section508, section508.22.f | true |
| skip-link | Ensure all skip links have a focusable target | Moderate | cat.keyboard, best-practice | true |
| tabindex | Ensures tabindex attribute values are not greater than 0 | Serious | cat.keyboard, best-practice | true |
Expand Down
16 changes: 16 additions & 0 deletions lib/checks/keyboard/focusable-content.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Note:
* Check if given node contains focusable elements (excluding thyself)
*/
const tabbableElements = virtualNode.tabbableElements;

if (!tabbableElements) {
return false;
}

// remove thyself from tabbable elements (if exists)
const tabbableContentElements = tabbableElements.filter(
el => el !== virtualNode
);

return tabbableContentElements.length > 0;
12 changes: 12 additions & 0 deletions lib/checks/keyboard/focusable-content.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"id": "focusable-content",
"evaluate": "focusable-content.js",
"metadata": {
"impact": "moderate",
"messages": {
"pass": "Element contains focusable elements",
"fail": "Element should have focusable content",
"incomplete": ""
jeeyyy marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
13 changes: 13 additions & 0 deletions lib/checks/keyboard/focusable-element.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Note:
* Check
* - if element is focusable
* - if element is in focus order via `tabindex`
*/
const isFocusable = virtualNode.isFocusable;

let tabIndex = virtualNode.actualNode.getAttribute('tabindex');
jeeyyy marked this conversation as resolved.
Show resolved Hide resolved
tabIndex =
tabIndex && !isNaN(parseInt(tabIndex, 10)) ? parseInt(tabIndex) : null;

return tabIndex ? isFocusable && tabIndex >= 0 : isFocusable;
11 changes: 11 additions & 0 deletions lib/checks/keyboard/focusable-element.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"id": "focusable-element",
"evaluate": "focusable-element.js",
"metadata": {
"impact": "moderate",
"messages": {
"pass": "Element is focusable",
"fail": "Element should be focusable"
}
}
}
2 changes: 1 addition & 1 deletion lib/commons/dom/get-tabbable-elements.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* global dom */

/**
* Get all elements (including given node) that are part if the tab order
* Get all elements (including given node) that are part of the tab order
* @method getTabbableElements
* @memberof axe.commons.dom
* @instance
Expand Down
20 changes: 20 additions & 0 deletions lib/core/utils/get-scroll.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Get the scroll position of given element
*/
axe.utils.getScroll = function getScroll(elm) {
const style = window.getComputedStyle(elm);
jeeyyy marked this conversation as resolved.
Show resolved Hide resolved
const visibleOverflowY = style.getPropertyValue('overflow-y') === 'visible';
const visibleOverflowX = style.getPropertyValue('overflow-x') === 'visible';

if (
// See if the element hides overflowing content
(!visibleOverflowY && elm.scrollHeight > elm.clientHeight) ||
(!visibleOverflowX && elm.scrollWidth > elm.clientWidth)
) {
return {
elm,
top: elm.scrollTop,
left: elm.scrollLeft
};
}
};
19 changes: 1 addition & 18 deletions lib/core/utils/scroll-state.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,3 @@
/**
* Return the scroll position of scrollable elements
*/
function getScroll(elm) {
const style = window.getComputedStyle(elm);
const visibleOverflowY = style.getPropertyValue('overflow-y') === 'visible';
const visibleOverflowX = style.getPropertyValue('overflow-x') === 'visible';

if (
// See if the element hides overflowing content
(!visibleOverflowY && elm.scrollHeight > elm.clientHeight) ||
(!visibleOverflowX && elm.scrollWidth > elm.clientWidth)
) {
return { elm, top: elm.scrollTop, left: elm.scrollLeft };
}
}

/**
* set the scroll position of an element
*/
Expand All @@ -32,7 +15,7 @@ function setScroll(elm, top, left) {
*/
function getElmScrollRecursive(root) {
return Array.from(root.children).reduce((scrolls, elm) => {
const scroll = getScroll(elm);
const scroll = axe.utils.getScroll(elm);
if (scroll) {
scrolls.push(scroll);
}
Expand Down
9 changes: 9 additions & 0 deletions lib/rules/scrollable-region-focusable-matches.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Note:
* `excludeHidden=true` for this rule, thus considering only elements in the accessibility tree.
*/
const nodeAndDescendents = axe.utils.querySelectorAll(virtualNode, '*');
jeeyyy marked this conversation as resolved.
Show resolved Hide resolved
const scrollableElements = nodeAndDescendents.filter(
vNode => !!axe.utils.getScroll(vNode.actualNode)
jeeyyy marked this conversation as resolved.
Show resolved Hide resolved
);
return scrollableElements.length > 0;
13 changes: 13 additions & 0 deletions lib/rules/scrollable-region-focusable.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"id": "scrollable-region-focusable",
"excludeHidden": true,
jeeyyy marked this conversation as resolved.
Show resolved Hide resolved
"matches": "scrollable-region-focusable-matches.js",
"tags": ["wcag2a", "wcag211"],
"metadata": {
"description": "Ensure that scrollable region has keyboard access",
"help": "Elements that have scrollable content should be accessible by keyboard"
jeeyyy marked this conversation as resolved.
Show resolved Hide resolved
},
"all": [],
"any": ["focusable-content", "focusable-element"],
"none": []
}
126 changes: 126 additions & 0 deletions test/checks/keyboard/focusable-content.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
describe('focusable-content tests', 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-content'];
});

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

it('returns false when there are no focusable content elements (content element `div` is not focusable)', function() {
var params = checkSetup(
'<div id="target">' + '<div> Content </div>' + '</div>'
);
var actual = check.evaluate.apply(checkContext, params);
assert.isFalse(actual);
});

it('returns false when content element is taken out of focusable order (tabindex = -1)', function() {
var params = checkSetup(
'<div id="target">' + '<input type="text" tabindex="-1">' + '</div>'
);
var actual = check.evaluate.apply(checkContext, params);
assert.isFalse(actual);
});

it('returns false when parent element is focusable (only checks if contents are focusable)', function() {
jeeyyy marked this conversation as resolved.
Show resolved Hide resolved
var params = checkSetup(
'<div id="target" tabindex="0">' +
'<p style="height: 200px;"></p>' +
'</div>'
);
var actual = check.evaluate.apply(checkContext, params);
assert.isFalse(actual);
});

it('returns false when all content elements are not focusable', function() {
var params = checkSetup(
'<div id="target">' +
'<input type="text" tabindex="-1">' +
'<select tabindex="-1"></select>' +
'<textarea tabindex="-1"></textarea>' +
'</div>'
);
var actual = check.evaluate.apply(checkContext, params);
assert.isFalse(actual);
});

it('returns true when one deeply nested content element is focusable', function() {
var params = checkSetup(
'<div id="target">' +
'<div style="height: 200px"> ' +
'<div style="height: 200px">' +
'<input type="text">' +
'</div>' +
'</div>' +
'</div>'
);
var actual = check.evaluate.apply(checkContext, params);
assert.isTrue(actual);
});

it('returns true when content element can be focused', function() {
var params = checkSetup(
'<div id="target">' + '<input type="text">' + '</div>'
);
var actual = check.evaluate.apply(checkContext, params);
assert.isTrue(actual);
});

it('returns true when any one of the many content elements can be focused', function() {
var params = checkSetup(
'<div id="target">' +
'<input type="text" tabindex="-1">' +
'<select tabindex="-1"></select>' +
'<textarea tabindex="-1"></textarea>' +
'<p style="height: 200px;" tabindex="0"></p>' +
'</div>'
);
var actual = check.evaluate.apply(checkContext, params);
assert.isTrue(actual);
});

describe('shadowDOM - focusable content', function() {
before(function() {
if (!shadowSupported) {
this.skip();
}
});

it('returns true when content element can be focused', function() {
fixtureSetup('<div id="target">' + '</div>');
var node = fixture.querySelector('#target');
var shadow = node.attachShadow({ mode: 'open' });
shadow.innerHTML = '<input type="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);
});

it('returns false when no focusable content', function() {
fixtureSetup('<div id="target">' + '</div>');
var node = fixture.querySelector('#target');
var shadow = node.attachShadow({ mode: 'open' });
shadow.innerHTML =
'<input type="text" tabindex="-1"> <p>just some text</p>';
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);
});
});
});
46 changes: 46 additions & 0 deletions test/checks/keyboard/focusable-element.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
describe('focusable-element tests', function() {
'use strict';

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

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

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

it('returns true when element is focusable', function() {
var params = checkSetup('<input id="target" type="radio">');
var actual = check.evaluate.apply(checkContext, params);
assert.isTrue(actual);
});

it('returns false when element made not focusable by tabindex', function() {
var params = checkSetup(
'<input id="target" type="checkbox" tabindex="-1">'
);
var actual = check.evaluate.apply(checkContext, params);
assert.isFalse(actual);
});

it('returns false when element is not focusable by virtue', function() {
jeeyyy marked this conversation as resolved.
Show resolved Hide resolved
var params = checkSetup('<p id="target">I hold some text </p>');
var actual = check.evaluate.apply(checkContext, params);
assert.isFalse(actual);
});

it('returns true when element made focusable by tabindex', function() {
var params = checkSetup(
'<p id="target" tabindex="0">I hold some text </p>'
);
var actual = check.evaluate.apply(checkContext, params);
assert.isTrue(actual);
});
});
Loading