diff --git a/packages/ui-color-picker/src/ColorContrast/README.md b/packages/ui-color-picker/src/ColorContrast/README.md index 98c099a98b..0695b203a1 100644 --- a/packages/ui-color-picker/src/ColorContrast/README.md +++ b/packages/ui-color-picker/src/ColorContrast/README.md @@ -32,13 +32,23 @@ type: example super(props) this.state = { selectedForeGround: '#0CBF94', - selectedBackGround: '#35423A' + selectedBackGround: '#35423A', + validationLevel: 'AA' } } render() { return (
+ this.setState({ validationLevel: value })} + name="example1" + defaultValue="AA" + description="validationLevel" + > + + +
) @@ -99,9 +110,19 @@ type: example const Example = () => { const [selectedForeGround, setSelectedForeGround] = useState('#0CBF94') const [selectedBackGround, setSelectedBackGround] = useState('#35423A') + const [validationLevel, setValidationLevel] = useState('AA') return (
+ setValidationLevel(value)} + name="example1" + defaultValue="AA" + description="validationLevel" + > + + +
) diff --git a/packages/ui-color-picker/src/ColorContrast/index.tsx b/packages/ui-color-picker/src/ColorContrast/index.tsx index 10ce6be41c..4efe3dc197 100644 --- a/packages/ui-color-picker/src/ColorContrast/index.tsx +++ b/packages/ui-color-picker/src/ColorContrast/index.tsx @@ -29,9 +29,10 @@ import React, { Component } from 'react' import { omitProps } from '@instructure/ui-react-utils' import { testable } from '@instructure/ui-testable' import { error } from '@instructure/console' -import { contrast as getContrast } from '@instructure/ui-color-utils' -import conversions from '@instructure/ui-color-utils' -import type { RGBAType } from '@instructure/ui-color-utils' +import { + contrastWithAlpha, + validateContrast +} from '@instructure/ui-color-utils' import { withStyle, jsx } from '@instructure/emotion' import { Text } from '@instructure/ui-text' @@ -40,7 +41,7 @@ import { Pill } from '@instructure/ui-pill' import ColorIndicator from '../ColorIndicator' import { propTypes, allowedProps } from './props' -import type { ColorContrastProps } from './props' +import type { ColorContrastProps, ColorContrastState } from './props' import generateStyle from './styles' import generateComponentTheme from './theme' @@ -52,17 +53,25 @@ category: components **/ @withStyle(generateStyle, generateComponentTheme) @testable() -class ColorContrast extends Component { +class ColorContrast extends Component { static propTypes = propTypes static allowedProps = allowedProps static readonly componentId = 'ColorContrast' static defaultProps = { - withoutColorPreview: false + withoutColorPreview: false, + validationLevel: 'AA' } constructor(props: ColorContrastProps) { super(props) + + this.state = { + contrast: 1, + isValidNormalText: false, + isValidLargeText: false, + isValidGraphicsText: false + } } ref: HTMLDivElement | null = null @@ -79,10 +88,29 @@ class ColorContrast extends Component { componentDidMount() { this.props.makeStyles?.() + this.calcState() } - componentDidUpdate() { + componentDidUpdate(prevProps: ColorContrastProps) { this.props.makeStyles?.() + if ( + prevProps?.firstColor !== this.props?.firstColor || + prevProps?.secondColor !== this.props?.secondColor || + prevProps?.validationLevel !== this.props?.validationLevel + ) { + const newState = this.calcState() + + this.props?.onContrastChange?.( + newState.contrast, + { + isValidNormalText: newState.isValidNormalText, + isValidLargeText: newState.isValidLargeText, + isValidGraphicsText: newState.isValidGraphicsText + }, + this.props.firstColor, + this.props.secondColor + ) + } } renderStatus = (pass: boolean, description: string) => { @@ -153,32 +181,17 @@ class ColorContrast extends Component { ) } - calcBlendedColor = (c1: RGBAType, c2: RGBAType) => { - const alpha = 1 - (1 - c1.a) * (1 - c2.a) - return { - r: (c2.r * c2.a) / alpha + (c1.r * c1.a * (1 - c2.a)) / alpha, - g: (c2.g * c2.a) / alpha + (c1.g * c1.a * (1 - c2.a)) / alpha, - b: (c2.b * c2.a) / alpha + (c1.b * c1.a * (1 - c2.a)) / alpha, - a: 1 - } - } - - //We project the firstColor onto an opaque white background, then we project the secondColor onto - //the projected first color. We calculate the contrast of these two, projected colors. - get calcContrast() { - const c1RGBA = conversions.colorToRGB(this.props.firstColor) - const c2RGBA = conversions.colorToRGB(this.props.secondColor) - const c1OnWhite = this.calcBlendedColor( - { r: 255, g: 255, b: 255, a: 1 }, - c1RGBA - ) - const c2OnC1OnWhite = this.calcBlendedColor(c1OnWhite, c2RGBA) - - return getContrast( - conversions.colorToHex8(c1OnWhite), - conversions.colorToHex8(c2OnC1OnWhite), - 2 + calcState() { + const contrast = contrastWithAlpha( + this.props.firstColor, + this.props.secondColor ) + const newState = { + contrast, + ...validateContrast(contrast, this.props.validationLevel) + } + this.setState(newState) + return newState } render() { @@ -190,7 +203,12 @@ class ColorContrast extends Component { graphicsTextLabel } = this.props - const contrast = this.calcContrast + const { + contrast, + isValidNormalText, + isValidLargeText, + isValidGraphicsText + } = this.state return (
{
{contrast}:1 {this.renderPreview()} - {this.renderStatus(contrast >= 4.5, normalTextLabel)} - {this.renderStatus(contrast >= 3, largeTextLabel)} - {this.renderStatus(contrast >= 3, graphicsTextLabel)} + {this.renderStatus(isValidNormalText, normalTextLabel)} + {this.renderStatus(isValidLargeText, largeTextLabel)} + {this.renderStatus(isValidGraphicsText, graphicsTextLabel)} ) } diff --git a/packages/ui-color-picker/src/ColorContrast/props.ts b/packages/ui-color-picker/src/ColorContrast/props.ts index 966a4e9661..7ebf5658e3 100644 --- a/packages/ui-color-picker/src/ColorContrast/props.ts +++ b/packages/ui-color-picker/src/ColorContrast/props.ts @@ -83,6 +83,48 @@ type ColorContrastOwnProps = { * Otherwise, it is required. */ withoutColorPreview?: boolean + /** + * Triggers a callback whenever the contrast changes, due to a changing color input. + * Communicates the contrast and the success/fail state of the contrast, depending on + * the situation: + * + * isValidNormalText true if at least 4.5:1 + * + * isValidLargeText true if at least 3:1 + * + * isValidGraphicsText true if at least 3:1 + */ + onContrastChange?: ( + contrast: number, + contrastValidation: { + isValidNormalText: boolean + isValidLargeText: boolean + isValidGraphicsText: boolean + }, + firstColor: string, + secondColor: string + ) => null + /** + * According to WCAG 2.2 + * + * AA level (https://www.w3.org/TR/WCAG22/#contrast-minimum) + * + * text: 4.5:1 + * + * large text: 3:1 + * + * non-text: 3:1 (https://www.w3.org/TR/WCAG22/#non-text-contrast) + * + * + * AAA level (https://www.w3.org/TR/WCAG22/#contrast-enhanced) + * + * text: 7:1 + * + * large text: 4.5:1 + * + * non-text: 3:1 (https://www.w3.org/TR/WCAG22/#non-text-contrast) + */ + validationLevel?: 'AA' | 'AAA' } type PropKeys = keyof ColorContrastOwnProps @@ -106,6 +148,8 @@ type ColorContrastStyle = ComponentStyle< | 'firstColorPreview' | 'secondColorPreview' | 'label' + | 'onContrastChange' + | 'validationLevel' > const propTypes: PropValidators = { @@ -120,7 +164,9 @@ const propTypes: PropValidators = { normalTextLabel: PropTypes.string.isRequired, secondColor: PropTypes.string.isRequired, secondColorLabel: PropTypes.string, - successLabel: PropTypes.string.isRequired + successLabel: PropTypes.string.isRequired, + onContrastChange: PropTypes.func, + validationLevel: PropTypes.oneOf(['AA', 'AAA']) } const allowedProps: AllowedPropKeys = [ @@ -135,8 +181,16 @@ const allowedProps: AllowedPropKeys = [ 'normalTextLabel', 'secondColor', 'secondColorLabel', - 'successLabel' + 'successLabel', + 'onContrastChange', + 'validationLevel' ] -export type { ColorContrastProps, ColorContrastStyle } +type ColorContrastState = { + contrast: number + isValidNormalText: boolean + isValidLargeText: boolean + isValidGraphicsText: boolean +} +export type { ColorContrastProps, ColorContrastStyle, ColorContrastState } export { propTypes, allowedProps } diff --git a/packages/ui-color-picker/src/ColorPicker/index.tsx b/packages/ui-color-picker/src/ColorPicker/index.tsx index 55cbfb2872..7bcdba22ea 100644 --- a/packages/ui-color-picker/src/ColorPicker/index.tsx +++ b/packages/ui-color-picker/src/ColorPicker/index.tsx @@ -548,6 +548,9 @@ class ColorPicker extends Component { secondColorLabel={ this.props.colorMixerSettings.colorContrast.secondColorLabel } + onContrastChange={ + this.props.colorMixerSettings.colorContrast.onContrastChange + } /> )} diff --git a/packages/ui-color-picker/src/ColorPicker/props.ts b/packages/ui-color-picker/src/ColorPicker/props.ts index eefc9aa745..d96bf55419 100644 --- a/packages/ui-color-picker/src/ColorPicker/props.ts +++ b/packages/ui-color-picker/src/ColorPicker/props.ts @@ -32,7 +32,6 @@ import type { OtherHTMLAttributes, PropValidators } from '@instructure/shared-types' - type ContrastStrength = 'min' | 'mid' | 'max' type ColorPickerOwnProps = { @@ -100,6 +99,16 @@ type ColorPickerOwnProps = { graphicsTextLabel: string firstColorLabel: string secondColorLabel: string + onContrastChange?: ( + contrast: number, + contrastValidation: { + isValidNormalText: boolean + isValidLargeText: boolean + isValidGraphicsText: boolean + }, + firstColor: string, + secondColor: string + ) => null } } @@ -288,7 +297,8 @@ const propTypes: PropValidators = { largeTextLabel: PropTypes.string.isRequired, graphicsTextLabel: PropTypes.string.isRequired, firstColorLabel: PropTypes.string.isRequired, - secondColorLabel: PropTypes.string.isRequired + secondColorLabel: PropTypes.string.isRequired, + onContrastChange: PropTypes.func }) }), children: PropTypes.func, diff --git a/packages/ui-color-utils/src/contrastWithAlpha.ts b/packages/ui-color-utils/src/contrastWithAlpha.ts new file mode 100644 index 0000000000..8974ae0aab --- /dev/null +++ b/packages/ui-color-utils/src/contrastWithAlpha.ts @@ -0,0 +1,50 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { colorToRGB, colorToHex8 } from './conversions' +import { overlayColors } from './overlayColors' +import { contrast } from './contrast' + +/** + * --- + * category: utilities + * --- + * Calculates two, not necesseraly opaque color's contrast on top of each other. + * The method assumes that the bottom color is on top of a white background (only important if it isn't opaque) + * @module contrastWithAlpha + * @param {String} color1 + * @param {String} color2 + * @param {Number} decimalPlaces + * @returns {Number} color contrast ratio + */ +const contrastWithAlpha = (color1: string, color2: string): number => { + const c1RGBA = colorToRGB(color1) + const c2RGBA = colorToRGB(color2) + const c1OnWhite = overlayColors({ r: 255, g: 255, b: 255, a: 1 }, c1RGBA) + const c2OnC1OnWhite = overlayColors(c1OnWhite, c2RGBA) + + return contrast(colorToHex8(c1OnWhite), colorToHex8(c2OnC1OnWhite), 2) +} + +export { contrastWithAlpha } diff --git a/packages/ui-color-utils/src/index.ts b/packages/ui-color-utils/src/index.ts index c4b720228a..ff418f4b94 100644 --- a/packages/ui-color-utils/src/index.ts +++ b/packages/ui-color-utils/src/index.ts @@ -27,6 +27,9 @@ export { darken } from './darken' export { lighten } from './lighten' export { contrast } from './contrast' export { isValid } from './isValid' +export { overlayColors } from './overlayColors' +export { contrastWithAlpha } from './contrastWithAlpha' +export { validateContrast } from './validateContrast' export { color2hex, colorToHex8, diff --git a/packages/ui-color-utils/src/overlayColors.ts b/packages/ui-color-utils/src/overlayColors.ts new file mode 100644 index 0000000000..95be8451f2 --- /dev/null +++ b/packages/ui-color-utils/src/overlayColors.ts @@ -0,0 +1,58 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { RGBAType } from './colorTypes' + +/** + * @typedef {Object} RGBAResult + * @property {Number} r - Red component as a string + * @property {Number} g - Green component as a string + * @property {Number} b - Blue component as a string + * @property {Number} a - Alpha component as a string + */ + +/** + * --- + * category: utilities + * --- + * Place two RGBA colors on top of each other. The second one (c2) goes on top. + * The method calculates what color would be visible. If the second color (c2) is opaque, the result + * will be c2, if fully transparent, c1. If anything in between, it calculates the real color. + * Alpha is always set to 1 after the calculation + * @module overlayColors + * @param {RGBAType} c1 + * @param {RGBAType} c2 + * @returns {RGBAType} color as rgb string + */ +const overlayColors = (c1: RGBAType, c2: RGBAType): RGBAType => { + const alpha = 1 - (1 - c1.a) * (1 - c2.a) + return { + r: (c2.r * c2.a) / alpha + (c1.r * c1.a * (1 - c2.a)) / alpha, + g: (c2.g * c2.a) / alpha + (c1.g * c1.a * (1 - c2.a)) / alpha, + b: (c2.b * c2.a) / alpha + (c1.b * c1.a * (1 - c2.a)) / alpha, + a: 1 + } +} + +export { overlayColors } diff --git a/packages/ui-color-utils/src/validateContrast.ts b/packages/ui-color-utils/src/validateContrast.ts new file mode 100644 index 0000000000..8a987a582b --- /dev/null +++ b/packages/ui-color-utils/src/validateContrast.ts @@ -0,0 +1,77 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +interface ValidatedContrasts { + isValidNormalText: boolean + isValidLargeText: boolean + isValidGraphicsText: boolean +} + +/** + * @typedef {Object} ValidatedContrasts + * @property {Boolean} isValidNormalText - Is the contrast high enough for normal text size? + * @property {Boolean} isValidLargeText - Is the contrast high enough for large text size? + * @property {Boolean} isValidGraphicsText - Is the contrast high enough for graphics text? + */ + +/** + * --- + * category: utilities + * --- + * Decides if the given contrast is sufficient for different text sizes and situations. + * + * According to WCAG 2.2 + * + * AA level (https://www.w3.org/TR/WCAG22/#contrast-minimum) + * + * text: 4.5:1 + * + * large text: 3:1 + * + * non-text: 3:1 (https://www.w3.org/TR/WCAG22/#non-text-contrast) + * + * + * AAA level (https://www.w3.org/TR/WCAG22/#contrast-enhanced) + * + * text: 7:1 + * + * large text: 4.5:1 + * + * non-text: 3:1 (https://www.w3.org/TR/WCAG22/#non-text-contrast) + * @module validateContrast + * @param {Number} contrast + * @returns {ValidatedContrasts} validation object + */ +const validateContrast = ( + contrast: number, + validationLevel?: 'AA' | 'AAA' +): ValidatedContrasts => { + return { + isValidNormalText: contrast >= (validationLevel === 'AAA' ? 7 : 4.5), + isValidLargeText: contrast >= (validationLevel === 'AAA' ? 4.5 : 3), + isValidGraphicsText: contrast >= 3 + } +} + +export { validateContrast }