Skip to content

Commit

Permalink
Update IssueLabelToken colors (#2966)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
colebemis and colebemis authored Mar 30, 2023
1 parent 16c5c0c commit d6b8583
Show file tree
Hide file tree
Showing 8 changed files with 151 additions and 234 deletions.
5 changes: 5 additions & 0 deletions .changeset/gold-actors-fry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": minor
---

Update `IssueLabelToken` colors to improve color contrast with all possible user-provided colors
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
"focus-visible": "^5.2.0",
"fzy.js": "0.4.1",
"history": "^5.0.0",
"hsluv": "1.0.0",
"react-intersection-observer": "9.4.1",
"styled-system": "^5.1.5"
},
Expand Down
158 changes: 94 additions & 64 deletions src/Token/IssueLabelToken.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React, {forwardRef, MouseEventHandler, useMemo} from 'react'
import {CSSObject} from '@styled-system/css'
import {getContrast, getLuminance, toHex} from 'color2k'
import {Hsluv} from 'hsluv'
import React from 'react'
import {get} from '../constants'
import {useTheme} from '../ThemeProvider'
import {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
import TokenBase, {defaultTokenSize, isTokenInteractive, TokenBaseProps} from './TokenBase'
import RemoveTokenButton from './_RemoveTokenButton'
import {parseToHsla, parseToRgba} from 'color2k'
import {useTheme} from '../ThemeProvider'
import TokenTextContainer from './_TokenTextContainer'
import {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'

export interface IssueLabelTokenProps extends TokenBaseProps {
/**
Expand All @@ -16,31 +18,7 @@ export interface IssueLabelTokenProps extends TokenBaseProps {

const tokenBorderWidthPx = 1

const lightModeStyles = {
'--lightness-threshold': '0.453',
'--border-threshold': '0.96',
'--border-alpha': 'max(0, min(calc((var(--perceived-lightness) - var(--border-threshold)) * 100), 1))',
background: 'rgb(var(--label-r), var(--label-g), var(--label-b))',
color: 'hsl(0, 0%, calc(var(--lightness-switch) * 100%))',
borderWidth: tokenBorderWidthPx,
borderStyle: 'solid',
borderColor: 'hsla(var(--label-h),calc(var(--label-s) * 1%),calc((var(--label-l) - 25) * 1%),var(--border-alpha))',
}

const darkModeStyles = {
'--lightness-threshold': '0.6',
'--background-alpha': '0.18',
'--border-alpha': '0.3',
'--lighten-by': 'calc(((var(--lightness-threshold) - var(--perceived-lightness)) * 100) * var(--lightness-switch))',
borderWidth: tokenBorderWidthPx,
borderStyle: 'solid',
background: 'rgba(var(--label-r), var(--label-g), var(--label-b), var(--background-alpha))',
color: 'hsl(var(--label-h), calc(var(--label-s) * 1%), calc((var(--label-l) + var(--lighten-by)) * 1%))',
borderColor:
'hsla(var(--label-h), calc(var(--label-s) * 1%),calc((var(--label-l) + var(--lighten-by)) * 1%),var(--border-alpha))',
}

const IssueLabelToken = forwardRef((props, forwardedRef) => {
const IssueLabelToken = React.forwardRef((props, forwardedRef) => {
const {
as,
fillColor = '#999',
Expand All @@ -54,42 +32,52 @@ const IssueLabelToken = forwardRef((props, forwardedRef) => {
onClick,
...rest
} = props
const interactiveTokenProps = {
as,
href,
onClick,
}
const {colorScheme} = useTheme()

const interactiveTokenProps = {as, href, onClick}

const colorMode = useColorMode()

const hasMultipleActionTargets = isTokenInteractive(props) && Boolean(onRemove) && !hideRemoveButton
const onRemoveClick: MouseEventHandler = e => {
e.stopPropagation()
onRemove && onRemove()

const onRemoveClick: React.MouseEventHandler = event => {
event.stopPropagation()
onRemove?.()
}
const labelStyles: CSSObject = useMemo(() => {
const [r, g, b] = parseToRgba(fillColor)
const [h, s, l] = parseToHsla(fillColor)

// label hack taken from https://github.com/github/github/blob/master/app/assets/stylesheets/hacks/hx_primer-labels.scss#L43-L108
// 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.
const labelStyles: CSSObject = React.useMemo(() => {
// Parse label color into hue, saturation, lightness using HSLUV
const {h, s} = hexToHsluv(fillColor)

// Initialize color variables
let bgColor = ''
let textColor = ''
let borderColor = ''

// Set color variables based on current color mode
switch (colorMode) {
case 'light': {
bgColor = hsluvToHex({h, s: Math.min(s, 90), l: 97})
textColor = minContrast(hsluvToHex({h, s: Math.min(s, 85), l: 45}), bgColor, 4.5)
borderColor = hsluvToHex({h, s: Math.min(s, 70), l: 82})
break
}

case 'dark': {
bgColor = hsluvToHex({h, s: Math.min(s, 90), l: 8})
textColor = minContrast(hsluvToHex({h, s: Math.min(s, 50), l: 70}), bgColor, 4.5)
borderColor = hsluvToHex({h, s: Math.min(s, 80), l: 20})
break
}
}

return {
'--label-r': String(r),
'--label-g': String(g),
'--label-b': String(b),
'--label-h': String(Math.round(h)),
'--label-s': String(Math.round(s * 100)),
'--label-l': String(Math.round(l * 100)),
'--perceived-lightness':
'calc(((var(--label-r) * 0.2126) + (var(--label-g) * 0.7152) + (var(--label-b) * 0.0722)) / 255)',
'--lightness-switch': 'max(0, min(calc((var(--perceived-lightness) - var(--lightness-threshold)) * -1000), 1))',
paddingRight: hideRemoveButton || !onRemove ? undefined : 0,
position: 'relative',
...(colorScheme === 'light' ? lightModeStyles : darkModeStyles),
color: textColor,
background: bgColor,
border: `${tokenBorderWidthPx}px solid ${borderColor}`,
paddingRight: onRemove && !hideRemoveButton ? 0 : undefined,
...(isSelected
? {
background:
colorScheme === 'light'
? 'hsl(var(--label-h), calc(var(--label-s) * 1%), calc((var(--label-l) - 5) * 1%))'
: darkModeStyles.background,
':focus': {
outline: 'none',
},
Expand All @@ -103,17 +91,13 @@ const IssueLabelToken = forwardRef((props, forwardedRef) => {
left: `-${tokenBorderWidthPx * 2}px`,
display: 'block',
pointerEvents: 'none',
boxShadow: `0 0 0 ${tokenBorderWidthPx * 2}px ${
colorScheme === 'light'
? 'rgb(var(--label-r), var(--label-g), var(--label-b))'
: 'hsl(var(--label-h), calc(var(--label-s) * 1%), calc((var(--label-l) + var(--lighten-by)) * 1%))'
}`,
boxShadow: `0 0 0 ${tokenBorderWidthPx * 2}px ${textColor}`,
borderRadius: '999px',
},
}
: {}),
}
}, [colorScheme, fillColor, isSelected, hideRemoveButton, onRemove])
}, [colorMode, fillColor, isSelected, hideRemoveButton, onRemove])

return (
<TokenBase
Expand Down Expand Up @@ -152,3 +136,49 @@ const IssueLabelToken = forwardRef((props, forwardedRef) => {
IssueLabelToken.displayName = 'IssueLabelToken'

export default IssueLabelToken

// Helper functions

function useColorMode(): 'light' | 'dark' {
const {theme} = useTheme()
// Determine color mode by luminance
const colorMode = getLuminance(get('colors.canvas.default')({theme}) || '#fff') > 0.5 ? 'light' : 'dark'
return colorMode
}

function hexToHsluv(hex: string) {
const color = new Hsluv()
color.hex = toHex(hex) // Ensure hex is actually a hex color
color.hexToHsluv()
return {h: color.hsluv_h, s: color.hsluv_s, l: color.hsluv_l}
}

function hsluvToHex({h, s, l}: {h: number; s: number; l: number}) {
const color = new Hsluv()
// eslint-disable-next-line camelcase
color.hsluv_h = h
// eslint-disable-next-line camelcase
color.hsluv_s = s
// eslint-disable-next-line camelcase
color.hsluv_l = l
color.hsluvToHex()
return color.hex
}

/** Returns a foreground color that has a given minimum contrast ratio against the given background color */
function minContrast(fg: string, bg: string, minRatio: number) {
// eslint-disable-next-line prefer-const
let {h, s, l} = hexToHsluv(fg)

// While foreground color doesn't meet the contrast ratio,
// increase or decrease the lightness until it does
while (getContrast(hsluvToHex({h, s, l}), bg) < minRatio && l <= 100 && l >= 0) {
if (getLuminance(bg) > getLuminance(fg)) {
l -= 1
} else {
l += 1
}
}

return hsluvToHex({h, s, l})
}
Loading

0 comments on commit d6b8583

Please sign in to comment.