Skip to content

Commit e756c7d

Browse files
committed
feat(ui-color-picker,ui-color-utils): add callback for contrast validation information and export validation methods
1 parent 21c61b1 commit e756c7d

File tree

9 files changed

+333
-42
lines changed

9 files changed

+333
-42
lines changed

packages/ui-color-picker/src/ColorContrast/README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,23 @@ type: example
3232
super(props)
3333
this.state = {
3434
selectedForeGround: '#0CBF94',
35-
selectedBackGround: '#35423A'
35+
selectedBackGround: '#35423A',
36+
validationLevel: 'AA'
3637
}
3738
}
3839

3940
render() {
4041
return (
4142
<div>
43+
<RadioInputGroup
44+
onChange={(_e, value) => this.setState({ validationLevel: value })}
45+
name="example1"
46+
defaultValue="AA"
47+
description="validationLevel"
48+
>
49+
<RadioInput key="AA" value="AA" label="AA" />
50+
<RadioInput key="AAA" value="AAA" label="AAA" />
51+
</RadioInputGroup>
4252
<ColorPreset
4353
label="Background"
4454
colors={[
@@ -86,6 +96,8 @@ type: example
8696
normalTextLabel="Normal text"
8797
largeTextLabel="Large text"
8898
graphicsTextLabel="Graphics text"
99+
validationLevel={this.state.validationLevel}
100+
onContrastChange={(contrastData) => console.log(contrastData)}
89101
/>
90102
</div>
91103
)
@@ -99,9 +111,19 @@ type: example
99111
const Example = () => {
100112
const [selectedForeGround, setSelectedForeGround] = useState('#0CBF94')
101113
const [selectedBackGround, setSelectedBackGround] = useState('#35423A')
114+
const [validationLevel, setValidationLevel] = useState('AA')
102115

103116
return (
104117
<div>
118+
<RadioInputGroup
119+
onChange={(_e, value) => setValidationLevel(value)}
120+
name="example1"
121+
defaultValue="AA"
122+
description="validationLevel"
123+
>
124+
<RadioInput key="AA" value="AA" label="AA" />
125+
<RadioInput key="AAA" value="AAA" label="AAA" />
126+
</RadioInputGroup>
105127
<ColorPreset
106128
label="Background"
107129
colors={[
@@ -149,6 +171,8 @@ type: example
149171
normalTextLabel="Normal text"
150172
largeTextLabel="Large text"
151173
graphicsTextLabel="Graphics text"
174+
validationLevel={validationLevel}
175+
onContrastChange={(contrastData) => console.log(contrastData)}
152176
/>
153177
</div>
154178
)

packages/ui-color-picker/src/ColorContrast/index.tsx

Lines changed: 52 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ import React, { Component } from 'react'
2929
import { omitProps } from '@instructure/ui-react-utils'
3030
import { testable } from '@instructure/ui-testable'
3131
import { error } from '@instructure/console'
32-
import { contrast as getContrast } from '@instructure/ui-color-utils'
33-
import conversions from '@instructure/ui-color-utils'
34-
import type { RGBAType } from '@instructure/ui-color-utils'
32+
import {
33+
contrastWithAlpha,
34+
validateContrast
35+
} from '@instructure/ui-color-utils'
3536
import { withStyle, jsx } from '@instructure/emotion'
3637

3738
import { Text } from '@instructure/ui-text'
@@ -40,7 +41,7 @@ import { Pill } from '@instructure/ui-pill'
4041
import ColorIndicator from '../ColorIndicator'
4142

4243
import { propTypes, allowedProps } from './props'
43-
import type { ColorContrastProps } from './props'
44+
import type { ColorContrastProps, ColorContrastState } from './props'
4445

4546
import generateStyle from './styles'
4647
import generateComponentTheme from './theme'
@@ -52,17 +53,25 @@ category: components
5253
**/
5354
@withStyle(generateStyle, generateComponentTheme)
5455
@testable()
55-
class ColorContrast extends Component<ColorContrastProps> {
56+
class ColorContrast extends Component<ColorContrastProps, ColorContrastState> {
5657
static propTypes = propTypes
5758
static allowedProps = allowedProps
5859
static readonly componentId = 'ColorContrast'
5960

6061
static defaultProps = {
61-
withoutColorPreview: false
62+
withoutColorPreview: false,
63+
validationLevel: 'AA'
6264
}
6365

6466
constructor(props: ColorContrastProps) {
6567
super(props)
68+
69+
this.state = {
70+
contrast: 1,
71+
isValidNormalText: false,
72+
isValidLargeText: false,
73+
isValidGraphicsText: false
74+
}
6675
}
6776

6877
ref: HTMLDivElement | null = null
@@ -79,10 +88,27 @@ class ColorContrast extends Component<ColorContrastProps> {
7988

8089
componentDidMount() {
8190
this.props.makeStyles?.()
91+
this.calcState()
8292
}
8393

84-
componentDidUpdate() {
94+
componentDidUpdate(prevProps: ColorContrastProps) {
8595
this.props.makeStyles?.()
96+
if (
97+
prevProps?.firstColor !== this.props?.firstColor ||
98+
prevProps?.secondColor !== this.props?.secondColor ||
99+
prevProps?.validationLevel !== this.props?.validationLevel
100+
) {
101+
const newState = this.calcState()
102+
103+
this.props?.onContrastChange?.({
104+
contrast: newState.contrast,
105+
isValidNormalText: newState.isValidNormalText,
106+
isValidLargeText: newState.isValidLargeText,
107+
isValidGraphicsText: newState.isValidGraphicsText,
108+
firstColor: this.props.firstColor,
109+
secondColor: this.props.secondColor
110+
})
111+
}
86112
}
87113

88114
renderStatus = (pass: boolean, description: string) => {
@@ -153,32 +179,17 @@ class ColorContrast extends Component<ColorContrastProps> {
153179
)
154180
}
155181

156-
calcBlendedColor = (c1: RGBAType, c2: RGBAType) => {
157-
const alpha = 1 - (1 - c1.a) * (1 - c2.a)
158-
return {
159-
r: (c2.r * c2.a) / alpha + (c1.r * c1.a * (1 - c2.a)) / alpha,
160-
g: (c2.g * c2.a) / alpha + (c1.g * c1.a * (1 - c2.a)) / alpha,
161-
b: (c2.b * c2.a) / alpha + (c1.b * c1.a * (1 - c2.a)) / alpha,
162-
a: 1
163-
}
164-
}
165-
166-
//We project the firstColor onto an opaque white background, then we project the secondColor onto
167-
//the projected first color. We calculate the contrast of these two, projected colors.
168-
get calcContrast() {
169-
const c1RGBA = conversions.colorToRGB(this.props.firstColor)
170-
const c2RGBA = conversions.colorToRGB(this.props.secondColor)
171-
const c1OnWhite = this.calcBlendedColor(
172-
{ r: 255, g: 255, b: 255, a: 1 },
173-
c1RGBA
174-
)
175-
const c2OnC1OnWhite = this.calcBlendedColor(c1OnWhite, c2RGBA)
176-
177-
return getContrast(
178-
conversions.colorToHex8(c1OnWhite),
179-
conversions.colorToHex8(c2OnC1OnWhite),
180-
2
182+
calcState() {
183+
const contrast = contrastWithAlpha(
184+
this.props.firstColor,
185+
this.props.secondColor
181186
)
187+
const newState = {
188+
contrast,
189+
...validateContrast(contrast, this.props.validationLevel)
190+
}
191+
this.setState(newState)
192+
return newState
182193
}
183194

184195
render() {
@@ -190,7 +201,12 @@ class ColorContrast extends Component<ColorContrastProps> {
190201
graphicsTextLabel
191202
} = this.props
192203

193-
const contrast = this.calcContrast
204+
const {
205+
contrast,
206+
isValidNormalText,
207+
isValidLargeText,
208+
isValidGraphicsText
209+
} = this.state
194210

195211
return (
196212
<div
@@ -205,9 +221,9 @@ class ColorContrast extends Component<ColorContrastProps> {
205221
</div>
206222
<Text size="x-large">{contrast}:1</Text>
207223
{this.renderPreview()}
208-
{this.renderStatus(contrast >= 4.5, normalTextLabel)}
209-
{this.renderStatus(contrast >= 3, largeTextLabel)}
210-
{this.renderStatus(contrast >= 3, graphicsTextLabel)}
224+
{this.renderStatus(isValidNormalText, normalTextLabel)}
225+
{this.renderStatus(isValidLargeText, largeTextLabel)}
226+
{this.renderStatus(isValidGraphicsText, graphicsTextLabel)}
211227
</div>
212228
)
213229
}

packages/ui-color-picker/src/ColorContrast/props.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,46 @@ type ColorContrastOwnProps = {
8383
* Otherwise, it is required.
8484
*/
8585
withoutColorPreview?: boolean
86+
/**
87+
* Triggers a callback whenever the contrast changes, due to a changing color input.
88+
* Communicates the contrast and the success/fail state of the contrast, depending on
89+
* the situation:
90+
*
91+
* isValidNormalText true if at least 4.5:1
92+
*
93+
* isValidLargeText true if at least 3:1
94+
*
95+
* isValidGraphicsText true if at least 3:1
96+
*/
97+
onContrastChange?: (conrastData: {
98+
contrast: number
99+
isValidNormalText: boolean
100+
isValidLargeText: boolean
101+
isValidGraphicsText: boolean
102+
firstColor: string
103+
secondColor: string
104+
}) => null
105+
/**
106+
* According to WCAG 2.2
107+
*
108+
* AA level (https://www.w3.org/TR/WCAG22/#contrast-minimum)
109+
*
110+
* text: 4.5:1
111+
*
112+
* large text: 3:1
113+
*
114+
* non-text: 3:1 (https://www.w3.org/TR/WCAG22/#non-text-contrast)
115+
*
116+
*
117+
* AAA level (https://www.w3.org/TR/WCAG22/#contrast-enhanced)
118+
*
119+
* text: 7:1
120+
*
121+
* large text: 4.5:1
122+
*
123+
* non-text: 3:1 (https://www.w3.org/TR/WCAG22/#non-text-contrast)
124+
*/
125+
validationLevel?: 'AA' | 'AAA'
86126
}
87127

88128
type PropKeys = keyof ColorContrastOwnProps
@@ -106,6 +146,8 @@ type ColorContrastStyle = ComponentStyle<
106146
| 'firstColorPreview'
107147
| 'secondColorPreview'
108148
| 'label'
149+
| 'onContrastChange'
150+
| 'validationLevel'
109151
>
110152

111153
const propTypes: PropValidators<PropKeys> = {
@@ -120,7 +162,9 @@ const propTypes: PropValidators<PropKeys> = {
120162
normalTextLabel: PropTypes.string.isRequired,
121163
secondColor: PropTypes.string.isRequired,
122164
secondColorLabel: PropTypes.string,
123-
successLabel: PropTypes.string.isRequired
165+
successLabel: PropTypes.string.isRequired,
166+
onContrastChange: PropTypes.func,
167+
validationLevel: PropTypes.oneOf(['AA', 'AAA'])
124168
}
125169

126170
const allowedProps: AllowedPropKeys = [
@@ -135,8 +179,16 @@ const allowedProps: AllowedPropKeys = [
135179
'normalTextLabel',
136180
'secondColor',
137181
'secondColorLabel',
138-
'successLabel'
182+
'successLabel',
183+
'onContrastChange',
184+
'validationLevel'
139185
]
140186

141-
export type { ColorContrastProps, ColorContrastStyle }
187+
type ColorContrastState = {
188+
contrast: number
189+
isValidNormalText: boolean
190+
isValidLargeText: boolean
191+
isValidGraphicsText: boolean
192+
}
193+
export type { ColorContrastProps, ColorContrastStyle, ColorContrastState }
142194
export { propTypes, allowedProps }

packages/ui-color-picker/src/ColorPicker/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,9 @@ class ColorPicker extends Component<ColorPickerProps, ColorPickerState> {
548548
secondColorLabel={
549549
this.props.colorMixerSettings.colorContrast.secondColorLabel
550550
}
551+
onContrastChange={
552+
this.props.colorMixerSettings.colorContrast.onContrastChange
553+
}
551554
/>
552555
</div>
553556
)}

packages/ui-color-picker/src/ColorPicker/props.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import type {
3232
OtherHTMLAttributes,
3333
PropValidators
3434
} from '@instructure/shared-types'
35-
3635
type ContrastStrength = 'min' | 'mid' | 'max'
3736

3837
type ColorPickerOwnProps = {
@@ -100,6 +99,14 @@ type ColorPickerOwnProps = {
10099
graphicsTextLabel: string
101100
firstColorLabel: string
102101
secondColorLabel: string
102+
onContrastChange?: (conrastData: {
103+
contrast: number
104+
isValidNormalText: boolean
105+
isValidLargeText: boolean
106+
isValidGraphicsText: boolean
107+
firstColor: string
108+
secondColor: string
109+
}) => null
103110
}
104111
}
105112

@@ -288,7 +295,8 @@ const propTypes: PropValidators<PropKeys> = {
288295
largeTextLabel: PropTypes.string.isRequired,
289296
graphicsTextLabel: PropTypes.string.isRequired,
290297
firstColorLabel: PropTypes.string.isRequired,
291-
secondColorLabel: PropTypes.string.isRequired
298+
secondColorLabel: PropTypes.string.isRequired,
299+
onContrastChange: PropTypes.func
292300
})
293301
}),
294302
children: PropTypes.func,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* The MIT License (MIT)
3+
*
4+
* Copyright (c) 2015 - present Instructure, Inc.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
import { colorToRGB, colorToHex8 } from './conversions'
26+
import { overlayColors } from './overlayColors'
27+
import { contrast } from './contrast'
28+
29+
/**
30+
* ---
31+
* category: utilities
32+
* ---
33+
* Calculates two, not necesseraly opaque color's contrast on top of each other.
34+
* The method assumes that the bottom color is on top of a white background (only important if it isn't opaque)
35+
* @module contrastWithAlpha
36+
* @param {String} color1
37+
* @param {String} color2
38+
* @param {Number} decimalPlaces
39+
* @returns {Number} color contrast ratio
40+
*/
41+
const contrastWithAlpha = (color1: string, color2: string): number => {
42+
const c1RGBA = colorToRGB(color1)
43+
const c2RGBA = colorToRGB(color2)
44+
const c1OnWhite = overlayColors({ r: 255, g: 255, b: 255, a: 1 }, c1RGBA)
45+
const c2OnC1OnWhite = overlayColors(c1OnWhite, c2RGBA)
46+
47+
return contrast(colorToHex8(c1OnWhite), colorToHex8(c2OnC1OnWhite), 2)
48+
}
49+
50+
export { contrastWithAlpha }

0 commit comments

Comments
 (0)