@automattic/charts 0.59.0 → 1.0.1
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 +18 -0
- package/dist/index.cjs +90 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +3 -0
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +6 -4
- package/dist/index.d.ts +6 -4
- package/dist/index.js +90 -26
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
- 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 +13 -19
- package/src/providers/chart-context/test/chart-context.test.tsx +124 -3
- 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": "0.
|
|
3
|
+
"version": "1.0.1",
|
|
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.3",
|
|
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",
|
|
@@ -111,13 +112,13 @@
|
|
|
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
117
|
"esbuild": "0.25.9",
|
|
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",
|
|
@@ -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
|
}
|
|
@@ -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
|
}
|
|
@@ -86,25 +86,19 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
|
|
|
86
86
|
if ( Array.isArray( colors ) ) {
|
|
87
87
|
for ( const color of colors ) {
|
|
88
88
|
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 );
|
|
89
|
+
// Normalize color to hex format, handling CSS variables, RGB, HSL, etc.
|
|
90
|
+
// This uses normalizeColorToHex which resolves CSS variables and converts
|
|
91
|
+
// rgb(), rgba(), hsl() formats to hex
|
|
92
|
+
const normalizedColor = normalizeColorToHex(
|
|
93
|
+
color,
|
|
94
|
+
wrapperRef.current,
|
|
95
|
+
resolveCssVariable
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Only process valid hex colors
|
|
99
|
+
if ( normalizedColor.startsWith( '#' ) ) {
|
|
100
|
+
resolvedColors.push( normalizedColor );
|
|
101
|
+
const hslColor = d3Hsl( normalizedColor );
|
|
108
102
|
// d3Hsl returns NaN values for invalid colors
|
|
109
103
|
if ( ! isNaN( hslColor.h ) ) {
|
|
110
104
|
const hslTuple: [ number, number, number ] = [
|
|
@@ -1907,7 +1907,7 @@ describe( 'ChartContext', () => {
|
|
|
1907
1907
|
};
|
|
1908
1908
|
|
|
1909
1909
|
const cssVarTheme: ChartTheme = {
|
|
1910
|
-
colors: [ '--rgb-color', '#
|
|
1910
|
+
colors: [ '--rgb-color', '#00ff00' ],
|
|
1911
1911
|
} as ChartTheme;
|
|
1912
1912
|
|
|
1913
1913
|
render(
|
|
@@ -1916,7 +1916,7 @@ describe( 'ChartContext', () => {
|
|
|
1916
1916
|
</GlobalChartsProvider>
|
|
1917
1917
|
);
|
|
1918
1918
|
|
|
1919
|
-
//
|
|
1919
|
+
// RGB colors should now be converted to hex and used
|
|
1920
1920
|
const color = contextValue.getElementStyles( {
|
|
1921
1921
|
data: undefined,
|
|
1922
1922
|
index: 0,
|
|
@@ -1924,6 +1924,127 @@ describe( 'ChartContext', () => {
|
|
|
1924
1924
|
|
|
1925
1925
|
expect( color ).toBe( '#ff0000' );
|
|
1926
1926
|
} );
|
|
1927
|
+
|
|
1928
|
+
it( 'handles CSS variables resolving to HSL colors', () => {
|
|
1929
|
+
window.getComputedStyle = jest.fn( () => ( {
|
|
1930
|
+
getPropertyValue: ( prop: string ) => {
|
|
1931
|
+
if ( prop === '--hsl-color' ) {
|
|
1932
|
+
return 'hsl(120, 100%, 50%)'; // HSL format (green)
|
|
1933
|
+
}
|
|
1934
|
+
return '';
|
|
1935
|
+
},
|
|
1936
|
+
} ) ) as unknown as typeof window.getComputedStyle;
|
|
1937
|
+
|
|
1938
|
+
let contextValue: GlobalChartsContextValue;
|
|
1939
|
+
|
|
1940
|
+
const TestComponent = () => {
|
|
1941
|
+
contextValue = useGlobalChartsContext();
|
|
1942
|
+
return <div>Test</div>;
|
|
1943
|
+
};
|
|
1944
|
+
|
|
1945
|
+
const cssVarTheme: ChartTheme = {
|
|
1946
|
+
colors: [ '--hsl-color', '#ff0000' ],
|
|
1947
|
+
} as ChartTheme;
|
|
1948
|
+
|
|
1949
|
+
render(
|
|
1950
|
+
<GlobalChartsProvider theme={ cssVarTheme }>
|
|
1951
|
+
<TestComponent />
|
|
1952
|
+
</GlobalChartsProvider>
|
|
1953
|
+
);
|
|
1954
|
+
|
|
1955
|
+
// HSL colors should be converted to hex and used
|
|
1956
|
+
const color = contextValue.getElementStyles( {
|
|
1957
|
+
data: undefined,
|
|
1958
|
+
index: 0,
|
|
1959
|
+
} ).color;
|
|
1960
|
+
|
|
1961
|
+
expect( color ).toBe( '#00ff00' );
|
|
1962
|
+
} );
|
|
1963
|
+
|
|
1964
|
+
it( 'handles CSS variables resolving to RGBA colors', () => {
|
|
1965
|
+
window.getComputedStyle = jest.fn( () => ( {
|
|
1966
|
+
getPropertyValue: ( prop: string ) => {
|
|
1967
|
+
if ( prop === '--rgba-color' ) {
|
|
1968
|
+
return 'rgba(0, 0, 255, 0.5)'; // RGBA format (blue with transparency)
|
|
1969
|
+
}
|
|
1970
|
+
return '';
|
|
1971
|
+
},
|
|
1972
|
+
} ) ) as unknown as typeof window.getComputedStyle;
|
|
1973
|
+
|
|
1974
|
+
let contextValue: GlobalChartsContextValue;
|
|
1975
|
+
|
|
1976
|
+
const TestComponent = () => {
|
|
1977
|
+
contextValue = useGlobalChartsContext();
|
|
1978
|
+
return <div>Test</div>;
|
|
1979
|
+
};
|
|
1980
|
+
|
|
1981
|
+
const cssVarTheme: ChartTheme = {
|
|
1982
|
+
colors: [ '--rgba-color', '#ff0000' ],
|
|
1983
|
+
} as ChartTheme;
|
|
1984
|
+
|
|
1985
|
+
render(
|
|
1986
|
+
<GlobalChartsProvider theme={ cssVarTheme }>
|
|
1987
|
+
<TestComponent />
|
|
1988
|
+
</GlobalChartsProvider>
|
|
1989
|
+
);
|
|
1990
|
+
|
|
1991
|
+
// RGBA colors are converted to hex (alpha is stripped)
|
|
1992
|
+
const color = contextValue.getElementStyles( {
|
|
1993
|
+
data: undefined,
|
|
1994
|
+
index: 0,
|
|
1995
|
+
} ).color;
|
|
1996
|
+
|
|
1997
|
+
expect( color ).toBe( '#0000ff' );
|
|
1998
|
+
} );
|
|
1999
|
+
|
|
2000
|
+
it( 'handles mix of RGB, HSL, and hex in theme colors', () => {
|
|
2001
|
+
window.getComputedStyle = jest.fn( () => ( {
|
|
2002
|
+
getPropertyValue: ( prop: string ) => {
|
|
2003
|
+
if ( prop === '--rgb-red' ) {
|
|
2004
|
+
return 'rgb(255, 0, 0)';
|
|
2005
|
+
}
|
|
2006
|
+
if ( prop === '--hsl-green' ) {
|
|
2007
|
+
return 'hsl(120, 100%, 50%)';
|
|
2008
|
+
}
|
|
2009
|
+
return '';
|
|
2010
|
+
},
|
|
2011
|
+
} ) ) as unknown as typeof window.getComputedStyle;
|
|
2012
|
+
|
|
2013
|
+
let contextValue: GlobalChartsContextValue;
|
|
2014
|
+
|
|
2015
|
+
const TestComponent = () => {
|
|
2016
|
+
contextValue = useGlobalChartsContext();
|
|
2017
|
+
return <div>Test</div>;
|
|
2018
|
+
};
|
|
2019
|
+
|
|
2020
|
+
const cssVarTheme: ChartTheme = {
|
|
2021
|
+
colors: [ '--rgb-red', '--hsl-green', '#0000ff' ],
|
|
2022
|
+
} as ChartTheme;
|
|
2023
|
+
|
|
2024
|
+
render(
|
|
2025
|
+
<GlobalChartsProvider theme={ cssVarTheme }>
|
|
2026
|
+
<TestComponent />
|
|
2027
|
+
</GlobalChartsProvider>
|
|
2028
|
+
);
|
|
2029
|
+
|
|
2030
|
+
// All color formats should be properly converted
|
|
2031
|
+
const color1 = contextValue.getElementStyles( {
|
|
2032
|
+
data: undefined,
|
|
2033
|
+
index: 0,
|
|
2034
|
+
} ).color;
|
|
2035
|
+
const color2 = contextValue.getElementStyles( {
|
|
2036
|
+
data: undefined,
|
|
2037
|
+
index: 1,
|
|
2038
|
+
} ).color;
|
|
2039
|
+
const color3 = contextValue.getElementStyles( {
|
|
2040
|
+
data: undefined,
|
|
2041
|
+
index: 2,
|
|
2042
|
+
} ).color;
|
|
2043
|
+
|
|
2044
|
+
expect( color1 ).toBe( '#ff0000' ); // RGB red
|
|
2045
|
+
expect( color2 ).toBe( '#00ff00' ); // HSL green
|
|
2046
|
+
expect( color3 ).toBe( '#0000ff' ); // Hex blue
|
|
2047
|
+
} );
|
|
1927
2048
|
} );
|
|
1928
2049
|
|
|
1929
2050
|
describe( 'Error Handling', () => {
|
|
@@ -2058,7 +2179,7 @@ describe( 'ChartContext', () => {
|
|
|
2058
2179
|
} ).color;
|
|
2059
2180
|
|
|
2060
2181
|
expect( color1 ).toBe( '#ff0000' );
|
|
2061
|
-
expect( color2 ).toBe( '#
|
|
2182
|
+
expect( color2 ).toBe( '#bbaadd' ); // #bad is expanded to #bbaadd by normalizeColorToHex
|
|
2062
2183
|
expect( color3 ).toBe( '#0000ff' );
|
|
2063
2184
|
} );
|
|
2064
2185
|
} );
|
package/src/utils/color-utils.ts
CHANGED
|
@@ -111,13 +111,15 @@ export const parseHslString = ( hslString: string ): [ number, number, number ]
|
|
|
111
111
|
/**
|
|
112
112
|
* Parse an RGB string like 'rgb(255, 0, 0)' into a hex color.
|
|
113
113
|
*
|
|
114
|
-
* @
|
|
115
|
-
* @
|
|
114
|
+
* @deprecated Use normalizeColorToHex() instead, which handles all color formats including rgb() and rgba().
|
|
115
|
+
* @param rgbString - RGB color string (not RGBA)
|
|
116
|
+
* @return hex color string or null if invalid
|
|
116
117
|
*/
|
|
117
118
|
export const parseRgbString = ( rgbString: string ): string | null => {
|
|
118
119
|
const lower = rgbString.toLowerCase().trim();
|
|
119
120
|
|
|
120
121
|
// Check prefix - only handle rgb(), not rgba()
|
|
122
|
+
// This is intentional - use normalizeColorToHex for rgba() support
|
|
121
123
|
if ( ! lower.startsWith( 'rgb(' ) || lower.startsWith( 'rgba(' ) ) {
|
|
122
124
|
return null;
|
|
123
125
|
}
|
|
@@ -135,17 +137,19 @@ export const parseRgbString = ( rgbString: string ): string | null => {
|
|
|
135
137
|
|
|
136
138
|
/**
|
|
137
139
|
* Normalize any CSS color value to a hex color string.
|
|
138
|
-
* Handles hex
|
|
140
|
+
* Handles hex, HSL, HSLA, RGB, RGBA, named CSS colors, and CSS variables.
|
|
139
141
|
*
|
|
140
142
|
* @param color - Any CSS color value
|
|
141
143
|
* @param element - Optional DOM element for resolving CSS variables
|
|
142
144
|
* @param resolveCss - Function to resolve CSS variables (injected for testability)
|
|
145
|
+
* @param _depth - Internal recursion depth counter to prevent infinite loops
|
|
143
146
|
* @return hex color string, or the original value if conversion fails
|
|
144
147
|
*/
|
|
145
148
|
export const normalizeColorToHex = (
|
|
146
149
|
color: string,
|
|
147
150
|
element?: HTMLElement | null,
|
|
148
|
-
resolveCss?: ( value: string, el?: HTMLElement | null ) => string | null
|
|
151
|
+
resolveCss?: ( value: string, el?: HTMLElement | null ) => string | null,
|
|
152
|
+
_depth = 0
|
|
149
153
|
): string => {
|
|
150
154
|
if ( ! color || typeof color !== 'string' ) {
|
|
151
155
|
return '';
|
|
@@ -170,21 +174,22 @@ export const normalizeColorToHex = (
|
|
|
170
174
|
if ( trimmed.startsWith( '--' ) || trimmed.startsWith( 'var(' ) ) {
|
|
171
175
|
if ( resolveCss ) {
|
|
172
176
|
const resolved = resolveCss( color, element );
|
|
173
|
-
if ( resolved ) {
|
|
177
|
+
if ( resolved && resolved !== color && _depth < 10 ) {
|
|
174
178
|
// Recursively normalize the resolved value
|
|
175
|
-
return normalizeColorToHex( resolved, element, resolveCss );
|
|
179
|
+
return normalizeColorToHex( resolved, element, resolveCss, _depth + 1 );
|
|
176
180
|
}
|
|
177
181
|
}
|
|
178
182
|
// Can't resolve CSS variable, return original
|
|
179
183
|
return color;
|
|
180
184
|
}
|
|
181
185
|
|
|
182
|
-
// Handle HSL
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
// Handle HSL, HSLA, RGB, and RGBA strings using d3-color
|
|
187
|
+
if (
|
|
188
|
+
trimmed.startsWith( 'hsl(' ) ||
|
|
189
|
+
trimmed.startsWith( 'hsla(' ) ||
|
|
190
|
+
trimmed.startsWith( 'rgb(' ) ||
|
|
191
|
+
trimmed.startsWith( 'rgba(' )
|
|
192
|
+
) {
|
|
188
193
|
const parsed = d3Color( trimmed );
|
|
189
194
|
if ( parsed ) {
|
|
190
195
|
return parsed.formatHex();
|
|
@@ -192,6 +197,12 @@ export const normalizeColorToHex = (
|
|
|
192
197
|
return color;
|
|
193
198
|
}
|
|
194
199
|
|
|
200
|
+
// Attempt d3-color for any remaining format (e.g. named CSS colors like "steelblue")
|
|
201
|
+
const parsed = d3Color( trimmed );
|
|
202
|
+
if ( parsed ) {
|
|
203
|
+
return parsed.formatHex();
|
|
204
|
+
}
|
|
205
|
+
|
|
195
206
|
// Unknown format, return as-is
|
|
196
207
|
return color;
|
|
197
208
|
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External dependencies
|
|
3
|
+
*/
|
|
4
|
+
import DOMPurify from 'dompurify';
|
|
5
|
+
|
|
6
|
+
// Enforce rel="noopener noreferrer" on links with target="_blank" to prevent tab-napping.
|
|
7
|
+
// Registered once at module level since DOMPurify hooks are global state.
|
|
8
|
+
DOMPurify.addHook( 'afterSanitizeAttributes', ( node: Element ) => {
|
|
9
|
+
if ( node.tagName === 'A' && node.getAttribute( 'target' ) === '_blank' ) {
|
|
10
|
+
node.setAttribute( 'rel', 'noopener noreferrer' );
|
|
11
|
+
}
|
|
12
|
+
} );
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sanitizes an HTML string using DOMPurify, allowing only safe formatting
|
|
16
|
+
* markup suitable for chart tooltip content.
|
|
17
|
+
*
|
|
18
|
+
* @param html - The HTML string to sanitize
|
|
19
|
+
* @return Sanitized HTML string safe for rendering
|
|
20
|
+
*/
|
|
21
|
+
export function sanitizeHtml( html: string ): string {
|
|
22
|
+
return DOMPurify.sanitize( html, {
|
|
23
|
+
ALLOWED_TAGS: [
|
|
24
|
+
'a',
|
|
25
|
+
'b',
|
|
26
|
+
'br',
|
|
27
|
+
'div',
|
|
28
|
+
'em',
|
|
29
|
+
'i',
|
|
30
|
+
'li',
|
|
31
|
+
'ol',
|
|
32
|
+
'p',
|
|
33
|
+
'small',
|
|
34
|
+
'span',
|
|
35
|
+
'strong',
|
|
36
|
+
'sub',
|
|
37
|
+
'sup',
|
|
38
|
+
'table',
|
|
39
|
+
'tbody',
|
|
40
|
+
'td',
|
|
41
|
+
'th',
|
|
42
|
+
'thead',
|
|
43
|
+
'tr',
|
|
44
|
+
'u',
|
|
45
|
+
'ul',
|
|
46
|
+
],
|
|
47
|
+
ALLOWED_ATTR: [ 'class', 'href', 'target', 'rel' ],
|
|
48
|
+
} );
|
|
49
|
+
}
|