Skip to content

Commit

Permalink
feat(rule): Scrollable region focusable (#1396)
Browse files Browse the repository at this point in the history
  • Loading branch information
jeeyyy authored May 14, 2019
1 parent a4e177b commit 861371a
Show file tree
Hide file tree
Showing 20 changed files with 639 additions and 41 deletions.
1 change: 1 addition & 0 deletions doc/rule-descriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,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 | Elements that have scrollable content should be accessible by keyboard | 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;
11 changes: 11 additions & 0 deletions lib/checks/keyboard/focusable-content.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"id": "focusable-content",
"evaluate": "focusable-content.js",
"metadata": {
"impact": "moderate",
"messages": {
"pass": "Element contains focusable elements",
"fail": "Element should have focusable content"
}
}
}
12 changes: 12 additions & 0 deletions lib/checks/keyboard/focusable-element.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Note:
* Check
* - if element is focusable
* - if element is in focus order via `tabindex`
*/
const isFocusable = virtualNode.isFocusable;

let tabIndex = parseInt(virtualNode.actualNode.getAttribute('tabindex'), 10);
tabIndex = !isNaN(tabIndex) ? 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
10 changes: 6 additions & 4 deletions lib/commons/dom/has-content-virtual.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,17 @@ function hasChildTextNodes(elm) {
* @instance
* @param {VirtualNode} elm Virtual Node to search
* @param {Boolean} noRecursion If true, only the element is checked, otherwise it will search all child nodes
* @param {Boolean} ignoreAria if true, ignores `aria label` computation for content deduction
* @return {Boolean}
*/
dom.hasContentVirtual = function(elm, noRecursion) {
dom.hasContentVirtual = function(elm, noRecursion, ignoreAria) {
return (
// It has text
hasChildTextNodes(elm) ||
// It is a graphical element
dom.isVisualContent(elm.actualNode) ||
// It has an ARIA label
!!aria.labelVirtual(elm) ||
(!!ignoreAria || !!aria.labelVirtual(elm)) ||
// or one of it's descendants does
(!noRecursion &&
elm.children.some(
Expand All @@ -56,11 +57,12 @@ dom.hasContentVirtual = function(elm, noRecursion) {
* @instance
* @param {DOMNode} elm DOMNode element to check
* @param {Boolean} noRecursion If true, only the element is checked, otherwise it will search all child nodes
* @param {Boolean} ignoreAria if true, ignores `aria label` computation for content deduction
* @return {Boolean}
*/
dom.hasContent = function hasContent(elm, noRecursion) {
dom.hasContent = function hasContent(elm, noRecursion, ignoreAria) {
elm = axe.utils.getNodeFromTree(elm);
return dom.hasContentVirtual(elm, noRecursion);
return dom.hasContentVirtual(elm, noRecursion, ignoreAria);
};

/**
Expand Down
35 changes: 35 additions & 0 deletions lib/core/utils/get-scroll.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Get the scroll position of given element
* @method getScroll
* @memberof axe.utils
* @param {Element} node
* @param {buffer} (Optional) allowed negligence in overflow
* @returns {Object | undefined}
*/
axe.utils.getScroll = function getScroll(elm, buffer = 0) {
const overflowX = elm.scrollWidth > elm.clientWidth + buffer;
const overflowY = elm.scrollHeight > elm.clientHeight + buffer;

/**
* if there is neither `overflow-x` or `overflow-y`
* -> return
*/
if (!(overflowX || overflowY)) {
return;
}

const style = window.getComputedStyle(elm);
const scrollableX = style.getPropertyValue('overflow-x') !== 'visible';
const scrollableY = style.getPropertyValue('overflow-y') !== 'visible';

/**
* check direction of `overflow` and `scrollable`
*/
if ((overflowX && scrollableX) || (overflowY && scrollableY)) {
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
30 changes: 30 additions & 0 deletions lib/rules/scrollable-region-focusable-matches.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Note:
* `excludeHidden=true` for this rule, thus considering only elements in the accessibility tree.
*/
const { querySelectorAll } = axe.utils;
const { hasContentVirtual } = axe.commons.dom;

/**
* if not scrollable -> `return`
*/
if (!!axe.utils.getScroll(node, 13) === false) {
return false;
}

/**
* check if node has visible contents
*/
const nodeAndDescendents = querySelectorAll(virtualNode, '*');
const hasVisibleChildren = nodeAndDescendents.some(elm =>
hasContentVirtual(
elm,
true, // noRecursion
true // ignoreAria
)
);
if (!hasVisibleChildren) {
return false;
}

return true;
12 changes: 12 additions & 0 deletions lib/rules/scrollable-region-focusable.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"id": "scrollable-region-focusable",
"matches": "scrollable-region-focusable-matches.js",
"tags": ["wcag2a", "wcag211"],
"metadata": {
"description": "Elements that have scrollable content should be accessible by keyboard",
"help": "Ensure that scrollable region has keyboard access"
},
"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 element is focusable (only checks if contents are focusable)', function() {
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 default', function() {
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

0 comments on commit 861371a

Please sign in to comment.