@automattic/charts 4.10.0 → 4.11.0

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.
Files changed (32) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/TOKENS.md +7 -3
  3. package/dist/index.cjs +337 -708
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +52 -3
  6. package/dist/index.d.cts +39 -39
  7. package/dist/index.d.ts +39 -39
  8. package/dist/index.js +308 -660
  9. package/dist/index.js.map +1 -1
  10. package/dist/visx/group/index.js +1 -2
  11. package/dist/visx/legend/index.js +1 -2
  12. package/dist/visx/text/index.js +1 -2
  13. package/package.json +13 -12
  14. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +5 -0
  15. package/src/charts/geo-chart/geo-chart.module.scss +27 -0
  16. package/src/charts/geo-chart/geo-chart.tsx +68 -43
  17. package/src/charts/geo-chart/private/use-tooltip-position.ts +106 -0
  18. package/src/charts/geo-chart/test/geo-chart.test.tsx +68 -42
  19. package/src/charts/geo-chart/test/use-tooltip-position.test.ts +43 -0
  20. package/src/charts/heatmap-chart/heatmap-chart.module.scss +3 -3
  21. package/src/charts/heatmap-chart/private/heatmap-scale.ts +48 -43
  22. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +5 -5
  23. package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +55 -27
  24. package/src/charts/heatmap-chart/types.ts +1 -1
  25. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +8 -5
  26. package/src/components/legend/private/base-legend.module.scss +5 -0
  27. package/src/components/legend/private/base-legend.tsx +8 -2
  28. package/src/components/tooltip/_surface.scss +19 -0
  29. package/src/components/tooltip/private/bounded-tooltip.tsx +2 -2
  30. package/src/components/tooltip/tooltip-box.module.scss +3 -17
  31. package/src/styles/chart-scope.scss +6 -4
  32. package/src/styles/test/chart-scope.test.ts +6 -2
@@ -1,2 +1 @@
1
- import { Group } from "@visx/group";
2
- export { Group };
1
+ export { Group } from "@visx/group";
@@ -1,2 +1 @@
1
- import { CircleShape, LineShape, RectShape } from "@visx/legend";
2
- export { CircleShape, LineShape, RectShape };
1
+ export { CircleShape, LineShape, RectShape } from "@visx/legend";
@@ -1,2 +1 @@
1
- import { Text, getStringWidth, useText } from "@visx/text";
2
- export { Text, getStringWidth, useText };
1
+ export { Text, getStringWidth, useText } from "@visx/text";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "4.10.0",
3
+ "version": "4.11.0",
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.2.10",
66
+ "@automattic/number-formatters": "^1.2.11",
67
67
  "@babel/runtime": "7.29.7",
68
68
  "@date-fns/tz": "1.5.0",
69
69
  "@react-spring/web": "10.1.1",
@@ -84,9 +84,9 @@
84
84
  "@visx/vendor": "^4.0.0",
85
85
  "@visx/xychart": "^4.0.0",
86
86
  "@wordpress/i18n": "^6.0.0",
87
- "@wordpress/icons": "^16.0.0",
88
- "@wordpress/theme": "2.1.1-next.v.202609031004.0",
89
- "@wordpress/ui": "0.22.0",
87
+ "@wordpress/icons": "^18.0.0",
88
+ "@wordpress/theme": "2.3.0",
89
+ "@wordpress/ui": "0.24.0",
90
90
  "clsx": "2.1.1",
91
91
  "date-fns": "^4.1.0",
92
92
  "deepmerge": "4.3.1",
@@ -104,26 +104,27 @@
104
104
  "@testing-library/jest-dom": "^6.0.0",
105
105
  "@testing-library/react": "^16.0.0",
106
106
  "@testing-library/user-event": "^14.4.3",
107
- "@tsdown/css": "0.22.14",
107
+ "@tsdown/css": "0.23.1",
108
108
  "@types/jest": "^30.0.0",
109
109
  "@types/node": "^24.12.0",
110
110
  "@types/react": "18.3.31",
111
111
  "@types/react-dom": "18.3.7",
112
112
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
113
113
  "@visx/glyph": "4.0.0",
114
- "@wordpress/components": "40.1.0",
115
- "@wordpress/element": "8.7.0",
116
- "@wordpress/private-apis": "1.55.0",
117
- "babel-jest": "30.5.1",
114
+ "@wordpress/components": "42.0.0",
115
+ "@wordpress/element": "9.0.0",
116
+ "@wordpress/private-apis": "2.0.0",
117
+ "babel-jest": "30.5.2",
118
118
  "identity-obj-proxy": "^3.0.0",
119
- "jest": "30.5.1",
119
+ "jest": "30.5.2",
120
120
  "jest-extended": "7.0.0",
121
+ "lightningcss": "^1.33.0",
121
122
  "magic-string": "0.30.21",
122
123
  "react": "18.3.1",
123
124
  "react-dom": "18.3.1",
124
125
  "sass-embedded": "1.97.3",
125
126
  "storybook": "10.4.6",
126
- "tsdown": "0.22.14",
127
+ "tsdown": "0.23.1",
127
128
  "typescript": "6.0.3"
128
129
  },
129
130
  "peerDependencies": {
@@ -85,6 +85,11 @@
85
85
  background-color: var(--a8c-charts-color-surface-secondary);
86
86
  position: relative;
87
87
  cursor: pointer;
88
+
89
+ &:focus-visible {
90
+ outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus);
91
+ outline-offset: var(--wpds-border-width-focus);
92
+ }
88
93
  }
89
94
 
90
95
  .funnel-bar {
@@ -1,3 +1,5 @@
1
+ @use "../../components/tooltip/surface" as tooltip;
2
+
1
3
  .container {
2
4
  position: relative;
3
5
  background-color: var(--a8c-charts-color-background, #fff);
@@ -8,5 +10,30 @@
8
10
  // whole and grows the box instead.
9
11
  :global(.google-visualization-tooltip) {
10
12
  white-space: nowrap;
13
+ border: 0;
14
+
15
+ @include tooltip.surface;
16
+ }
17
+
18
+ :global(.google-visualization-tooltip-item-list) {
19
+ margin: 0;
20
+ padding: 0;
21
+ }
22
+
23
+ // The long chain outranks Google's `:first-child` item rule.
24
+ :global(.google-visualization-tooltip .google-visualization-tooltip-item-list .google-visualization-tooltip-item) {
25
+ margin: 0;
26
+ padding: 0;
27
+
28
+ &:first-child {
29
+ padding-block-end: var(--wpds-dimension-padding-md);
30
+ }
31
+ }
32
+
33
+ // Google writes these inline on every span.
34
+ :global(.google-visualization-tooltip-item) span {
35
+ color: inherit !important;
36
+ font-family: inherit !important;
37
+ font-size: inherit !important;
11
38
  }
12
39
  }
@@ -3,11 +3,12 @@
3
3
  */
4
4
  import { __ } from '@wordpress/i18n';
5
5
  import clsx from 'clsx';
6
- import { FC, useContext, useEffect, useMemo, useRef } from 'react';
6
+ import { FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
7
7
  import { Chart, type GoogleChartPackages, type ReactGoogleChartEvent } from 'react-google-charts';
8
8
  /**
9
9
  * Internal dependencies
10
10
  */
11
+ import { TooltipTheme } from '../../components/tooltip/private/tooltip-theme';
11
12
  import {
12
13
  GlobalChartsContext,
13
14
  GlobalChartsProvider,
@@ -21,6 +22,7 @@ import { sanitizeHtml } from '../../utils/sanitize-html';
21
22
  import { Center } from '../private/center';
22
23
  import { withResponsive } from '../private/with-responsive';
23
24
  import styles from './geo-chart.module.scss';
25
+ import { useTooltipPosition } from './private/use-tooltip-position';
24
26
  import type { GeoChartError, GeoChartProps } from './types';
25
27
 
26
28
  const DEFAULT_FEATURE_FILL_COLOR = '#ffffff';
@@ -90,6 +92,40 @@ function involvesGeoChartErrorElement( node: Node ): boolean {
90
92
  );
91
93
  }
92
94
 
95
+ const isTooltipColumn = ( column: unknown ): boolean =>
96
+ typeof column === 'object' && column !== null && 'role' in column && column.role === 'tooltip';
97
+
98
+ // Google renders a column or cell as markup when its `p.html` is truthy, even the string 'false'.
99
+ const hasHtmlFlag = ( columnOrCell: unknown ): boolean =>
100
+ typeof columnOrCell === 'object' &&
101
+ columnOrCell !== null &&
102
+ 'p' in columnOrCell &&
103
+ typeof columnOrCell.p === 'object' &&
104
+ columnOrCell.p !== null &&
105
+ 'html' in columnOrCell.p &&
106
+ !! columnOrCell.p.html;
107
+
108
+ const sanitizeIfString = ( value: unknown ) =>
109
+ typeof value === 'string' ? sanitizeHtml( value ) : value;
110
+
111
+ /**
112
+ * Sanitizes a tooltip cell Google will render as markup, whether a string or a `{ v, f, p }` object.
113
+ *
114
+ * @param cell - The tooltip cell.
115
+ * @return The cell with its markup sanitized.
116
+ */
117
+ function sanitizeTooltipCell( cell: unknown ): unknown {
118
+ if ( typeof cell !== 'object' || cell === null ) {
119
+ return sanitizeIfString( cell );
120
+ }
121
+
122
+ return {
123
+ ...cell,
124
+ ...( 'v' in cell && { v: sanitizeIfString( cell.v ) } ),
125
+ ...( 'f' in cell && { f: sanitizeIfString( cell.f ) } ),
126
+ };
127
+ }
128
+
93
129
  /**
94
130
  * Normalizes the raw Google Charts error event into the GeoChart error shape.
95
131
  *
@@ -155,6 +191,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
155
191
  const scopeElement = useChartScopeElement();
156
192
  const containerRef = useRef< HTMLDivElement >( null );
157
193
  const reportedErrorIdsRef = useRef< Set< string > >( new Set() );
194
+ const [ isLoaded, setIsLoaded ] = useState( false );
158
195
 
159
196
  // The ChartWrapper `error` event does not fire for every draw failure —
160
197
  // notably not when GeoChart's asynchronous map-file load fails (e.g.
@@ -197,7 +234,10 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
197
234
  return () => observer.disconnect();
198
235
  }, [ onError ] );
199
236
 
200
- // Render loading placeholder
237
+ useTooltipPosition( containerRef );
238
+
239
+ const handleLoad = useCallback( () => setIsLoaded( true ), [] );
240
+
201
241
  const loadingPlaceholder = (
202
242
  <Center
203
243
  className={ clsx( 'geo-chart', styles.container, className ) }
@@ -219,49 +259,30 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
219
259
  normalizeColorToHex( CATALOG_POINTERS.surfaceSecondary, scopeElement, resolveCssVariable ) ||
220
260
  DEFAULT_FEATURE_FILL_COLOR;
221
261
 
222
- // Identify HTML tooltip column indices and sanitize their content to prevent XSS.
223
262
  const sanitizedData = useMemo( () => {
224
263
  if ( data.length === 0 ) {
225
- return { data, hasHtmlTooltips: false };
264
+ return data;
226
265
  }
227
266
 
228
- const htmlTooltipIndices: number[] = [];
229
- for ( let i = 0; i < data[ 0 ].length; i++ ) {
230
- const col = data[ 0 ][ i ];
231
- if (
232
- typeof col === 'object' &&
233
- col !== null &&
234
- 'role' in col &&
235
- col.role === 'tooltip' &&
236
- 'p' in col &&
237
- typeof col.p === 'object' &&
238
- col.p !== null &&
239
- 'html' in col.p &&
240
- col.p.html === true
241
- ) {
242
- htmlTooltipIndices.push( i );
243
- }
244
- }
267
+ const tooltipColumns = data[ 0 ].flatMap( ( column, index ) =>
268
+ isTooltipColumn( column ) ? [ { index, isHtml: hasHtmlFlag( column ) } ] : []
269
+ );
245
270
 
246
- if ( htmlTooltipIndices.length === 0 ) {
247
- return { data, hasHtmlTooltips: false };
271
+ if ( tooltipColumns.length === 0 ) {
272
+ return data;
248
273
  }
249
274
 
250
- // Sanitize HTML content in tooltip columns for data rows (skip header row)
251
275
  const sanitizedRows = data.slice( 1 ).map( row => {
252
276
  const newRow = [ ...row ];
253
- for ( const colIndex of htmlTooltipIndices ) {
254
- if ( typeof newRow[ colIndex ] === 'string' ) {
255
- newRow[ colIndex ] = sanitizeHtml( newRow[ colIndex ] as string );
277
+ for ( const { index, isHtml } of tooltipColumns ) {
278
+ if ( isHtml || hasHtmlFlag( newRow[ index ] ) ) {
279
+ newRow[ index ] = sanitizeTooltipCell( newRow[ index ] ) as ( typeof newRow )[ number ];
256
280
  }
257
281
  }
258
282
  return newRow;
259
283
  } );
260
284
 
261
- return {
262
- data: [ data[ 0 ], ...sanitizedRows ] as typeof data,
263
- hasHtmlTooltips: true,
264
- };
285
+ return [ data[ 0 ], ...sanitizedRows ] as typeof data;
265
286
  }, [ data ] );
266
287
 
267
288
  const options: GoogleChartOptions = useMemo(
@@ -274,7 +295,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
274
295
  backgroundColor: backgroundColorHex,
275
296
  datalessRegionColor: defaultFillColorHex,
276
297
  defaultColor: defaultFillColorHex,
277
- tooltip: { trigger: 'focus', isHtml: sanitizedData.hasHtmlTooltips },
298
+ tooltip: { trigger: 'focus', isHtml: true },
278
299
  legend: 'none',
279
300
  keepAspectRatio: true,
280
301
  } ),
@@ -287,7 +308,6 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
287
308
  fullColorHex,
288
309
  backgroundColorHex,
289
310
  defaultFillColorHex,
290
- sanitizedData.hasHtmlTooltips,
291
311
  ]
292
312
  );
293
313
 
@@ -326,16 +346,21 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
326
346
  data-testid="geo-chart"
327
347
  style={ { width, height } }
328
348
  >
329
- <Chart
330
- chartType="GeoChart"
331
- chartPackages={ GEO_CHART_PACKAGES }
332
- width={ width }
333
- height={ height }
334
- data={ sanitizedData.data }
335
- options={ options }
336
- chartEvents={ chartEvents }
337
- loader={ loadingPlaceholder }
338
- />
349
+ { /* Outside TooltipTheme, so a custom placeholder keeps the host theme; `loader` would put it inside. */ }
350
+ { ! isLoaded && loadingPlaceholder }
351
+ { /* Every `--wpds-*` read inside resolves for the dark tooltip; map colors must keep coming from scopeElement. */ }
352
+ <TooltipTheme>
353
+ <Chart
354
+ chartType="GeoChart"
355
+ chartPackages={ GEO_CHART_PACKAGES }
356
+ width={ width }
357
+ height={ height }
358
+ data={ sanitizedData }
359
+ options={ options }
360
+ chartEvents={ chartEvents }
361
+ onLoad={ handleLoad }
362
+ />
363
+ </TooltipTheme>
339
364
  </Center>
340
365
  );
341
366
  };
@@ -0,0 +1,106 @@
1
+ import { useEffect, type RefObject } from 'react';
2
+ import {
3
+ DEFAULT_OFFSET,
4
+ findClippingAncestor,
5
+ getBoundedPosition,
6
+ } from '../../../components/tooltip/private/bounded-tooltip';
7
+
8
+ const TOOLTIP_CLASS = 'google-visualization-tooltip';
9
+
10
+ const isTooltip = ( node: Node ): node is HTMLElement =>
11
+ node instanceof HTMLElement && node.classList.contains( TOOLTIP_CLASS );
12
+
13
+ /**
14
+ * Moves Google's HTML tooltip to where the other chart tooltips sit, just off the cursor. Google places it about 30px away and has no option for it.
15
+ *
16
+ * @param containerRef - The element Google draws the chart into.
17
+ */
18
+ export function useTooltipPosition( containerRef: RefObject< HTMLElement > ) {
19
+ useEffect( () => {
20
+ const container = containerRef.current;
21
+
22
+ if ( ! container || typeof MutationObserver === 'undefined' ) {
23
+ return undefined;
24
+ }
25
+
26
+ let pointer: { x: number; y: number } | null = null;
27
+ let clip: { parent: Element; element: Element | null } | null = null;
28
+ const ownStyles = new WeakMap< HTMLElement, string >();
29
+
30
+ const place = ( tooltip: HTMLElement ) => {
31
+ const parent = tooltip.offsetParent;
32
+
33
+ if ( ! pointer || ! parent ) {
34
+ return;
35
+ }
36
+
37
+ if ( clip?.parent !== parent ) {
38
+ clip = { parent, element: findClippingAncestor( parent ) };
39
+ }
40
+
41
+ const parentRect = parent.getBoundingClientRect();
42
+ const clipRect = clip.element?.getBoundingClientRect() ?? {
43
+ left: 0,
44
+ top: 0,
45
+ right: window.innerWidth,
46
+ bottom: window.innerHeight,
47
+ };
48
+ const { x, y } = getBoundedPosition( {
49
+ left: pointer.x - parentRect.left,
50
+ top: pointer.y - parentRect.top,
51
+ offsetLeft: DEFAULT_OFFSET,
52
+ offsetTop: DEFAULT_OFFSET,
53
+ box: { width: tooltip.offsetWidth, height: tooltip.offsetHeight },
54
+ wrapper: { left: 0, top: 0, right: parentRect.width, bottom: parentRect.height },
55
+ bounds: {
56
+ left: clipRect.left - parentRect.left,
57
+ top: clipRect.top - parentRect.top,
58
+ right: clipRect.right - parentRect.left,
59
+ bottom: clipRect.bottom - parentRect.top,
60
+ },
61
+ } );
62
+
63
+ tooltip.style.left = `${ x }px`;
64
+ tooltip.style.top = `${ y }px`;
65
+ ownStyles.set( tooltip, tooltip.style.cssText );
66
+ };
67
+
68
+ const trackPointer = ( event: MouseEvent ) => {
69
+ pointer = { x: event.clientX, y: event.clientY };
70
+ };
71
+
72
+ const observer = new MutationObserver( records => {
73
+ const tooltips = new Set< HTMLElement >();
74
+
75
+ for ( const record of records ) {
76
+ if ( isTooltip( record.target ) ) {
77
+ tooltips.add( record.target );
78
+ }
79
+ record.addedNodes.forEach( node => isTooltip( node ) && tooltips.add( node ) );
80
+ }
81
+
82
+ for ( const tooltip of tooltips ) {
83
+ // Our own write is observed too; placing again would loop.
84
+ if ( ownStyles.get( tooltip ) !== tooltip.style.cssText ) {
85
+ place( tooltip );
86
+ }
87
+ }
88
+ } );
89
+
90
+ // Google's own mouse events, captured so the pointer is current before Google writes the tooltip. Chrome sends mouse events, but no pointer events, when the map moves under a resting cursor.
91
+ container.addEventListener( 'mouseover', trackPointer, true );
92
+ container.addEventListener( 'mousemove', trackPointer, true );
93
+ observer.observe( container, {
94
+ childList: true,
95
+ subtree: true,
96
+ attributes: true,
97
+ attributeFilter: [ 'style' ],
98
+ } );
99
+
100
+ return () => {
101
+ container.removeEventListener( 'mouseover', trackPointer, true );
102
+ container.removeEventListener( 'mousemove', trackPointer, true );
103
+ observer.disconnect();
104
+ };
105
+ }, [ containerRef ] );
106
+ }
@@ -1,7 +1,8 @@
1
- import { render, screen, waitFor } from '@testing-library/react';
2
- import { Chart } from 'react-google-charts';
1
+ import { act, render, screen, waitFor } from '@testing-library/react';
2
+ import { Chart, type GoogleDataTableColumnRoleType } from 'react-google-charts';
3
3
  import { GlobalChartsProvider } from '../../../providers';
4
4
  import GeoChart, { GeoChartUnresponsive } from '../geo-chart';
5
+ import type { GeoChartProps } from '../types';
5
6
 
6
7
  // Mock react-google-charts
7
8
  jest.mock( 'react-google-charts', () => ( {
@@ -97,20 +98,54 @@ describe( 'GeoChart', () => {
97
98
  expect( data ).toEqual( testData );
98
99
  } );
99
100
 
100
- test( 'sanitizes HTML tooltip content to prevent XSS', () => {
101
- const testData: [ ( string | object )[], ...[ string, number, string ][] ] = [
102
- [ 'Country', 'Value', { type: 'string', role: 'tooltip', p: { html: true } } ],
103
- [ 'US', 100, '<b>United States</b><script>alert("xss")</script>' ],
104
- [ 'CA', 50, '<b>Canada</b><img src=x onerror="alert(1)">' ],
105
- ];
106
- renderWithTheme( { data: testData } );
101
+ test.each( [ true, 'true', 1, 'false' ] )(
102
+ 'sanitizes HTML tooltip content when p.html is %p',
103
+ html => {
104
+ const testData: [ ( string | object )[], ...[ string, number, string ][] ] = [
105
+ [ 'Country', 'Value', { type: 'string', role: 'tooltip', p: { html } } ],
106
+ [ 'US', 100, '<b>United States</b><script>alert("xss")</script>' ],
107
+ [ 'CA', 50, '<b>Canada</b><img src=x onerror="alert(1)">' ],
108
+ ];
109
+ renderWithTheme( { data: testData } );
110
+
111
+ const chartData = screen.getByTestId( 'chart-data' );
112
+ const data = JSON.parse( chartData.textContent || '[]' );
113
+
114
+ // Script tags and img (not in allowlist) should be stripped
115
+ expect( data[ 1 ][ 2 ] ).toBe( '<b>United States</b>' );
116
+ expect( data[ 2 ][ 2 ] ).toBe( '<b>Canada</b>' );
117
+ }
118
+ );
107
119
 
108
- const chartData = screen.getByTestId( 'chart-data' );
109
- const data = JSON.parse( chartData.textContent || '[]' );
120
+ const unsafe = '<b>US</b><img src=x onerror="alert(1)">';
121
+ const htmlColumn = { type: 'string', role: 'tooltip', p: { html: true } };
122
+ const textColumn = { type: 'string', role: 'tooltip' };
123
+
124
+ test.each( [
125
+ [
126
+ 'a cell flagged HTML in a text column',
127
+ textColumn,
128
+ { v: unsafe, p: { html: true } },
129
+ { v: '<b>US</b>', p: { html: true } },
130
+ ],
131
+ [
132
+ 'an object cell in an HTML column',
133
+ htmlColumn,
134
+ { v: unsafe, f: unsafe },
135
+ { v: '<b>US</b>', f: '<b>US</b>' },
136
+ ],
137
+ [ 'a plain cell in a text column, which Google escapes', textColumn, unsafe, unsafe ],
138
+ ] )( 'sanitizes tooltip markup in %s', ( _, column, cell, expected ) => {
139
+ renderWithTheme( {
140
+ data: [
141
+ [ 'Country', 'Value', column ],
142
+ [ 'US', 100, cell ],
143
+ ],
144
+ } );
110
145
 
111
- // Script tags and img (not in allowlist) should be stripped
112
- expect( data[ 1 ][ 2 ] ).toBe( '<b>United States</b>' );
113
- expect( data[ 2 ][ 2 ] ).toBe( '<b>Canada</b>' );
146
+ const data = JSON.parse( screen.getByTestId( 'chart-data' ).textContent || '[]' );
147
+
148
+ expect( data[ 1 ][ 2 ] ).toEqual( expected );
114
149
  } );
115
150
 
116
151
  test( 'handles header-only data with HTML tooltip column and no data rows', () => {
@@ -170,41 +205,22 @@ describe( 'GeoChart', () => {
170
205
  expect( options.legend ).toBe( 'none' );
171
206
  } );
172
207
 
173
- test( 'sets tooltip trigger to focus with isHtml false by default', () => {
174
- renderWithTheme();
175
-
176
- const chartOptions = screen.getByTestId( 'chart-options' );
177
- const options = JSON.parse( chartOptions.textContent || '{}' );
178
-
179
- expect( options.tooltip ).toEqual( { trigger: 'focus', isHtml: false } );
180
- } );
181
-
182
- test( 'enables HTML tooltips when data has HTML tooltip column', () => {
183
- const testData: [ ( string | object )[], ...[ string, number, string ][] ] = [
184
- [ 'Country', 'Value', { type: 'string', role: 'tooltip', p: { html: true } } ],
185
- [ 'US', 100, '<b>United States</b><br/>100 orders' ],
186
- [ 'CA', 50, '<b>Canada</b><br/>50 orders' ],
187
- ];
188
- renderWithTheme( { data: testData } );
189
-
190
- const chartOptions = screen.getByTestId( 'chart-options' );
191
- const options = JSON.parse( chartOptions.textContent || '{}' );
192
-
193
- expect( options.tooltip ).toEqual( { trigger: 'focus', isHtml: true } );
194
- } );
195
-
196
- test( 'keeps isHtml false for text-only tooltips', () => {
197
- const testData: [ ( string | object )[], ...[ string, number, string ][] ] = [
198
- [ 'Country', 'Value', { type: 'string', role: 'tooltip' } ],
208
+ test( 'sets tooltip trigger to focus with isHtml true', () => {
209
+ const data: GeoChartProps[ 'data' ] = [
210
+ [
211
+ 'Country',
212
+ 'Value',
213
+ { type: 'string', role: 'tooltip' as GoogleDataTableColumnRoleType },
214
+ ],
199
215
  [ 'US', 100, 'United States: 100 orders' ],
200
216
  [ 'CA', 50, 'Canada: 50 orders' ],
201
217
  ];
202
- renderWithTheme( { data: testData } );
218
+ renderWithTheme( { data } );
203
219
 
204
220
  const chartOptions = screen.getByTestId( 'chart-options' );
205
221
  const options = JSON.parse( chartOptions.textContent || '{}' );
206
222
 
207
- expect( options.tooltip ).toEqual( { trigger: 'focus', isHtml: false } );
223
+ expect( options.tooltip ).toEqual( { trigger: 'focus', isHtml: true } );
208
224
  } );
209
225
 
210
226
  test( 'does not include region in options when set to world (default)', () => {
@@ -418,6 +434,16 @@ describe( 'GeoChart', () => {
418
434
  // The component should call the custom placeholder function
419
435
  expect( customPlaceholder ).toHaveBeenCalled();
420
436
  } );
437
+
438
+ test( 'keeps the placeholder outside the chart until Google Charts loads', () => {
439
+ renderWithTheme();
440
+
441
+ expect( screen.getByTestId( 'geo-chart-loading' ) ).toBeInTheDocument();
442
+
443
+ act( () => ChartMock.mock.calls[ ChartMock.mock.calls.length - 1 ][ 0 ].onLoad() );
444
+
445
+ expect( screen.queryByTestId( 'geo-chart-loading' ) ).not.toBeInTheDocument();
446
+ } );
421
447
  } );
422
448
 
423
449
  describe( 'Responsive Wrapper', () => {
@@ -0,0 +1,43 @@
1
+ import { renderHook, waitFor } from '@testing-library/react';
2
+ import { useTooltipPosition } from '../private/use-tooltip-position';
3
+
4
+ const renderChart = () => {
5
+ const container = document.createElement( 'div' );
6
+ const chart = document.createElement( 'div' );
7
+ const tooltip = document.createElement( 'div' );
8
+ tooltip.className = 'google-visualization-tooltip';
9
+ chart.append( tooltip );
10
+ container.append( chart );
11
+ document.body.append( container );
12
+
13
+ chart.getBoundingClientRect = () => new DOMRect( 100, 50, 400, 300 );
14
+ Object.defineProperties( tooltip, {
15
+ offsetParent: { value: chart },
16
+ offsetWidth: { value: 80 },
17
+ offsetHeight: { value: 40 },
18
+ } );
19
+
20
+ renderHook( () => useTooltipPosition( { current: container } ) );
21
+
22
+ return { chart, tooltip };
23
+ };
24
+
25
+ describe( 'useTooltipPosition', () => {
26
+ afterEach( () => document.body.replaceChildren() );
27
+
28
+ it.each( [
29
+ [ 'beside the cursor', 200, 100, '110px', '60px' ],
30
+ [ 'flipped left near the right edge', 480, 100, '290px', '60px' ],
31
+ ] )( 'moves the tooltip Google places %s', async ( _, clientX, clientY, left, top ) => {
32
+ const { chart, tooltip } = renderChart();
33
+ chart.addEventListener( 'mousemove', () => {
34
+ tooltip.style.left = '0px';
35
+ tooltip.style.top = '0px';
36
+ } );
37
+
38
+ // A bare mouse event, as Chrome sends when the map moves under a resting cursor.
39
+ chart.dispatchEvent( new MouseEvent( 'mousemove', { bubbles: true, clientX, clientY } ) );
40
+
41
+ await waitFor( () => expect( tooltip ).toHaveStyle( { left, top } ) );
42
+ } );
43
+ } );
@@ -5,8 +5,8 @@ $focus-ring-width: var(--wpds-border-width-focus);
5
5
 
6
6
  $heatmap-empty-fill: var(--a8c-charts-color-track);
7
7
 
8
- // JS sets the ends (getHeatmapScale): 3:1 against background and empty cell at
9
- // the low end. Unresolved, the floor is 15% of the primary.
8
+ // JS sets the ends (getHeatmapScale). Unresolved, the floor is 15% of the
9
+ // primary.
10
10
  $heatmap-fill: color-mix(in sRGB, var(--a8c-charts-color-heatmap-high, var(--a8c-charts-color-heatmap-primary)) calc(var(--a8c-charts-heatmap-cell-intensity) * 100%), var(--a8c-charts-color-heatmap-low, color-mix(in sRGB, var(--a8c-charts-color-heatmap-primary) 15%, var(--a8c-charts-color-background, #fff))));
11
11
 
12
12
  .heatmap-chart {
@@ -49,7 +49,7 @@ $heatmap-fill: color-mix(in sRGB, var(--a8c-charts-color-heatmap-high, var(--a8c
49
49
  outline:
50
50
  $focus-ring-width solid
51
51
  var(--wpds-color-stroke-focus);
52
- outline-offset: 2px;
52
+ outline-offset: $focus-ring-width;
53
53
  border-radius: var(--wpds-border-radius-sm);
54
54
  }
55
55
  }