Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(color-contrast): correctly compute background color for elements with opacity #3944

Merged
merged 16 commits into from
Mar 22, 2023
180 changes: 90 additions & 90 deletions lib/commons/color/color.js
Original file line number Diff line number Diff line change
@@ -1,68 +1,7 @@
import standards from '../../standards';

/**
* Convert a CSS color value into a number
*/
function convertColorVal(colorFunc, value, index) {
if (/%$/.test(value)) {
//<percentage>
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;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took this opportunity to do something I've been wanting to do for awhile now which is convert the Color constructor function into a true class. The advantage to this was that I was able to do deepEquals on two Color objects whereas before that was not possible (thus greatly simplifying the tests). Another advantage is that all Color objects now share memory for all their functions thanks to the prototype whereas before each Color object had their own memory instance for each function.

const colorFnRegex = /^((?:rgb|hsl)a?)\s*\(([^\)]*)\)/i;

/**
* @class Color
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -102,23 +43,20 @@ 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"
* @method parseString
* @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];
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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;
}
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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)) {
//<percentage>
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);
}
38 changes: 19 additions & 19 deletions lib/commons/color/get-background-color.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -47,59 +48,57 @@ 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++) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By turning this into a loop instead of a some we can return early inside the loop rather than trying to return after the some has already gone through all elements of the stack.

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
if (
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)
Expand Down Expand Up @@ -166,6 +165,7 @@ function fullyEncompasses(node, rects) {
function normalizeBlendMode(blendmode) {
return !!blendmode ? blendmode : undefined;
}

/**
* Get the page background color.
* @private
Expand Down
1 change: 1 addition & 0 deletions lib/commons/color/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading