diff --git a/lib/checks/color/color-contrast-evaluate.js b/lib/checks/color/color-contrast-evaluate.js index e10b0bc6bf..a87816f643 100644 --- a/lib/checks/color/color-contrast-evaluate.js +++ b/lib/checks/color/color-contrast-evaluate.js @@ -10,11 +10,10 @@ import { getForegroundColor, incompleteData, getContrast, - getOwnBackgroundColor, getTextShadowColors, flattenShadowColors } from '../../commons/color'; -import { memoize } from '../../core/utils'; +import { findPseudoElement } from '../../commons/dom'; export default function colorContrastEvaluate(node, options, virtualNode) { const { @@ -72,7 +71,11 @@ export default function colorContrastEvaluate(node, options, virtualNode) { } const bgNodes = []; - const bgColor = getBackgroundColor(node, bgNodes, shadowOutlineEmMax); + const bgColor = getBackgroundColor(node, bgNodes, { + shadowOutlineEmMax, + ignorePseudo, + pseudoSizeThreshold + }); const fgColor = getForegroundColor(node, false, bgColor); // Thin shadows only. Thicker shadows are included in the background instead const shadowColors = getTextShadowColors(node, { @@ -162,57 +165,6 @@ export default function colorContrastEvaluate(node, options, virtualNode) { return isValid; } -function findPseudoElement( - vNode, - { pseudoSizeThreshold = 0.25, ignorePseudo = false } -) { - if (ignorePseudo) { - return; - } - const rect = vNode.boundingClientRect; - const minimumSize = rect.width * rect.height * pseudoSizeThreshold; - do { - const beforeSize = getPseudoElementArea(vNode.actualNode, ':before'); - const afterSize = getPseudoElementArea(vNode.actualNode, ':after'); - if (beforeSize + afterSize > minimumSize) { - return vNode; // Combined area of before and after exceeds the minimum size - } - } while ((vNode = vNode.parent)); -} - -const getPseudoElementArea = memoize(function getPseudoElementArea( - node, - pseudo -) { - const style = window.getComputedStyle(node, pseudo); - const matchPseudoStyle = (prop, value) => - style.getPropertyValue(prop) === value; - if ( - matchPseudoStyle('content', 'none') || - matchPseudoStyle('display', 'none') || - matchPseudoStyle('visibility', 'hidden') || - matchPseudoStyle('position', 'absolute') === false - ) { - return 0; // The pseudo element isn't visible - } - - if ( - getOwnBackgroundColor(style).alpha === 0 && - matchPseudoStyle('background-image', 'none') - ) { - return 0; // There is no background - } - - // Find the size of the pseudo element; - const pseudoWidth = parseUnit(style.getPropertyValue('width')); - const pseudoHeight = parseUnit(style.getPropertyValue('height')); - if (pseudoWidth.unit !== 'px' || pseudoHeight.unit !== 'px') { - // IE doesn't normalize to px. Infinity gets everything to undefined - return pseudoWidth.value === 0 || pseudoHeight.value === 0 ? 0 : Infinity; - } - return pseudoWidth.value * pseudoHeight.value; -}); - function textIsEmojis(visibleText) { const options = { nonBmp: true }; const hasUnicodeChars = hasUnicode(visibleText, options); @@ -220,12 +172,3 @@ function textIsEmojis(visibleText) { sanitize(removeUnicode(visibleText, options)) === ''; return hasUnicodeChars && hasNonUnicodeChars; } - -function parseUnit(str) { - const unitRegex = /^([0-9.]+)([a-z]+)$/i; - const [, value = '', unit = ''] = str.match(unitRegex) || []; - return { - value: parseFloat(value), - unit: unit.toLowerCase() - }; -} diff --git a/lib/commons/color/get-background-color.js b/lib/commons/color/get-background-color.js index 499511b770..3976c9bd32 100644 --- a/lib/commons/color/get-background-color.js +++ b/lib/commons/color/get-background-color.js @@ -18,19 +18,27 @@ import visuallyContains from '../dom/visually-contains'; * @param {Element} elm Element to determine background color * @param {Array} [bgElms=[]] elements to inspect * @param {Number} shadowOutlineEmMax Thickness of `text-shadow` at which it becomes a background color + * @param {Object} options + * @deprecated shadowOutlineEmMax parameter. Pass an object instead with shadowOutlineEmMax as property. * @returns {Color} */ export default function getBackgroundColor( elm, bgElms = [], - shadowOutlineEmMax = 0.1 + shadowOutlineEmMax, + options ) { + if (['undefined', 'object'].includes(typeof shadowOutlineEmMax)) { + options = shadowOutlineEmMax ?? {}; + shadowOutlineEmMax = options.shadowOutlineEmMax ?? 0.1; + } + let bgColors = getTextShadowColors(elm, { minRatio: shadowOutlineEmMax }); if (bgColors.length) { bgColors = [{ color: bgColors.reduce(flattenShadowColors) }]; } - const elmStack = getBackgroundStack(elm); + const elmStack = getBackgroundStack(elm, options); // Search the stack until we have an alpha === 1 background (elmStack || []).some(bgElm => { diff --git a/lib/commons/color/get-background-stack.js b/lib/commons/color/get-background-stack.js index 10a54dccfe..72f0a76e2e 100644 --- a/lib/commons/color/get-background-stack.js +++ b/lib/commons/color/get-background-stack.js @@ -3,50 +3,37 @@ import elementHasImage from './element-has-image'; import getOwnBackgroundColor from './get-own-background-color'; import incompleteData from './incomplete-data'; import reduceToElementsBelowFloating from '../dom/reduce-to-elements-below-floating'; +import visibleTextNodes from '../text/visible-text-nodes'; +import findPseudoElement from '../dom/find-pseudo-element'; +import { getNodeFromTree } from '../../core/utils'; /** - * Determine if element B is an inline descendant of A - * @private - * @param {Element} node - * @param {Element} descendant - * @return {Boolean} + * 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 + * @param {Object} options + * @return {Array} */ -function isInlineDescendant(node, descendant) { - const CONTAINED_BY = Node.DOCUMENT_POSITION_CONTAINED_BY; - // eslint-disable-next-line no-bitwise - if (!(node.compareDocumentPosition(descendant) & CONTAINED_BY)) { - return false; - } - const style = window.getComputedStyle(descendant); - const display = style.getPropertyValue('display'); - if (!display.includes('inline')) { - return false; +export default function getBackgroundStack(elm, options) { + let elmStack = filteredRectStack(elm); + + if (elmStack === null) { + return null; } - // IE needs this; It doesn't set display:block when position is set - const position = style.getPropertyValue('position') - return position === 'static'; -} + elmStack = reduceToElementsBelowFloating(elmStack, elm); + elmStack = sortPageBackground(elmStack); -/** - * Determine if the element obscures / overlaps with the text - * @private - * @param {Number} elmIndex - * @param {Array} elmStack - * @param {Element} originalElm - * @return {Number|undefined} - */ -function calculateObscuringElement(elmIndex, elmStack, originalElm) { - // Reverse order, so that we can safely splice - for (let i = elmIndex - 1; i >= 0; i--) { - if (!isInlineDescendant(originalElm, elmStack[i])) { - return true; - } - // Ignore inline descendants, for example: - //
text
; We don't care about the element, - // since it does not overlap the text inside of- elmStack.splice(i, 1); + // Return all elements BELOW the current element, null if the element is undefined + const elmIndex = elmStack.indexOf(elm); + if (calculateObscuringElement(elmIndex, elmStack, elm, options)) { + // if the total of the elements above our element results in total obscuring, return null + incompleteData.set('bgColor', 'bgOverlap'); + return null; } - return false; + return elmIndex !== -1 ? elmStack : null; } /** @@ -95,31 +82,89 @@ function sortPageBackground(elmStack) { } /** - * 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 {Array} + * Determine if the element obscures / overlaps with the text + * @private + * @param {Number} elmIndex + * @param {Array} elmStack + * @param {Element} originalElm + * @param {Object} options + * @return {Number|undefined} */ -function getBackgroundStack(elm) { - let elmStack = filteredRectStack(elm); - - if (elmStack === null) { - return null; +function calculateObscuringElement(elmIndex, elmStack, originalElm, options) { + // Reverse order, so that we can safely splice + for (let i = elmIndex - 1; i >= 0; i--) { + if ( + !isInlineDescendant(originalElm, elmStack[i]) && + !isEmptyElement(elmStack[i], options) + ) { + return true; + } + // Ignore inline descendants, for example: + //
text
; We don't care about the element, + // since it does not overlap the text inside of+ elmStack.splice(i, 1); } - elmStack = reduceToElementsBelowFloating(elmStack, elm); - elmStack = sortPageBackground(elmStack); + return false; +} - // Return all elements BELOW the current element, null if the element is undefined - const elmIndex = elmStack.indexOf(elm); - if (calculateObscuringElement(elmIndex, elmStack, elm)) { - // if the total of the elements above our element results in total obscuring, return null - incompleteData.set('bgColor', 'bgOverlap'); - return null; +/** + * Determine if element B is an inline descendant of A + * @private + * @param {Element} node + * @param {Element} descendant + * @return {Boolean} + */ +function isInlineDescendant(node, descendant) { + const CONTAINED_BY = Node.DOCUMENT_POSITION_CONTAINED_BY; + // eslint-disable-next-line no-bitwise + if (!(node.compareDocumentPosition(descendant) & CONTAINED_BY)) { + return false; } - return elmIndex !== -1 ? elmStack : null; + const style = window.getComputedStyle(descendant); + const display = style.getPropertyValue('display'); + if (!display.includes('inline')) { + return false; + } + // IE needs this; It doesn't set display:block when position is set + const position = style.getPropertyValue('position'); + return position === 'static'; } -export default getBackgroundStack; +/** + * Determine if an element is empty and would not contribute to the background color + * @see https://github.com/dequelabs/axe-core/issues/3464 + * @private + * @param {Element} node + * @param {Object} options + */ +function isEmptyElement(node, { pseudoSizeThreshold, ignorePseudo }) { + const vNode = getNodeFromTree(node); + const style = window.getComputedStyle(node); + const bgColor = getOwnBackgroundColor(style); + const hasPseudoElement = !!findPseudoElement(vNode, { + pseudoSizeThreshold, + ignorePseudo, + recurse: false + }); + // IE11 does not support border, border-width, or + // outline computed shorthand properties + const hasBorder = [ + 'border-bottom-width', + 'border-top-width', + 'border-left-width', + 'border-right-width', + 'outline-width' + ].some(prop => parseInt(vNode.getComputedStylePropertyValue(prop), 10) !== 0); + + if ( + visibleTextNodes(vNode).length === 0 && + bgColor.alpha === 0 && + !elementHasImage(node, style) && + !hasPseudoElement && + !hasBorder + ) { + return true; + } + + return false; +} diff --git a/lib/commons/dom/find-pseudo-element.js b/lib/commons/dom/find-pseudo-element.js new file mode 100644 index 0000000000..5c4689d9c5 --- /dev/null +++ b/lib/commons/dom/find-pseudo-element.js @@ -0,0 +1,71 @@ +import memoize from '../../core/utils/memoize'; +import getOwnBackgroundColor from '../color/get-own-background-color'; + +/** + * Find the pseudo element of node that meets the minimum size threshold. + * @method findPseudoElement + * @memberof axe.commons.dom + * @instance + * @param {VirtualNode} vNode The VirtualNode to find pseudo elements for + * @param {Object} options + * @returns {VirtualNode|undefined} The VirtualNode which has matching pseudo elements. + */ +export default function findPseudoElement( + vNode, + { pseudoSizeThreshold = 0.25, ignorePseudo = false, recurse = true } = {} +) { + if (ignorePseudo) { + return; + } + const rect = vNode.boundingClientRect; + const minimumSize = rect.width * rect.height * pseudoSizeThreshold; + do { + const beforeSize = getPseudoElementArea(vNode.actualNode, ':before'); + const afterSize = getPseudoElementArea(vNode.actualNode, ':after'); + if (beforeSize + afterSize > minimumSize) { + return vNode; // Combined area of before and after exceeds the minimum size + } + } while (recurse && (vNode = vNode.parent)); +} + +const getPseudoElementArea = memoize(function getPseudoElementArea( + node, + pseudo +) { + const style = window.getComputedStyle(node, pseudo); + const matchPseudoStyle = (prop, value) => + style.getPropertyValue(prop) === value; + if ( + matchPseudoStyle('content', 'none') || + matchPseudoStyle('display', 'none') || + matchPseudoStyle('visibility', 'hidden') || + matchPseudoStyle('position', 'absolute') === false + ) { + return 0; // The pseudo element isn't visible + } + + if ( + getOwnBackgroundColor(style).alpha === 0 && + matchPseudoStyle('background-image', 'none') + ) { + return 0; // There is no background + } + + // Find the size of the pseudo element; + const pseudoWidth = parseUnit(style.getPropertyValue('width')); + const pseudoHeight = parseUnit(style.getPropertyValue('height')); + if (pseudoWidth.unit !== 'px' || pseudoHeight.unit !== 'px') { + // IE doesn't normalize to px. Infinity gets everything to undefined + return pseudoWidth.value === 0 || pseudoHeight.value === 0 ? 0 : Infinity; + } + return pseudoWidth.value * pseudoHeight.value; +}); + +function parseUnit(str) { + const unitRegex = /^([0-9.]+)([a-z]+)$/i; + const [, value = '', unit = ''] = str.match(unitRegex) || []; + return { + value: parseFloat(value), + unit: unit.toLowerCase() + }; +} diff --git a/lib/commons/dom/index.js b/lib/commons/dom/index.js index 6e60cec766..9572f03ed2 100644 --- a/lib/commons/dom/index.js +++ b/lib/commons/dom/index.js @@ -4,6 +4,7 @@ * @memberof axe.commons */ export { default as findElmsInContext } from './find-elms-in-context'; +export { default as findPseudoElement } from './find-pseudo-element'; export { default as findUpVirtual } from './find-up-virtual'; export { default as findUp } from './find-up'; export { default as getComposedParent } from './get-composed-parent'; diff --git a/test/commons/color/get-background-color.js b/test/commons/color/get-background-color.js index 9fc20d7a3e..672e531441 100644 --- a/test/commons/color/get-background-color.js +++ b/test/commons/color/get-background-color.js @@ -1,4 +1,4 @@ -describe('color.getBackgroundColor', function() { +describe('color.getBackgroundColor', function () { 'use strict'; var fixture = document.getElementById('fixture'); @@ -8,12 +8,12 @@ describe('color.getBackgroundColor', function() { var origBodyBg; var origHtmlBg; - before(function() { + before(function () { origBodyBg = document.body.style.background; origHtmlBg = document.documentElement.style.background; }); - afterEach(function() { + afterEach(function () { document.body.style.background = origBodyBg; document.documentElement.style.background = origHtmlBg; @@ -21,7 +21,7 @@ describe('color.getBackgroundColor', function() { axe._tree = undefined; }); - it('should return the blended color if it has no background set', function() { + it('should return the blended color if it has no background set', function () { fixture.innerHTML = '