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 = + ''; + 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 = + ''; + 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 = + ''; + 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 = + '
' + + '
' + + '' + + '
' + + '
'; + 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); + }); +});