@automattic/charts 4.9.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 (67) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/TOKENS.md +7 -3
  3. package/dist/index.cjs +496 -774
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +66 -3
  6. package/dist/index.d.cts +65 -60
  7. package/dist/index.d.ts +65 -60
  8. package/dist/index.js +467 -726
  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/area-chart/area-chart.tsx +2 -1
  15. package/src/charts/area-chart/test/area-chart.test.tsx +0 -8
  16. package/src/charts/area-chart/types.ts +0 -2
  17. package/src/charts/bar-chart/bar-chart.tsx +7 -2
  18. package/src/charts/bar-chart/test/bar-chart.test.tsx +0 -8
  19. package/src/charts/bar-chart/types.ts +0 -2
  20. package/src/charts/bar-list-chart/bar-list-chart.tsx +3 -0
  21. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +5 -0
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +23 -7
  23. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +22 -0
  24. package/src/charts/conversion-funnel-chart/types.ts +1 -1
  25. package/src/charts/geo-chart/geo-chart.module.scss +27 -0
  26. package/src/charts/geo-chart/geo-chart.tsx +85 -45
  27. package/src/charts/geo-chart/private/use-tooltip-position.ts +106 -0
  28. package/src/charts/geo-chart/test/geo-chart.test.tsx +77 -46
  29. package/src/charts/geo-chart/test/use-tooltip-position.test.ts +43 -0
  30. package/src/charts/geo-chart/types.ts +1 -1
  31. package/src/charts/heatmap-chart/heatmap-chart.module.scss +3 -3
  32. package/src/charts/heatmap-chart/private/heatmap-scale.ts +48 -43
  33. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +5 -11
  34. package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +55 -27
  35. package/src/charts/heatmap-chart/types.ts +1 -3
  36. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  37. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +6 -0
  38. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +34 -19
  39. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +35 -6
  40. package/src/charts/leaderboard-chart/types.ts +1 -0
  41. package/src/charts/line-chart/line-chart.tsx +3 -0
  42. package/src/charts/line-chart/test/line-chart.test.tsx +0 -8
  43. package/src/charts/line-chart/types.ts +0 -2
  44. package/src/charts/pie-chart/pie-chart.module.scss +3 -0
  45. package/src/charts/pie-chart/pie-chart.tsx +0 -5
  46. package/src/charts/pie-chart/test/pie-chart.test.tsx +4 -8
  47. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +3 -0
  48. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +0 -5
  49. package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +8 -3
  50. package/src/charts/test/accessible-name.test.tsx +181 -0
  51. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +63 -3
  52. package/src/components/legend/hooks/use-chart-legend-items.ts +63 -1
  53. package/src/components/legend/private/base-legend.module.scss +5 -0
  54. package/src/components/legend/private/base-legend.tsx +35 -17
  55. package/src/components/legend/test/legend.test.tsx +42 -0
  56. package/src/components/legend/types.ts +2 -0
  57. package/src/components/tooltip/_surface.scss +19 -0
  58. package/src/components/tooltip/private/bounded-tooltip.tsx +2 -2
  59. package/src/components/tooltip/tooltip-box.module.scss +3 -17
  60. package/src/hooks/test/use-xychart-theme.test.tsx +22 -0
  61. package/src/hooks/use-xychart-theme.ts +33 -2
  62. package/src/index.ts +1 -0
  63. package/src/styles/chart-scope.scss +6 -4
  64. package/src/styles/test/chart-scope.test.ts +6 -2
  65. package/src/types.ts +19 -1
  66. package/src/utils/resolve-font-size.ts +9 -10
  67. package/src/utils/test/resolve-font-size.test.ts +11 -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.9.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": {
@@ -262,8 +262,9 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
262
262
  glyphSize: 0,
263
263
  collapseGroups: legend.collapseGroups ?? false,
264
264
  comparisonItem: legend.comparisonItem ?? false,
265
+ comparisonSeriesItems: legend.comparisonSeriesItems,
265
266
  } ),
266
- [ legend.collapseGroups, legend.comparisonItem ]
267
+ [ legend.collapseGroups, legend.comparisonItem, legend.comparisonSeriesItems ]
267
268
  );
268
269
  const legendItems = useChartLegendItems( dataSorted, legendOptions, legendShape );
269
270
 
@@ -785,14 +785,6 @@ describe( 'AreaChart', () => {
785
785
  const chart = screen.getByRole( 'application', { name: /area chart/i } );
786
786
  expect( chart ).toHaveAttribute( 'tabIndex', '0' );
787
787
  expect( chart ).toHaveAttribute( 'aria-label', 'Area chart' );
788
- } );
789
-
790
- test.each( [
791
- [ undefined, 'Area chart' ],
792
- [ 'Views over time', 'Views over time' ],
793
- ] )( 'names the chart container from ariaLabel %p', ( ariaLabel, name ) => {
794
- renderWithProvider( { ariaLabel } );
795
- expect( screen.getByRole( 'application', { name } ) ).toBeInTheDocument();
796
788
  expect( screen.queryByLabelText( 'XYChart' ) ).not.toBeInTheDocument();
797
789
  } );
798
790
  } );
@@ -11,8 +11,6 @@ import type { CurveType } from '../line-chart/types';
11
11
  import type { ReactNode } from 'react';
12
12
 
13
13
  export interface AreaChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
14
- /** Accessible name of the chart. Defaults to a localized "Area chart". */
15
- ariaLabel?: string;
16
14
  /**
17
15
  * Legend configuration. Supports `collapseGroups` on top of the shared options.
18
16
  */
@@ -139,6 +139,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
139
139
  const legendInteractive = legend.interactive ?? false;
140
140
  const legendCollapseGroups = legend.collapseGroups ?? false;
141
141
  const legendComparisonItem = legend.comparisonItem ?? false;
142
+ const legendComparisonSeriesItems = legend.comparisonSeriesItems;
142
143
  const horizontal = orientation === 'horizontal';
143
144
  const chartId = useChartId( providedChartId );
144
145
  const hiddenSeries = useDefaultHiddenSeries( chartId, defaultHiddenSeries );
@@ -159,8 +160,12 @@ const BarChartInternal: FC< BarChartProps > = ( {
159
160
 
160
161
  // Create legend items using the reusable hook
161
162
  const legendOptions = useMemo(
162
- () => ( { collapseGroups: legendCollapseGroups, comparisonItem: legendComparisonItem } ),
163
- [ legendCollapseGroups, legendComparisonItem ]
163
+ () => ( {
164
+ collapseGroups: legendCollapseGroups,
165
+ comparisonItem: legendComparisonItem,
166
+ comparisonSeriesItems: legendComparisonSeriesItems,
167
+ } ),
168
+ [ legendCollapseGroups, legendComparisonItem, legendComparisonSeriesItems ]
164
169
  );
165
170
  const legendItems = useChartLegendItems( dataSorted, legendOptions );
166
171
 
@@ -1685,14 +1685,6 @@ describe( 'BarChart', () => {
1685
1685
  expect( chart ).toHaveAttribute( 'tabIndex', '0' );
1686
1686
  expect( chart ).toHaveAttribute( 'role', 'application' );
1687
1687
  expect( chart ).toHaveAttribute( 'aria-label', 'Bar chart' );
1688
- } );
1689
-
1690
- test.each( [
1691
- [ undefined, 'Bar chart' ],
1692
- [ 'Views over time', 'Views over time' ],
1693
- ] )( 'names the chart container from ariaLabel %p', ( ariaLabel, name ) => {
1694
- renderWithTheme( { ariaLabel } );
1695
- expect( screen.getByRole( 'application', { name } ) ).toBeInTheDocument();
1696
1688
  expect( screen.queryByLabelText( 'XYChart' ) ).not.toBeInTheDocument();
1697
1689
  } );
1698
1690
 
@@ -19,8 +19,6 @@ export type BandHighlightSelection = {
19
19
  };
20
20
 
21
21
  export interface BarChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
22
- /** Accessible name of the chart. Defaults to a localized "Bar chart". */
23
- ariaLabel?: string;
24
22
  /**
25
23
  * Legend configuration. Supports `collapseGroups` on top of the shared options.
26
24
  */
@@ -2,6 +2,7 @@ import { formatNumberCompact } from '@automattic/number-formatters';
2
2
  import { Group } from '@visx/group';
3
3
  import { createScale, scaleBand } from '@visx/scale';
4
4
  import { Text, type TextProps } from '@visx/text';
5
+ import { __ } from '@wordpress/i18n';
5
6
  import clsx from 'clsx';
6
7
  import { useContext, useMemo } from 'react';
7
8
  import { GlobalChartsContext, GlobalChartsProvider, useGlobalChartsContext } from '../../providers';
@@ -235,6 +236,7 @@ const BarListChartInternal: FC< BarListChartProps > = ( {
235
236
  top: 0,
236
237
  },
237
238
  className,
239
+ ariaLabel,
238
240
  ...rest
239
241
  } ) => {
240
242
  const { getElementStyles } = useGlobalChartsContext();
@@ -333,6 +335,7 @@ const BarListChartInternal: FC< BarListChartProps > = ( {
333
335
  xScale: chartOptions.xScale,
334
336
  yScale: chartOptions.yScale,
335
337
  } }
338
+ ariaLabel={ ariaLabel ?? __( 'Bar list chart', 'jetpack-charts' ) }
336
339
  { ...rest }
337
340
  />
338
341
  );
@@ -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,9 +1,10 @@
1
1
  import { useTooltip } from '@visx/tooltip';
2
+ import { __ } from '@wordpress/i18n';
2
3
  import { Stack } from '@wordpress/ui';
3
4
  import clsx from 'clsx';
4
5
  import { type FC, useRef, useMemo, useEffect, useState, useCallback, useContext } from 'react';
5
6
  import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
6
- import { usePrefersReducedMotion } from '../../hooks';
7
+ import { useElementSize, usePrefersReducedMotion } from '../../hooks';
7
8
  import {
8
9
  GlobalChartsProvider,
9
10
  GlobalChartsContext,
@@ -19,6 +20,9 @@ import styles from './conversion-funnel-chart.module.scss';
19
20
  import { useFunnelSelection } from './private';
20
21
  import type { FunnelStep, ConversionFunnelChartProps } from './types';
21
22
 
23
+ /** Chart height (px) below which the funnel tightens its gaps so the bars keep their room. */
24
+ const SHORT_CHART_HEIGHT = 180;
25
+
22
26
  /**
23
27
  * Internal ConversionFunnelChart component with chart registration
24
28
  *
@@ -30,6 +34,7 @@ import type { FunnelStep, ConversionFunnelChartProps } from './types';
30
34
  * @param props.loading - Whether the chart is in loading state
31
35
  * @param props.animation - Whether to show chart animation on initial render or not
32
36
  * @param props.className - Additional CSS class name
37
+ * @param props.ariaLabel - Accessible name for the chart group
33
38
  * @param props.height - Height of the chart container. Falls back to style.height if set, otherwise defaults to "100%".
34
39
  * @param props.style - Custom styling
35
40
  * @param props.renderStepLabel - Custom render function for step labels
@@ -46,6 +51,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
46
51
  loading = false,
47
52
  animation,
48
53
  className,
54
+ ariaLabel,
49
55
  chartId: providedChartId,
50
56
  height,
51
57
  style,
@@ -69,11 +75,19 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
69
75
  const { handleBarClick, handleBarKeyDown, clearSelection, getStepState } =
70
76
  useFunnelSelection( hideTooltip );
71
77
  const standaloneScopeClass = useStandaloneScopeClass();
78
+ const [ measureChartRef, , chartHeight ] = useElementSize< HTMLDivElement >();
79
+ // A zero height is an unmeasured or hidden chart, not a short one.
80
+ const isShort = chartHeight > 0 && chartHeight < SHORT_CHART_HEIGHT;
81
+ const gap = isShort ? 'sm' : 'xl';
72
82
  // Stable identity so React doesn't detach/reattach (and re-render the scope context) on every commit. Keep it firing on unmount (node === null).
73
- const setChartRef = useCallback( ( node: HTMLDivElement | null ) => {
74
- chartRef.current = node;
75
- setScopeNode( node );
76
- }, [] );
83
+ const setChartRef = useCallback(
84
+ ( node: HTMLDivElement | null ) => {
85
+ chartRef.current = node;
86
+ setScopeNode( node );
87
+ measureChartRef( node );
88
+ },
89
+ [ measureChartRef ]
90
+ );
77
91
 
78
92
  // Wrapper to clear selectedBarRef after clearing selection
79
93
  const clearSelectionAndRef = useCallback( () => {
@@ -309,8 +323,10 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
309
323
  return (
310
324
  <Stack
311
325
  direction="column"
312
- gap="xl"
326
+ gap={ gap }
313
327
  data-testid="conversion-funnel-chart"
328
+ role="group"
329
+ aria-label={ ariaLabel ?? __( 'Conversion funnel chart', 'jetpack-charts' ) }
314
330
  ref={ setChartRef }
315
331
  className={ clsx(
316
332
  'conversion-funnel-chart',
@@ -351,7 +367,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
351
367
  isColorPaletteResolved && styles[ 'funnel-step--animated' ],
352
368
  isBlurred && styles[ 'funnel-step--blurred' ]
353
369
  ) }
354
- gap="xl"
370
+ gap={ gap }
355
371
  >
356
372
  { /* Step Label and Rate */ }
357
373
  <Stack direction="column" gap="xs">
@@ -3,6 +3,11 @@ import userEvent from '@testing-library/user-event';
3
3
  import ConversionFunnelChart from '../conversion-funnel-chart';
4
4
  import type { FunnelStep } from '../types';
5
5
 
6
+ let mockChartHeight = 0;
7
+ jest.mock( '../../../hooks/use-element-size', () => ( {
8
+ useElementSize: () => [ jest.fn(), 500, mockChartHeight ],
9
+ } ) );
10
+
6
11
  // Mock data for testing
7
12
  const mockSteps: FunnelStep[] = [
8
13
  {
@@ -42,6 +47,10 @@ const renderWithoutTheme = ( component: React.ReactElement ) => {
42
47
  };
43
48
 
44
49
  describe( 'ConversionFunnelChart', () => {
50
+ afterEach( () => {
51
+ mockChartHeight = 0;
52
+ } );
53
+
45
54
  describe( 'Basic Rendering', () => {
46
55
  it( 'renders the main conversion rate', () => {
47
56
  renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
@@ -116,6 +125,19 @@ describe( 'ConversionFunnelChart', () => {
116
125
  height: '250px',
117
126
  } );
118
127
  } );
128
+
129
+ it.each( [
130
+ [ 'unmeasured', 0, 'xl' ],
131
+ [ 'short', 120, 'sm' ],
132
+ [ 'tall', 320, 'xl' ],
133
+ ] )( 'spaces a %s (%ipx) chart with the %s gap', ( _, height, gap ) => {
134
+ mockChartHeight = height;
135
+ renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
136
+
137
+ expect( screen.getByTestId( 'conversion-funnel-chart' ).style.gap ).toContain(
138
+ `gap-${ gap }`
139
+ );
140
+ } );
119
141
  } );
120
142
 
121
143
  describe( 'Empty State', () => {
@@ -59,7 +59,7 @@ export interface TooltipRenderProps {
59
59
  */
60
60
  export interface ConversionFunnelChartProps extends Pick<
61
61
  BaseChartProps,
62
- 'className' | 'chartId' | 'animation'
62
+ 'className' | 'chartId' | 'animation' | 'ariaLabel'
63
63
  > {
64
64
  /** Main conversion rate to highlight */
65
65
  mainRate: number;
@@ -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
  *
@@ -126,6 +162,7 @@ function normalizeGeoChartError( eventArgs: unknown ): GeoChartError {
126
162
  *
127
163
  * @param props - The props for the GeoChart component
128
164
  * @param props.data - Data in Google Charts format (array of arrays with headers)
165
+ * @param props.ariaLabel - Accessible name of the chart
129
166
  * @param props.width - Width of the chart in pixels
130
167
  * @param props.height - Height of the chart in pixels
131
168
  * @param props.region - Region to display ('world', 'US', or ISO 3166-1 alpha-2 code)
@@ -140,6 +177,7 @@ function normalizeGeoChartError( eventArgs: unknown ): GeoChartError {
140
177
  const GeoChartInternal: FC< GeoChartProps > = ( {
141
178
  className,
142
179
  data,
180
+ ariaLabel,
143
181
  width,
144
182
  height,
145
183
  region = 'world',
@@ -153,6 +191,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
153
191
  const scopeElement = useChartScopeElement();
154
192
  const containerRef = useRef< HTMLDivElement >( null );
155
193
  const reportedErrorIdsRef = useRef< Set< string > >( new Set() );
194
+ const [ isLoaded, setIsLoaded ] = useState( false );
156
195
 
157
196
  // The ChartWrapper `error` event does not fire for every draw failure —
158
197
  // notably not when GeoChart's asynchronous map-file load fails (e.g.
@@ -195,7 +234,10 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
195
234
  return () => observer.disconnect();
196
235
  }, [ onError ] );
197
236
 
198
- // Render loading placeholder
237
+ useTooltipPosition( containerRef );
238
+
239
+ const handleLoad = useCallback( () => setIsLoaded( true ), [] );
240
+
199
241
  const loadingPlaceholder = (
200
242
  <Center
201
243
  className={ clsx( 'geo-chart', styles.container, className ) }
@@ -217,49 +259,30 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
217
259
  normalizeColorToHex( CATALOG_POINTERS.surfaceSecondary, scopeElement, resolveCssVariable ) ||
218
260
  DEFAULT_FEATURE_FILL_COLOR;
219
261
 
220
- // Identify HTML tooltip column indices and sanitize their content to prevent XSS.
221
262
  const sanitizedData = useMemo( () => {
222
263
  if ( data.length === 0 ) {
223
- return { data, hasHtmlTooltips: false };
264
+ return data;
224
265
  }
225
266
 
226
- const htmlTooltipIndices: number[] = [];
227
- for ( let i = 0; i < data[ 0 ].length; i++ ) {
228
- const col = data[ 0 ][ i ];
229
- if (
230
- typeof col === 'object' &&
231
- col !== null &&
232
- 'role' in col &&
233
- col.role === 'tooltip' &&
234
- 'p' in col &&
235
- typeof col.p === 'object' &&
236
- col.p !== null &&
237
- 'html' in col.p &&
238
- col.p.html === true
239
- ) {
240
- htmlTooltipIndices.push( i );
241
- }
242
- }
267
+ const tooltipColumns = data[ 0 ].flatMap( ( column, index ) =>
268
+ isTooltipColumn( column ) ? [ { index, isHtml: hasHtmlFlag( column ) } ] : []
269
+ );
243
270
 
244
- if ( htmlTooltipIndices.length === 0 ) {
245
- return { data, hasHtmlTooltips: false };
271
+ if ( tooltipColumns.length === 0 ) {
272
+ return data;
246
273
  }
247
274
 
248
- // Sanitize HTML content in tooltip columns for data rows (skip header row)
249
275
  const sanitizedRows = data.slice( 1 ).map( row => {
250
276
  const newRow = [ ...row ];
251
- for ( const colIndex of htmlTooltipIndices ) {
252
- if ( typeof newRow[ colIndex ] === 'string' ) {
253
- 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 ];
254
280
  }
255
281
  }
256
282
  return newRow;
257
283
  } );
258
284
 
259
- return {
260
- data: [ data[ 0 ], ...sanitizedRows ] as typeof data,
261
- hasHtmlTooltips: true,
262
- };
285
+ return [ data[ 0 ], ...sanitizedRows ] as typeof data;
263
286
  }, [ data ] );
264
287
 
265
288
  const options: GoogleChartOptions = useMemo(
@@ -272,7 +295,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
272
295
  backgroundColor: backgroundColorHex,
273
296
  datalessRegionColor: defaultFillColorHex,
274
297
  defaultColor: defaultFillColorHex,
275
- tooltip: { trigger: 'focus', isHtml: sanitizedData.hasHtmlTooltips },
298
+ tooltip: { trigger: 'focus', isHtml: true },
276
299
  legend: 'none',
277
300
  keepAspectRatio: true,
278
301
  } ),
@@ -285,16 +308,26 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
285
308
  fullColorHex,
286
309
  backgroundColorHex,
287
310
  defaultFillColorHex,
288
- sanitizedData.hasHtmlTooltips,
289
311
  ]
290
312
  );
291
313
 
292
- const chartEvents = useMemo< ReactGoogleChartEvent[] | undefined >( () => {
314
+ const chartEvents = useMemo< ReactGoogleChartEvent[] >( () => {
315
+ const readyEvent: ReactGoogleChartEvent = {
316
+ eventName: 'ready',
317
+ // Google hardcodes aria-label="A chart." on its SVG, which repeats the chart's own name.
318
+ callback: () => {
319
+ containerRef.current
320
+ ?.querySelectorAll( 'svg' )
321
+ .forEach( svg => svg.setAttribute( 'aria-hidden', 'true' ) );
322
+ },
323
+ };
324
+
293
325
  if ( ! onError ) {
294
- return undefined;
326
+ return [ readyEvent ];
295
327
  }
296
328
 
297
329
  return [
330
+ readyEvent,
298
331
  {
299
332
  eventName: 'error',
300
333
  callback: ( { eventArgs } ) => {
@@ -307,20 +340,27 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
307
340
  return (
308
341
  <Center
309
342
  ref={ containerRef }
343
+ role="group"
344
+ aria-label={ ariaLabel ?? __( 'Geo chart', 'jetpack-charts' ) }
310
345
  className={ clsx( 'geo-chart', styles.container, className ) }
311
346
  data-testid="geo-chart"
312
347
  style={ { width, height } }
313
348
  >
314
- <Chart
315
- chartType="GeoChart"
316
- chartPackages={ GEO_CHART_PACKAGES }
317
- width={ width }
318
- height={ height }
319
- data={ sanitizedData.data }
320
- options={ options }
321
- chartEvents={ chartEvents }
322
- loader={ loadingPlaceholder }
323
- />
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>
324
364
  </Center>
325
365
  );
326
366
  };