@automattic/charts 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Display charts within Automattic products.",
5
5
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
6
6
  "bugs": {
@@ -63,7 +63,7 @@
63
63
  "typecheck": "tsgo --noEmit"
64
64
  },
65
65
  "dependencies": {
66
- "@automattic/number-formatters": "^1.1.2",
66
+ "@automattic/number-formatters": "^1.1.4",
67
67
  "@babel/runtime": "7.28.6",
68
68
  "@react-spring/web": "9.7.5",
69
69
  "@visx/annotation": "^3.12.0",
@@ -88,6 +88,7 @@
88
88
  "clsx": "2.1.1",
89
89
  "date-fns": "^4.1.0",
90
90
  "deepmerge": "4.3.1",
91
+ "dompurify": "^3.3.3",
91
92
  "fast-deep-equal": "3.1.3",
92
93
  "gridicons": "3.4.2",
93
94
  "react-google-charts": "^5.2.1",
@@ -98,8 +99,8 @@
98
99
  "@babel/core": "7.29.0",
99
100
  "@babel/preset-react": "7.28.5",
100
101
  "@babel/preset-typescript": "7.28.5",
101
- "@storybook/addon-docs": "10.3.1",
102
- "@storybook/react": "10.3.1",
102
+ "@storybook/addon-docs": "10.3.3",
103
+ "@storybook/react": "10.3.3",
103
104
  "@testing-library/dom": "^10.0.0",
104
105
  "@testing-library/jest-dom": "^6.0.0",
105
106
  "@testing-library/react": "^16.0.0",
@@ -111,20 +112,20 @@
111
112
  "@visx/glyph": "3.12.0",
112
113
  "@wordpress/components": "32.4.0",
113
114
  "@wordpress/element": "6.42.0",
114
- "babel-jest": "30.2.0",
115
+ "babel-jest": "30.3.0",
115
116
  "babel-plugin-react-remove-properties": "^0.3.1",
116
- "esbuild": "0.25.9",
117
+ "esbuild": "0.27.4",
117
118
  "esbuild-plugin-babel": "^0.2.3",
118
119
  "esbuild-sass-plugin": "^3.1.0",
119
120
  "identity-obj-proxy": "^3.0.0",
120
- "jest": "30.2.0",
121
+ "jest": "30.3.0",
121
122
  "jest-extended": "7.0.0",
122
123
  "postcss": "8.5.6",
123
124
  "postcss-modules": "6.0.1",
124
125
  "react": "18.3.1",
125
126
  "react-dom": "18.3.1",
126
127
  "sass-embedded": "1.97.3",
127
- "storybook": "10.3.1",
128
+ "storybook": "10.3.3",
128
129
  "tsup": "8.5.1",
129
130
  "typescript": "5.9.3"
130
131
  },
@@ -133,6 +134,6 @@
133
134
  "react-dom": "^17.0.0 || ^18.0.0"
134
135
  },
135
136
  "engines": {
136
- "node": ">=20.10.0"
137
+ "node": ">=20.11.0"
137
138
  }
138
139
  }
@@ -1,7 +1,7 @@
1
- .conversionFunnelChart {
1
+ .conversion-funnel-chart {
2
2
  font-family: var(--funnel-font-family, "SF Pro Text");
3
3
 
4
- &.loading {
4
+ &--loading {
5
5
  opacity: 0.6;
6
6
  pointer-events: none;
7
7
  }
@@ -53,9 +53,12 @@
53
53
  display: flex;
54
54
  flex-direction: column;
55
55
  height: 100%;
56
- transition: all 0.3s ease;
57
56
 
58
- &.blurred {
57
+ &--animated {
58
+ transition: opacity 0.3s ease;
59
+ }
60
+
61
+ &--blurred {
59
62
  opacity: 0.3;
60
63
  }
61
64
  }
@@ -95,24 +98,12 @@
95
98
  border-radius: 4px;
96
99
  position: relative;
97
100
  cursor: pointer;
98
- transition: all 0.2s ease;
99
-
100
-
101
- &.disabled {
102
- cursor: pointer;
103
- }
104
101
  }
105
102
 
106
103
  .funnel-bar {
107
104
  width: 100%;
108
105
  min-height: 4px;
109
106
  border-radius: 4px 4px 0 0;
110
- transition: all 0.3s ease;
111
-
112
- &.selected {
113
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
114
- filter: brightness(1.1);
115
- }
116
107
 
117
108
  &--animated {
118
109
  transform-origin: bottom;
@@ -52,7 +52,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
52
52
  } ) => {
53
53
  const chartId = useChartId( providedChartId );
54
54
  const { conversionFunnelChart: conversionFunnelChartSettings } = useGlobalChartsTheme();
55
- const { getElementStyles } = useGlobalChartsContext();
55
+ const { getElementStyles, isColorPaletteResolved } = useGlobalChartsContext();
56
56
  const chartRef = useRef< HTMLDivElement >( null );
57
57
  const selectedBarRef = useRef< HTMLDivElement | null >( null );
58
58
 
@@ -301,7 +301,11 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
301
301
  <Stack
302
302
  direction="column"
303
303
  data-testid="conversion-funnel-chart"
304
- className={ clsx( styles.conversionFunnelChart, loading && styles.loading, className ) }
304
+ className={ clsx(
305
+ styles[ 'conversion-funnel-chart' ],
306
+ loading && styles[ 'conversion-funnel-chart--loading' ],
307
+ className
308
+ ) }
305
309
  style={ { ...style, height: resolvedHeight } }
306
310
  >
307
311
  <div className={ styles[ 'empty-state' ] }>
@@ -324,7 +328,11 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
324
328
  portalContainerRef( node );
325
329
  chartRef.current = node;
326
330
  } }
327
- className={ clsx( styles.conversionFunnelChart, loading && styles.loading, className ) }
331
+ className={ clsx(
332
+ styles[ 'conversion-funnel-chart' ],
333
+ loading && styles[ 'conversion-funnel-chart--loading' ],
334
+ className
335
+ ) }
328
336
  style={ { ...style, height: resolvedHeight } }
329
337
  >
330
338
  { /* Main Metric */ }
@@ -348,7 +356,12 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
348
356
  return (
349
357
  <div
350
358
  key={ step.id }
351
- className={ clsx( styles[ 'funnel-step' ], isBlurred && styles.blurred ) }
359
+ data-testid="funnel-step"
360
+ className={ clsx(
361
+ styles[ 'funnel-step' ],
362
+ isColorPaletteResolved && styles[ 'funnel-step--animated' ],
363
+ isBlurred && styles[ 'funnel-step--blurred' ]
364
+ ) }
352
365
  >
353
366
  { /* Step Label and Rate */ }
354
367
  <div className={ styles[ 'step-header' ] }>
@@ -376,7 +389,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
376
389
 
377
390
  { /* Funnel Bar */ }
378
391
  <div
379
- className={ clsx( styles[ 'bar-container' ], isBlurred && styles.disabled ) }
392
+ className={ styles[ 'bar-container' ] }
380
393
  onClick={ stepHandlers.get( step.id )?.onClick }
381
394
  onKeyDown={ stepHandlers.get( step.id )?.onKeyDown }
382
395
  role="button"
@@ -461,4 +461,15 @@ describe( 'ConversionFunnelChart', () => {
461
461
  expect( customRenderMainMetric ).toHaveBeenCalled();
462
462
  } );
463
463
  } );
464
+
465
+ describe( 'Color palette readiness', () => {
466
+ it( 'enables transitions once color palette is resolved', () => {
467
+ render( <ConversionFunnelChart { ...defaultProps } /> );
468
+
469
+ // After render, effects have run (via useEffect in GlobalChartsProvider),
470
+ // so the palette is resolved and the animated class is applied to funnel steps
471
+ const funnelStep = screen.getAllByTestId( 'funnel-step' )[ 0 ];
472
+ expect( funnelStep ).toHaveClass( 'funnel-step--animated' );
473
+ } );
474
+ } );
464
475
  } );
@@ -11,6 +11,7 @@ import { Chart, type GoogleChartOptions } from 'react-google-charts';
11
11
  import { GlobalChartsContext, GlobalChartsProvider, useGlobalChartsContext } from '../../providers';
12
12
  import { lightenHexColor, normalizeColorToHex } from '../../utils/color-utils';
13
13
  import { resolveCssVariable } from '../../utils/resolve-css-var';
14
+ import { sanitizeHtml } from '../../utils/sanitize-html';
14
15
  import { withResponsive } from '../private/with-responsive';
15
16
  import styles from './geo-chart.module.scss';
16
17
  import { GeoChartProps } from './types';
@@ -74,24 +75,50 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
74
75
  const defaultFillColorHex =
75
76
  normalizeColorToHex( featureFillColor, null, resolveCssVariable ) || DEFAULT_FEATURE_FILL_COLOR;
76
77
 
77
- // Check if data has HTML tooltips (column with role: 'tooltip' and p.html: true)
78
- const hasHtmlTooltips = useMemo(
79
- () =>
80
- data.length > 0 &&
81
- data[ 0 ].some(
82
- col =>
83
- typeof col === 'object' &&
84
- col !== null &&
85
- 'role' in col &&
86
- col.role === 'tooltip' &&
87
- 'p' in col &&
88
- typeof col.p === 'object' &&
89
- col.p !== null &&
90
- 'html' in col.p &&
91
- col.p.html === true
92
- ),
93
- [ data ]
94
- );
78
+ // Identify HTML tooltip column indices and sanitize their content to prevent XSS.
79
+ const sanitizedData = useMemo( () => {
80
+ if ( data.length === 0 ) {
81
+ return { data, hasHtmlTooltips: false };
82
+ }
83
+
84
+ const htmlTooltipIndices: number[] = [];
85
+ for ( let i = 0; i < data[ 0 ].length; i++ ) {
86
+ const col = data[ 0 ][ i ];
87
+ if (
88
+ typeof col === 'object' &&
89
+ col !== null &&
90
+ 'role' in col &&
91
+ col.role === 'tooltip' &&
92
+ 'p' in col &&
93
+ typeof col.p === 'object' &&
94
+ col.p !== null &&
95
+ 'html' in col.p &&
96
+ col.p.html === true
97
+ ) {
98
+ htmlTooltipIndices.push( i );
99
+ }
100
+ }
101
+
102
+ if ( htmlTooltipIndices.length === 0 ) {
103
+ return { data, hasHtmlTooltips: false };
104
+ }
105
+
106
+ // Sanitize HTML content in tooltip columns for data rows (skip header row)
107
+ const sanitizedRows = data.slice( 1 ).map( row => {
108
+ const newRow = [ ...row ];
109
+ for ( const colIndex of htmlTooltipIndices ) {
110
+ if ( typeof newRow[ colIndex ] === 'string' ) {
111
+ newRow[ colIndex ] = sanitizeHtml( newRow[ colIndex ] as string );
112
+ }
113
+ }
114
+ return newRow;
115
+ } );
116
+
117
+ return {
118
+ data: [ data[ 0 ], ...sanitizedRows ] as typeof data,
119
+ hasHtmlTooltips: true,
120
+ };
121
+ }, [ data ] );
95
122
 
96
123
  const options: GoogleChartOptions = useMemo(
97
124
  () => ( {
@@ -101,7 +128,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
101
128
  backgroundColor: backgroundColorHex,
102
129
  datalessRegionColor: defaultFillColorHex,
103
130
  defaultColor: defaultFillColorHex,
104
- tooltip: { trigger: 'focus', isHtml: hasHtmlTooltips },
131
+ tooltip: { trigger: 'focus', isHtml: sanitizedData.hasHtmlTooltips },
105
132
  legend: 'none',
106
133
  keepAspectRatio: true,
107
134
  } ),
@@ -112,7 +139,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
112
139
  fullColorHex,
113
140
  backgroundColorHex,
114
141
  defaultFillColorHex,
115
- hasHtmlTooltips,
142
+ sanitizedData.hasHtmlTooltips,
116
143
  ]
117
144
  );
118
145
 
@@ -126,7 +153,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
126
153
  chartType="GeoChart"
127
154
  width={ width }
128
155
  height={ height }
129
- data={ data }
156
+ data={ sanitizedData.data }
130
157
  options={ options }
131
158
  loader={ loadingPlaceholder }
132
159
  />
@@ -67,24 +67,62 @@ describe( 'GeoChart', () => {
67
67
  } );
68
68
 
69
69
  describe( 'Data Handling', () => {
70
- test( 'passes data directly to Google Charts without modification', () => {
71
- // Test with complex data including plain values, formatted values, and tooltip columns
72
- const testData: [
73
- ( string | object )[],
74
- ...[ string, number | { v: number; f: string }, string ][],
75
- ] = [
76
- [ 'Country', 'Revenue', { type: 'string', role: 'tooltip', p: { html: true } } ],
77
- [ 'US', { v: 1234567, f: '$1.23M' }, '<b>United States</b>' ],
78
- [ 'CA', 543210, '<b>Canada</b>' ],
70
+ test( 'passes non-tooltip data without modification', () => {
71
+ const testData: [ string[], ...[ string, number ][] ] = [
72
+ [ 'Country', 'Revenue' ],
73
+ [ 'US', 1234567 ],
74
+ [ 'CA', 543210 ],
79
75
  ];
80
76
  renderWithTheme( { data: testData } );
81
77
 
82
78
  const chartData = screen.getByTestId( 'chart-data' );
83
79
  const data = JSON.parse( chartData.textContent || '[]' );
84
80
 
85
- // Data should be passed through exactly as provided
86
81
  expect( data ).toEqual( testData );
87
82
  } );
83
+
84
+ test( 'sanitizes HTML tooltip content to prevent XSS', () => {
85
+ const testData: [ ( string | object )[], ...[ string, number, string ][] ] = [
86
+ [ 'Country', 'Value', { type: 'string', role: 'tooltip', p: { html: true } } ],
87
+ [ 'US', 100, '<b>United States</b><script>alert("xss")</script>' ],
88
+ [ 'CA', 50, '<b>Canada</b><img src=x onerror="alert(1)">' ],
89
+ ];
90
+ renderWithTheme( { data: testData } );
91
+
92
+ const chartData = screen.getByTestId( 'chart-data' );
93
+ const data = JSON.parse( chartData.textContent || '[]' );
94
+
95
+ // Script tags and img (not in allowlist) should be stripped
96
+ expect( data[ 1 ][ 2 ] ).toBe( '<b>United States</b>' );
97
+ expect( data[ 2 ][ 2 ] ).toBe( '<b>Canada</b>' );
98
+ } );
99
+
100
+ test( 'handles header-only data with HTML tooltip column and no data rows', () => {
101
+ const testData: [ ( string | object )[] ] = [
102
+ [ 'Country', 'Value', { type: 'string', role: 'tooltip', p: { html: true } } ],
103
+ ];
104
+ renderWithTheme( { data: testData } );
105
+
106
+ const chartData = screen.getByTestId( 'chart-data' );
107
+ const data = JSON.parse( chartData.textContent || '[]' );
108
+
109
+ // Header row should be preserved as-is
110
+ expect( data ).toHaveLength( 1 );
111
+ expect( data[ 0 ][ 0 ] ).toBe( 'Country' );
112
+ } );
113
+
114
+ test( 'preserves safe HTML in tooltip content', () => {
115
+ const testData: [ ( string | object )[], ...[ string, number, string ][] ] = [
116
+ [ 'Country', 'Value', { type: 'string', role: 'tooltip', p: { html: true } } ],
117
+ [ 'US', 100, '<b>United States</b><br>100 orders' ],
118
+ ];
119
+ renderWithTheme( { data: testData } );
120
+
121
+ const chartData = screen.getByTestId( 'chart-data' );
122
+ const data = JSON.parse( chartData.textContent || '[]' );
123
+
124
+ expect( data[ 1 ][ 2 ] ).toBe( '<b>United States</b><br>100 orders' );
125
+ } );
88
126
  } );
89
127
 
90
128
  describe( 'Chart Options', () => {
@@ -4,4 +4,12 @@
4
4
  flex: 1;
5
5
  min-height: 0;
6
6
  min-width: 0;
7
+
8
+ // The svg displaying inline includes a few px for descenders, which
9
+ // confuses some browser environments (e.g. headless Chrome) into
10
+ // constantly growing the container. Setting it to display as block avoids
11
+ // that.
12
+ svg {
13
+ display: block;
14
+ }
7
15
  }
@@ -70,11 +70,16 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
70
70
  maxHue: 0,
71
71
  } ) );
72
72
 
73
+ // Track if the color palette has been resolved from the DOM
74
+ // Useful for animations that should only run after the color palette is resolved
75
+ const [ isColorPaletteResolved, setIsColorPaletteResolved ] = useState( false );
76
+
73
77
  // Compute color cache after DOM is updated (so CSS variables are available)
74
78
  // Resolves CSS variables from the wrapper element's scope to handle scoped variables
75
79
  // Note: Only re-runs when providerTheme changes, not when wrapper element changes.
76
80
  // This is intentional, as wrapperRef is expected to be stable for the lifetime of the provider.
77
81
  useLayoutEffect( () => {
82
+ setIsColorPaletteResolved( false );
78
83
  const { colors } = providerTheme;
79
84
  const resolvedColors: string[] = [];
80
85
  const hues: number[] = [];
@@ -86,25 +91,19 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
86
91
  if ( Array.isArray( colors ) ) {
87
92
  for ( const color of colors ) {
88
93
  if ( color && typeof color === 'string' ) {
89
- let colorValue = color;
90
-
91
- // Handle CSS custom properties - resolve them to actual values
92
- // Supports both '--var-name' and 'var(--var-name)' formats
93
- // Use wrapper element to resolve scoped CSS variables
94
- if ( color.startsWith( '--' ) || color.startsWith( 'var(' ) ) {
95
- const resolved = resolveCssVariable( color, wrapperRef.current );
96
-
97
- if ( resolved === null || resolved === '' ) {
98
- continue;
99
- }
100
-
101
- colorValue = resolved;
102
- }
103
-
104
- // Process hex colors
105
- if ( colorValue.startsWith( '#' ) ) {
106
- resolvedColors.push( colorValue );
107
- const hslColor = d3Hsl( colorValue );
94
+ // Normalize color to hex format, handling CSS variables, RGB, HSL, etc.
95
+ // This uses normalizeColorToHex which resolves CSS variables and converts
96
+ // rgb(), rgba(), hsl() formats to hex
97
+ const normalizedColor = normalizeColorToHex(
98
+ color,
99
+ wrapperRef.current,
100
+ resolveCssVariable
101
+ );
102
+
103
+ // Only process valid hex colors
104
+ if ( normalizedColor.startsWith( '#' ) ) {
105
+ resolvedColors.push( normalizedColor );
106
+ const hslColor = d3Hsl( normalizedColor );
108
107
  // d3Hsl returns NaN values for invalid colors
109
108
  if ( ! isNaN( hslColor.h ) ) {
110
109
  const hslTuple: [ number, number, number ] = [
@@ -131,6 +130,12 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
131
130
  } );
132
131
  }, [ providerTheme ] );
133
132
 
133
+ useEffect( () => {
134
+ if ( colorCache.colors.length > 0 ) {
135
+ setIsColorPaletteResolved( true );
136
+ }
137
+ }, [ colorCache ] );
138
+
134
139
  const [ groupToColorMap, setGroupToColorMap ] = useState< Map< string, string > >(
135
140
  () => new Map()
136
141
  );
@@ -277,6 +282,7 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
277
282
  toggleSeriesVisibility,
278
283
  isSeriesVisible,
279
284
  getHiddenSeries,
285
+ isColorPaletteResolved,
280
286
  } ),
281
287
  [
282
288
  charts,
@@ -288,6 +294,7 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
288
294
  toggleSeriesVisibility,
289
295
  isSeriesVisible,
290
296
  getHiddenSeries,
297
+ isColorPaletteResolved,
291
298
  ]
292
299
  );
293
300
 
@@ -47,6 +47,57 @@ describe( 'ChartContext', () => {
47
47
  expect( contextValue.charts ).toBeInstanceOf( Map );
48
48
  } );
49
49
 
50
+ it( 'exposes isColorPaletteResolved as true after render', () => {
51
+ let contextValue: GlobalChartsContextValue;
52
+
53
+ const TestComponent = () => {
54
+ contextValue = useGlobalChartsContext();
55
+ return <div>Test</div>;
56
+ };
57
+
58
+ render(
59
+ <GlobalChartsProvider>
60
+ <TestComponent />
61
+ </GlobalChartsProvider>
62
+ );
63
+
64
+ // After render and effects, isColorPaletteResolved should be true
65
+ expect( contextValue.isColorPaletteResolved ).toBe( true );
66
+ } );
67
+
68
+ it( 'resolves palette again after theme change', () => {
69
+ let contextValue: GlobalChartsContextValue;
70
+
71
+ const TestComponent = () => {
72
+ contextValue = useGlobalChartsContext();
73
+ return <div>Test</div>;
74
+ };
75
+
76
+ const theme1: Partial< ChartTheme > = {
77
+ colors: [ '#006DAB', '#1F9828' ],
78
+ };
79
+ const theme2: Partial< ChartTheme > = {
80
+ colors: [ '#FF0000', '#00FF00' ],
81
+ };
82
+
83
+ const { rerender } = render(
84
+ <GlobalChartsProvider theme={ theme1 }>
85
+ <TestComponent />
86
+ </GlobalChartsProvider>
87
+ );
88
+
89
+ expect( contextValue.isColorPaletteResolved ).toBe( true );
90
+
91
+ rerender(
92
+ <GlobalChartsProvider theme={ theme2 }>
93
+ <TestComponent />
94
+ </GlobalChartsProvider>
95
+ );
96
+
97
+ // After theme change, palette should re-resolve to true
98
+ expect( contextValue.isColorPaletteResolved ).toBe( true );
99
+ } );
100
+
50
101
  it( 'throws error when useGlobalChartsContext is used outside provider', () => {
51
102
  const TestComponent = () => {
52
103
  useGlobalChartsContext();
@@ -1907,7 +1958,7 @@ describe( 'ChartContext', () => {
1907
1958
  };
1908
1959
 
1909
1960
  const cssVarTheme: ChartTheme = {
1910
- colors: [ '--rgb-color', '#ff0000' ],
1961
+ colors: [ '--rgb-color', '#00ff00' ],
1911
1962
  } as ChartTheme;
1912
1963
 
1913
1964
  render(
@@ -1916,7 +1967,7 @@ describe( 'ChartContext', () => {
1916
1967
  </GlobalChartsProvider>
1917
1968
  );
1918
1969
 
1919
- // Non-hex colors are currently skipped, should use second color
1970
+ // RGB colors should now be converted to hex and used
1920
1971
  const color = contextValue.getElementStyles( {
1921
1972
  data: undefined,
1922
1973
  index: 0,
@@ -1924,6 +1975,127 @@ describe( 'ChartContext', () => {
1924
1975
 
1925
1976
  expect( color ).toBe( '#ff0000' );
1926
1977
  } );
1978
+
1979
+ it( 'handles CSS variables resolving to HSL colors', () => {
1980
+ window.getComputedStyle = jest.fn( () => ( {
1981
+ getPropertyValue: ( prop: string ) => {
1982
+ if ( prop === '--hsl-color' ) {
1983
+ return 'hsl(120, 100%, 50%)'; // HSL format (green)
1984
+ }
1985
+ return '';
1986
+ },
1987
+ } ) ) as unknown as typeof window.getComputedStyle;
1988
+
1989
+ let contextValue: GlobalChartsContextValue;
1990
+
1991
+ const TestComponent = () => {
1992
+ contextValue = useGlobalChartsContext();
1993
+ return <div>Test</div>;
1994
+ };
1995
+
1996
+ const cssVarTheme: ChartTheme = {
1997
+ colors: [ '--hsl-color', '#ff0000' ],
1998
+ } as ChartTheme;
1999
+
2000
+ render(
2001
+ <GlobalChartsProvider theme={ cssVarTheme }>
2002
+ <TestComponent />
2003
+ </GlobalChartsProvider>
2004
+ );
2005
+
2006
+ // HSL colors should be converted to hex and used
2007
+ const color = contextValue.getElementStyles( {
2008
+ data: undefined,
2009
+ index: 0,
2010
+ } ).color;
2011
+
2012
+ expect( color ).toBe( '#00ff00' );
2013
+ } );
2014
+
2015
+ it( 'handles CSS variables resolving to RGBA colors', () => {
2016
+ window.getComputedStyle = jest.fn( () => ( {
2017
+ getPropertyValue: ( prop: string ) => {
2018
+ if ( prop === '--rgba-color' ) {
2019
+ return 'rgba(0, 0, 255, 0.5)'; // RGBA format (blue with transparency)
2020
+ }
2021
+ return '';
2022
+ },
2023
+ } ) ) as unknown as typeof window.getComputedStyle;
2024
+
2025
+ let contextValue: GlobalChartsContextValue;
2026
+
2027
+ const TestComponent = () => {
2028
+ contextValue = useGlobalChartsContext();
2029
+ return <div>Test</div>;
2030
+ };
2031
+
2032
+ const cssVarTheme: ChartTheme = {
2033
+ colors: [ '--rgba-color', '#ff0000' ],
2034
+ } as ChartTheme;
2035
+
2036
+ render(
2037
+ <GlobalChartsProvider theme={ cssVarTheme }>
2038
+ <TestComponent />
2039
+ </GlobalChartsProvider>
2040
+ );
2041
+
2042
+ // RGBA colors are converted to hex (alpha is stripped)
2043
+ const color = contextValue.getElementStyles( {
2044
+ data: undefined,
2045
+ index: 0,
2046
+ } ).color;
2047
+
2048
+ expect( color ).toBe( '#0000ff' );
2049
+ } );
2050
+
2051
+ it( 'handles mix of RGB, HSL, and hex in theme colors', () => {
2052
+ window.getComputedStyle = jest.fn( () => ( {
2053
+ getPropertyValue: ( prop: string ) => {
2054
+ if ( prop === '--rgb-red' ) {
2055
+ return 'rgb(255, 0, 0)';
2056
+ }
2057
+ if ( prop === '--hsl-green' ) {
2058
+ return 'hsl(120, 100%, 50%)';
2059
+ }
2060
+ return '';
2061
+ },
2062
+ } ) ) as unknown as typeof window.getComputedStyle;
2063
+
2064
+ let contextValue: GlobalChartsContextValue;
2065
+
2066
+ const TestComponent = () => {
2067
+ contextValue = useGlobalChartsContext();
2068
+ return <div>Test</div>;
2069
+ };
2070
+
2071
+ const cssVarTheme: ChartTheme = {
2072
+ colors: [ '--rgb-red', '--hsl-green', '#0000ff' ],
2073
+ } as ChartTheme;
2074
+
2075
+ render(
2076
+ <GlobalChartsProvider theme={ cssVarTheme }>
2077
+ <TestComponent />
2078
+ </GlobalChartsProvider>
2079
+ );
2080
+
2081
+ // All color formats should be properly converted
2082
+ const color1 = contextValue.getElementStyles( {
2083
+ data: undefined,
2084
+ index: 0,
2085
+ } ).color;
2086
+ const color2 = contextValue.getElementStyles( {
2087
+ data: undefined,
2088
+ index: 1,
2089
+ } ).color;
2090
+ const color3 = contextValue.getElementStyles( {
2091
+ data: undefined,
2092
+ index: 2,
2093
+ } ).color;
2094
+
2095
+ expect( color1 ).toBe( '#ff0000' ); // RGB red
2096
+ expect( color2 ).toBe( '#00ff00' ); // HSL green
2097
+ expect( color3 ).toBe( '#0000ff' ); // Hex blue
2098
+ } );
1927
2099
  } );
1928
2100
 
1929
2101
  describe( 'Error Handling', () => {
@@ -2058,7 +2230,7 @@ describe( 'ChartContext', () => {
2058
2230
  } ).color;
2059
2231
 
2060
2232
  expect( color1 ).toBe( '#ff0000' );
2061
- expect( color2 ).toBe( '#bad' ); // Invalid color is still in palette
2233
+ expect( color2 ).toBe( '#bbaadd' ); // #bad is expanded to #bbaadd by normalizeColorToHex
2062
2234
  expect( color3 ).toBe( '#0000ff' );
2063
2235
  } );
2064
2236
  } );