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 }