Skip to content

Commit

Permalink
feat(ui-color-picker,ui-color-utils): add callback for contrast valid…
Browse files Browse the repository at this point in the history
…ation information and export validation methods
  • Loading branch information
HerrTopi committed Nov 14, 2024
1 parent 21c61b1 commit e756c7d
Show file tree
Hide file tree
Showing 9 changed files with 333 additions and 42 deletions.
26 changes: 25 additions & 1 deletion packages/ui-color-picker/src/ColorContrast/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,23 @@ type: example
super(props)
this.state = {
selectedForeGround: '#0CBF94',
selectedBackGround: '#35423A'
selectedBackGround: '#35423A',
validationLevel: 'AA'
}
}

render() {
return (
<div>
<RadioInputGroup
onChange={(_e, value) => this.setState({ validationLevel: value })}
name="example1"
defaultValue="AA"
description="validationLevel"
>
<RadioInput key="AA" value="AA" label="AA" />
<RadioInput key="AAA" value="AAA" label="AAA" />
</RadioInputGroup>
<ColorPreset
label="Background"
colors={[
Expand Down Expand Up @@ -86,6 +96,8 @@ type: example
normalTextLabel="Normal text"
largeTextLabel="Large text"
graphicsTextLabel="Graphics text"
validationLevel={this.state.validationLevel}
onContrastChange={(contrastData) => console.log(contrastData)}
/>
</div>
)
Expand All @@ -99,9 +111,19 @@ type: example
const Example = () => {
const [selectedForeGround, setSelectedForeGround] = useState('#0CBF94')
const [selectedBackGround, setSelectedBackGround] = useState('#35423A')
const [validationLevel, setValidationLevel] = useState('AA')
return (
<div>
<RadioInputGroup
onChange={(_e, value) => setValidationLevel(value)}
name="example1"
defaultValue="AA"
description="validationLevel"
>
<RadioInput key="AA" value="AA" label="AA" />
<RadioInput key="AAA" value="AAA" label="AAA" />
</RadioInputGroup>
<ColorPreset
label="Background"
colors={[
Expand Down Expand Up @@ -149,6 +171,8 @@ type: example
normalTextLabel="Normal text"
largeTextLabel="Large text"
graphicsTextLabel="Graphics text"
validationLevel={validationLevel}
onContrastChange={(contrastData) => console.log(contrastData)}
/>
</div>
)
Expand Down
88 changes: 52 additions & 36 deletions packages/ui-color-picker/src/ColorContrast/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand All @@ -52,17 +53,25 @@ category: components
**/
@withStyle(generateStyle, generateComponentTheme)
@testable()
class ColorContrast extends Component<ColorContrastProps> {
class ColorContrast extends Component<ColorContrastProps, ColorContrastState> {
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
Expand All @@ -79,10 +88,27 @@ class ColorContrast extends Component<ColorContrastProps> {

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?.({
contrast: newState.contrast,
isValidNormalText: newState.isValidNormalText,
isValidLargeText: newState.isValidLargeText,
isValidGraphicsText: newState.isValidGraphicsText,
firstColor: this.props.firstColor,
secondColor: this.props.secondColor
})
}
}

renderStatus = (pass: boolean, description: string) => {
Expand Down Expand Up @@ -153,32 +179,17 @@ class ColorContrast extends Component<ColorContrastProps> {
)
}

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() {
Expand All @@ -190,7 +201,12 @@ class ColorContrast extends Component<ColorContrastProps> {
graphicsTextLabel
} = this.props

const contrast = this.calcContrast
const {
contrast,
isValidNormalText,
isValidLargeText,
isValidGraphicsText
} = this.state

return (
<div
Expand All @@ -205,9 +221,9 @@ class ColorContrast extends Component<ColorContrastProps> {
</div>
<Text size="x-large">{contrast}:1</Text>
{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)}
</div>
)
}
Expand Down
58 changes: 55 additions & 3 deletions packages/ui-color-picker/src/ColorContrast/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,46 @@ 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?: (conrastData: {
contrast: number
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
Expand All @@ -106,6 +146,8 @@ type ColorContrastStyle = ComponentStyle<
| 'firstColorPreview'
| 'secondColorPreview'
| 'label'
| 'onContrastChange'
| 'validationLevel'
>

const propTypes: PropValidators<PropKeys> = {
Expand All @@ -120,7 +162,9 @@ const propTypes: PropValidators<PropKeys> = {
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 = [
Expand All @@ -135,8 +179,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 }
3 changes: 3 additions & 0 deletions packages/ui-color-picker/src/ColorPicker/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,9 @@ class ColorPicker extends Component<ColorPickerProps, ColorPickerState> {
secondColorLabel={
this.props.colorMixerSettings.colorContrast.secondColorLabel
}
onContrastChange={
this.props.colorMixerSettings.colorContrast.onContrastChange
}
/>
</div>
)}
Expand Down
12 changes: 10 additions & 2 deletions packages/ui-color-picker/src/ColorPicker/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import type {
OtherHTMLAttributes,
PropValidators
} from '@instructure/shared-types'

type ContrastStrength = 'min' | 'mid' | 'max'

type ColorPickerOwnProps = {
Expand Down Expand Up @@ -100,6 +99,14 @@ type ColorPickerOwnProps = {
graphicsTextLabel: string
firstColorLabel: string
secondColorLabel: string
onContrastChange?: (conrastData: {
contrast: number
isValidNormalText: boolean
isValidLargeText: boolean
isValidGraphicsText: boolean
firstColor: string
secondColor: string
}) => null
}
}

Expand Down Expand Up @@ -288,7 +295,8 @@ const propTypes: PropValidators<PropKeys> = {
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,
Expand Down
50 changes: 50 additions & 0 deletions packages/ui-color-utils/src/contrastWithAlpha.ts
Original file line number Diff line number Diff line change
@@ -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 }
Loading

0 comments on commit e756c7d

Please sign in to comment.