From c051fe851fb5eaa75e6dc0205c4db5e75d80f3a4 Mon Sep 17 00:00:00 2001 From: Steven Lambert <2433219+straker@users.noreply.github.com> Date: Wed, 22 Mar 2023 17:20:31 -0600 Subject: [PATCH] fix(color-contrast): correctly compute background color for elements with opacity (#3944) * fix(create-grid): correctly compute stack order for non-positioned stacking contexts * chore: Refactor createStackingOrder (#3932) * chore: Refactor createStackingOrder * Remove magic numbers * use treewalker nodeIndex * no floatpoint precision errors * pass nodeIndex to createGrid for shadowDom * do it properly * remove whitespace * fix(color-contrast): correctly handle opacity on parent elements * working * idea finalized * remove comments * move function * tests! * fix comments * fix stuff --------- Co-authored-by: Wilco Fiers --- lib/commons/color/color.js | 180 +++++++------- lib/commons/color/flatten-colors.js | 36 +-- lib/commons/color/get-background-color.js | 38 +-- lib/commons/color/index.js | 1 + lib/commons/color/stacking-context.js | 227 +++++++++++++++++ lib/commons/dom/create-grid.js | 21 +- lib/commons/dom/visually-sort.js | 4 +- test/commons/color/get-background-color.js | 53 ++++ test/commons/color/stacking-context.js | 268 +++++++++++++++++++++ 9 files changed, 695 insertions(+), 133 deletions(-) create mode 100644 lib/commons/color/stacking-context.js create mode 100644 test/commons/color/stacking-context.js diff --git a/lib/commons/color/color.js b/lib/commons/color/color.js index d4cdae2285..3077931839 100644 --- a/lib/commons/color/color.js +++ b/lib/commons/color/color.js @@ -1,68 +1,7 @@ import standards from '../../standards'; -/** - * Convert a CSS color value into a number - */ -function convertColorVal(colorFunc, value, index) { - if (/%$/.test(value)) { - // - if (index === 3) { - // alpha - return parseFloat(value) / 100; - } - return (parseFloat(value) * 255) / 100; - } - if (colorFunc[index] === 'h') { - // hue - if (/turn$/.test(value)) { - return parseFloat(value) * 360; - } - if (/rad$/.test(value)) { - return parseFloat(value) * 57.3; - } - } - return parseFloat(value); -} - -/** - * Convert HSL to RGB - */ -function hslToRgb([hue, saturation, lightness, alpha]) { - // Must be fractions of 1 - saturation /= 255; - lightness /= 255; - - const high = (1 - Math.abs(2 * lightness - 1)) * saturation; - const low = high * (1 - Math.abs(((hue / 60) % 2) - 1)); - const base = lightness - high / 2; - - let colors; - if (hue < 60) { - // red - yellow - colors = [high, low, 0]; - } else if (hue < 120) { - // yellow - green - colors = [low, high, 0]; - } else if (hue < 180) { - // green - cyan - colors = [0, high, low]; - } else if (hue < 240) { - // cyan - blue - colors = [0, low, high]; - } else if (hue < 300) { - // blue - purple - colors = [low, 0, high]; - } else { - // purple - red - colors = [high, 0, low]; - } - - return colors - .map(color => { - return Math.round((color + base) * 255); - }) - .concat(alpha); -} +const hexRegex = /^#[0-9a-f]{3,8}$/i; +const colorFnRegex = /^((?:rgb|hsl)a?)\s*\(([^\)]*)\)/i; /** * @class Color @@ -72,18 +11,20 @@ function hslToRgb([hue, saturation, lightness, alpha]) { * @param {number} blue * @param {number} alpha */ -function Color(red, green, blue, alpha = 1) { - /** @type {number} */ - this.red = red; +export default class Color { + constructor(red, green, blue, alpha = 1) { + /** @type {number} */ + this.red = red; - /** @type {number} */ - this.green = green; + /** @type {number} */ + this.green = green; - /** @type {number} */ - this.blue = blue; + /** @type {number} */ + this.blue = blue; - /** @type {number} */ - this.alpha = alpha; + /** @type {number} */ + this.alpha = alpha; + } /** * Provide the hex string value for the color @@ -92,7 +33,7 @@ function Color(red, green, blue, alpha = 1) { * @instance * @return {string} */ - this.toHexString = function toHexString() { + toHexString() { var redString = Math.round(this.red).toString(16); var greenString = Math.round(this.green).toString(16); var blueString = Math.round(this.blue).toString(16); @@ -102,15 +43,12 @@ function Color(red, green, blue, alpha = 1) { (this.green > 15.5 ? greenString : '0' + greenString) + (this.blue > 15.5 ? blueString : '0' + blueString) ); - }; + } - this.toJSON = function toJSON() { + toJSON() { const { red, green, blue, alpha } = this; return { red, green, blue, alpha }; - }; - - const hexRegex = /^#[0-9a-f]{3,8}$/i; - const colorFnRegex = /^((?:rgb|hsl)a?)\s*\(([^\)]*)\)/i; + } /** * Parse any valid color string and assign its values to "this" @@ -118,7 +56,7 @@ function Color(red, green, blue, alpha = 1) { * @memberof axe.commons.color.Color * @instance */ - this.parseString = function parseString(colorString) { + parseString(colorString) { // IE occasionally returns named colors instead of RGB(A) values if (standards.cssColors[colorString] || colorString === 'transparent') { const [red, green, blue] = standards.cssColors[colorString] || [0, 0, 0]; @@ -139,7 +77,7 @@ function Color(red, green, blue, alpha = 1) { return this; } throw new Error(`Unable to parse color "${colorString}"`); - }; + } /** * Set the color value based on a CSS RGB/RGBA string @@ -149,7 +87,7 @@ function Color(red, green, blue, alpha = 1) { * @instance * @param {string} rgb The string value */ - this.parseRgbString = function parseRgbString(colorString) { + parseRgbString(colorString) { // IE can pass transparent as value instead of rgba if (colorString === 'transparent') { this.red = 0; @@ -159,7 +97,7 @@ function Color(red, green, blue, alpha = 1) { return; } this.parseColorFnString(colorString); - }; + } /** * Set the color value based on a CSS RGB/RGBA string @@ -169,7 +107,7 @@ function Color(red, green, blue, alpha = 1) { * @instance * @param {string} rgb The string value */ - this.parseHexString = function parseHexString(colorString) { + parseHexString(colorString) { if (!colorString.match(hexRegex) || [6, 8].includes(colorString.length)) { return; } @@ -191,7 +129,7 @@ function Color(red, green, blue, alpha = 1) { } else { this.alpha = 1; } - }; + } /** * Set the color value based on a CSS RGB/RGBA string @@ -201,7 +139,7 @@ function Color(red, green, blue, alpha = 1) { * @instance * @param {string} rgb The string value */ - this.parseColorFnString = function parseColorFnString(colorString) { + parseColorFnString(colorString) { const [, colorFunc, colorValStr] = colorString.match(colorFnRegex) || []; if (!colorFunc || !colorValStr) { return; @@ -226,7 +164,7 @@ function Color(red, green, blue, alpha = 1) { this.green = colorNums[1]; this.blue = colorNums[2]; this.alpha = typeof colorNums[3] === 'number' ? colorNums[3] : 1; - }; + } /** * Get the relative luminance value @@ -236,7 +174,7 @@ function Color(red, green, blue, alpha = 1) { * @instance * @return {number} The luminance value, ranges from 0 to 1 */ - this.getRelativeLuminance = function getRelativeLuminance() { + getRelativeLuminance() { var rSRGB = this.red / 255; var gSRGB = this.green / 255; var bSRGB = this.blue / 255; @@ -249,7 +187,69 @@ function Color(red, green, blue, alpha = 1) { bSRGB <= 0.03928 ? bSRGB / 12.92 : Math.pow((bSRGB + 0.055) / 1.055, 2.4); return 0.2126 * r + 0.7152 * g + 0.0722 * b; - }; + } +} + +/** + * Convert a CSS color value into a number + */ +function convertColorVal(colorFunc, value, index) { + if (/%$/.test(value)) { + // + if (index === 3) { + // alpha + return parseFloat(value) / 100; + } + return (parseFloat(value) * 255) / 100; + } + if (colorFunc[index] === 'h') { + // hue + if (/turn$/.test(value)) { + return parseFloat(value) * 360; + } + if (/rad$/.test(value)) { + return parseFloat(value) * 57.3; + } + } + return parseFloat(value); } -export default Color; +/** + * Convert HSL to RGB + */ +function hslToRgb([hue, saturation, lightness, alpha]) { + // Must be fractions of 1 + saturation /= 255; + lightness /= 255; + + const high = (1 - Math.abs(2 * lightness - 1)) * saturation; + const low = high * (1 - Math.abs(((hue / 60) % 2) - 1)); + const base = lightness - high / 2; + + let colors; + if (hue < 60) { + // red - yellow + colors = [high, low, 0]; + } else if (hue < 120) { + // yellow - green + colors = [low, high, 0]; + } else if (hue < 180) { + // green - cyan + colors = [0, high, low]; + } else if (hue < 240) { + // cyan - blue + colors = [0, low, high]; + } else if (hue < 300) { + // blue - purple + colors = [low, 0, high]; + } else { + // purple - red + colors = [high, 0, low]; + } + + return colors + .map(color => { + return Math.round((color + base) * 255); + }) + .concat(alpha); +} diff --git a/lib/commons/color/flatten-colors.js b/lib/commons/color/flatten-colors.js index 2a780c57c3..9ac19f0d11 100644 --- a/lib/commons/color/flatten-colors.js +++ b/lib/commons/color/flatten-colors.js @@ -88,37 +88,41 @@ function simpleAlphaCompositing(Cs, αs, Cb, αb, blendMode) { * @method flattenColors * @memberof axe.commons.color.Color * @instance - * @param {Color} fgColor Foreground color - * @param {Color} bgColor Background color + * @param {Color} sourceColor Foreground color + * @param {Color} backdrop Background color * @return {Color} Blended color */ -function flattenColors(fgColor, bgColor, blendMode = 'normal') { +function flattenColors(sourceColor, backdrop, blendMode = 'normal') { // foreground is the "source" color and background is the "backdrop" color const r = simpleAlphaCompositing( - fgColor.red, - fgColor.alpha, - bgColor.red, - bgColor.alpha, + sourceColor.red, + sourceColor.alpha, + backdrop.red, + backdrop.alpha, blendMode ); const g = simpleAlphaCompositing( - fgColor.green, - fgColor.alpha, - bgColor.green, - bgColor.alpha, + sourceColor.green, + sourceColor.alpha, + backdrop.green, + backdrop.alpha, blendMode ); const b = simpleAlphaCompositing( - fgColor.blue, - fgColor.alpha, - bgColor.blue, - bgColor.alpha, + sourceColor.blue, + sourceColor.alpha, + backdrop.blue, + backdrop.alpha, blendMode ); // formula: αo = αs + αb x (1 - αs) // clamp alpha between 0 and 1 - const αo = clamp(fgColor.alpha + bgColor.alpha * (1 - fgColor.alpha), 0, 1); + const αo = clamp( + sourceColor.alpha + backdrop.alpha * (1 - sourceColor.alpha), + 0, + 1 + ); if (αo === 0) { return new Color(r, g, b, αo); } diff --git a/lib/commons/color/get-background-color.js b/lib/commons/color/get-background-color.js index f564554517..262f90fe1b 100644 --- a/lib/commons/color/get-background-color.js +++ b/lib/commons/color/get-background-color.js @@ -8,6 +8,7 @@ import flattenShadowColors from './flatten-shadow-colors'; import getTextShadowColors from './get-text-shadow-colors'; import getVisibleChildTextRects from '../dom/get-visible-child-text-rects'; import { getNodeFromTree } from '../../core/utils'; +import { getStackingContext, stackingContextToColor } from './stacking-context'; /** * Returns background color for element @@ -47,29 +48,32 @@ export default function getBackgroundColor( } function _getBackgroundColor(elm, bgElms, shadowOutlineEmMax) { + const elmStack = getBackgroundStack(elm); + if (!elmStack) { + return null; + } + + const textRects = getVisibleChildTextRects(elm); let bgColors = getTextShadowColors(elm, { minRatio: shadowOutlineEmMax }); if (bgColors.length) { bgColors = [{ color: bgColors.reduce(flattenShadowColors) }]; } - const elmStack = getBackgroundStack(elm); - const textRects = getVisibleChildTextRects(elm); - // Search the stack until we have an alpha === 1 background - (elmStack || []).some(bgElm => { + for (let i = 0; i < elmStack.length; i++) { + const bgElm = elmStack[i]; const bgElmStyle = window.getComputedStyle(bgElm); if (elementHasImage(bgElm, bgElmStyle)) { - bgColors = null; bgElms.push(bgElm); - return true; + return null; } // Get the background color const bgColor = getOwnBackgroundColor(bgElmStyle); if (bgColor.alpha === 0) { - return false; + continue; } // abort if a node is partially obscured and obscuring element has a background @@ -77,29 +81,24 @@ function _getBackgroundColor(elm, bgElms, shadowOutlineEmMax) { bgElmStyle.getPropertyValue('display') !== 'inline' && !fullyEncompasses(bgElm, textRects) ) { - bgColors = null; bgElms.push(bgElm); incompleteData.set('bgColor', 'elmPartiallyObscured'); - return true; + return null; } // store elements contributing to the bg color. bgElms.push(bgElm); - const blendMode = bgElmStyle.getPropertyValue('mix-blend-mode'); - bgColors.unshift({ - color: bgColor, - blendMode: normalizeBlendMode(blendMode) - }); // Exit if the background is opaque - return bgColor.alpha === 1; - }); - - if (bgColors === null || elmStack === null) { - return null; + if (bgColor.alpha === 1) { + break; + } } + const stackingContext = getStackingContext(elm, elmStack); + bgColors = stackingContext.map(stackingContextToColor).concat(bgColors); + const pageBgs = getPageBackgroundColors( elm, elmStack.includes(document.body) @@ -166,6 +165,7 @@ function fullyEncompasses(node, rects) { function normalizeBlendMode(blendmode) { return !!blendmode ? blendmode : undefined; } + /** * Get the page background color. * @private diff --git a/lib/commons/color/index.js b/lib/commons/color/index.js index 156cb3eded..78cc26c68b 100644 --- a/lib/commons/color/index.js +++ b/lib/commons/color/index.js @@ -19,3 +19,4 @@ export { default as getRectStack } from './get-rect-stack'; export { default as hasValidContrastRatio } from './has-valid-contrast-ratio'; export { default as incompleteData } from './incomplete-data'; export { default as getTextShadowColors } from './get-text-shadow-colors'; +export { getStackingContext, stackingContextToColor } from './stacking-context'; diff --git a/lib/commons/color/stacking-context.js b/lib/commons/color/stacking-context.js new file mode 100644 index 0000000000..805c2fb2f2 --- /dev/null +++ b/lib/commons/color/stacking-context.js @@ -0,0 +1,227 @@ +import { getNodeFromTree } from '../../core/utils'; +import getBackgroundStack from './get-background-stack'; +import Color from './color'; +import flattenColors from './flatten-colors'; + +/** + * Create a stacking context hierarchy tree for an element. This structure closely mimics the painting order of a page. + * @see https://www.w3.org/TR/CSS22/zindex.html#painting-order + * + * @example + * Given the following HTML structure: + * + *
+ *
Text
+ *
+ * + * Produces the following stacking context tree. Since the #parent element creates a stacking context due to `opacity`, the #target element's stacking context belongs under the #parent's context. + * + * [ + * { + * vNode: #parent, + * opacity: 0.8, + * blendMode: 'normal', + * bgColor: Color(255,0,0,1), + * descendants: [ + * { + * vNode: #target, + * opacity: 1, + * blendMode: 'normal', + * bgColor: Color(0,255,0,0.5), + * descendants: [] + * } + * ] + * } + * ] + * + * The stacking context hierarchy tree does not mimic the HTML structure. That is, elements that are on the same context level are siblings in the stacking context tree even if they have a parent/child HTML relationship. + * + * For example, given the following HTML structure: + * + *
+ *
+ *

Hello World

+ *
+ *
+ *

Lorium ipsum dolores...

+ * + * Produces the following tree structure: + * + * body + * - main + * - header + * - h1 + * - span + * - p + * - a + * + * @param {Node} elm + * @param {Node[]} [elmStack] - Optional element stack array to save on computing it again. + * @return {Object} + */ +export function getStackingContext(elm, elmStack) { + const vNode = getNodeFromTree(elm); + if (vNode._stackingContext) { + return vNode._stackingContext; + } + + const stackingContext = []; + const contextMap = new Map(); + elmStack = elmStack ?? getBackgroundStack(elm); + + elmStack.forEach(bgElm => { + const bgVNode = getNodeFromTree(bgElm); + const bgColor = getOwnBackgroundColor(bgVNode); + + /* + remove the ROOT_ORDER element to treat all root stacks and first-order + stacks at the same level (instead of nesting the first-order stack inside + the root stack) + + e.g. an element that creates a non-positioned stacking context at the + root level should be a sibling to root level elements that do not create + a stacking context + */ + const stackingOrder = bgVNode._stackingOrder.filter(({ vNode }) => !!vNode); + + // create a stacking context for each node in the stacking order + stackingOrder.forEach(({ vNode }, index) => { + const ancestorVNode = stackingOrder[index - 1]?.vNode; + const context = addToStackingContext(contextMap, vNode, ancestorVNode); + + if (index === 0 && !contextMap.get(vNode)) { + stackingContext.unshift(context); + } + contextMap.set(vNode, context); + }); + + // create a stacking context for the current node + const ancestorVNode = stackingOrder[stackingOrder.length - 1]?.vNode; + const context = addToStackingContext(contextMap, bgVNode, ancestorVNode); + if (!stackingOrder.length) { + stackingContext.unshift(context); + } + + // only assign the color to the current node so we don't apply any + // background colors from ancestor nodes that are not part of the element + // stack + context.bgColor = bgColor; + }); + + vNode._stackingContext = stackingContext; + return stackingContext; +} + +/** + * Transform a stacking context object into a Color. + * @param {Object} context + * @return {Object} + */ +export function stackingContextToColor(context) { + if (!context.descendants?.length) { + const color = context.bgColor; + color.alpha *= context.opacity; + + return { + color, + blendMode: context.blendMode + }; + } + + const sourceColor = context.descendants.reduce( + reduceToColor, + // ensure an array with a single context is reduced to a color by passing + // in an empty stacking context + createStackingContext() + ); + const color = flattenColors( + sourceColor, + context.bgColor, + context.descendants[0].blendMode + ); + color.alpha *= context.opacity; + + // carry forward the mix-blind-mode property so background color algorithm + // can use it to flatten multiple contexts together + return { + color, + blendMode: context.blendMode + }; +} + +/** + * Reduce two context objects into a Color by blending them together + * @param {Object} backdropContext + * @param {Object} sourceContext + * @return {Color} + */ +function reduceToColor(backdropContext, sourceContext) { + let backdrop; + if (backdropContext instanceof Color) { + backdrop = backdropContext; + } else { + backdrop = stackingContextToColor(backdropContext).color; + } + + const sourceColor = stackingContextToColor(sourceContext).color; + return flattenColors(sourceColor, backdrop, sourceContext.blendMode); +} + +/** + * Create a stacking context object for a virtual node. + * @param {VirtualNode} vNod + * @return {Object} + */ +function createStackingContext(vNode) { + return { + vNode: vNode, + opacity: parseFloat(vNode?.getComputedStylePropertyValue('opacity') ?? 1), + bgColor: new Color(0, 0, 0, 0), + blendMode: normalizeBlendMode( + vNode?.getComputedStylePropertyValue('mix-blend-mode') + ), + descendants: [] + }; +} + +/** + * Normalize a mix-blend-mode CSS value + * @param {String} blendmode + * @return {String|undefined} + */ +function normalizeBlendMode(blendmode) { + return !!blendmode ? blendmode : undefined; +} + +/** + * Create a stacking context for a virtual node and add it as a descendant of an ancestor's context. + * @param {Map} contextMap + * @param {VirtualNode} vNode + * @param {VirtualNode} ancestorVNode + * @return {Object} + */ +function addToStackingContext(contextMap, vNode, ancestorVNode) { + const context = contextMap.get(vNode) ?? createStackingContext(vNode); + const ancestorContext = contextMap.get(ancestorVNode); + if ( + ancestorContext && + ancestorVNode !== vNode && + !ancestorContext.descendants.includes(context) + ) { + ancestorContext.descendants.unshift(context); + } + + return context; +} + +/** + * Get the background color for a virtual node + * @param {VirtualNode} vNode + * @return {Color} + */ +function getOwnBackgroundColor(vNode) { + const bgColor = new Color(); + bgColor.parseString(vNode.getComputedStylePropertyValue('background-color')); + + return bgColor; +} diff --git a/lib/commons/dom/create-grid.js b/lib/commons/dom/create-grid.js index d2fed47748..465acdc0f2 100644 --- a/lib/commons/dom/create-grid.js +++ b/lib/commons/dom/create-grid.js @@ -39,7 +39,7 @@ export default function createGrid( } nodeIndex = 0; - vNode._stackingOrder = [ROOT_ORDER]; + vNode._stackingOrder = [createContext(ROOT_ORDER, null)]; rootGrid ??= new Grid(); addNodeToGrid(rootGrid, vNode); @@ -267,11 +267,11 @@ function createStackingOrder(vNode, parentVNode, nodeIndex) { if (!isStackingContext(vNode, parentVNode)) { if (vNode.getComputedStylePropertyValue('position') !== 'static') { // Put positioned elements above floated elements - stackingOrder.push(POSITION_STATIC_ORDER); + stackingOrder.push(createContext(POSITION_STATIC_ORDER, vNode)); } else if (vNode.getComputedStylePropertyValue('float') !== 'none') { // Put floated elements above z-index: 0 // (step #5 floating get sorted below step #8 positioned) - stackingOrder.push(FLOAT_ORDER); + stackingOrder.push(createContext(FLOAT_ORDER, vNode)); } return stackingOrder; } @@ -281,7 +281,7 @@ function createStackingOrder(vNode, parentVNode, nodeIndex) { // floated elements without a z-index) and create a new stack at // that point (step #5 and step #8) // @see https://www.w3.org/Style/css2-updates/css2/zindex.html - const index = stackingOrder.findIndex(value => + const index = stackingOrder.findIndex(({ value }) => [ROOT_ORDER, FLOAT_ORDER, POSITION_STATIC_ORDER].includes(value) ); if (index !== -1) { @@ -290,7 +290,7 @@ function createStackingOrder(vNode, parentVNode, nodeIndex) { const zIndex = getRealZIndex(vNode, parentVNode); if (!['auto', '0'].includes(zIndex)) { - stackingOrder.push(parseInt(zIndex)); + stackingOrder.push(createContext(parseInt(zIndex), vNode)); return stackingOrder; } // since many things can create a new stacking context without position or @@ -303,11 +303,20 @@ function createStackingOrder(vNode, parentVNode, nodeIndex) { while (float.length < 10) { float = '0' + float; } - stackingOrder.push(parseFloat(`${DEFAULT_ORDER}${float}`)); + stackingOrder.push( + createContext(parseFloat(`${DEFAULT_ORDER}${float}`), vNode) + ); return stackingOrder; } +function createContext(value, vNode) { + return { + value, + vNode + }; +} + function getRealZIndex(vNode, parentVNode) { const position = vNode.getComputedStylePropertyValue('position'); if (position === 'static' && !isFlexOrGridContainer(parentVNode)) { diff --git a/lib/commons/dom/visually-sort.js b/lib/commons/dom/visually-sort.js index 9dd22bc015..1ceaf8245e 100644 --- a/lib/commons/dom/visually-sort.js +++ b/lib/commons/dom/visually-sort.js @@ -19,12 +19,12 @@ export default function visuallySort(a, b) { } // 7. the child stacking contexts with positive stack levels (least positive first). - if (b._stackingOrder[i] > a._stackingOrder[i]) { + if (b._stackingOrder[i].value > a._stackingOrder[i].value) { return 1; } // 2. the child stacking contexts with negative stack levels (most negative first). - if (b._stackingOrder[i] < a._stackingOrder[i]) { + if (b._stackingOrder[i].value < a._stackingOrder[i].value) { return -1; } } diff --git a/test/commons/color/get-background-color.js b/test/commons/color/get-background-color.js index 7bb84a894b..fb76e89328 100644 --- a/test/commons/color/get-background-color.js +++ b/test/commons/color/get-background-color.js @@ -145,6 +145,40 @@ describe('color.getBackgroundColor', function () { assert.deepEqual(bgNodes, [target, parent]); }); + it('should apply opacity after blending', function () { + fixture.innerHTML = ` +
+
+
`; + var target = fixture.querySelector('#target'); + var bgNodes = []; + axe.testUtils.flatTreeSetup(fixture); + var actual = axe.commons.color.getBackgroundColor(target, bgNodes); + var expected = new axe.commons.color.Color(102, 153, 51, 1); + assert.equal(actual.red, expected.red); + assert.equal(actual.green, expected.green); + assert.equal(actual.blue, expected.blue); + assert.equal(actual.alpha, expected.alpha); + }); + + it('should apply opacity from an ancestor not in the element stack', function () { + fixture.innerHTML = ` +
+
+
+
+
`; + var target = fixture.querySelector('#target'); + var bgNodes = []; + axe.testUtils.flatTreeSetup(fixture); + var actual = axe.commons.color.getBackgroundColor(target, bgNodes); + var expected = new axe.commons.color.Color(102, 153, 51, 1); + assert.equal(actual.red, expected.red); + assert.equal(actual.green, expected.green); + assert.equal(actual.blue, expected.blue); + assert.equal(actual.alpha, expected.alpha); + }); + it('should return null if containing parent has a background image and is non-opaque', function () { fixture.innerHTML = '
+
+
exclusion1
+
+
+ `; + + axe.testUtils.flatTreeSetup(fixture); + var target = fixture.querySelector('#target'); + var actual = axe.commons.color.getBackgroundColor(target, []); + + assert.closeTo(actual.red, 128, 0); + assert.closeTo(actual.green, 223, 0); + assert.closeTo(actual.blue, 191, 0); + assert.closeTo(actual.alpha, 1, 0); + }); + (shadowSupported ? it : xit)( 'finds colors in shadow boundaries', function () { diff --git a/test/commons/color/stacking-context.js b/test/commons/color/stacking-context.js new file mode 100644 index 0000000000..8134873046 --- /dev/null +++ b/test/commons/color/stacking-context.js @@ -0,0 +1,268 @@ +describe('color.stackingContext', () => { + const { Color, getStackingContext, stackingContextToColor } = + axe.commons.color; + const { getElementStack } = axe.commons.dom; + const { querySelectorAll } = axe.utils; + const { queryFixture } = axe.testUtils; + const fixture = document.querySelector('#fixture'); + + beforeEach(() => { + // remove html, body, and fixture from the + // element stack to make testing easier + document.documentElement.style.height = 0; + document.body.style.height = 0; + fixture.style.height = 0; + }); + + afterEach(() => { + document.documentElement.removeAttribute('style'); + document.body.removeAttribute('style'); + fixture.removeAttribute('style'); + }); + + describe('color.getStackingContexts', () => { + it('creates a context for a single element', () => { + const vNode = queryFixture('
Hello World
'); + // html is always added as the last element + // so we'll remove it to make testing easier + const elmStack = getElementStack(vNode.actualNode).slice(0, -1); + const stackingContext = getStackingContext(vNode.actualNode, elmStack); + + assert.deepEqual(stackingContext, [ + { + vNode, + opacity: 1, + bgColor: new Color(0, 0, 0, 0), + blendMode: 'normal', + descendants: [] + } + ]); + }); + + it('creates a context for every node in the element stack', () => { + const vNode = queryFixture(` +
+
+
Hello World
+
+
+ `); + const elmStack = getElementStack(vNode.actualNode).slice(0, -1); + const stackingContext = getStackingContext(vNode.actualNode, elmStack); + + assert.deepEqual(stackingContext, [ + { + vNode: querySelectorAll(axe._tree[0], '#elm1')[0], + opacity: 1, + bgColor: new Color(0, 0, 0, 0), + blendMode: 'normal', + descendants: [] + }, + { + vNode: querySelectorAll(axe._tree[0], '#elm2')[0], + opacity: 1, + bgColor: new Color(0, 0, 0, 0), + blendMode: 'normal', + descendants: [] + }, + { + vNode, + opacity: 1, + bgColor: new Color(0, 0, 0, 0), + blendMode: 'normal', + descendants: [] + } + ]); + }); + + it('nests contexts', () => { + const vNode = queryFixture(` +
+
+
Hello World
+
+
+ `); + const elmStack = getElementStack(vNode.actualNode).slice(0, -1); + const stackingContext = getStackingContext(vNode.actualNode, elmStack); + + assert.deepEqual(stackingContext, [ + { + vNode: querySelectorAll(axe._tree[0], '#elm1')[0], + opacity: 1, + bgColor: new Color(0, 0, 0, 0), + blendMode: 'normal', + descendants: [ + { + vNode: querySelectorAll(axe._tree[0], '#elm2')[0], + opacity: 1, + bgColor: new Color(0, 0, 0, 0), + blendMode: 'normal', + descendants: [] + }, + { + vNode, + opacity: 1, + bgColor: new Color(0, 0, 0, 0), + blendMode: 'normal', + descendants: [] + } + ] + } + ]); + }); + + it('sets context properties', () => { + const vNode = queryFixture( + '
Hello World
' + ); + const elmStack = getElementStack(vNode.actualNode).slice(0, -1); + const stackingContext = getStackingContext(vNode.actualNode, elmStack); + + assert.deepEqual(stackingContext, [ + { + vNode, + opacity: 0.8, + bgColor: new Color(255, 0, 0, 0.5), + blendMode: 'difference', + descendants: [] + } + ]); + }); + + it('creates a context for ancestors that create a stacking context but are not in the element stack', () => { + const vNode = queryFixture(` +
+
Hello World
+
+ `); + const elmStack = getElementStack(vNode.actualNode).slice(0, -1); + const stackingContext = getStackingContext(vNode.actualNode, elmStack); + + assert.lengthOf(elmStack, 1); + assert.deepEqual(stackingContext, [ + { + vNode: querySelectorAll(axe._tree[0], '#elm1')[0], + opacity: 0.8, + bgColor: new Color(0, 0, 0, 0), + blendMode: 'normal', + descendants: [ + { + vNode, + opacity: 1, + bgColor: new Color(0, 0, 0, 0), + blendMode: 'normal', + descendants: [] + } + ] + } + ]); + }); + }); + + describe('color.stackingContextToColor', () => { + it('reduces a context to a color', () => { + const context = { + opacity: 1, + bgColor: new Color(255, 128, 0, 1), + blendMode: 'normal', + descendants: [] + }; + + assert.deepEqual(stackingContextToColor(context), { + color: new Color(255, 128, 0, 1), + blendMode: 'normal' + }); + }); + + it('reduces a nested context to a color', () => { + const context = { + opacity: 1, + bgColor: new Color(255, 128, 0, 1), + blendMode: 'normal', + descendants: [ + { + opacity: 1, + bgColor: new Color(0, 255, 128, 0.5), + blendMode: 'normal', + descendants: [] + } + ] + }; + + assert.deepEqual(stackingContextToColor(context), { + color: new Color(128, 192, 64, 1), + blendMode: 'normal' + }); + }); + + it('applies opacity after blending', () => { + const context = { + opacity: 0.8, + bgColor: new Color(255, 128, 0, 1), + blendMode: 'normal', + descendants: [ + { + opacity: 1, + bgColor: new Color(0, 255, 128, 0.5), + blendMode: 'normal', + descendants: [] + } + ] + }; + + assert.deepEqual(stackingContextToColor(context), { + color: new Color(128, 192, 64, 0.8), + blendMode: 'normal' + }); + }); + + it('applies mix-blend-mode from a sibling context', () => { + const context = { + opacity: 1, + bgColor: new Color(0, 0, 0, 0), + blendMode: 'normal', + descendants: [ + { + opacity: 1, + bgColor: new Color(255, 128, 0, 1), + blendMode: 'normal', + descendants: [] + }, + { + opacity: 1, + bgColor: new Color(0, 255, 128, 0.5), + blendMode: 'difference', + descendants: [] + } + ] + }; + + assert.deepEqual(stackingContextToColor(context), { + color: new Color(255, 128, 64, 1), + blendMode: 'normal' + }); + }); + + it('applies mix-blend-mode from a nested context', () => { + const context = { + opacity: 1, + bgColor: new Color(255, 128, 0, 1), + blendMode: 'normal', + descendants: [ + { + opacity: 1, + bgColor: new Color(0, 255, 128, 0.5), + blendMode: 'difference', + descendants: [] + } + ] + }; + + assert.deepEqual(stackingContextToColor(context), { + color: new Color(255, 128, 64, 1), + blendMode: 'normal' + }); + }); + }); +});