@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/CHANGELOG.md +22 -0
- package/dist/index.cjs +174 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +9 -13
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +7 -4
- package/dist/index.d.ts +7 -4
- package/dist/index.js +174 -63
- package/dist/index.js.map +1 -1
- package/package.json +10 -9
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +7 -16
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +18 -5
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +11 -0
- package/src/charts/geo-chart/geo-chart.tsx +48 -21
- package/src/charts/geo-chart/test/geo-chart.test.tsx +48 -10
- package/src/charts/private/chart-layout/chart-layout.module.scss +8 -0
- package/src/providers/chart-context/global-charts-provider.tsx +26 -19
- package/src/providers/chart-context/test/chart-context.test.tsx +175 -3
- package/src/providers/chart-context/types.ts +1 -0
- package/src/utils/color-utils.ts +23 -12
- package/src/utils/sanitize-html.ts +49 -0
- package/src/utils/test/color-utils.test.ts +110 -0
- package/src/utils/test/sanitize-html.test.ts +101 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "1.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.
|
|
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.
|
|
102
|
-
"@storybook/react": "10.3.
|
|
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.
|
|
115
|
+
"babel-jest": "30.3.0",
|
|
115
116
|
"babel-plugin-react-remove-properties": "^0.3.1",
|
|
116
|
-
"esbuild": "0.
|
|
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.
|
|
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.
|
|
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.
|
|
137
|
+
"node": ">=20.11.0"
|
|
137
138
|
}
|
|
138
139
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
.
|
|
1
|
+
.conversion-funnel-chart {
|
|
2
2
|
font-family: var(--funnel-font-family, "SF Pro Text");
|
|
3
3
|
|
|
4
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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={
|
|
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
|
-
//
|
|
78
|
-
const
|
|
79
|
-
()
|
|
80
|
-
data
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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', '#
|
|
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
|
-
//
|
|
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( '#
|
|
2233
|
+
expect( color2 ).toBe( '#bbaadd' ); // #bad is expanded to #bbaadd by normalizeColorToHex
|
|
2062
2234
|
expect( color3 ).toBe( '#0000ff' );
|
|
2063
2235
|
} );
|
|
2064
2236
|
} );
|