@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
|
@@ -35,4 +35,5 @@ export interface GlobalChartsContextValue {
|
|
|
35
35
|
toggleSeriesVisibility: ( chartId: string, seriesLabel: string ) => void;
|
|
36
36
|
isSeriesVisible: ( chartId: string, seriesLabel: string ) => boolean;
|
|
37
37
|
getHiddenSeries: ( chartId: string ) => Set< string >;
|
|
38
|
+
isColorPaletteResolved: boolean;
|
|
38
39
|
}
|
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
|
+
}
|
|
@@ -716,6 +716,21 @@ describe( 'normalizeColorToHex', () => {
|
|
|
716
716
|
} );
|
|
717
717
|
} );
|
|
718
718
|
|
|
719
|
+
describe( 'HSLA strings', () => {
|
|
720
|
+
it( 'converts hsla(0, 100%, 50%, 1) to #ff0000', () => {
|
|
721
|
+
expect( normalizeColorToHex( 'hsla(0, 100%, 50%, 1)' ) ).toBe( '#ff0000' );
|
|
722
|
+
} );
|
|
723
|
+
|
|
724
|
+
it( 'converts hsla(120, 100%, 50%, 0.5) to #00ff00', () => {
|
|
725
|
+
expect( normalizeColorToHex( 'hsla(120, 100%, 50%, 0.5)' ) ).toBe( '#00ff00' );
|
|
726
|
+
} );
|
|
727
|
+
|
|
728
|
+
it( 'converts fully transparent hsla to #000000', () => {
|
|
729
|
+
// d3-color converts fully transparent colors (alpha=0) to black
|
|
730
|
+
expect( normalizeColorToHex( 'hsla(240, 100%, 50%, 0)' ) ).toBe( '#000000' );
|
|
731
|
+
} );
|
|
732
|
+
} );
|
|
733
|
+
|
|
719
734
|
describe( 'RGB strings', () => {
|
|
720
735
|
it( 'converts rgb(255, 0, 0) to #ff0000', () => {
|
|
721
736
|
expect( normalizeColorToHex( 'rgb(255, 0, 0)' ) ).toBe( '#ff0000' );
|
|
@@ -726,6 +741,36 @@ describe( 'normalizeColorToHex', () => {
|
|
|
726
741
|
} );
|
|
727
742
|
} );
|
|
728
743
|
|
|
744
|
+
describe( 'RGBA strings', () => {
|
|
745
|
+
it( 'converts rgba(255, 0, 0, 1) to #ff0000', () => {
|
|
746
|
+
expect( normalizeColorToHex( 'rgba(255, 0, 0, 1)' ) ).toBe( '#ff0000' );
|
|
747
|
+
} );
|
|
748
|
+
|
|
749
|
+
it( 'converts rgba(0, 0, 255, 0.5) to #0000ff', () => {
|
|
750
|
+
// Alpha channel is stripped in hex conversion
|
|
751
|
+
expect( normalizeColorToHex( 'rgba(0, 0, 255, 0.5)' ) ).toBe( '#0000ff' );
|
|
752
|
+
} );
|
|
753
|
+
|
|
754
|
+
it( 'converts fully transparent rgba to #000000', () => {
|
|
755
|
+
// d3-color converts fully transparent colors (alpha=0) to black
|
|
756
|
+
expect( normalizeColorToHex( 'rgba(128, 128, 128, 0)' ) ).toBe( '#000000' );
|
|
757
|
+
} );
|
|
758
|
+
} );
|
|
759
|
+
|
|
760
|
+
describe( 'Named CSS colors', () => {
|
|
761
|
+
it( 'converts steelblue to hex', () => {
|
|
762
|
+
expect( normalizeColorToHex( 'steelblue' ) ).toBe( '#4682b4' );
|
|
763
|
+
} );
|
|
764
|
+
|
|
765
|
+
it( 'converts red to hex', () => {
|
|
766
|
+
expect( normalizeColorToHex( 'red' ) ).toBe( '#ff0000' );
|
|
767
|
+
} );
|
|
768
|
+
|
|
769
|
+
it( 'returns unknown strings as-is', () => {
|
|
770
|
+
expect( normalizeColorToHex( 'notacolor' ) ).toBe( 'notacolor' );
|
|
771
|
+
} );
|
|
772
|
+
} );
|
|
773
|
+
|
|
729
774
|
describe( 'CSS variables', () => {
|
|
730
775
|
it( 'returns original if no resolveCss function provided', () => {
|
|
731
776
|
expect( normalizeColorToHex( '--my-color' ) ).toBe( '--my-color' );
|
|
@@ -755,6 +800,71 @@ describe( 'normalizeColorToHex', () => {
|
|
|
755
800
|
const mockResolve = jest.fn().mockReturnValue( null );
|
|
756
801
|
expect( normalizeColorToHex( '--my-color', null, mockResolve ) ).toBe( '--my-color' );
|
|
757
802
|
} );
|
|
803
|
+
|
|
804
|
+
it( 'returns original when CSS variable resolves to itself', () => {
|
|
805
|
+
const mockResolve = jest.fn().mockImplementation( ( v: string ) => v );
|
|
806
|
+
expect( normalizeColorToHex( '--loop', null, mockResolve ) ).toBe( '--loop' );
|
|
807
|
+
} );
|
|
808
|
+
|
|
809
|
+
it( 'returns original when CSS variable resolves to empty string', () => {
|
|
810
|
+
const mockResolve = jest.fn().mockReturnValue( '' );
|
|
811
|
+
expect( normalizeColorToHex( '--empty', null, mockResolve ) ).toBe( '--empty' );
|
|
812
|
+
} );
|
|
813
|
+
|
|
814
|
+
it( 'does not infinite loop on indirect CSS variable cycle', () => {
|
|
815
|
+
const mockResolve = jest.fn().mockImplementation( ( v: string ) => {
|
|
816
|
+
if ( v === '--a' ) return 'var(--b)';
|
|
817
|
+
if ( v === 'var(--b)' ) return '--a';
|
|
818
|
+
|
|
819
|
+
return null;
|
|
820
|
+
} );
|
|
821
|
+
|
|
822
|
+
expect( () => normalizeColorToHex( '--a', null, mockResolve ) ).not.toThrow();
|
|
823
|
+
} );
|
|
824
|
+
|
|
825
|
+
it( 'resolves multi-hop CSS variable chain', () => {
|
|
826
|
+
const mockResolve = jest.fn().mockImplementation( ( v: string ) => {
|
|
827
|
+
if ( v === '--a' ) return 'var(--b)';
|
|
828
|
+
if ( v === 'var(--b)' ) return 'hsl(0, 100%, 50%)';
|
|
829
|
+
|
|
830
|
+
return null;
|
|
831
|
+
} );
|
|
832
|
+
|
|
833
|
+
expect( normalizeColorToHex( '--a', null, mockResolve ) ).toBe( '#ff0000' );
|
|
834
|
+
} );
|
|
835
|
+
} );
|
|
836
|
+
|
|
837
|
+
describe( 'Whitespace handling', () => {
|
|
838
|
+
it( 'trims leading and trailing spaces from hex', () => {
|
|
839
|
+
expect( normalizeColorToHex( ' #ff0000 ' ) ).toBe( '#ff0000' );
|
|
840
|
+
} );
|
|
841
|
+
|
|
842
|
+
it( 'trims spaces from HSL string', () => {
|
|
843
|
+
expect( normalizeColorToHex( ' hsl(0, 100%, 50%) ' ) ).toBe( '#ff0000' );
|
|
844
|
+
} );
|
|
845
|
+
|
|
846
|
+
it( 'trims spaces from named color', () => {
|
|
847
|
+
expect( normalizeColorToHex( ' red ' ) ).toBe( '#ff0000' );
|
|
848
|
+
} );
|
|
849
|
+
} );
|
|
850
|
+
|
|
851
|
+
describe( 'Case insensitivity', () => {
|
|
852
|
+
it( 'converts uppercase HSL', () => {
|
|
853
|
+
expect( normalizeColorToHex( 'HSL(0, 100%, 50%)' ) ).toBe( '#ff0000' );
|
|
854
|
+
} );
|
|
855
|
+
|
|
856
|
+
it( 'converts uppercase RGB', () => {
|
|
857
|
+
expect( normalizeColorToHex( 'RGB(255, 0, 0)' ) ).toBe( '#ff0000' );
|
|
858
|
+
} );
|
|
859
|
+
|
|
860
|
+
it( 'converts uppercase RGBA', () => {
|
|
861
|
+
expect( normalizeColorToHex( 'RGBA(0, 0, 255, 1)' ) ).toBe( '#0000ff' );
|
|
862
|
+
} );
|
|
863
|
+
|
|
864
|
+
it( 'handles uppercase VAR() syntax', () => {
|
|
865
|
+
const mockResolve = jest.fn().mockReturnValue( '#ff0000' );
|
|
866
|
+
expect( normalizeColorToHex( 'VAR(--my-color)', null, mockResolve ) ).toBe( '#ff0000' );
|
|
867
|
+
} );
|
|
758
868
|
} );
|
|
759
869
|
|
|
760
870
|
describe( 'Invalid inputs', () => {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { sanitizeHtml } from '../sanitize-html';
|
|
2
|
+
|
|
3
|
+
describe( 'sanitizeHtml', () => {
|
|
4
|
+
test( 'preserves safe formatting tags', () => {
|
|
5
|
+
const input = '<b>Bold</b> <em>italic</em> <strong>strong</strong>';
|
|
6
|
+
expect( sanitizeHtml( input ) ).toBe( '<b>Bold</b> <em>italic</em> <strong>strong</strong>' );
|
|
7
|
+
} );
|
|
8
|
+
|
|
9
|
+
test( 'strips style attributes', () => {
|
|
10
|
+
const input = '<div style="padding: 12px;"><span style="color: red;">text</span></div>';
|
|
11
|
+
expect( sanitizeHtml( input ) ).toBe( '<div><span>text</span></div>' );
|
|
12
|
+
} );
|
|
13
|
+
|
|
14
|
+
test( 'preserves br tags', () => {
|
|
15
|
+
const input = 'line one<br>line two';
|
|
16
|
+
expect( sanitizeHtml( input ) ).toBe( 'line one<br>line two' );
|
|
17
|
+
} );
|
|
18
|
+
|
|
19
|
+
test( 'removes script tags', () => {
|
|
20
|
+
const input = '<b>Safe</b><script>alert("xss")</script>';
|
|
21
|
+
expect( sanitizeHtml( input ) ).toBe( '<b>Safe</b>' );
|
|
22
|
+
} );
|
|
23
|
+
|
|
24
|
+
test( 'removes img tags with onerror handlers', () => {
|
|
25
|
+
const input = '<b>Safe</b><img src=x onerror="alert(document.cookie)">';
|
|
26
|
+
expect( sanitizeHtml( input ) ).toBe( '<b>Safe</b>' );
|
|
27
|
+
} );
|
|
28
|
+
|
|
29
|
+
test( 'removes iframe tags', () => {
|
|
30
|
+
const input = '<div>text</div><iframe src="https://evil.com"></iframe>';
|
|
31
|
+
expect( sanitizeHtml( input ) ).toBe( '<div>text</div>' );
|
|
32
|
+
} );
|
|
33
|
+
|
|
34
|
+
test( 'removes event handler attributes from allowed tags', () => {
|
|
35
|
+
const input = '<div onclick="alert(1)" onmouseover="steal()">text</div>';
|
|
36
|
+
expect( sanitizeHtml( input ) ).toBe( '<div>text</div>' );
|
|
37
|
+
} );
|
|
38
|
+
|
|
39
|
+
test( 'removes javascript: URLs from href', () => {
|
|
40
|
+
const input = '<a href="javascript:alert(1)">click</a>';
|
|
41
|
+
expect( sanitizeHtml( input ) ).toBe( '<a>click</a>' );
|
|
42
|
+
} );
|
|
43
|
+
|
|
44
|
+
test( 'preserves safe href values', () => {
|
|
45
|
+
const input = '<a href="https://example.com" target="_blank" rel="noopener">link</a>';
|
|
46
|
+
expect( sanitizeHtml( input ) ).toBe(
|
|
47
|
+
'<a href="https://example.com" target="_blank" rel="noopener noreferrer">link</a>'
|
|
48
|
+
);
|
|
49
|
+
} );
|
|
50
|
+
|
|
51
|
+
test( 'removes object and embed tags', () => {
|
|
52
|
+
const input = '<div>safe</div><object data="x"></object><embed src="y">';
|
|
53
|
+
expect( sanitizeHtml( input ) ).toBe( '<div>safe</div>' );
|
|
54
|
+
} );
|
|
55
|
+
|
|
56
|
+
test( 'removes form tags but preserves safe child content', () => {
|
|
57
|
+
const input = '<form action="https://evil.com"><div>trap</div></form>';
|
|
58
|
+
const result = sanitizeHtml( input );
|
|
59
|
+
expect( result ).not.toContain( '<form' );
|
|
60
|
+
expect( result ).not.toContain( 'action' );
|
|
61
|
+
expect( result ).toContain( '<div>trap</div>' );
|
|
62
|
+
} );
|
|
63
|
+
|
|
64
|
+
test( 'handles complex tooltip HTML and strips style attributes', () => {
|
|
65
|
+
const input = `<div style="padding: 12px; font-family: sans-serif;">
|
|
66
|
+
<div style="font-weight: bold;">United States</div>
|
|
67
|
+
<div style="color: #666;">Orders: <strong>1,000</strong></div>
|
|
68
|
+
</div>`;
|
|
69
|
+
const result = sanitizeHtml( input );
|
|
70
|
+
expect( result ).toContain( 'United States' );
|
|
71
|
+
expect( result ).toContain( '<strong>1,000</strong>' );
|
|
72
|
+
expect( result ).not.toContain( 'style' );
|
|
73
|
+
} );
|
|
74
|
+
|
|
75
|
+
test( 'handles empty string', () => {
|
|
76
|
+
expect( sanitizeHtml( '' ) ).toBe( '' );
|
|
77
|
+
} );
|
|
78
|
+
|
|
79
|
+
test( 'handles plain text', () => {
|
|
80
|
+
expect( sanitizeHtml( 'just text' ) ).toBe( 'just text' );
|
|
81
|
+
} );
|
|
82
|
+
|
|
83
|
+
test( 'removes disallowed attributes like id and style', () => {
|
|
84
|
+
const input = '<div id="evil" style="color: red;">text</div>';
|
|
85
|
+
expect( sanitizeHtml( input ) ).toBe( '<div>text</div>' );
|
|
86
|
+
} );
|
|
87
|
+
|
|
88
|
+
test( 'enforces rel="noopener noreferrer" on target="_blank" links', () => {
|
|
89
|
+
const input = '<a href="https://example.com" target="_blank">link</a>';
|
|
90
|
+
expect( sanitizeHtml( input ) ).toBe(
|
|
91
|
+
'<a href="https://example.com" target="_blank" rel="noopener noreferrer">link</a>'
|
|
92
|
+
);
|
|
93
|
+
} );
|
|
94
|
+
|
|
95
|
+
test( 'overrides insufficient rel on target="_blank" links', () => {
|
|
96
|
+
const input = '<a href="https://example.com" target="_blank" rel="noopener">link</a>';
|
|
97
|
+
expect( sanitizeHtml( input ) ).toBe(
|
|
98
|
+
'<a href="https://example.com" target="_blank" rel="noopener noreferrer">link</a>'
|
|
99
|
+
);
|
|
100
|
+
} );
|
|
101
|
+
} );
|