diff --git a/lib/commons/color/center-point-of-rect.js b/lib/commons/color/center-point-of-rect.js new file mode 100644 index 0000000000..e5200767b5 --- /dev/null +++ b/lib/commons/color/center-point-of-rect.js @@ -0,0 +1,30 @@ +/* global color */ + +/** + * Get coordinates for an element's client rects or bounding client rect + * + * @method centerPointOfRect + * @memberof axe.commons.color + * @param {DOMRect} rect + * @returns {Object | undefined} + */ +color.centerPointOfRect = function centerPointOfRect(rect) { + if (rect.left > window.innerWidth) { + return undefined; + } + + if (rect.top > window.innerHeight) { + return undefined; + } + + const x = Math.min( + Math.ceil(rect.left + rect.width / 2), + window.innerWidth - 1 + ); + const y = Math.min( + Math.ceil(rect.top + rect.height / 2), + window.innerHeight - 1 + ); + + return { x, y }; +}; diff --git a/lib/commons/color/element-has-image.js b/lib/commons/color/element-has-image.js new file mode 100644 index 0000000000..c1be869c06 --- /dev/null +++ b/lib/commons/color/element-has-image.js @@ -0,0 +1,36 @@ +/* global color */ + +/** + * Reports if an element has a background image or gradient + * + * @method elementHasImage + * @memberof axe.commons.color + * @private + * @param {Element} elm + * @param {Object|null} style + * @return {Boolean} + */ +color.elementHasImage = function elementHasImage(elm, style) { + const graphicNodes = ['IMG', 'CANVAS', 'OBJECT', 'IFRAME', 'VIDEO', 'SVG']; + const nodeName = elm.nodeName.toUpperCase(); + + if (graphicNodes.includes(nodeName)) { + axe.commons.color.incompleteData.set('bgColor', 'imgNode'); + return true; + } + + style = style || window.getComputedStyle(elm); + + const bgImageStyle = style.getPropertyValue('background-image'); + const hasBgImage = bgImageStyle !== 'none'; + + if (hasBgImage) { + const hasGradient = /gradient/.test(bgImageStyle); + axe.commons.color.incompleteData.set( + 'bgColor', + hasGradient ? 'bgGradient' : 'bgImage' + ); + } + + return hasBgImage; +}; diff --git a/lib/commons/color/get-background-color.js b/lib/commons/color/get-background-color.js index 8cffcf4be6..ef5b7f481f 100644 --- a/lib/commons/color/get-background-color.js +++ b/lib/commons/color/get-background-color.js @@ -1,121 +1,229 @@ /* global axe, color, dom */ -const graphicNodes = ['IMG', 'CANVAS', 'OBJECT', 'IFRAME', 'VIDEO', 'SVG']; /** - * Reports if an element has a background image or gradient - * @private - * @param {Element} elm - * @param {Object|null} style - * @return {Boolean} + * Returns background color for element + * Uses getBackgroundStack() to get all elements rendered underneath the current element, + * to help determine the composite background color. + * + * @method getBackgroundColor + * @memberof axe.commons.color + * @param {Element} elm Element to determine background color + * @param {Array} [bgElms=[]] elements to inspect + * @param {Boolean} [noScroll=false] should scroll + * @returns {Color} */ -function elmHasImage(elm, style) { - var nodeName = elm.nodeName.toUpperCase(); - if (graphicNodes.includes(nodeName)) { - axe.commons.color.incompleteData.set('bgColor', 'imgNode'); - return true; +color.getBackgroundColor = function getBackgroundColor( + elm, + bgElms = [], + noScroll = false +) { + if (noScroll !== true) { + /** + * Avoid scrolling overflow:hidden containers, by only aligning to top, + * when not doing so would move the center point above the viewport top. + */ + const clientHeight = elm.getBoundingClientRect().height; + const alignToTop = clientHeight - 2 >= window.innerHeight * 2; + elm.scrollIntoView(alignToTop); } - style = style || window.getComputedStyle(elm); - var bgImageStyle = style.getPropertyValue('background-image'); - var hasBgImage = bgImageStyle !== 'none'; - if (hasBgImage) { - var hasGradient = /gradient/.test(bgImageStyle); - axe.commons.color.incompleteData.set( - 'bgColor', - hasGradient ? 'bgGradient' : 'bgImage' - ); + let bgColors = []; + let elmStack = color.getBackgroundStack(elm); + + // Search the stack until we have an alpha === 1 background + (elmStack || []).some(bgElm => { + const bgElmStyle = window.getComputedStyle(bgElm); + + // Get the background color + let bgColor = color.getOwnBackgroundColor(bgElmStyle); + + if ( + // abort if a node is partially obscured and obscuring element has a background + elmPartiallyObscured(elm, bgElm, bgColor) || + // OR if the background elm is a graphic + color.elementHasImage(bgElm, bgElmStyle) + ) { + bgColors = null; + bgElms.push(bgElm); + + return true; + } + + if (bgColor.alpha !== 0) { + // store elements contributing to the br color. + bgElms.push(bgElm); + bgColors.push(bgColor); + + // Exit if the background is opaque + return bgColor.alpha === 1; + } else { + return false; + } + }); + + if (bgColors !== null && elmStack !== null) { + // Mix the colors together, on top of a default white + bgColors.push(new color.Color(255, 255, 255, 1)); + var colors = bgColors.reduce(color.flattenColors); + return colors; } - return hasBgImage; -} + + return null; +}; /** - * Returns the non-alpha-blended background color of an element - * @private + * Get all elements rendered underneath the current element, + * In the order they are displayed (front to back) + * + * @method getBackgroundStack + * @memberof axe.commons.color * @param {Element} elm - * @return {Color} + * @return {Array} */ -function getBgColor(elm, elmStyle) { - elmStyle = elmStyle || window.getComputedStyle(elm); +color.getBackgroundStack = function getBackgroundStack(elm) { + let elmStack = color.filteredRectStack(elm); - let bgColor = new color.Color(); - bgColor.parseRgbString(elmStyle.getPropertyValue('background-color')); + if (elmStack === null) { + return null; + } + elmStack = includeMissingElements(elmStack, elm); + elmStack = dom.reduceToElementsBelowFloating(elmStack, elm); + elmStack = sortPageBackground(elmStack); - if (bgColor.alpha !== 0) { - let opacity = elmStyle.getPropertyValue('opacity'); - bgColor.alpha = bgColor.alpha * opacity; + // Return all elements BELOW the current element, null if the element is undefined + let elmIndex = elmStack.indexOf(elm); + if (calculateObscuringAlpha(elmIndex, elmStack, elm) >= 0.99) { + // if the total of the elements above our element results in total obscuring, return null + axe.commons.color.incompleteData.set('bgColor', 'bgOverlap'); + return null; } - return bgColor; -} + return elmIndex !== -1 ? elmStack : null; +}; /** - * Determines overlap of node's content with a bgNode. Used for inline elements - * @private - * @param {Element} targetElement - * @param {Element} bgNode - * @return {Boolean} + * Get filtered stack of block and inline elements, excluding line breaks + * @method filteredRectStack + * @memberof axe.commons.color + * @param {Element} elm + * @return {Array} */ -function contentOverlapping(targetElement, bgNode) { - // get content box of target element - // check to see if the current bgNode is overlapping - var targetRect = targetElement.getClientRects()[0]; - var obscuringElements = dom.shadowElementsFromPoint( - targetRect.left, - targetRect.top - ); - if (obscuringElements) { - for (var i = 0; i < obscuringElements.length; i++) { - if ( - obscuringElements[i] !== targetElement && - obscuringElements[i] === bgNode - ) { - return true; +color.filteredRectStack = function filteredRectStack(elm) { + const rectStack = color.getRectStack(elm); + + if (rectStack && rectStack.length === 1) { + return rectStack[0]; + } + + if (rectStack && rectStack.length > 1) { + const boundingStack = rectStack.shift(); + let isSame; + + // iterating over arrays of DOMRects + rectStack.forEach((rectList, index) => { + if (index === 0) { + return; } + // if the stacks are the same, use the first one. otherwise, return null. + let rectA = rectStack[index - 1], + rectB = rectStack[index]; + + // if elements in clientRects are the same + // or the boundingClientRect contains the differing element, pass it + isSame = + rectA.every( + (element, elementIndex) => element === rectB[elementIndex] + ) || boundingStack.includes(elm); + }); + if (!isSame) { + axe.commons.color.incompleteData.set('bgColor', 'elmPartiallyObscuring'); + return null; } + // pass the first stack if it wasn't partially covered + return rectStack[0]; } - return false; -} + + // rect outside of viewport + axe.commons.color.incompleteData.set('bgColor', 'outsideViewport'); + return null; +}; + /** - * Calculate alpha transparency of a background element obscuring the current node - * @private - * @param {Number} elmIndex - * @param {Array} elmStack - * @param {Element} originalElm - * @return {Number|undefined} + * Get relevant stacks of block and inline elements, excluding line breaks + * @method getRectStack + * @memberof axe.commons.color + * @param {Element} elm + * @return {Array} */ -function calculateObscuringAlpha(elmIndex, elmStack, originalElm) { - var totalAlpha = 0; +color.getRectStack = function(elm) { + const boundingCoords = axe.commons.color.centerPointOfRect( + elm.getBoundingClientRect() + ); - if (elmIndex > 0) { - // there are elements above our element, check if they contribute to the background - for (var i = elmIndex - 1; i >= 0; i--) { - let bgElm = elmStack[i]; - let bgElmStyle = window.getComputedStyle(bgElm); - let bgColor = getBgColor(bgElm, bgElmStyle); - if (bgColor.alpha && contentOverlapping(originalElm, bgElm)) { - totalAlpha += bgColor.alpha; - } else { - // remove elements not contributing to the background - elmStack.splice(i, 1); + if (!boundingCoords) { + return null; + } + + let boundingStack = dom.shadowElementsFromPoint( + boundingCoords.x, + boundingCoords.y + ); + + let rects = Array.from(elm.getClientRects()); + // If the element does not have multiple rects, like for display:block, return a single stack + if (!rects || rects.length <= 1) { + return [boundingStack]; + } + + // Handle inline elements spanning multiple lines to be evaluated + let filteredArr = rects + .filter(rect => { + // exclude manual line breaks in Chrome/Safari + return rect.width && rect.width > 0; + }) + .map(rect => { + const coords = axe.commons.color.centerPointOfRect(rect); + if (coords) { + return dom.shadowElementsFromPoint(coords.x, coords.y); } - } + }); + + if (filteredArr.some(stack => stack === undefined)) { + // Can be happen when one or more of the rects sits outside the viewport + return null; } - return totalAlpha; -} + + // add bounding client rect stack for comparison later + filteredArr.splice(0, 0, boundingStack); + return filteredArr; +}; + /** - * Determine if element is partially overlapped, triggering a Can't Tell result + * Look at document and body elements for relevant background information + * @method sortPageBackground * @private - * @param {Element} elm - * @param {Element} bgElm - * @param {Object} bgColor - * @return {Boolean} + * @param {Array} elmStack + * @returns {Array} */ -function elmPartiallyObscured(elm, bgElm, bgColor) { - var obscured = - elm !== bgElm && !dom.visuallyContains(elm, bgElm) && bgColor.alpha !== 0; - if (obscured) { - axe.commons.color.incompleteData.set('bgColor', 'elmPartiallyObscured'); +function sortPageBackground(elmStack) { + let bodyIndex = elmStack.indexOf(document.body); + let bgNodes = elmStack; + + if ( + // Check that the body background is the page's background + bodyIndex > 1 && // only if there are negative z-index elements + !color.elementHasImage(document.documentElement) && + color.getOwnBackgroundColor( + window.getComputedStyle(document.documentElement) + ).alpha === 0 + ) { + // Remove body and html from it's current place + bgNodes.splice(bodyIndex, 1); + bgNodes.splice(elmStack.indexOf(document.documentElement), 1); + + // Put the body background as the lowest element + bgNodes.push(document.body); } - return obscured; + return bgNodes; } /** @@ -123,10 +231,10 @@ function elmPartiallyObscured(elm, bgElm, bgColor) { * document.elementsFromPoint misses some elements we need * i.e. TR is missing from table elementStack and leaves out bgColor * https://github.com/dequelabs/axe-core/issues/273 - * * @private * @param {Array} elmStack * @param {Element} elm + * @returns {Array} */ function includeMissingElements(elmStack, elm) { /*eslint max-depth:["error",7]*/ @@ -176,229 +284,77 @@ function includeMissingElements(elmStack, elm) { } /** - * Look at document and body elements for relevant background information + * Determine if element is partially overlapped, triggering a Can't Tell result * @private - * @param {Array} elmStack + * @param {Element} elm + * @param {Element} bgElm + * @param {Object} bgColor + * @return {Boolean} */ -function sortPageBackground(elmStack) { - let bodyIndex = elmStack.indexOf(document.body); - - let bgNodes = elmStack; - - if ( - // Check that the body background is the page's background - bodyIndex > 1 && // only if there are negative z-index elements - !elmHasImage(document.documentElement) && - getBgColor(document.documentElement).alpha === 0 - ) { - // Remove body and html from it's current place - bgNodes.splice(bodyIndex, 1); - bgNodes.splice(elmStack.indexOf(document.documentElement), 1); - - // Put the body background as the lowest element - bgNodes.push(document.body); +function elmPartiallyObscured(elm, bgElm, bgColor) { + var obscured = + elm !== bgElm && !dom.visuallyContains(elm, bgElm) && bgColor.alpha !== 0; + if (obscured) { + axe.commons.color.incompleteData.set('bgColor', 'elmPartiallyObscured'); } - return bgNodes; + return obscured; } -/** - * Get coordinates for an element's client rects or bounding client rect - * @method getCoords - * @memberof axe.commons.color - * @instance - * @param {DOMRect} rect - * @return {Object} - */ -color.getCoords = function(rect) { - let x, y; - if (rect.left > window.innerWidth) { - return; - } - if (rect.top > window.innerHeight) { - return; - } - x = Math.min(Math.ceil(rect.left + rect.width / 2), window.innerWidth - 1); - y = Math.min(Math.ceil(rect.top + rect.height / 2), window.innerHeight - 1); - return { x, y }; -}; /** - * Get relevant stacks of block and inline elements, excluding line breaks - * @method getRectStack - * @memberof axe.commons.color - * @instance - * @param {Element} elm - * @return {Array} + * Calculate alpha transparency of a background element obscuring the current node + * @private + * @param {Number} elmIndex + * @param {Array} elmStack + * @param {Element} originalElm + * @return {Number|undefined} */ -color.getRectStack = function(elm) { - let boundingCoords = color.getCoords(elm.getBoundingClientRect()); - if (!boundingCoords) { - return null; - } - - let boundingStack = dom.shadowElementsFromPoint( - boundingCoords.x, - boundingCoords.y - ); - - let rects = Array.from(elm.getClientRects()); - // If the element does not have multiple rects, like for display:block, return a single stack - if (!rects || rects.length <= 1) { - return [boundingStack]; - } - - // Handle inline elements spanning multiple lines to be evaluated - let filteredArr = rects - .filter(rect => { - // exclude manual line breaks in Chrome/Safari - return rect.width && rect.width > 0; - }) - .map(rect => { - let coords = color.getCoords(rect); - if (coords) { - return dom.shadowElementsFromPoint(coords.x, coords.y); - } - }); - - if (filteredArr.some(stack => stack === undefined)) { - // Can be happen when one or more of the rects sits outside the viewport - return null; - } +function calculateObscuringAlpha(elmIndex, elmStack, originalElm) { + var totalAlpha = 0; - // add bounding client rect stack for comparison later - filteredArr.splice(0, 0, boundingStack); - return filteredArr; -}; -/** - * Get filtered stack of block and inline elements, excluding line breaks - * @method filteredRectStack - * @memberof axe.commons.color - * @instance - * @param {Element} elm - * @return {Array} - */ -color.filteredRectStack = function(elm) { - let rectStack = color.getRectStack(elm); - if (rectStack && rectStack.length === 1) { - // default case, elm.getBoundingClientRect() - return rectStack[0]; - } else if (rectStack && rectStack.length > 1) { - let boundingStack = rectStack.shift(); - let isSame; - // iterating over arrays of DOMRects - rectStack.forEach((rectList, index) => { - if (index === 0) { - return; + if (elmIndex > 0) { + // there are elements above our element, check if they contribute to the background + for (var i = elmIndex - 1; i >= 0; i--) { + let bgElm = elmStack[i]; + let bgElmStyle = window.getComputedStyle(bgElm); + let bgColor = color.getOwnBackgroundColor(bgElmStyle); + if (bgColor.alpha && contentOverlapping(originalElm, bgElm)) { + totalAlpha += bgColor.alpha; + } else { + // remove elements not contributing to the background + elmStack.splice(i, 1); } - // if the stacks are the same, use the first one. otherwise, return null. - let rectA = rectStack[index - 1], - rectB = rectStack[index]; - - // if elements in clientRects are the same - // or the boundingClientRect contains the differing element, pass it - isSame = - rectA.every(function(element, elementIndex) { - return element === rectB[elementIndex]; - }) || boundingStack.includes(elm); - }); - if (!isSame) { - axe.commons.color.incompleteData.set('bgColor', 'elmPartiallyObscuring'); - return null; } - // pass the first stack if it wasn't partially covered - return rectStack[0]; - } else { - // rect outside of viewport - axe.commons.color.incompleteData.set('bgColor', 'outsideViewport'); - return null; } -}; -/** - * Get all elements rendered underneath the current element, In the order they are displayed (front to back) - * @method getBackgroundStack - * @memberof axe.commons.color - * @instance - * @param {Element} elm - * @return {Array} - */ -color.getBackgroundStack = function(elm) { - let elmStack = color.filteredRectStack(elm); - if (elmStack === null) { - return null; - } - elmStack = includeMissingElements(elmStack, elm); - elmStack = dom.reduceToElementsBelowFloating(elmStack, elm); - elmStack = sortPageBackground(elmStack); - - // Return all elements BELOW the current element, null if the element is undefined - let elmIndex = elmStack.indexOf(elm); - if (calculateObscuringAlpha(elmIndex, elmStack, elm) >= 0.99) { - // if the total of the elements above our element results in total obscuring, return null - axe.commons.color.incompleteData.set('bgColor', 'bgOverlap'); - return null; - } - return elmIndex !== -1 ? elmStack : null; -}; + return totalAlpha; +} /** - * Returns background color for element - * Uses color.getBackgroundStack() to get all elements rendered underneath the current element to - * help determine the background color. - * @param {Element} elm Element to determine background color - * @param {Array} [bgElms=[]] [description] - * @param {Boolean} [noScroll=false] [description] - * @return {Color} [description] + * Determines overlap of node's content with a bgNode. Used for inline elements + * @private + * @param {Element} targetElement + * @param {Element} bgNode + * @return {Boolean} */ -color.getBackgroundColor = function(elm, bgElms = [], noScroll = false) { - if (noScroll !== true) { - // Avoid scrolling overflow:hidden containers, by only aligning to top - // when not doing so would move the center point above the viewport top. - const clientHeight = elm.getBoundingClientRect().height; - const alignToTop = clientHeight - 2 >= window.innerHeight * 2; - elm.scrollIntoView(alignToTop); - } - let bgColors = []; - let elmStack = color.getBackgroundStack(elm); - - // Search the stack until we have an alpha === 1 background - (elmStack || []).some(bgElm => { - let bgElmStyle = window.getComputedStyle(bgElm); - - // Get the background color - let bgColor = getBgColor(bgElm, bgElmStyle); - - if ( - // abort if a node is partially obscured and obscuring element has a background - elmPartiallyObscured(elm, bgElm, bgColor) || - // OR if the background elm is a graphic - elmHasImage(bgElm, bgElmStyle) - ) { - bgColors = null; - bgElms.push(bgElm); - - return true; - } - - if (bgColor.alpha !== 0) { - // store elements contributing to the br color. - bgElms.push(bgElm); - bgColors.push(bgColor); - - // Exit if the background is opaque - return bgColor.alpha === 1; - } else { - return false; +function contentOverlapping(targetElement, bgNode) { + // get content box of target element + // check to see if the current bgNode is overlapping + var targetRect = targetElement.getClientRects()[0]; + var obscuringElements = dom.shadowElementsFromPoint( + targetRect.left, + targetRect.top + ); + if (obscuringElements) { + for (var i = 0; i < obscuringElements.length; i++) { + if ( + obscuringElements[i] !== targetElement && + obscuringElements[i] === bgNode + ) { + return true; + } } - }); - - if (bgColors !== null && elmStack !== null) { - // Mix the colors together, on top of a default white - bgColors.push(new color.Color(255, 255, 255, 1)); - var colors = bgColors.reduce(color.flattenColors); - return colors; } - - return null; -}; + return false; +} /** * Determines whether an element has a fully opaque background, whether solid color or an image @@ -406,6 +362,9 @@ color.getBackgroundColor = function(elm, bgElms = [], noScroll = false) { * @return {Boolean} false if the background is transparent, true otherwise */ dom.isOpaque = function(node) { - let style = window.getComputedStyle(node); - return elmHasImage(node, style) || getBgColor(node, style).alpha === 1; + const style = window.getComputedStyle(node); + return ( + color.elementHasImage(node, style) || + color.getOwnBackgroundColor(style).alpha === 1 + ); }; diff --git a/lib/commons/color/get-own-background-color.js b/lib/commons/color/get-own-background-color.js new file mode 100644 index 0000000000..ad63d25def --- /dev/null +++ b/lib/commons/color/get-own-background-color.js @@ -0,0 +1,22 @@ +/* global color */ + +/** + * Returns the non-alpha-blended background color of an element + * + * @method getOwnBackgroundColor + * @memberof axe.commons.color + * + * @param {Object} elmStyle style of the element + * @return {Color} + */ +color.getOwnBackgroundColor = function getOwnBackgroundColor(elmStyle) { + const bgColor = new color.Color(); + bgColor.parseRgbString(elmStyle.getPropertyValue('background-color')); + + if (bgColor.alpha !== 0) { + const opacity = elmStyle.getPropertyValue('opacity'); + bgColor.alpha = bgColor.alpha * opacity; + } + + return bgColor; +}; diff --git a/test/commons/color/center-point-of-rect.js b/test/commons/color/center-point-of-rect.js new file mode 100644 index 0000000000..289b85b877 --- /dev/null +++ b/test/commons/color/center-point-of-rect.js @@ -0,0 +1,38 @@ +describe('color.centerPointOfRect', function() { + 'use strict'; + + it('returns `undefined` when element is placed outside of viewport (left position > window dimension)', function() { + var actual = axe.commons.color.centerPointOfRect({ + left: 9999, + top: 0, + width: 200, + height: 100 + }); + assert.isUndefined(actual); + }); + + it('returns `{x,y}` when element is with in viewport', function() { + var actual = axe.commons.color.centerPointOfRect({ + left: 0, + top: 0, + width: 200, + height: 100 + }); + assert.isDefined(actual); + assert.hasAllKeys(actual, ['x', 'y']); + }); + + it('returns `{x,y}` when element is with in viewport (check returned coordinate values)', function() { + var actual = axe.commons.color.centerPointOfRect({ + left: 100, + top: 100, + width: 250, + height: 250 + }); + + assert.isDefined(actual); + assert.hasAllKeys(actual, ['x', 'y']); + assert.equal(actual.x, 225); + assert.equal(actual.y, 225); + }); +}); diff --git a/test/commons/color/element-has-image.js b/test/commons/color/element-has-image.js new file mode 100644 index 0000000000..1ea568614b --- /dev/null +++ b/test/commons/color/element-has-image.js @@ -0,0 +1,86 @@ +describe('color.elementHasImage', function() { + 'use strict'; + + var fixture = document.getElementById('fixture'); + var queryFixture = axe.testUtils.queryFixture; + var elementHasImage = axe.commons.color.elementHasImage; + var origColorIncompleteData = axe.commons.color.incompleteData; + + afterEach(function() { + fixture.innerHTML = ''; + axe._tree = undefined; + axe.commons.color.incompleteData = origColorIncompleteData; + }); + + it('returns true when `HTMLElement` is of graphical type', function() { + ['img', 'canvas', 'object', 'iframe', 'video', 'svg'].forEach(function( + nodeName + ) { + var vNode = queryFixture( + '<' + nodeName + ' id="target">' + nodeName + '>' + ); + var actual = elementHasImage(vNode.actualNode); + assert.isTrue(actual); + assert.equal(axe.commons.color.incompleteData.get('bgColor'), 'imgNode'); + }); + }); + + it('returns false when `HTMLElement` has no background-image style set', function() { + var vNode = queryFixture( + '