diff --git a/lib/commons/dom/is-focusable.js b/lib/commons/dom/is-focusable.js
index 060a9a5eb6..725b155052 100644
--- a/lib/commons/dom/is-focusable.js
+++ b/lib/commons/dom/is-focusable.js
@@ -9,7 +9,7 @@
function focusDisabled(el) {
return (
el.disabled ||
- (!dom.isVisible(el, true) && el.nodeName.toUpperCase() !== 'AREA')
+ (dom.isHiddenWithCSS(el) && el.nodeName.toUpperCase() !== 'AREA')
);
}
diff --git a/lib/commons/dom/is-hidden-with-css.js b/lib/commons/dom/is-hidden-with-css.js
new file mode 100644
index 0000000000..7665ffa87a
--- /dev/null
+++ b/lib/commons/dom/is-hidden-with-css.js
@@ -0,0 +1,60 @@
+/* eslint complexity: ["error", 13], max-statements: ["error", 25] */
+/* global dom */
+
+/**
+ * Determine whether an element is hidden based on css
+ * @method isHiddenWithCSS
+ * @memberof axe.commons.dom
+ * @instance
+ * @param {HTMLElement} el The HTML Element
+ * @param {Boolean} descendentVisibilityValue (Optional) immediate descendant visibility value used for recursive computation
+ * @return {Boolean} the element's hidden status
+ */
+dom.isHiddenWithCSS = function isHiddenWithCSS(el, descendentVisibilityValue) {
+ if (el.nodeType === 9) {
+ // 9 === Node.DOCUMENT
+ return false;
+ }
+
+ if (el.nodeType === 11) {
+ // 11 === Node.DOCUMENT_FRAGMENT_NODE
+ el = el.host; // swap to host node
+ }
+
+ if (['STYLE', 'SCRIPT'].includes(el.nodeName.toUpperCase())) {
+ return false;
+ }
+
+ const style = window.getComputedStyle(el, null);
+ if (!style) {
+ throw new Error('Style does not exist for the given element.');
+ }
+
+ const displayValue = style.getPropertyValue('display');
+ if (displayValue === 'none') {
+ return true;
+ }
+
+ const HIDDEN_VISIBILITY_VALUES = ['hidden', 'collapse'];
+ const visibilityValue = style.getPropertyValue('visibility');
+ if (
+ HIDDEN_VISIBILITY_VALUES.includes(visibilityValue) &&
+ !descendentVisibilityValue
+ ) {
+ return true;
+ }
+
+ if (
+ HIDDEN_VISIBILITY_VALUES.includes(visibilityValue) &&
+ (descendentVisibilityValue &&
+ HIDDEN_VISIBILITY_VALUES.includes(descendentVisibilityValue))
+ ) {
+ return true;
+ }
+
+ const parent = dom.getComposedParent(el);
+ if (parent && !HIDDEN_VISIBILITY_VALUES.includes(visibilityValue)) {
+ return dom.isHiddenWithCSS(parent, visibilityValue);
+ }
+ return false;
+};
diff --git a/test/commons/dom/is-focusable.js b/test/commons/dom/is-focusable.js
index fe9f160aaa..59f7b35c91 100644
--- a/test/commons/dom/is-focusable.js
+++ b/test/commons/dom/is-focusable.js
@@ -230,6 +230,14 @@ describe('is-focusable', function() {
assert.isFalse(axe.commons.dom.isNativelyFocusable(el));
});
+ it('should return false for elements collapsed with visibility:collapse', function() {
+ fixture.innerHTML =
+ '';
+ var el = document.getElementById('target');
+
+ assert.isFalse(axe.commons.dom.isNativelyFocusable(el));
+ });
+
it('should return true for clipped elements', function() {
fixture.innerHTML = '';
var el = document.getElementById('target');
@@ -262,6 +270,14 @@ describe('is-focusable', function() {
assert.isFalse(axe.commons.dom.isNativelyFocusable(el));
});
+ it('should return false for elements collapsed with visibility:collapse on an ancestor', function() {
+ fixture.innerHTML =
+ '
';
+ var el = document.getElementById('target');
+
+ assert.isFalse(axe.commons.dom.isNativelyFocusable(el));
+ });
+
it('should return true for elements with a clipped ancestor', function() {
fixture.innerHTML =
'';
diff --git a/test/commons/dom/is-hidden-with-css.js b/test/commons/dom/is-hidden-with-css.js
new file mode 100644
index 0000000000..19443e60ab
--- /dev/null
+++ b/test/commons/dom/is-hidden-with-css.js
@@ -0,0 +1,312 @@
+describe('dom.isHiddenWithCSS', function() {
+ 'use strict';
+
+ var fixture = document.getElementById('fixture');
+ var shadowSupported = axe.testUtils.shadowSupport.v1;
+ var isHiddenWithCSSFn = axe.commons.dom.isHiddenWithCSS;
+ var origComputedStyle = window.getComputedStyle;
+
+ function createContentSlotted(mainProps, targetProps) {
+ var group = document.createElement('div');
+ group.innerHTML =
+ '';
+ return group;
+ }
+
+ function makeShadowTree(node, mainProps, targetProps) {
+ var root = node.attachShadow({ mode: 'open' });
+ var node = createContentSlotted(mainProps, targetProps);
+ root.appendChild(node);
+ }
+
+ afterEach(function() {
+ window.getComputedStyle = origComputedStyle;
+ document.getElementById('fixture').innerHTML = '';
+ });
+
+ it('should throw an error if computedStyle returns null', function() {
+ window.getComputedStyle = function() {
+ return null;
+ };
+ var fakeNode = {
+ nodeType: Node.ELEMENT_NODE,
+ nodeName: 'div'
+ };
+ assert.throws(function() {
+ isHiddenWithCSSFn(fakeNode);
+ });
+ });
+
+ it('should return false on static-positioned, visible element', function() {
+ fixture.innerHTML = '
I am visible
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isFalse(actual);
+ });
+
+ it('should return true on static-positioned, hidden element', function() {
+ fixture.innerHTML =
+ '
I am not visible
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isTrue(actual);
+ });
+
+ it('should return false on absolutely positioned elements that are on-screen', function() {
+ fixture.innerHTML =
+ '
I am visible
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isFalse(actual);
+ });
+
+ it('should return false for off-screen and aria-hidden element', function() {
+ fixture.innerHTML =
+ '';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isFalse(actual);
+ });
+
+ it('should return false on fixed position elements that are on-screen', function() {
+ fixture.innerHTML =
+ '
I am visible
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isFalse(actual);
+ });
+
+ it('should return false for off-screen absolutely positioned element', function() {
+ fixture.innerHTML =
+ '
I am visible
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isFalse(actual);
+ });
+
+ it('should return false for off-screen fixed positioned element', function() {
+ fixture.innerHTML =
+ '
I am visible
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isFalse(actual);
+ });
+
+ it('should return false on detached elements', function() {
+ var el = document.createElement('div');
+ el.innerHTML = 'I am not visible because I am detached!';
+ var actual = isHiddenWithCSSFn(el);
+ assert.isFalse(actual);
+ });
+
+ it('should return false on a document', function() {
+ var actual = isHiddenWithCSSFn(document);
+ assert.isFalse(actual);
+ });
+
+ it('should return false if static-position but top/left is set', function() {
+ fixture.innerHTML =
+ '
I am visible
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isFalse(actual);
+ });
+
+ it('should return false, and not be affected by `aria-hidden`', function() {
+ fixture.innerHTML =
+ '
I am visible with css (although hidden to screen readers)
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isFalse(actual);
+ });
+
+ it('should return false for STYLE node', function() {
+ fixture.innerHTML = "";
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isFalse(actual);
+ });
+
+ it('should return false for SCRIPT node', function() {
+ fixture.innerHTML =
+ "";
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isFalse(actual);
+ });
+
+ // `display` test
+ it('should return true for if parent of element set to `display:none`', function() {
+ fixture.innerHTML =
+ '
' +
+ '
' +
+ '
I am not visible
' +
+ '
' +
+ '
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isTrue(actual);
+ });
+
+ it('should return true for if parent of element set to `display:none`', function() {
+ fixture.innerHTML =
+ '
' +
+ '
' +
+ '
I am not visible
' +
+ '
' +
+ '
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isTrue(actual);
+ });
+
+ it('should return false for if parent of element set to `display:block`', function() {
+ fixture.innerHTML =
+ '
' +
+ '
' +
+ '
I am visible
' +
+ '
' +
+ '
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isFalse(actual);
+ });
+
+ (shadowSupported ? it : it.skip)(
+ 'should return true if `display:none` inside shadowDOM',
+ function() {
+ fixture.innerHTML = '';
+ makeShadowTree(fixture.firstChild, 'display:none;', '');
+ var tree = axe.utils.getFlattenedTree(fixture.firstChild);
+ var el = axe.utils.querySelectorAll(tree, 'p')[0];
+ var actual = isHiddenWithCSSFn(el.actualNode);
+ assert.isTrue(actual);
+ }
+ );
+
+ // `visibility` test
+ it('should return true for element that has `visibility:hidden`', function() {
+ fixture.innerHTML =
+ '
I am not visible
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isTrue(actual);
+ });
+
+ it('should return false and compute how `visibility` of self and parent is configured', function() {
+ fixture.innerHTML =
+ '
' +
+ '
' +
+ '
I am visible
' +
+ '
' +
+ '
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isFalse(actual);
+ });
+
+ it('should return false and compute how `visibility` of self and parent is configured', function() {
+ fixture.innerHTML =
+ '
' +
+ '
' +
+ '
I am visible
' +
+ '
' +
+ '
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isFalse(actual);
+ });
+
+ it('should return true and as parent is set to `visibility:hidden`', function() {
+ fixture.innerHTML =
+ '
' +
+ '
' +
+ '
I am not visible
' +
+ '
' +
+ '
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isTrue(actual);
+ });
+
+ (shadowSupported ? it : xit)(
+ 'should return true as parent shadowDOM host is set to `visibility:hidden`',
+ function() {
+ fixture.innerHTML = '';
+ makeShadowTree(fixture.firstChild, 'visibility:hidden', '');
+ var tree = axe.utils.getFlattenedTree(fixture.firstChild);
+ var el = axe.utils.querySelectorAll(tree, 'p')[0];
+ var actual = isHiddenWithCSSFn(el.actualNode);
+ assert.isTrue(actual);
+ }
+ );
+
+ (shadowSupported ? it : xit)(
+ 'should return false as parent shadowDOM host set to `visibility:hidden` is overriden',
+ function() {
+ fixture.innerHTML = '';
+ makeShadowTree(
+ fixture.firstChild,
+ 'visibility:hidden',
+ 'visibility:visible'
+ );
+ var tree = axe.utils.getFlattenedTree(fixture.firstChild);
+ var el = axe.utils.querySelectorAll(tree, 'p')[0];
+ var actual = isHiddenWithCSSFn(el.actualNode);
+ assert.isFalse(actual);
+ }
+ );
+
+ // mixing display and visibility
+ it('should return true and compute using both `display` and `visibility` set on element and parent(s)', function() {
+ fixture.innerHTML =
+ '
' +
+ '
' +
+ '
I am not visible
' +
+ '
' +
+ '
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isTrue(actual);
+ });
+
+ it('should return false and compute using both `display` and `visibility` set on element and parent(s)', function() {
+ fixture.innerHTML =
+ '
' +
+ '
' +
+ '
I am visible
' +
+ '
' +
+ '
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isFalse(actual);
+ });
+
+ it('should return true and compute using both `display` and `visibility` set on element and parent(s)', function() {
+ fixture.innerHTML =
+ '
' +
+ '
' +
+ '
I am not visible
' +
+ '
' +
+ '
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isTrue(actual);
+ });
+
+ it('should return true and compute using both `display` and `visibility` set on element and parent(s)', function() {
+ fixture.innerHTML =
+ '
' +
+ '
' +
+ '
I am not visible
' +
+ '
' +
+ '
';
+ var el = document.getElementById('target');
+ var actual = isHiddenWithCSSFn(el);
+ assert.isTrue(actual);
+ });
+});