diff --git a/README.md b/README.md index 826f18e..a5ee82b 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 1bd5bd1..aed944e 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -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() const requiredContrastRatioChangeHandler = useCallback((_event: unknown, value: number | number[]) => { setRequiredContrastRatio(Number(value)) @@ -49,6 +50,10 @@ function App(): JSX.Element { setPreserveContrastDirectionIfPossible(event.target.checked) }, []) + const backgroundColorOverrideChangeHandler = useCallback((event: React.ChangeEvent) => { + setBackgroundColorOverride(event.target.checked ? '#000000' : undefined) + }, []) + return ( <> @@ -66,6 +71,7 @@ function App(): JSX.Element { requiredContrastRatio={requiredContrastRatio} flipBlackAndWhite={flipBlackAndWhite} preserveContrastDirectionIfPossible={preserveContrastDirectionIfPossible} + backgroundColorOverride={backgroundColorOverride} >

{'With '}

@@ -87,6 +93,7 @@ function App(): JSX.Element { requiredContrastRatio={requiredContrastRatio} flipBlackAndWhite={flipBlackAndWhite} preserveContrastDirectionIfPossible={preserveContrastDirectionIfPossible} + backgroundColorOverride={backgroundColorOverride} >
@@ -97,14 +104,14 @@ function App(): JSX.Element { - + Background Color - + Foreground Color @@ -145,6 +152,22 @@ function App(): JSX.Element { /> + + + Background Color Override + + {backgroundColorOverride && ( + <> + + + + )} + + diff --git a/src/ReactColorA11y.cy.tsx b/src/ReactColorA11y.cy.tsx index c258239..b54c1e2 100644 --- a/src/ReactColorA11y.cy.tsx +++ b/src/ReactColorA11y.cy.tsx @@ -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( +
+ +

{'text'}

+
+
+ ) + + cy.contains('text').shouldHaveColor('css', 'color', 'rgb(0, 0, 0)') + }) + }) + + describe('transparency handling', () => { + it('should consider alpha values when determining effective background', () => { + cy.mount( +
+
+
+ +

{'text'}

+
+
+
+
+ ) + + cy.contains('text').shouldHaveColor('css', 'color', 'rgb(143, 143, 143)') + }) + }) }) diff --git a/src/ReactColorA11y.tsx b/src/ReactColorA11y.tsx index 5401818..71efc3a 100644 --- a/src/ReactColorA11y.tsx +++ b/src/ReactColorA11y.tsx @@ -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 @@ -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 => { @@ -58,6 +84,7 @@ export interface ReactColorA11yProps { requiredContrastRatio?: number flipBlackAndWhite?: boolean preserveContrastDirectionIfPossible?: boolean + backgroundColorOverride?: string } const ReactColorA11y: React.FunctionComponent = ({ @@ -65,13 +92,13 @@ const ReactColorA11y: React.FunctionComponent = ({ 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) { @@ -137,32 +164,34 @@ const ReactColorA11y: React.FunctionComponent = ({ 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) } } }