Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ColorPalette: Ensure text label contrast checking works with CSS variables #47373

Merged
merged 16 commits into from
Feb 7, 2023

Conversation

t-hamano
Copy link
Contributor

@t-hamano t-hamano commented Jan 24, 2023

Closes: #41459

What?

This PR performs a contrast check for text labels displayed in the custom color palette, taking into account CSS variables, and displays them in the correct color.

Why?

Passing a CSS variable to contrast() function of the colord returns an incorrect contrast value because it cannot interpret the value correctly. As a result, the text labels in the custom color palette will always be white (#fff).

How?

As was done in #47181, I used computed colors from the custom color palette for the contrast check.

Issues about transparency

I have discovered that the contrast() function doesn't take into account the alpha value, as reported in this issue.

For example, the following two return the same value:

console.log( colord( 'rgb(100,100,100)' ).contrast() );
console.log( colord( 'rgba(100,100,100, 0.2)' ).contrast() );

I think that we need to use a library that converts rgba to rgb, or similar logic, and would like to discuss the appropriate approach.

Testing Instructions

In the block editor

Enable Empty Theme and update theme.json as follows:

/test/emptytheme/theme.json
{
	"$schema": "https://schemas.wp.org/trunk/theme.json",
	"version": 2,
	"settings": {
		"appearanceTools": true,
		"layout": {
			"contentSize": "840px",
			"wideSize": "1100px"
		},
		"color": {
			"palette": [
				{
					"name": "Hex Red",
					"slug": "hex-red",
					"color": "var(--wp--custom--hex-red)"
				},
				{
					"name": "RGB Red",
					"slug": "rgb-red",
					"color": "var(--wp--custom--rgb-red)"
				},
				{
					"name": "RGBA Red",
					"slug": "rgba-red",
					"color": "var(--wp--custom--rgba-red)"
				},
				{
					"name": "Hex Green",
					"slug": "hex-green",
					"color": "var(--wp--custom--hex-green)"
				},
				{
					"name": "RGB Green",
					"slug": "rgb-green",
					"color": "var(--wp--custom--rgb-green)"
				},
				{
					"name": "RGBA Green",
					"slug": "rgba-green",
					"color": "var(--wp--custom--rgba-green)"
				},
				{
					"name": "Hex Blue",
					"slug": "hex-blue",
					"color": "var(--wp--custom--hex-blue)"
				},
				{
					"name": "RGB Blue",
					"slug": "rgb-blue",
					"color": "var(--wp--custom--rgb-blue)"
				},
				{
					"name": "RGBA Blue",
					"slug": "rgba-blue",
					"color": "var(--wp--custom--rgba-blue)"
				},
				{
					"name": "Hex Yellow",
					"slug": "hex-yellow",
					"color": "var(--wp--custom--hex-yellow)"
				},
				{
					"name": "RGB Yellow",
					"slug": "rgb-yellow",
					"color": "var(--wp--custom--rgb-yellow)"
				},
				{
					"name": "RGBA Yellow",
					"slug": "rgba-yellow",
					"color": "var(--wp--custom--rgba-yellow)"
				}
			]
		},
		"custom": {
			"hex-red": "#E10800",
			"rgb-red": "rgb(225,8,0)",
			"rgba-red": "rgba(225,8,0,0.3)",
			"hex-green": "#3EC300",
			"rgb-green": "rgb(62,195,0)",
			"rgba-green": "rgba(62,195,0,0.3)",
			"hex-blue": "#337CA0",
			"rgb-blue": "rgb(51,124,160)",
			"rgba-blue": "rgba(51,124,160,0.3)",
			"hex-yellow": "#FFFC31",
			"rgb-yellow": "rgb(255,252,49)",
			"rgba-yellow": "rgba(255,252,49,0.3)"
		}
	}
}
  • Inserts a block that supports the color.
  • Open the Text Color or Background Color menu and click on one of the palettes in the THEME area.
  • Confirm that the text label changes between black and white depending on the color selected.

Note: Palettes with rgba values may result in unintended colors, as mentioned above.

0be534e8c412514e7d53af84bb3349bf.mp4

In the storybook

  • Build Storybook and access "ColorPalette" > "CSS Variables".
  • Confirm that the text label has black color when red or yellow is selected.
storybook.mp4

@t-hamano t-hamano changed the title ColorPalette: Ensure text label contrast checking works with CSS vari… ColorPalette: Ensure text label contrast checking works with CSS variables Jan 24, 2023
@t-hamano t-hamano self-assigned this Jan 24, 2023
@t-hamano t-hamano added [Type] Bug An existing feature does not function as intended [Package] Components /packages/components labels Jan 24, 2023
@t-hamano t-hamano marked this pull request as ready for review January 24, 2023 07:50
@t-hamano t-hamano requested review from mirka and ciampo January 24, 2023 07:51
@ciampo
Copy link
Contributor

ciampo commented Jan 25, 2023

cc'ing @brookewp as she's been also looking at how to best take transparent values into account when computing accessible contrast with colord

Copy link
Contributor

@ciampo ciampo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is testing well for me! I just left a comment about a potential edge case.

Noted re. the transparency issue — as pointed out in the previous comment, it was also noticed by Brooke a few days ago. I highlighted what I believe are the challenges in doing this properly in this comment in the original colord issue — TL;DR: we need to find a way to "merge" 2 or more colors into a solid color to be used for colord accessibility calculations, since when the color is transparent, the color underneath affects the contrast too.

setNormalizedColorValue(
normalizeColorValue( value, customColorPaletteRef )
);
}, [ value ] );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this effect runs only when value changes and has an early return when customColorPaletteRef.current, I think that there could be an issue with this scenario:

  1. component renders with disableCustomColors prop set to true
    • this means that CustomColorPickerDropdown is not rendered, which means that customColorPaletteRef.current is null
    • this also means that setNormalizedColorValue doesn't get called, because of the early return on the ref check
  2. Later, disableCustomColors gets set to false
    • customColorPaletteRef.current gets updated to reference the HTML element, but that doesn't cause the component to re-render (that's how a React ref works)
  3. The normalizedColorValue at this point could be out of sync, because the useEffect hook that is in charge of calculating the normalizedColorValue won't be called until the value prop changes

We could do some manual testing (or unit testing) to check against this behaviour. A couple of approaches to fix this potential issue could be to:

  1. Add disableCustomColors as a dependency of the useEffect hook (and use disableCustomColors in the hook internal check) — this is more of an indirect check, and it's not my favourite option because it's not clear why we'd need to check for it
  2. Store customColorPaletteRef in the component state, by using a ref callback + useState. This method would ensure that, when the ref updates, the component re-renders — thus forcing the useEffect hook to run and keep normalizeColorValue in sync

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, I have confirmed that the re-rendering will not take place even if disableCustomColors is changed. Below is an example where the text labels remain in an unintended color because normalizedColorValue is not executed when disableCustomColors is switched:

I may not have understood your advice correctly, but in 75f9c62, it appears to have worked correctly by rewriting useEffect with useCallback. Is this implementation appropriate?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Below is an example

I can't see any example, maybe the attachment didn't upload correctly?

I may not have understood your advice correctly, but in 75f9c62, it appears to have worked correctly by rewriting useEffect with useCallback. Is this implementation appropriate?

Excuses if I wasn't clear enough with my advice.

Your implementation looks good and works well in my tests — but I think we can clean it up further:

  • we can change the normalizeColorValue color value to accept directly an element (HTMLElement | null), instead of a ref
  • since we're using a callback ref, there's no need to keep the customColorPaletteRef variable
diff --git a/packages/components/src/color-palette/index.tsx b/packages/components/src/color-palette/index.tsx
index 48fca6bc44..abba5855b1 100644
--- a/packages/components/src/color-palette/index.tsx
+++ b/packages/components/src/color-palette/index.tsx
@@ -182,7 +182,6 @@ function UnforwardedColorPalette(
 	props: WordPressComponentProps< ColorPaletteProps, 'div' >,
 	forwardedRef: ForwardedRef< any >
 ) {
-	const customColorPaletteRef = useRef< HTMLElement | null >( null );
 	const {
 		clearable = true,
 		colors = [],
@@ -197,13 +196,8 @@ function UnforwardedColorPalette(
 	const clearColor = useCallback( () => onChange( undefined ), [ onChange ] );
 
 	const customColorPaletteCallbackRef = useCallback(
-		( ref: HTMLElement | null ) => {
-			if ( ref ) {
-				customColorPaletteRef.current = ref;
-				setNormalizedColorValue(
-					normalizeColorValue( value, customColorPaletteRef )
-				);
-			}
+		( node: HTMLElement | null ) => {
+			setNormalizedColorValue( normalizeColorValue( value, node ) );
 		},
 		[ value ]
 	);
diff --git a/packages/components/src/color-palette/utils.ts b/packages/components/src/color-palette/utils.ts
index 0e988e0fb0..38b2722912 100644
--- a/packages/components/src/color-palette/utils.ts
+++ b/packages/components/src/color-palette/utils.ts
@@ -80,19 +80,18 @@ export const isMultiplePaletteArray = (
 
 export const normalizeColorValue = (
 	value: string | undefined,
-	ref: RefObject< HTMLElement > | null
+	element: HTMLElement | null
 ) => {
 	const currentValueIsCssVariable = /^var\(/.test( value ?? '' );
 
-	if ( ! currentValueIsCssVariable || ! ref?.current ) {
+	if ( ! currentValueIsCssVariable || element === null ) {
 		return value;
 	}
 
-	const { ownerDocument } = ref.current;
+	const { ownerDocument } = element;
 	const { defaultView } = ownerDocument;
-	const computedBackgroundColor = defaultView?.getComputedStyle(
-		ref.current
-	).backgroundColor;
+	const computedBackgroundColor =
+		defaultView?.getComputedStyle( element ).backgroundColor;
 
 	return computedBackgroundColor
 		? colord( computedBackgroundColor ).toHex()

What do you think?

It would also be great if we added a JSDoc comment to the normalizeColorValue utility function, to specify that currently its purpose is to "transform" a CSS variable used as background color into the color value itself.

@t-hamano
Copy link
Contributor Author

t-hamano commented Jan 26, 2023

TL;DR: we need to find a way to "merge" 2 or more colors into a solid color to be used for colord accessibility calculations, since when the color is transparent, the color underneath affects the contrast too.

I tried the mix() method on 9327bc9 to get a real visible color from a transparent rgba color.

The logic is as follows:

  • In this case, the underlying color (background color of popover content) should be white.
  • If the alpha value of the palette color is less than 1, it is considered transparent.
  • If it is transparent, mix a non-transparent color and #fff.
  • The original alpha value is used for ratio, the second argument of mix().

From what I have tested, it seems to be working well, but what about this approach?

ee2ef025c761114ad0967eb11b83ecd9.mp4

@ciampo
Copy link
Contributor

ciampo commented Jan 26, 2023

It seems to be working well!

I think we can refine this approach in a few ways:

  1. We could move it to a separate utility (ie. getCompositeBackgroundColor or something?)
  2. I'd like to find a way to avoid having a hardcoded #fff value. But this is not trivial, since background-color can not be read from getComputedStyle on the parent.
  • We could force the background color inside the component to be var( --wp-components-color-background ), and therefore we could then run getComputedStyle on that DOM element and use it for the computation
  • Alternatively (but this is way more convoluted), we could find a way of "taking a screenshot" of the element, print it to a canvas in memory, then sample the resulting color. This second method would be also better in case of multiple layers of transparent background colors

What do you think? Curious to hear @mirka 's thoughts too

@brookewp
Copy link
Contributor

This is great! I tried the 'alpha blending' formula for #47476 (I quickly put it up as a draft to share) in relation to this issue #42715 which yielded nearly identical results. However, your solution is much cleaner. :)

Screenshot 2023-01-26 at 3 58 40 PM

Since you're discussing how to approach the background color, I wanted to share the above PR/issue as it's been requested to show the checkered background behind semi-transparent color values. I believe we should use grey rather than white for the background, as I think there will be more readability issues with the text over the grey. What do you all think?

@ciampo
Copy link
Contributor

ciampo commented Jan 27, 2023

Thank you for chiming in, Brooke!

which yielded nearly identical results. However, your solution is much cleaner. :)

Awesome! The timing of the two of you working on these related issues couldn't have been better :)

it's been requested to show the checkered background behind semi-transparent color values

Thank you for sharing this and giving more perspective on the requirements for this component!

I believe we should use grey rather than white for the background, as I think there will be more readability issues with the text over the grey. What do you all think?

I'm not 100% sure I understand your suggestion here — I can see 2 ways of interpret it:

  1. Visually show a grey background as the background color for the ColorPalette picker
  2. Continue to show a white background, while using a mid grey (instead of white) when computing the final blended color, to make up for the checkered background

The challenge here is that we can't assume that the background would always be white, especially since soon we may expose the ability for consumers of the package to change the background color to any color), and therefore we can't assume in advance what will the resulting "average" color be.

I also though about whether we could instead just assume a "darkened" version of the background, but that also wouldn't work well in case we ever switched to a "dark" theme.

I think it's probably ok to "ignore" the impact of the checkered color for now — we can take a look at the results and iterate if necessary?

@github-actions
Copy link

github-actions bot commented Jan 28, 2023

Flaky tests detected in 2945982.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/4109786938
📝 Reported issues:

@t-hamano
Copy link
Contributor Author

Thanks for the review, @ciampo!

We could move it to a separate utility (ie. getCompositeBackgroundColor or something?)

I have responded at 98bf1dc. If you find anything inappropriate, please feel free to commit 🙏

We could force the background color inside the component to be var( --wp-components-color-background ), and therefore we could then run getComputedStyle on that DOM element and use it for the computation

Does this variable var( --wp-components-color-background ) have anything to do with the themeable color implemented in #45466?

@mirka
Copy link
Member

mirka commented Jan 30, 2023

My suggestion is to keep the scope of this PR to the CSs variable issue only, and deal with alphas as a separate issue.

This is because I think the alpha issue may require some design changes that should get proper input from the design team. We probably do in fact need to keep the checkered background showing through to signal that the color is semi-transparent. In this case we will always (I think?) run into readability issues at some point in the gradient scale if we put text on top of that checkered pattern. I think it's inevitable that we modify the design so it accommodates this case — for example, placing the text outside of the swatch, or placing the text on a pill-style solid background. The current design really does not take into account things like CSS variables (i.e. longer strings) and transparency.

Overcomplicating the algorithm may not be necessary once the design issue is addressed!

Copy link
Contributor

@ciampo ciampo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great job so far, @t-hamano !

Could you also add some unit tests around CSS variables, so that we can prevent regressions in the future?

The unit tests CI issue should hopefully go away after rebasing on latest trunk.

My suggestion is to keep the scope of this PR to the CSs variable issue only, and deal with alphas as a separate issue.

@mirka makes a good point here — I agree that we should keep this PR focused on the CSS variable issue. We can tackle the transparency problem separately (or even, as Lena points out, solve it at the design level).

setNormalizedColorValue(
normalizeColorValue( value, customColorPaletteRef )
);
}, [ value ] );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Below is an example

I can't see any example, maybe the attachment didn't upload correctly?

I may not have understood your advice correctly, but in 75f9c62, it appears to have worked correctly by rewriting useEffect with useCallback. Is this implementation appropriate?

Excuses if I wasn't clear enough with my advice.

Your implementation looks good and works well in my tests — but I think we can clean it up further:

  • we can change the normalizeColorValue color value to accept directly an element (HTMLElement | null), instead of a ref
  • since we're using a callback ref, there's no need to keep the customColorPaletteRef variable
diff --git a/packages/components/src/color-palette/index.tsx b/packages/components/src/color-palette/index.tsx
index 48fca6bc44..abba5855b1 100644
--- a/packages/components/src/color-palette/index.tsx
+++ b/packages/components/src/color-palette/index.tsx
@@ -182,7 +182,6 @@ function UnforwardedColorPalette(
 	props: WordPressComponentProps< ColorPaletteProps, 'div' >,
 	forwardedRef: ForwardedRef< any >
 ) {
-	const customColorPaletteRef = useRef< HTMLElement | null >( null );
 	const {
 		clearable = true,
 		colors = [],
@@ -197,13 +196,8 @@ function UnforwardedColorPalette(
 	const clearColor = useCallback( () => onChange( undefined ), [ onChange ] );
 
 	const customColorPaletteCallbackRef = useCallback(
-		( ref: HTMLElement | null ) => {
-			if ( ref ) {
-				customColorPaletteRef.current = ref;
-				setNormalizedColorValue(
-					normalizeColorValue( value, customColorPaletteRef )
-				);
-			}
+		( node: HTMLElement | null ) => {
+			setNormalizedColorValue( normalizeColorValue( value, node ) );
 		},
 		[ value ]
 	);
diff --git a/packages/components/src/color-palette/utils.ts b/packages/components/src/color-palette/utils.ts
index 0e988e0fb0..38b2722912 100644
--- a/packages/components/src/color-palette/utils.ts
+++ b/packages/components/src/color-palette/utils.ts
@@ -80,19 +80,18 @@ export const isMultiplePaletteArray = (
 
 export const normalizeColorValue = (
 	value: string | undefined,
-	ref: RefObject< HTMLElement > | null
+	element: HTMLElement | null
 ) => {
 	const currentValueIsCssVariable = /^var\(/.test( value ?? '' );
 
-	if ( ! currentValueIsCssVariable || ! ref?.current ) {
+	if ( ! currentValueIsCssVariable || element === null ) {
 		return value;
 	}
 
-	const { ownerDocument } = ref.current;
+	const { ownerDocument } = element;
 	const { defaultView } = ownerDocument;
-	const computedBackgroundColor = defaultView?.getComputedStyle(
-		ref.current
-	).backgroundColor;
+	const computedBackgroundColor =
+		defaultView?.getComputedStyle( element ).backgroundColor;
 
 	return computedBackgroundColor
 		? colord( computedBackgroundColor ).toHex()

What do you think?

It would also be great if we added a JSDoc comment to the normalizeColorValue utility function, to specify that currently its purpose is to "transform" a CSS variable used as background color into the color value itself.

@t-hamano
Copy link
Contributor Author

t-hamano commented Feb 4, 2023

Update:

  • ✅ Don't consider transparent color
  • ✅ Don't use ref, simplify normalizeColorValue() function
  • ✅ Added JSDoc to normalizeColorValue() function
  • ❌ Added two unit tests

In my unit tests, I expected the following, but both tests failed.

  • Custom color picker should have label color based on actual background color
  • When the custom color picker button is clicked, The color picker should have a HEX value based on the computed color

It appears that the normalizeColorValue() function is not getting the computed style of the element correctly. Or maybe I'm not passing the CSS variables to the test component correctly.

If there is a way to resolve this, I would love to hear about it 🙏

@ciampo
Copy link
Contributor

ciampo commented Feb 6, 2023

Mhh, it looks like CSS custom properties are not fully supported in jsdom. In case we can't write tests at the component level, we could at least write unit test for the normalizeColorValue function 🤷

@t-hamano
Copy link
Contributor Author

t-hamano commented Feb 6, 2023

I have changed to a unit test for the normalizeColorValue() function in e225bf5. I generated the argument element with createElement. The test passes as expected, but I just can't seem to fix the two type errors...

Comment on lines 54 to 57
// @ts-ignore
defaultView.getComputedStyle = () => ( {
backgroundColor: '#ff0000',
} );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not ideal, of course, but better than nothing. I'm not sure if @mirka has any idea to who we could get getComputedStyle to work our tests (afterall, it seems to work as expected in Theme's tests).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or even @jsnajdr , given his recent work on getComputedStyle

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSDOM has a very limited support for CSS, and it doesn't expand CSS variables to their real values. That means that normalizeValue is not possible to unit test with Jest and JSDOM. I would consider if I want to keep the unit test at all.

A less harsh way to "fake" the CSS value is to set the style on the tested element itself:

element.style = { backgroundColor: '#ff0000' };

Then getComputedStyle( element ) will find this style and will return it.

By the way, the normalizeValue function would be better off if its second param was just the element, not a ref:

normalizeColorValue( value: string | undefined, element: HTMLElement | undefined )

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, Jarda!

A less harsh way to "fake" the CSS value is to set the style on the tested element itself:

Good point. @t-hamano , would you mind trying out this approach?

By the way, the normalizeValue function would be better off if its second param was just the element, not a ref:

Agreed! Although this change should have been applied in a previous commit

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the advice! Perhaps I was thinking too hard. I think we handled it well with 2945982.

@ciampo
Copy link
Contributor

ciampo commented Feb 7, 2023

A rebase/merge with trunk should hopefully solve the CI failures (and the conflicts) — after that, we should be ready for final review & approval!

Copy link
Contributor

@ciampo ciampo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 LGTM

Thank you for working on this fix 🙏

@ciampo ciampo merged commit 805618c into trunk Feb 7, 2023
@ciampo ciampo deleted the fix/colorpalette-contrast-check branch February 7, 2023 17:22
@github-actions github-actions bot added this to the Gutenberg 15.2 milestone Feb 7, 2023
@ciampo ciampo added the Backport to WP 6.7 Beta/RC Pull request that needs to be backported to the WordPress major release that's currently in beta label Feb 7, 2023
@t-hamano
Copy link
Contributor Author

t-hamano commented Feb 8, 2023

Thank you, @ciampo!

ntsekouras pushed a commit that referenced this pull request Feb 9, 2023
…ables (#47373)

* ColorPalette: Ensure text label contrast checking works with CSS variables

* use `useEffect` to get normalized color value

* Update changelog

* Try to detect actual color from rgba

* Use function to get the composite background color

* Rewrite useEffect with useCallback

* Don't consider transparent color

* Don't use ref, simplify normalizeColorValue() function

* Add JSDoc

* Add unit tests

* Refactor unit tests

* Refactor unit test
@ntsekouras
Copy link
Contributor

Cherry-picked this PR to the wp/6.2 branch.

@ntsekouras ntsekouras removed the Backport to WP 6.7 Beta/RC Pull request that needs to be backported to the WordPress major release that's currently in beta label Feb 9, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Package] Components /packages/components [Type] Bug An existing feature does not function as intended
Projects
None yet
Development

Successfully merging this pull request may close these issues.

ColorPalette: Contrast checking doesn't work with CSS variables
6 participants