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

CustomGradientPicker: Support CSS variables #63915

Open
wants to merge 19 commits into
base: trunk
Choose a base branch
from
Open
5 changes: 5 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@
- `ColorPalette`: Remove extra bottom margin when `CircularOptionPicker` is unneeded ([#63961](https://github.com/WordPress/gutenberg/pull/63961)).
- `CustomSelectControl`: Restore `describedBy` functionality ([#63957](https://github.com/WordPress/gutenberg/pull/63957)).

### Enhancements

- `CustomGradientPicker`: Support CSS variables ([#63915](https://github.com/WordPress/gutenberg/pull/63915)).

### Internal

- `DropdownMenuV2`: break menu item help text on multiple lines for better truncation. ([#63916](https://github.com/WordPress/gutenberg/pull/63916)).
- Introduce `CSSVariableReplacer` utility component to get computed CSS variables ([#63915](https://github.com/WordPress/gutenberg/pull/63915)).

## 28.4.0 (2024-07-24)

Expand Down
23 changes: 22 additions & 1 deletion packages/components/src/custom-gradient-picker/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { type LinearGradientNode } from 'gradient-parser';
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import { useCallback, useState } from '@wordpress/element';

/**
* Internal dependencies
Expand All @@ -16,6 +17,10 @@ import CustomGradientBar from './gradient-bar';
import { Flex } from '../flex';
import SelectControl from '../select-control';
import { VStack } from '../v-stack';
import {
CSSVariableReplacer,
getCSSVariablesInString,
} from '../utils/css-variables';
import {
getGradientAstWithDefault,
getLinearGradientRepresentation,
Expand Down Expand Up @@ -142,7 +147,19 @@ export function CustomGradientPicker( {
onChange,
__experimentalIsRenderedInSidebar = false,
}: CustomGradientPickerProps ) {
const { gradientAST, hasGradient } = getGradientAstWithDefault( value );
const [ normalizedValue, setNormalizedValue ] = useState(
!! getCSSVariablesInString( value ?? '' ).length
? 'linear-gradient( #fff, #fff )' // prevent FOUC when there are CSS variables
Copy link
Contributor

Choose a reason for hiding this comment

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

If I understand correctly, this fallback gradient will only be shown for a very small time, until the onChange callback of the CSSVariableReplacer component fires with the parsed gradient.

Therefore, there shouldn't be any changes to how the gradient falls back to the DEFAULT_GRADIENT in the getGradientAstWithDefault function.

: value
);
const cssVariableReplacerOnChange: React.ComponentProps<
typeof CSSVariableReplacer
>[ 'onChange' ] = useCallback( ( { replacedCssString: str } ) => {
setNormalizedValue( str );
}, [] );

const { gradientAST, hasGradient } =
getGradientAstWithDefault( normalizedValue );

// On radial gradients the bar should display a linear gradient.
// On radial gradients the bar represents a slice of the gradient from the center until the outside.
Expand All @@ -163,6 +180,10 @@ export function CustomGradientPicker( {

return (
<VStack spacing={ 4 } className="components-custom-gradient-picker">
<CSSVariableReplacer
cssString={ value }
onChange={ cssVariableReplacerOnChange }
/>
<CustomGradientBar
__experimentalIsRenderedInSidebar={
__experimentalIsRenderedInSidebar
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ export default meta;

const CustomGradientPickerWithState: StoryFn<
typeof CustomGradientPicker
> = ( { onChange, ...props } ) => {
const [ gradient, setGradient ] = useState< string >();
> = ( { onChange, value, ...props } ) => {
const [ gradient, setGradient ] = useState( value );
return (
<CustomGradientPicker
{ ...props }
Expand All @@ -40,3 +40,9 @@ const CustomGradientPickerWithState: StoryFn<
};

export const Default = CustomGradientPickerWithState.bind( {} );

export const WithCSSVariables = CustomGradientPickerWithState.bind( {} );
WithCSSVariables.args = {
// eslint-disable-next-line no-restricted-syntax
value: 'linear-gradient(135deg,var(--wp-admin-theme-color) 0%,var(--undefined-color, magenta) 100%)',
};
201 changes: 201 additions & 0 deletions packages/components/src/utils/css-variables.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
* WordPress dependencies
*/
import { useEffect, useRef } from '@wordpress/element';

/**
* Internal dependencies
*/
import { VisuallyHidden } from '../visually-hidden';

type CSSVariableReplacerProps = {
cssString?: string | null;
/**
* Callback to be called when CSS variables in the given string
* have been replaced with their computed values.
*
* Should be memoized to avoid unnecessary reflows.
*/
onChange: ( args: {
replacedCssString: string;
computedVariables: ComputedCSSVariables;
} ) => void;
Comment on lines +19 to +22
Copy link
Contributor

Choose a reason for hiding this comment

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

For completeness, it would probably be a good idea to also pass back the original string

};
type ComputedCSSVariables = Record< string, string >;
type VarFunction = {
/** Start index of match. */
start: number;
/** End index of match. */
end: number;
/** Matched string, e.g. `var( --foo, #000 )`. */
raw: string;
/** CSS variable name, e.g. `--foo`. */
value: string;
/** Second argument of the `var()`, which could be a literal or another `var()`. */
fallback?: string;
};

/**
* Find the index of the matching closing parenthesis for a given opening parenthesis in a string.
*/
function findMatchingParenthesis( str: string, startPos: number ) {
let stack = 0;

for ( let i = startPos; i < str.length; i++ ) {
if ( str[ i ] === '(' ) {
stack++;
} else if ( str[ i ] === ')' ) {
stack--;
if ( stack === 0 ) {
return i;
}
}
}

throw new Error( 'No matching closing parenthesis found.' );
}

/**
* Find all `var()` functions in a CSS string.
*/
export function findVarFunctionsInString( str: string ) {
const regex = /(?<=\bvar)\(/g;
const matches: VarFunction[] = [];

let openingParen;
while ( ( openingParen = regex.exec( str ) ) !== null ) {
const closingParen = findMatchingParenthesis( str, openingParen.index );
const [ start, end ] = [
openingParen.index - 'var'.length,
closingParen + 1,
];
const raw = str.slice( start, end );
const value = raw.match( /--[\w-]+/ )?.[ 0 ];
Copy link
Member

Choose a reason for hiding this comment

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

Should this regex also be declared above so we don't redeclare it every time?


if ( ! value ) {
throw new Error( 'No CSS variable found in var() function.' );
}

matches.push( {
start,
end,
raw,
value,
fallback: raw.match( /,(.+)\)/ )?.[ 1 ].trim(),
Copy link
Member

Choose a reason for hiding this comment

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

Same about this regex and the rest of the regexes in here. In addition to the small performance benefit, it can actually make the code more readable if regexes are declared as constants and referenced by name.

} );

regex.lastIndex = closingParen + 1; // resume next regex search after this closing parenthesis
}

return matches;
}

/**
* Get the computed CSS variables for a given element.
*/
function getComputedCSSVariables(
propertyStrings: string[],
element: HTMLDivElement
) {
return propertyStrings.reduce( ( acc, propertyString ) => {
acc[ propertyString ] = window
.getComputedStyle( element )
.getPropertyValue( propertyString );
return acc;
}, {} as ComputedCSSVariables );
}

/**
* Replace all `var()` functions in a CSS string with their computed values.
*/
export function replaceCSSVariablesInString(
Copy link
Member

Choose a reason for hiding this comment

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

Have you considered introducing a caching layer here? There seem to be a bunch of regex operations here, and with large CSS strings, caching could potentially have a real, measurable impact. Or is the effect dependency management of CSSVariableReplacer enough? Again, I'm concerned that this one could be used directly and then caching won't be leveraged.

str: string,
computedVariables: ComputedCSSVariables
): string {
const varFunctions = findVarFunctionsInString( str );

let result = '';
let lastIndex = 0;

varFunctions.forEach( ( { start, end, value, fallback } ) => {
const replacement =
computedVariables[ value ] ||
replaceCSSVariablesInString( fallback ?? '', computedVariables );
Copy link
Member

Choose a reason for hiding this comment

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

If fallback is empty, do we even need to call replaceCSSVariablesInString? Can't we just use an empty string without calling replaceCSSVariablesInString?


if ( ! replacement ) {
throw new Error( `No value found for CSS variable ${ value }.` );
Copy link
Member

Choose a reason for hiding this comment

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

If this function is part of the public API, throwing an error can be a bit invasive. I see we're throwing it here to catch it in the CSSVariableReplacer but it may not be ideal if someone tries to consume replaceCSSVariablesInString directly and it errors just like that.

}

result += str.slice( lastIndex, start ) + replacement;
lastIndex = end;
} );

result += str.slice( lastIndex );

return result;
}

/**
* Find all CSS variable names (e.g. `--foo`) in a string.
*/
export function getCSSVariablesInString( str: string ) {
return str.match( /(?<=\bvar\(\s*)--[\w-]+/g ) ?? [];
}

/**
* A component that replaces CSS variables in a given CSS string with their computed values.
* The result is passed to the `onChange` callback.
*
* ```jsx
* function MyComponent() {
* const onChange = useCallback(
* ( { replacedCssString } ) => console.log( replacedCssString ),
* []
* );
* return (
* <CSSVariableReplacer
* cssString="var(--text-color, red)"
* onChange={ onChange }
* />
* );
* }
* ```
*/
export function CSSVariableReplacer( {
cssString,
onChange,
}: CSSVariableReplacerProps ) {
const ref = useRef< HTMLDivElement >( null );

useEffect( () => {
if ( cssString && ref.current ) {
const computedVariables = getComputedCSSVariables(
getCSSVariablesInString( cssString ),
ref.current
);

let replacedCssString = cssString;

try {
replacedCssString = replaceCSSVariablesInString(
cssString,
computedVariables
);
} catch ( error ) {
// eslint-disable-next-line no-console
console.warn(
Copy link
Member

Choose a reason for hiding this comment

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

Can we use the warning package instead? Seems like it's the recommended way to trigger developer errors and warnings now; see #63610

'wp.components.CSSVariableReplacer failed to parse the CSS string with error',
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we add the CSS string in the error message?

error
);
}

onChange( { replacedCssString, computedVariables } );
Copy link
Contributor

Choose a reason for hiding this comment

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

In case of an error, should we still fire onChange? If we do, should we have a flag to signal that parsing failed?

}
}, [ cssString, onChange ] );

return (
<VisuallyHidden>
<div ref={ ref } />
</VisuallyHidden>
Comment on lines +197 to +199
Copy link
Contributor

Choose a reason for hiding this comment

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

Nitty, but do we need the extra div ?

);
}
Loading
Loading