Skip to content

Commit d6b8583

Browse files
authored
Update IssueLabelToken colors (#2966)
* Update IssueLabelToken colors * Add isSelected control for storybook * Respect fillColor saturation * Update snapshots * Provide fallback color * Create .changeset/gold-actors-fry.md * test(vrt): update snapshots --------- Co-authored-by: colebemis <colebemis@users.noreply.github.com>
1 parent 16c5c0c commit d6b8583

File tree

8 files changed

+151
-234
lines changed

8 files changed

+151
-234
lines changed

.changeset/gold-actors-fry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/react": minor
3+
---
4+
5+
Update `IssueLabelToken` colors to improve color contrast with all possible user-provided colors

package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@
109109
"focus-visible": "^5.2.0",
110110
"fzy.js": "0.4.1",
111111
"history": "^5.0.0",
112+
"hsluv": "1.0.0",
112113
"react-intersection-observer": "9.4.1",
113114
"styled-system": "^5.1.5"
114115
},

src/Token/IssueLabelToken.tsx

Lines changed: 94 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import React, {forwardRef, MouseEventHandler, useMemo} from 'react'
21
import {CSSObject} from '@styled-system/css'
2+
import {getContrast, getLuminance, toHex} from 'color2k'
3+
import {Hsluv} from 'hsluv'
4+
import React from 'react'
5+
import {get} from '../constants'
6+
import {useTheme} from '../ThemeProvider'
7+
import {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
38
import TokenBase, {defaultTokenSize, isTokenInteractive, TokenBaseProps} from './TokenBase'
49
import RemoveTokenButton from './_RemoveTokenButton'
5-
import {parseToHsla, parseToRgba} from 'color2k'
6-
import {useTheme} from '../ThemeProvider'
710
import TokenTextContainer from './_TokenTextContainer'
8-
import {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
911

1012
export interface IssueLabelTokenProps extends TokenBaseProps {
1113
/**
@@ -16,31 +18,7 @@ export interface IssueLabelTokenProps extends TokenBaseProps {
1618

1719
const tokenBorderWidthPx = 1
1820

19-
const lightModeStyles = {
20-
'--lightness-threshold': '0.453',
21-
'--border-threshold': '0.96',
22-
'--border-alpha': 'max(0, min(calc((var(--perceived-lightness) - var(--border-threshold)) * 100), 1))',
23-
background: 'rgb(var(--label-r), var(--label-g), var(--label-b))',
24-
color: 'hsl(0, 0%, calc(var(--lightness-switch) * 100%))',
25-
borderWidth: tokenBorderWidthPx,
26-
borderStyle: 'solid',
27-
borderColor: 'hsla(var(--label-h),calc(var(--label-s) * 1%),calc((var(--label-l) - 25) * 1%),var(--border-alpha))',
28-
}
29-
30-
const darkModeStyles = {
31-
'--lightness-threshold': '0.6',
32-
'--background-alpha': '0.18',
33-
'--border-alpha': '0.3',
34-
'--lighten-by': 'calc(((var(--lightness-threshold) - var(--perceived-lightness)) * 100) * var(--lightness-switch))',
35-
borderWidth: tokenBorderWidthPx,
36-
borderStyle: 'solid',
37-
background: 'rgba(var(--label-r), var(--label-g), var(--label-b), var(--background-alpha))',
38-
color: 'hsl(var(--label-h), calc(var(--label-s) * 1%), calc((var(--label-l) + var(--lighten-by)) * 1%))',
39-
borderColor:
40-
'hsla(var(--label-h), calc(var(--label-s) * 1%),calc((var(--label-l) + var(--lighten-by)) * 1%),var(--border-alpha))',
41-
}
42-
43-
const IssueLabelToken = forwardRef((props, forwardedRef) => {
21+
const IssueLabelToken = React.forwardRef((props, forwardedRef) => {
4422
const {
4523
as,
4624
fillColor = '#999',
@@ -54,42 +32,52 @@ const IssueLabelToken = forwardRef((props, forwardedRef) => {
5432
onClick,
5533
...rest
5634
} = props
57-
const interactiveTokenProps = {
58-
as,
59-
href,
60-
onClick,
61-
}
62-
const {colorScheme} = useTheme()
35+
36+
const interactiveTokenProps = {as, href, onClick}
37+
38+
const colorMode = useColorMode()
39+
6340
const hasMultipleActionTargets = isTokenInteractive(props) && Boolean(onRemove) && !hideRemoveButton
64-
const onRemoveClick: MouseEventHandler = e => {
65-
e.stopPropagation()
66-
onRemove && onRemove()
41+
42+
const onRemoveClick: React.MouseEventHandler = event => {
43+
event.stopPropagation()
44+
onRemove?.()
6745
}
68-
const labelStyles: CSSObject = useMemo(() => {
69-
const [r, g, b] = parseToRgba(fillColor)
70-
const [h, s, l] = parseToHsla(fillColor)
7146

72-
// label hack taken from https://github.com/github/github/blob/master/app/assets/stylesheets/hacks/hx_primer-labels.scss#L43-L108
73-
// this logic should eventually live in primer/components. Also worthy of note is that the dotcom hack code will be moving to primer/css soon.
47+
const labelStyles: CSSObject = React.useMemo(() => {
48+
// Parse label color into hue, saturation, lightness using HSLUV
49+
const {h, s} = hexToHsluv(fillColor)
50+
51+
// Initialize color variables
52+
let bgColor = ''
53+
let textColor = ''
54+
let borderColor = ''
55+
56+
// Set color variables based on current color mode
57+
switch (colorMode) {
58+
case 'light': {
59+
bgColor = hsluvToHex({h, s: Math.min(s, 90), l: 97})
60+
textColor = minContrast(hsluvToHex({h, s: Math.min(s, 85), l: 45}), bgColor, 4.5)
61+
borderColor = hsluvToHex({h, s: Math.min(s, 70), l: 82})
62+
break
63+
}
64+
65+
case 'dark': {
66+
bgColor = hsluvToHex({h, s: Math.min(s, 90), l: 8})
67+
textColor = minContrast(hsluvToHex({h, s: Math.min(s, 50), l: 70}), bgColor, 4.5)
68+
borderColor = hsluvToHex({h, s: Math.min(s, 80), l: 20})
69+
break
70+
}
71+
}
72+
7473
return {
75-
'--label-r': String(r),
76-
'--label-g': String(g),
77-
'--label-b': String(b),
78-
'--label-h': String(Math.round(h)),
79-
'--label-s': String(Math.round(s * 100)),
80-
'--label-l': String(Math.round(l * 100)),
81-
'--perceived-lightness':
82-
'calc(((var(--label-r) * 0.2126) + (var(--label-g) * 0.7152) + (var(--label-b) * 0.0722)) / 255)',
83-
'--lightness-switch': 'max(0, min(calc((var(--perceived-lightness) - var(--lightness-threshold)) * -1000), 1))',
84-
paddingRight: hideRemoveButton || !onRemove ? undefined : 0,
8574
position: 'relative',
86-
...(colorScheme === 'light' ? lightModeStyles : darkModeStyles),
75+
color: textColor,
76+
background: bgColor,
77+
border: `${tokenBorderWidthPx}px solid ${borderColor}`,
78+
paddingRight: onRemove && !hideRemoveButton ? 0 : undefined,
8779
...(isSelected
8880
? {
89-
background:
90-
colorScheme === 'light'
91-
? 'hsl(var(--label-h), calc(var(--label-s) * 1%), calc((var(--label-l) - 5) * 1%))'
92-
: darkModeStyles.background,
9381
':focus': {
9482
outline: 'none',
9583
},
@@ -103,17 +91,13 @@ const IssueLabelToken = forwardRef((props, forwardedRef) => {
10391
left: `-${tokenBorderWidthPx * 2}px`,
10492
display: 'block',
10593
pointerEvents: 'none',
106-
boxShadow: `0 0 0 ${tokenBorderWidthPx * 2}px ${
107-
colorScheme === 'light'
108-
? 'rgb(var(--label-r), var(--label-g), var(--label-b))'
109-
: 'hsl(var(--label-h), calc(var(--label-s) * 1%), calc((var(--label-l) + var(--lighten-by)) * 1%))'
110-
}`,
94+
boxShadow: `0 0 0 ${tokenBorderWidthPx * 2}px ${textColor}`,
11195
borderRadius: '999px',
11296
},
11397
}
11498
: {}),
11599
}
116-
}, [colorScheme, fillColor, isSelected, hideRemoveButton, onRemove])
100+
}, [colorMode, fillColor, isSelected, hideRemoveButton, onRemove])
117101

118102
return (
119103
<TokenBase
@@ -152,3 +136,49 @@ const IssueLabelToken = forwardRef((props, forwardedRef) => {
152136
IssueLabelToken.displayName = 'IssueLabelToken'
153137

154138
export default IssueLabelToken
139+
140+
// Helper functions
141+
142+
function useColorMode(): 'light' | 'dark' {
143+
const {theme} = useTheme()
144+
// Determine color mode by luminance
145+
const colorMode = getLuminance(get('colors.canvas.default')({theme}) || '#fff') > 0.5 ? 'light' : 'dark'
146+
return colorMode
147+
}
148+
149+
function hexToHsluv(hex: string) {
150+
const color = new Hsluv()
151+
color.hex = toHex(hex) // Ensure hex is actually a hex color
152+
color.hexToHsluv()
153+
return {h: color.hsluv_h, s: color.hsluv_s, l: color.hsluv_l}
154+
}
155+
156+
function hsluvToHex({h, s, l}: {h: number; s: number; l: number}) {
157+
const color = new Hsluv()
158+
// eslint-disable-next-line camelcase
159+
color.hsluv_h = h
160+
// eslint-disable-next-line camelcase
161+
color.hsluv_s = s
162+
// eslint-disable-next-line camelcase
163+
color.hsluv_l = l
164+
color.hsluvToHex()
165+
return color.hex
166+
}
167+
168+
/** Returns a foreground color that has a given minimum contrast ratio against the given background color */
169+
function minContrast(fg: string, bg: string, minRatio: number) {
170+
// eslint-disable-next-line prefer-const
171+
let {h, s, l} = hexToHsluv(fg)
172+
173+
// While foreground color doesn't meet the contrast ratio,
174+
// increase or decrease the lightness until it does
175+
while (getContrast(hsluvToHex({h, s, l}), bg) < minRatio && l <= 100 && l >= 0) {
176+
if (getLuminance(bg) > getLuminance(fg)) {
177+
l -= 1
178+
} else {
179+
l += 1
180+
}
181+
}
182+
183+
return hsluvToHex({h, s, l})
184+
}

0 commit comments

Comments
 (0)