Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: changed

Support all CSS color formats (HSL, HSLA, RGB, RGBA, named colors) in theme colors.
Original file line number Diff line number Diff line change
Expand Up @@ -86,25 +86,19 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
if ( Array.isArray( colors ) ) {
for ( const color of colors ) {
if ( color && typeof color === 'string' ) {
let colorValue = color;

// Handle CSS custom properties - resolve them to actual values
// Supports both '--var-name' and 'var(--var-name)' formats
// Use wrapper element to resolve scoped CSS variables
if ( color.startsWith( '--' ) || color.startsWith( 'var(' ) ) {
const resolved = resolveCssVariable( color, wrapperRef.current );

if ( resolved === null || resolved === '' ) {
continue;
}

colorValue = resolved;
}

// Process hex colors
if ( colorValue.startsWith( '#' ) ) {
resolvedColors.push( colorValue );
const hslColor = d3Hsl( colorValue );
// Normalize color to hex format, handling CSS variables, RGB, HSL, etc.
// This uses normalizeColorToHex which resolves CSS variables and converts
// rgb(), rgba(), hsl() formats to hex
const normalizedColor = normalizeColorToHex(
color,
wrapperRef.current,
resolveCssVariable
);

// Only process valid hex colors
if ( normalizedColor.startsWith( '#' ) ) {
resolvedColors.push( normalizedColor );
const hslColor = d3Hsl( normalizedColor );
// d3Hsl returns NaN values for invalid colors
if ( ! isNaN( hslColor.h ) ) {
Comment thread
adamwoodnz marked this conversation as resolved.
const hslTuple: [ number, number, number ] = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1907,7 +1907,7 @@ describe( 'ChartContext', () => {
};

const cssVarTheme: ChartTheme = {
colors: [ '--rgb-color', '#ff0000' ],
colors: [ '--rgb-color', '#00ff00' ],
} as ChartTheme;

render(
Expand All @@ -1916,14 +1916,135 @@ describe( 'ChartContext', () => {
</GlobalChartsProvider>
);

// Non-hex colors are currently skipped, should use second color
// RGB colors should now be converted to hex and used
const color = contextValue.getElementStyles( {
data: undefined,
index: 0,
} ).color;

expect( color ).toBe( '#ff0000' );
} );

it( 'handles CSS variables resolving to HSL colors', () => {
window.getComputedStyle = jest.fn( () => ( {
getPropertyValue: ( prop: string ) => {
if ( prop === '--hsl-color' ) {
return 'hsl(120, 100%, 50%)'; // HSL format (green)
}
return '';
},
} ) ) as unknown as typeof window.getComputedStyle;

let contextValue: GlobalChartsContextValue;

const TestComponent = () => {
contextValue = useGlobalChartsContext();
return <div>Test</div>;
};

const cssVarTheme: ChartTheme = {
colors: [ '--hsl-color', '#ff0000' ],
} as ChartTheme;

render(
<GlobalChartsProvider theme={ cssVarTheme }>
<TestComponent />
</GlobalChartsProvider>
);

// HSL colors should be converted to hex and used
const color = contextValue.getElementStyles( {
data: undefined,
index: 0,
} ).color;

expect( color ).toBe( '#00ff00' );
} );

it( 'handles CSS variables resolving to RGBA colors', () => {
window.getComputedStyle = jest.fn( () => ( {
getPropertyValue: ( prop: string ) => {
if ( prop === '--rgba-color' ) {
return 'rgba(0, 0, 255, 0.5)'; // RGBA format (blue with transparency)
}
return '';
},
} ) ) as unknown as typeof window.getComputedStyle;

let contextValue: GlobalChartsContextValue;

const TestComponent = () => {
contextValue = useGlobalChartsContext();
return <div>Test</div>;
};

const cssVarTheme: ChartTheme = {
colors: [ '--rgba-color', '#ff0000' ],
} as ChartTheme;

render(
<GlobalChartsProvider theme={ cssVarTheme }>
<TestComponent />
</GlobalChartsProvider>
);

// RGBA colors are converted to hex (alpha is stripped)
const color = contextValue.getElementStyles( {
data: undefined,
index: 0,
} ).color;

expect( color ).toBe( '#0000ff' );
} );

it( 'handles mix of RGB, HSL, and hex in theme colors', () => {
window.getComputedStyle = jest.fn( () => ( {
getPropertyValue: ( prop: string ) => {
if ( prop === '--rgb-red' ) {
return 'rgb(255, 0, 0)';
}
if ( prop === '--hsl-green' ) {
return 'hsl(120, 100%, 50%)';
}
return '';
},
} ) ) as unknown as typeof window.getComputedStyle;

let contextValue: GlobalChartsContextValue;

const TestComponent = () => {
contextValue = useGlobalChartsContext();
return <div>Test</div>;
};

const cssVarTheme: ChartTheme = {
colors: [ '--rgb-red', '--hsl-green', '#0000ff' ],
} as ChartTheme;

render(
<GlobalChartsProvider theme={ cssVarTheme }>
<TestComponent />
</GlobalChartsProvider>
);

// All color formats should be properly converted
const color1 = contextValue.getElementStyles( {
data: undefined,
index: 0,
} ).color;
const color2 = contextValue.getElementStyles( {
data: undefined,
index: 1,
} ).color;
const color3 = contextValue.getElementStyles( {
data: undefined,
index: 2,
} ).color;

expect( color1 ).toBe( '#ff0000' ); // RGB red
expect( color2 ).toBe( '#00ff00' ); // HSL green
expect( color3 ).toBe( '#0000ff' ); // Hex blue
} );
} );

describe( 'Error Handling', () => {
Expand Down Expand Up @@ -2058,7 +2179,7 @@ describe( 'ChartContext', () => {
} ).color;

expect( color1 ).toBe( '#ff0000' );
expect( color2 ).toBe( '#bad' ); // Invalid color is still in palette
expect( color2 ).toBe( '#bbaadd' ); // #bad is expanded to #bbaadd by normalizeColorToHex
expect( color3 ).toBe( '#0000ff' );
} );
} );
Expand Down
37 changes: 37 additions & 0 deletions projects/js-packages/charts/src/stories/theme-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,49 @@ export const customTheme: ChartTheme = {
},
} as ChartTheme;

/**
* Theme that uses a variety of color formats (hex, RGB, RGBA, HSL, named)
* to demonstrate and test color normalization support.
*/
export const mixedColorFormatsTheme: ChartTheme = {
colors: [
'#e63946',
'rgb(42, 157, 143)',
'hsl(48, 96%, 53%)',
'rgba(38, 70, 83, 0.9)',
'steelblue',
Comment thread
adamwoodnz marked this conversation as resolved.
'hsl(280, 60%, 50%)',
'rgb(244, 162, 97)',
],
backgroundColor: 'hsl(0, 0%, 98%)',
gridColor: 'rgba(0, 0, 0, 0.1)',
gridColorDark: 'rgba(255, 255, 255, 0.15)',
gridStyles: {
stroke: 'rgb(200, 200, 200)',
strokeWidth: 1,
},
geoChart: {
featureFillColor: 'hsl(0, 0%, 93%)',
},
leaderboardChart: {
primaryColor: 'rgb(42, 157, 143)',
secondaryColor: 'rgb(148, 206, 199)',
deltaColors: [ 'hsl(0, 70%, 50%)', 'rgb(150, 150, 150)', '#2a9d8f' ],
},
conversionFunnelChart: {
primaryColor: 'hsl(200, 60%, 45%)',
positiveChangeColor: 'rgb(42, 157, 143)',
negativeChangeColor: 'hsl(0, 70%, 50%)',
},
} as ChartTheme;

/**
* Centralized theme map for all chart stories
*/
export const CHART_THEME_MAP: Record< string, ChartTheme | undefined > = {
default: defaultTheme,
custom: customTheme,
'mixed-color-formats': mixedColorFormatsTheme,
};

/**
Expand Down
35 changes: 23 additions & 12 deletions projects/js-packages/charts/src/utils/color-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,15 @@ export const parseHslString = ( hslString: string ): [ number, number, number ]
/**
* Parse an RGB string like 'rgb(255, 0, 0)' into a hex color.
*
* @param rgbString - RGB color string
* @return hex color string or null if invalid
* @deprecated Use normalizeColorToHex() instead, which handles all color formats including rgb() and rgba().
* @param rgbString - RGB color string (not RGBA)
* @return hex color string or null if invalid
*/
export const parseRgbString = ( rgbString: string ): string | null => {
const lower = rgbString.toLowerCase().trim();

// Check prefix - only handle rgb(), not rgba()
// This is intentional - use normalizeColorToHex for rgba() support
if ( ! lower.startsWith( 'rgb(' ) || lower.startsWith( 'rgba(' ) ) {
return null;
}
Expand All @@ -135,17 +137,19 @@ export const parseRgbString = ( rgbString: string ): string | null => {

/**
* Normalize any CSS color value to a hex color string.
* Handles hex colors, HSL strings, RGB strings, and CSS variables.
* Handles hex, HSL, HSLA, RGB, RGBA, named CSS colors, and CSS variables.
*
* @param color - Any CSS color value
* @param element - Optional DOM element for resolving CSS variables
* @param resolveCss - Function to resolve CSS variables (injected for testability)
* @param _depth - Internal recursion depth counter to prevent infinite loops
* @return hex color string, or the original value if conversion fails
*/
export const normalizeColorToHex = (
color: string,
element?: HTMLElement | null,
resolveCss?: ( value: string, el?: HTMLElement | null ) => string | null
resolveCss?: ( value: string, el?: HTMLElement | null ) => string | null,
_depth = 0
): string => {
if ( ! color || typeof color !== 'string' ) {
return '';
Expand All @@ -170,28 +174,35 @@ export const normalizeColorToHex = (
if ( trimmed.startsWith( '--' ) || trimmed.startsWith( 'var(' ) ) {
if ( resolveCss ) {
const resolved = resolveCss( color, element );
if ( resolved ) {
if ( resolved && resolved !== color && _depth < 10 ) {
// Recursively normalize the resolved value
return normalizeColorToHex( resolved, element, resolveCss );
return normalizeColorToHex( resolved, element, resolveCss, _depth + 1 );
}
}
// Can't resolve CSS variable, return original
return color;
}

// Handle HSL and RGB strings using d3-color
if ( trimmed.startsWith( 'hsl(' ) || trimmed.startsWith( 'rgb(' ) ) {
// Reject rgba() - we only handle rgb()
if ( trimmed.startsWith( 'rgba(' ) ) {
return color;
}
// Handle HSL, HSLA, RGB, and RGBA strings using d3-color
if (
trimmed.startsWith( 'hsl(' ) ||
Comment thread
adamwoodnz marked this conversation as resolved.
trimmed.startsWith( 'hsla(' ) ||
trimmed.startsWith( 'rgb(' ) ||
trimmed.startsWith( 'rgba(' )
) {
Comment thread
adamwoodnz marked this conversation as resolved.
const parsed = d3Color( trimmed );
if ( parsed ) {
return parsed.formatHex();
}
return color;
}

// Attempt d3-color for any remaining format (e.g. named CSS colors like "steelblue")
const parsed = d3Color( trimmed );
if ( parsed ) {
return parsed.formatHex();
}

// Unknown format, return as-is
return color;
};
Expand Down
Loading
Loading