Skip to content

Commit

Permalink
Consider alpha values when determining effective background (#15)
Browse files Browse the repository at this point in the history
## Describe your changes
Consider alpha values when determining effective background

## Checklist before requesting a review
- [x] I have performed a self-review of my code
- [x] If it is a core feature, I have added thorough tests.
  • Loading branch information
lounsbrough authored Jan 19, 2024
1 parent 976e4db commit e9c57b4
Show file tree
Hide file tree
Showing 4 changed files with 107 additions and 22 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,4 @@ It is recommended to provide a single child component inside `ReactColorA11y`. I
| requiredContrastRatio | number | 4.5 | This is the contrast Ratio that is required. Depending on the original colors, it may not be able to be reached, but will get as close as possible. https://webaim.org/resources/contrastchecker |
| flipBlackAndWhite | bool | false | This is an edge case. Should `#000000` be flipped to `#ffffff` when lightening, or should it only lighten as much as it needs to reach the required contrast ratio? Similarly for the opposite case. |
| preserveContrastDirectionIfPossible | bool | true | Try to preserve original contrast direction. For example, if the original foreground color is lighter than the background, try to lighten the foreground. If the required contrast ratio can not be met by lightening, then darkening may occur as determined by the luminance threshold. |
| backgroundColorOverride | string | '' | If provided, this color will be used as the effective background color for determining the foreground color. This may be necessary if autodetection of the effective background color is not working, because of absolute positioning, z-index, or other cases where determining this is complex. |
27 changes: 25 additions & 2 deletions demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function App(): JSX.Element {
const [requiredContrastRatio, setRequiredContrastRatio] = useState(4.5)
const [flipBlackAndWhite, setFlipBlackAndWhite] = useState(false)
const [preserveContrastDirectionIfPossible, setPreserveContrastDirectionIfPossible] = useState(true)
const [backgroundColorOverride, setBackgroundColorOverride] = useState<string | undefined>()

const requiredContrastRatioChangeHandler = useCallback((_event: unknown, value: number | number[]) => {
setRequiredContrastRatio(Number(value))
Expand All @@ -49,6 +50,10 @@ function App(): JSX.Element {
setPreserveContrastDirectionIfPossible(event.target.checked)
}, [])

const backgroundColorOverrideChangeHandler = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setBackgroundColorOverride(event.target.checked ? '#000000' : undefined)
}, [])

return (
<>
<Box sx={{ background: backgroundColor, flexGrow: 1, padding: 2 }}>
Expand All @@ -66,6 +71,7 @@ function App(): JSX.Element {
requiredContrastRatio={requiredContrastRatio}
flipBlackAndWhite={flipBlackAndWhite}
preserveContrastDirectionIfPossible={preserveContrastDirectionIfPossible}
backgroundColorOverride={backgroundColorOverride}
>
<div ref={textContentRef} style={{ padding: '20px', color: foregroundColor }}>
<h3>{'With <ReactColorA11y>'}</h3>
Expand All @@ -87,6 +93,7 @@ function App(): JSX.Element {
requiredContrastRatio={requiredContrastRatio}
flipBlackAndWhite={flipBlackAndWhite}
preserveContrastDirectionIfPossible={preserveContrastDirectionIfPossible}
backgroundColorOverride={backgroundColorOverride}
>
<div ref={svgContentRef}>
<SvgContent fillColor={foregroundColor} />
Expand All @@ -97,14 +104,14 @@ function App(): JSX.Element {
</Box>
<Box sx={{ flexGrow: 1, padding: 5 }}>
<Grid container spacing={2} alignItems="center">
<Grid xs={12} lg={3}>
<Grid xs={12} xl={2} md={3}>
<SettingsBox>
<Typography gutterBottom>Background Color</Typography>
<HexColorPicker style={{ margin: '15px auto' }} color={backgroundColor} onChange={setBackgroundColor} />
<HexColorInput alpha color={backgroundColor} onChange={setBackgroundColor} />
</SettingsBox>
</Grid>
<Grid xs={12} lg={3}>
<Grid xs={12} xl={2} md={3}>
<SettingsBox>
<Typography gutterBottom>Foreground Color</Typography>
<HexColorPicker style={{ margin: '15px auto' }} color={foregroundColor} onChange={setForegroundColor} />
Expand Down Expand Up @@ -145,6 +152,22 @@ function App(): JSX.Element {
/>
</SettingsBox>
</Grid>
<Grid xs={12} xl={2} md={3}>
<SettingsBox>
<Typography gutterBottom>Background Color Override</Typography>
<Switch
checked={!!backgroundColorOverride}
onChange={backgroundColorOverrideChangeHandler}
inputProps={{ 'aria-label': 'controlled' }}
/>
{backgroundColorOverride && (
<>
<HexColorPicker style={{ margin: '15px auto' }} color={backgroundColorOverride} onChange={setBackgroundColorOverride} />
<HexColorInput alpha color={backgroundColorOverride} onChange={setBackgroundColorOverride} />
</>
)}
</SettingsBox>
</Grid>
</Grid>
</Box>
</>
Expand Down
32 changes: 32 additions & 0 deletions src/ReactColorA11y.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,4 +176,36 @@ describe('ReactColorA11y', () => {
cy.contains('text').shouldHaveColor('css', 'color', 'rgb(227, 227, 227)')
})
})

describe('backgroundColor prop', () => {
it('should allow consumer to override background color if needed', () => {
cy.mount(
<div style={{ backgroundColor: 'rgb(0, 0, 0)' }}>
<ReactColorA11y backgroundColorOverride='rgb(255, 255, 255)'>
<p style={{ color: 'rgb(0, 0, 0)' }}>{'text'}</p>
</ReactColorA11y>
</div>
)

cy.contains('text').shouldHaveColor('css', 'color', 'rgb(0, 0, 0)')
})
})

describe('transparency handling', () => {
it('should consider alpha values when determining effective background', () => {
cy.mount(
<div style={{ backgroundColor: 'rgb(0, 0, 0)' }}>
<div style={{ backgroundColor: 'rgb(255, 200, 200, 0.05)' }}>
<div style={{ backgroundColor: 'rgb(200, 255, 200, 0.1)' }}>
<ReactColorA11y>
<p style={{ color: 'rgb(10, 10, 10)' }}>{'text'}</p>
</ReactColorA11y>
</div>
</div>
</div >
)

cy.contains('text').shouldHaveColor('css', 'color', 'rgb(143, 143, 143)')
})
})
})
69 changes: 49 additions & 20 deletions src/ReactColorA11y.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import React, { useEffect, useRef, cloneElement, isValidElement, ReactNode, Reac
import { colord, extend as extendColord, type Colord } from 'colord'
import colordNamesPlugin from 'colord/plugins/names'
import colordA11yPlugin from 'colord/plugins/a11y'
import colordMixPlugin from 'colord/plugins/mix'

extendColord([colordNamesPlugin, colordA11yPlugin])
extendColord([colordNamesPlugin, colordA11yPlugin, colordMixPlugin])

interface TargetLuminence {
min?: number
Expand All @@ -15,23 +16,48 @@ enum LuminanceChangeDirection {
Darken
}

const getEffectiveBackgroundColor = (element: Element): string | null => {
const backgroundColor = getComputedStyle(element).backgroundColor
const getBackgroundColordStack = (element: Element) => {
const stack = []
let currentElement = element

if ((backgroundColor !== 'rgba(0, 0, 0, 0)') && (backgroundColor !== 'transparent')) {
return backgroundColor
while (currentElement.parentElement) {
const { backgroundColor } = getComputedStyle(currentElement)

if (backgroundColor) {
const currentBackgroundColord = colord(backgroundColor)
stack.push(currentBackgroundColord)

if (currentBackgroundColord.alpha() === 1) {
break
}
}

currentElement = currentElement.parentElement
}

if (element.nodeName === 'body') {
return stack
}

const blendLayeredColors = (colors: Colord[]) => {
if (!colors.length) {
return null
}

const { parentElement } = element
if (parentElement !== null) {
return getEffectiveBackgroundColor(parentElement)
let mixedColord = colors.pop()!
let nextColord = colors.pop()
while (nextColord) {
const ratio = nextColord.alpha()
if (ratio > 0) {
mixedColord = mixedColord.mix(nextColord.alpha(1), ratio)
}
nextColord = colors.pop()
}

return backgroundColor
return mixedColord
}

const getEffectiveBackgroundColor = (element: Element): Colord | null => {
return blendLayeredColors(getBackgroundColordStack(element))
}

const shiftBrightnessUntilTargetLuminence = (originalColord: Colord, targetLuminence: TargetLuminence): Colord => {
Expand All @@ -58,20 +84,21 @@ export interface ReactColorA11yProps {
requiredContrastRatio?: number
flipBlackAndWhite?: boolean
preserveContrastDirectionIfPossible?: boolean
backgroundColorOverride?: string
}

const ReactColorA11y: React.FunctionComponent<ReactColorA11yProps> = ({
children,
colorPaletteKey = 'default',
requiredContrastRatio = 4.5,
flipBlackAndWhite = false,
preserveContrastDirectionIfPossible = true
preserveContrastDirectionIfPossible = true,
backgroundColorOverride
}: ReactColorA11yProps): JSX.Element => {
const internalRef = useRef(null)
const reactColorA11yRef = children?.ref ?? internalRef

const calculateA11yColor = (backgroundColor: string, originalColor: string): string => {
const backgroundColord = colord(backgroundColor)
const calculateA11yColor = (backgroundColord: Colord, originalColor: string): string => {
const originalColord = colord(originalColor)

if (backgroundColord.contrast(originalColord) >= requiredContrastRatio) {
Expand Down Expand Up @@ -137,32 +164,34 @@ const ReactColorA11y: React.FunctionComponent<ReactColorA11yProps> = ({
return
}

const backgroundColor = getEffectiveBackgroundColor(element)
const backgroundColord = backgroundColorOverride
? colord(backgroundColorOverride)
: getEffectiveBackgroundColor(element)

if (backgroundColor === null) {
if (backgroundColord === null) {
return
}

const fillColor = element.getAttribute('fill')
if (fillColor !== null) {
element.setAttribute('fill', calculateA11yColor(backgroundColor, fillColor))
element.setAttribute('fill', calculateA11yColor(backgroundColord, fillColor))
}

const strokeColor = element.getAttribute('stroke')
if (strokeColor !== null) {
element.setAttribute('stroke', calculateA11yColor(backgroundColor, strokeColor))
element.setAttribute('stroke', calculateA11yColor(backgroundColord, strokeColor))
}

if (element.style !== undefined) {
const { color: computedColor, stroke: computedStroke, fill: computedFill } = getComputedStyle(element)
if (computedColor !== null) {
element.style.color = calculateA11yColor(backgroundColor, computedColor)
element.style.color = calculateA11yColor(backgroundColord, computedColor)
}
if (computedFill !== null) {
element.style.fill = calculateA11yColor(backgroundColor, computedFill)
element.style.fill = calculateA11yColor(backgroundColord, computedFill)
}
if (computedStroke !== null) {
element.style.stroke = calculateA11yColor(backgroundColor, computedStroke)
element.style.stroke = calculateA11yColor(backgroundColord, computedStroke)
}
}
}
Expand Down

0 comments on commit e9c57b4

Please sign in to comment.