@automattic/charts 1.8.0 → 1.9.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 (49) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/index.cjs +541 -84
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +122 -34
  5. package/dist/index.d.cts +97 -7
  6. package/dist/index.d.ts +97 -7
  7. package/dist/index.js +539 -87
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/charts/area-chart/area-chart.module.scss +3 -1
  11. package/src/charts/area-chart/area-chart.tsx +5 -2
  12. package/src/charts/bar-chart/bar-chart.module.scss +6 -2
  13. package/src/charts/bar-chart/bar-chart.tsx +29 -22
  14. package/src/charts/bar-chart/private/comparison-bars.tsx +7 -0
  15. package/src/charts/bar-chart/private/use-bar-chart-options.ts +4 -1
  16. package/src/charts/bar-chart/test/bar-chart.test.tsx +141 -3
  17. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +15 -12
  18. package/src/charts/geo-chart/geo-chart.tsx +6 -1
  19. package/src/charts/geo-chart/test/geo-chart.test.tsx +11 -1
  20. package/src/charts/heatmap-chart/heatmap-chart.module.scss +103 -0
  21. package/src/charts/heatmap-chart/heatmap-chart.tsx +422 -0
  22. package/src/charts/heatmap-chart/index.ts +4 -0
  23. package/src/charts/heatmap-chart/private/build-calendar-data.ts +81 -0
  24. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +53 -0
  25. package/src/charts/heatmap-chart/private/index.ts +5 -0
  26. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +45 -0
  27. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +88 -0
  28. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +301 -0
  29. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +34 -0
  30. package/src/charts/heatmap-chart/types.ts +42 -0
  31. package/src/charts/index.ts +1 -0
  32. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +18 -6
  33. package/src/charts/line-chart/line-chart.module.scss +6 -4
  34. package/src/charts/line-chart/line-chart.tsx +6 -2
  35. package/src/charts/line-chart/private/line-chart-annotation.tsx +16 -3
  36. package/src/charts/private/grid-control/grid-control.module.scss +1 -4
  37. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  38. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +14 -0
  39. package/src/charts/private/with-responsive/with-responsive.tsx +12 -0
  40. package/src/charts/private/x-zoom.module.scss +6 -3
  41. package/src/components/legend/private/base-legend.module.scss +3 -1
  42. package/src/components/tooltip/base-tooltip.module.scss +4 -1
  43. package/src/components/trend-indicator/trend-indicator.module.scss +5 -3
  44. package/src/hooks/use-xychart-theme.ts +24 -0
  45. package/src/index.ts +12 -0
  46. package/src/providers/chart-context/themes.ts +29 -16
  47. package/src/types.ts +19 -2
  48. package/src/utils/color-utils.ts +36 -0
  49. package/src/utils/test/color-utils.test.ts +33 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "1.8.0",
3
+ "version": "1.9.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.4",
66
+ "@automattic/number-formatters": "^1.2.5",
67
67
  "@babel/runtime": "7.29.7",
68
68
  "@react-spring/web": "10.1.1",
69
69
  "@visx/annotation": "^4.0.0",
@@ -97,8 +97,8 @@
97
97
  "devDependencies": {
98
98
  "@automattic/jetpack-webpack-config": "workspace:*",
99
99
  "@babel/core": "^7.29.7",
100
- "@storybook/addon-docs": "10.3.6",
101
- "@storybook/react": "10.3.6",
100
+ "@storybook/addon-docs": "10.4.6",
101
+ "@storybook/react": "10.4.6",
102
102
  "@testing-library/dom": "^10.0.0",
103
103
  "@testing-library/jest-dom": "^6.0.0",
104
104
  "@testing-library/react": "^16.0.0",
@@ -109,9 +109,9 @@
109
109
  "@types/react-dom": "18.3.7",
110
110
  "@typescript/native-preview": "7.0.0-dev.20260225.1",
111
111
  "@visx/glyph": "4.0.0",
112
- "@wordpress/components": "35.0.1",
113
- "@wordpress/element": "8.0.1",
114
- "@wordpress/private-apis": "1.48.1",
112
+ "@wordpress/components": "36.1.0",
113
+ "@wordpress/element": "8.2.0",
114
+ "@wordpress/private-apis": "1.50.0",
115
115
  "babel-jest": "30.4.1",
116
116
  "identity-obj-proxy": "^3.0.0",
117
117
  "jest": "30.4.2",
@@ -120,7 +120,7 @@
120
120
  "react": "18.3.1",
121
121
  "react-dom": "18.3.1",
122
122
  "sass-embedded": "1.97.3",
123
- "storybook": "10.3.6",
123
+ "storybook": "10.4.6",
124
124
  "tsdown": "0.22.2",
125
125
  "typescript": "5.9.3"
126
126
  },
@@ -6,7 +6,9 @@
6
6
  path {
7
7
  transform-origin: 0 95%;
8
8
  transform: scaleY(0);
9
- animation: rise 1s ease-out forwards;
9
+ animation:
10
+ rise var(--wpds-motion-duration-xl, 400ms)
11
+ var(--wpds-motion-easing-expressive, cubic-bezier(0.25, 0, 0, 1)) forwards;
10
12
  }
11
13
  }
12
14
 
@@ -27,7 +27,7 @@ import {
27
27
  useGlobalChartsContext,
28
28
  useGlobalChartsTheme,
29
29
  } from '../../providers';
30
- import { attachSubComponents } from '../../utils';
30
+ import { attachSubComponents, resolveCssVariable } from '../../utils';
31
31
  import { renderDefaultTooltip } from '../line-chart';
32
32
  import { useChartChildren } from '../private/chart-composition';
33
33
  import { ChartLayout } from '../private/chart-layout';
@@ -461,7 +461,10 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
461
461
  stacked={ stacked }
462
462
  stackOffset={ stackOffset }
463
463
  getElementStyles={ getElementStyles }
464
- strokeColor={ providerTheme.backgroundColor }
464
+ strokeColor={
465
+ resolveCssVariable( providerTheme.backgroundColor ) ??
466
+ providerTheme.backgroundColor
467
+ }
465
468
  />
466
469
  </>
467
470
  ) }
@@ -8,7 +8,9 @@
8
8
  transform-origin: bottom;
9
9
  transform-box: fill-box;
10
10
  transform: scaleY(0);
11
- animation: rise 1s ease-out forwards;
11
+ animation:
12
+ rise var(--wpds-motion-duration-xl, 400ms)
13
+ var(--wpds-motion-easing-expressive, cubic-bezier(0.25, 0, 0, 1)) forwards;
12
14
  }
13
15
 
14
16
  @keyframes rise {
@@ -22,7 +24,9 @@
22
24
  transform-origin: left;
23
25
  transform-box: fill-box;
24
26
  transform: scaleX(0);
25
- animation: stretch 1s ease-out forwards;
27
+ animation:
28
+ stretch var(--wpds-motion-duration-xl, 400ms)
29
+ var(--wpds-motion-easing-expressive, cubic-bezier(0.25, 0, 0, 1)) forwards;
26
30
  }
27
31
 
28
32
  @keyframes stretch {
@@ -1,7 +1,7 @@
1
1
  import { formatNumber } from '@automattic/number-formatters';
2
2
  import { PatternLines, PatternCircles, PatternWaves, PatternHexagons } from '@visx/pattern';
3
3
  import { Axis, BarSeries, BarGroup, Grid, XYChart } from '@visx/xychart';
4
- import { __ } from '@wordpress/i18n';
4
+ import { __, sprintf } from '@wordpress/i18n';
5
5
  import clsx from 'clsx';
6
6
  import { useCallback, useContext, useState, useRef, useMemo } from 'react';
7
7
  import { Legend, useChartLegendItems } from '../../components/legend';
@@ -83,6 +83,19 @@ const validateData = ( data: SeriesData[] ) => {
83
83
 
84
84
  const getPatternId = ( chartId: string, index: number ) => `bar-pattern-${ chartId }-${ index }`;
85
85
 
86
+ // A "label: value" tooltip row. The join is a translated format string so the
87
+ // separator (a colon + space here) can be adapted per locale.
88
+ const renderTooltipRow = ( label: string | undefined, value: string ) => (
89
+ <div className={ styles[ 'bar-chart__tooltip-row' ] }>
90
+ { sprintf(
91
+ /* translators: 1: data series, period, or category label. 2: its formatted value. */
92
+ __( '%1$s: %2$s', 'jetpack-charts' ),
93
+ label,
94
+ value
95
+ ) }
96
+ </div>
97
+ );
98
+
86
99
  const BarChartInternal: FC< BarChartProps > = ( {
87
100
  data,
88
101
  chartId: providedChartId,
@@ -191,6 +204,14 @@ const BarChartInternal: FC< BarChartProps > = ( {
191
204
  [ primaryEntries ]
192
205
  );
193
206
 
207
+ // The keyboard-navigation index space and the highlight CSS both stride over primary
208
+ // bars only; the accessible tooltip must use the same list, or its datum diverges from
209
+ // the highlighted bar once a comparison series shifts the indices.
210
+ const primarySeries = useMemo(
211
+ () => primaryEntries.map( ( { series } ) => series ),
212
+ [ primaryEntries ]
213
+ );
214
+
194
215
  const comparisonEntries = useMemo( () => {
195
216
  const primaryByGroup = new Map< string | undefined, { label: string; index: number } >(
196
217
  primaryEntries.map( ( { series, index } ) => [
@@ -309,20 +330,11 @@ const BarChartInternal: FC< BarChartProps > = ( {
309
330
  return (
310
331
  <div className={ styles[ 'bar-chart__tooltip' ] }>
311
332
  <div className={ styles[ 'bar-chart__tooltip-header' ] }>{ categoryLabel }</div>
312
- <div className={ styles[ 'bar-chart__tooltip-row' ] }>
313
- <span className={ styles[ 'bar-chart__tooltip-label' ] }>{ primaryKey }:</span>
314
- <span className={ styles[ 'bar-chart__tooltip-value' ] }>
315
- { formatNumber( nearestDatum.value as number ) }
316
- </span>
317
- </div>
318
- <div className={ styles[ 'bar-chart__tooltip-row' ] }>
319
- <span className={ styles[ 'bar-chart__tooltip-label' ] }>
320
- { comparisonEntry.series.label }:
321
- </span>
322
- <span className={ styles[ 'bar-chart__tooltip-value' ] }>
323
- { formatNumber( comparisonDatum.value as number ) }
324
- </span>
325
- </div>
333
+ { renderTooltipRow( primaryKey, formatNumber( nearestDatum.value as number ) ) }
334
+ { renderTooltipRow(
335
+ comparisonEntry.series.label,
336
+ formatNumber( comparisonDatum.value as number )
337
+ ) }
326
338
  </div>
327
339
  );
328
340
  }
@@ -330,12 +342,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
330
342
  return (
331
343
  <div className={ styles[ 'bar-chart__tooltip' ] }>
332
344
  <div className={ styles[ 'bar-chart__tooltip-header' ] }>{ primaryKey }</div>
333
- <div className={ styles[ 'bar-chart__tooltip-row' ] }>
334
- <span className={ styles[ 'bar-chart__tooltip-label' ] }>{ categoryLabel }:</span>
335
- <span className={ styles[ 'bar-chart__tooltip-value' ] }>
336
- { formatNumber( nearestDatum.value as number ) }
337
- </span>
338
- </div>
345
+ { renderTooltipRow( categoryLabel, formatNumber( nearestDatum.value as number ) ) }
339
346
  </div>
340
347
  );
341
348
  },
@@ -622,7 +629,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
622
629
  keyboardFocusedClassName={
623
630
  styles[ 'bar-chart__tooltip--keyboard-focused' ]
624
631
  }
625
- series={ data }
632
+ series={ primarySeries }
626
633
  mode="individual"
627
634
  />
628
635
  ) }
@@ -10,6 +10,12 @@ import type { ElementStyles, GetElementStylesParams } from '../../../providers';
10
10
  import type { DataPointDate, SeriesData } from '../../../types';
11
11
  import type { FC, ReactNode } from 'react';
12
12
 
13
+ /*
14
+ * `process.env.NODE_ENV` is replaced by the bundler at build time. Declare a
15
+ * minimal `process` locally so this file type-checks as source under `jetpack:src`.
16
+ */
17
+ declare const process: { env: Record< string, string | undefined > };
18
+
13
19
  export type ComparisonSeriesEntry = {
14
20
  series: SeriesData;
15
21
  index: number;
@@ -146,6 +152,7 @@ export const ComparisonBars: FC< {
146
152
  <g
147
153
  className="bar-chart__comparison-bars"
148
154
  pointerEvents="none"
155
+ aria-hidden="true"
149
156
  data-testid="bar-chart-comparison-bars"
150
157
  >
151
158
  { rects }
@@ -123,8 +123,11 @@ export function useBarChartOptions(
123
123
  } );
124
124
  } );
125
125
  if ( allValues.length > 0 ) {
126
+ // Keep zero in the domain so bar length stays proportional to value — a
127
+ // non-zero baseline would exaggerate differences between periods. Math.max
128
+ // keeps zero on the far side too, so charts with negative values still span 0.
126
129
  valueScaleDomainOverride = {
127
- domain: [ Math.min( ...allValues ), Math.max( ...allValues ) ],
130
+ domain: [ Math.min( 0, ...allValues ), Math.max( 0, ...allValues ) ],
128
131
  };
129
132
  }
130
133
  }
@@ -322,6 +322,8 @@ describe( 'BarChart', () => {
322
322
  await user.keyboard( '{ArrowRight}' );
323
323
  expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
324
324
  expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Series A' );
325
+ // The category/value row joins with a space after the colon.
326
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Jan 1: 10' );
325
327
  expect( screen.queryByTestId( 'chart-tooltip-1' ) ).not.toBeInTheDocument();
326
328
 
327
329
  // Second tab should focus on the second tooltip.
@@ -409,11 +411,50 @@ describe( 'BarChart', () => {
409
411
 
410
412
  await user.keyboard( '{ArrowRight}' );
411
413
  const tooltip = screen.getByTestId( 'chart-tooltip-0' );
412
- // Both the current and previous period values are shown.
414
+ // Both periods are shown, each as "label: value" with a space after the colon.
415
+ expect( tooltip ).toHaveTextContent( 'This period: 10' );
416
+ expect( tooltip ).toHaveTextContent( 'Previous period: 15' );
417
+ } );
418
+
419
+ test( 'keyboard navigation past the first slot stays on the primary bar, not the comparison series', async () => {
420
+ const user = userEvent.setup();
421
+ renderWithTheme( {
422
+ withTooltips: true,
423
+ data: [
424
+ {
425
+ label: 'This period',
426
+ group: 'views',
427
+ data: [
428
+ { label: 'Mon', value: 10 },
429
+ { label: 'Tue', value: 20 },
430
+ ],
431
+ },
432
+ {
433
+ label: 'Previous period',
434
+ group: 'views',
435
+ options: { type: 'comparison' as const },
436
+ data: [
437
+ { label: 'Mon', value: 15 },
438
+ { label: 'Tue', value: 25 },
439
+ ],
440
+ },
441
+ ],
442
+ } );
443
+
444
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
445
+ chart.focus();
446
+
447
+ // Slot 0 is the first primary bar (Mon); slot 1 must be the SECOND primary
448
+ // bar (Tue) — not the comparison series' first bar. The keyboard index space
449
+ // counts only primary bars, so the tooltip must too.
450
+ await user.keyboard( '{ArrowRight}' );
451
+ await user.keyboard( '{ArrowRight}' );
452
+ const tooltip = screen.getByTestId( 'chart-tooltip-1' );
453
+ expect( tooltip ).toHaveTextContent( 'Tue' );
413
454
  expect( tooltip ).toHaveTextContent( 'This period' );
414
- expect( tooltip ).toHaveTextContent( '10' );
455
+ expect( tooltip ).toHaveTextContent( '20' );
415
456
  expect( tooltip ).toHaveTextContent( 'Previous period' );
416
- expect( tooltip ).toHaveTextContent( '15' );
457
+ expect( tooltip ).toHaveTextContent( '25' );
417
458
  } );
418
459
  } );
419
460
 
@@ -860,6 +901,57 @@ describe( 'BarChart', () => {
860
901
  expect( bars.length ).toBeGreaterThanOrEqual( 2 );
861
902
  } );
862
903
 
904
+ it( 'hides the comparison shadow group from assistive technology', () => {
905
+ const data = [
906
+ {
907
+ label: 'This year',
908
+ group: 'views',
909
+ data: [ { label: 'Jan', value: 100 } ],
910
+ },
911
+ {
912
+ label: 'Last year',
913
+ group: 'views',
914
+ options: { type: 'comparison' as const },
915
+ data: [ { label: 'Jan', value: 80 } ],
916
+ },
917
+ ];
918
+ render( <BarChart data={ data } width={ 400 } height={ 300 } /> );
919
+
920
+ // The shadow is decorative: no keyboard/hover target and its value is surfaced
921
+ // through the tooltip, so it must not be announced as a separate element.
922
+ expect( screen.getByTestId( 'bar-chart-comparison-bars' ) ).toHaveAttribute(
923
+ 'aria-hidden',
924
+ 'true'
925
+ );
926
+ } );
927
+
928
+ it( 'renders comparison shadows in horizontal orientation', () => {
929
+ const data = [
930
+ {
931
+ label: 'This year',
932
+ group: 'views',
933
+ data: [
934
+ { label: 'Jan', value: 100 },
935
+ { label: 'Feb', value: 120 },
936
+ ],
937
+ },
938
+ {
939
+ label: 'Last year',
940
+ group: 'views',
941
+ options: { type: 'comparison' as const },
942
+ data: [
943
+ { label: 'Jan', value: 80 },
944
+ { label: 'Feb', value: 140 },
945
+ ],
946
+ },
947
+ ];
948
+ render( <BarChart data={ data } orientation="horizontal" width={ 400 } height={ 300 } /> );
949
+
950
+ const shadows = screen.getAllByTestId( /^bar-chart-comparison-\d+-\d+$/ );
951
+ expect( shadows ).toHaveLength( 2 );
952
+ expect( shadows[ 0 ] ).toHaveAttribute( 'opacity', '0.5' );
953
+ } );
954
+
863
955
  it( 'expands value-axis domain to include comparison values exceeding the primary max', () => {
864
956
  // Comparison value 150 exceeds primary max 100.
865
957
  // Without domain expansion the value-axis scale config has no explicit domain,
@@ -886,6 +978,52 @@ describe( 'BarChart', () => {
886
978
  expect( ( yScale.domain as number[] )[ 1 ] ).toBeGreaterThanOrEqual( 150 );
887
979
  } );
888
980
 
981
+ it( 'keeps the value-axis baseline at zero so comparison bars encode magnitude truthfully', () => {
982
+ // All values are well above zero; the domain must still start at 0 so a bar's
983
+ // length stays proportional to its value (a non-zero baseline exaggerates differences).
984
+ const data = [
985
+ {
986
+ label: 'This period',
987
+ group: 'views',
988
+ data: [ { label: 'Mon', value: 420 } ],
989
+ },
990
+ {
991
+ label: 'Previous period',
992
+ group: 'views',
993
+ options: { type: 'comparison' as const },
994
+ data: [ { label: 'Mon', value: 510 } ],
995
+ },
996
+ ];
997
+
998
+ const { result } = renderHook( () => useBarChartOptions( data, false, {} ) );
999
+ const yScale = result.current.yScale as { domain?: number[] };
1000
+ expect( yScale.domain ).toBeDefined();
1001
+ expect( ( yScale.domain as number[] )[ 0 ] ).toBe( 0 );
1002
+ } );
1003
+
1004
+ it( 'zero-bases the value-axis domain in horizontal comparison charts', () => {
1005
+ const data = [
1006
+ {
1007
+ label: 'This period',
1008
+ group: 'views',
1009
+ data: [ { label: 'Mon', value: 420 } ],
1010
+ },
1011
+ {
1012
+ label: 'Previous period',
1013
+ group: 'views',
1014
+ options: { type: 'comparison' as const },
1015
+ data: [ { label: 'Mon', value: 510 } ],
1016
+ },
1017
+ ];
1018
+
1019
+ // In horizontal charts the value axis is x.
1020
+ const { result } = renderHook( () => useBarChartOptions( data, true, {} ) );
1021
+ const xScale = result.current.xScale as { domain?: number[] };
1022
+ expect( xScale.domain ).toBeDefined();
1023
+ expect( ( xScale.domain as number[] )[ 0 ] ).toBe( 0 );
1024
+ expect( ( xScale.domain as number[] )[ 1 ] ).toBeGreaterThanOrEqual( 510 );
1025
+ } );
1026
+
889
1027
  it( 'counts only primary series for keyboard navigation when a comparison series is present', async () => {
890
1028
  const user = userEvent.setup();
891
1029
  // 2 primary + 1 comparison. totalPoints should be 2*2=4, not 3*2=6.
@@ -12,7 +12,7 @@
12
12
 
13
13
  .main-rate {
14
14
  overflow: hidden;
15
- color: #1e1e1e;
15
+ color: var(--wpds-color-fg-content-neutral, #1e1e1e);
16
16
  text-overflow: ellipsis;
17
17
  font-size: var(--wpds-typography-font-size-xl, 18px);
18
18
  font-style: normal;
@@ -33,7 +33,7 @@
33
33
 
34
34
  .funnel-container {
35
35
  flex: 1;
36
- min-height: 200px;
36
+ min-height: 0;
37
37
  width: 100%;
38
38
  }
39
39
 
@@ -43,7 +43,9 @@
43
43
  height: 100%;
44
44
 
45
45
  &--animated {
46
- transition: opacity 0.3s ease;
46
+ transition:
47
+ opacity var(--wpds-motion-duration-lg, 300ms)
48
+ var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
47
49
  }
48
50
 
49
51
  &--blurred {
@@ -52,7 +54,7 @@
52
54
  }
53
55
 
54
56
  .step-label {
55
- color: #757575;
57
+ color: var(--wpds-color-fg-content-neutral-weak, #707070);
56
58
  font-size: var(--wpds-typography-font-size-sm, 12px);
57
59
  font-weight: var(--wpds-typography-font-weight-regular, 400);
58
60
  line-height: var(--wpds-typography-line-height-xs, 16px);
@@ -62,7 +64,7 @@
62
64
  }
63
65
 
64
66
  .step-rate {
65
- color: #1e1e1e;
67
+ color: var(--wpds-color-fg-content-neutral, #1e1e1e);
66
68
  font-size: var(--wpds-typography-font-size-md, 13px);
67
69
  font-weight: var(--wpds-typography-font-weight-medium, 499);
68
70
  line-height: var(--wpds-typography-line-height-xs, 16px);
@@ -84,7 +86,9 @@
84
86
  transform-origin: bottom;
85
87
  transform-box: fill-box;
86
88
  transform: scaleY(0);
87
- animation: stretch 1s ease-out forwards;
89
+ animation:
90
+ stretch var(--wpds-motion-duration-xl, 400ms)
91
+ var(--wpds-motion-easing-expressive, cubic-bezier(0.25, 0, 0, 1)) forwards;
88
92
  }
89
93
 
90
94
  @keyframes stretch {
@@ -96,18 +100,17 @@
96
100
  }
97
101
 
98
102
  .tooltip-wrapper {
99
- border-bottom: var(--wpds-border-width-xs, 1px) solid var(--Gray-Gray-5, #dcdcde);
100
- background: var(--black-white-white, #fff);
103
+ background: var(--wpds-color-bg-surface-neutral-strong, #fff);
101
104
 
102
105
  // Override .visx-tooltip inline styles.
103
106
  border-radius: var(--wpds-border-radius-md, 4px) !important;
104
107
  padding: var(--wpds-dimension-padding-md, 12px) !important;
105
- box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.15), 0 3px 9px 0 rgba(0, 0, 0, 0.12) !important;
108
+ box-shadow: var(--wpds-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005) !important;
106
109
 
107
110
  }
108
111
 
109
112
  .tooltip-title {
110
- color: #1e1e1e;
113
+ color: var(--wpds-color-fg-content-neutral, #1e1e1e);
111
114
  font-size: var(--wpds-typography-font-size-sm, 12px);
112
115
  font-style: normal;
113
116
  font-weight: var(--wpds-typography-font-weight-regular, 400);
@@ -115,7 +118,7 @@
115
118
  }
116
119
 
117
120
  .tooltip-content {
118
- color: #1e1e1e;
121
+ color: var(--wpds-color-fg-content-neutral, #1e1e1e);
119
122
  font-size: var(--wpds-typography-font-size-md, 13px);
120
123
  font-style: normal;
121
124
  font-weight: var(--wpds-typography-font-weight-medium, 499);
@@ -124,6 +127,6 @@
124
127
 
125
128
  .empty-state {
126
129
  text-align: center;
127
- color: #6b7280;
130
+ color: var(--wpds-color-fg-content-neutral-weak, #707070);
128
131
  font-size: var(--wpds-typography-font-size-lg, 16px);
129
132
  }
@@ -4,7 +4,7 @@
4
4
  import { __ } from '@wordpress/i18n';
5
5
  import clsx from 'clsx';
6
6
  import { FC, useContext, useMemo } from 'react';
7
- import { Chart } from 'react-google-charts';
7
+ import { Chart, type GoogleChartPackages } from 'react-google-charts';
8
8
  /**
9
9
  * Internal dependencies
10
10
  */
@@ -19,6 +19,10 @@ import { GeoChartProps } from './types';
19
19
 
20
20
  const DEFAULT_FEATURE_FILL_COLOR = '#ffffff';
21
21
  const DEFAULT_BACKGROUND_COLOR = '#ffffff';
22
+ // `chartPackages` replaces (not extends) react-google-charts' default `[ 'corechart', 'controls' ]`,
23
+ // so we restate the defaults and add `geochart`. Without it the loader backfills the geochart package
24
+ // late, which can clash with another Google Charts version already loaded on the page.
25
+ const GEO_CHART_PACKAGES: GoogleChartPackages[] = [ 'corechart', 'controls', 'geochart' ];
22
26
 
23
27
  type GoogleChartOptions = Record< string, unknown >;
24
28
 
@@ -154,6 +158,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
154
158
  >
155
159
  <Chart
156
160
  chartType="GeoChart"
161
+ chartPackages={ GEO_CHART_PACKAGES }
157
162
  width={ width }
158
163
  height={ height }
159
164
  data={ sanitizedData.data }
@@ -4,9 +4,10 @@ import GeoChart, { GeoChartUnresponsive } from '../geo-chart';
4
4
 
5
5
  // Mock react-google-charts
6
6
  jest.mock( 'react-google-charts', () => ( {
7
- Chart: jest.fn( ( { data, options, width, height } ) => {
7
+ Chart: jest.fn( ( { chartPackages, data, options, width, height } ) => {
8
8
  return (
9
9
  <div data-testid="google-chart-mock" data-width={ width } data-height={ height }>
10
+ <div data-testid="chart-packages">{ JSON.stringify( chartPackages ) }</div>
10
11
  <div data-testid="chart-data">{ JSON.stringify( data ) }</div>
11
12
  <div data-testid="chart-options">{ JSON.stringify( options ) }</div>
12
13
  </div>
@@ -64,6 +65,15 @@ describe( 'GeoChart', () => {
64
65
  expect( chart ).toHaveAttribute( 'data-width', '1200' );
65
66
  expect( chart ).toHaveAttribute( 'data-height', '600' );
66
67
  } );
68
+
69
+ test( 'loads the GeoChart package explicitly', () => {
70
+ renderWithTheme();
71
+
72
+ const chartPackages = screen.getByTestId( 'chart-packages' );
73
+ const packages = JSON.parse( chartPackages.textContent || '[]' );
74
+
75
+ expect( packages ).toEqual( [ 'corechart', 'controls', 'geochart' ] );
76
+ } );
67
77
  } );
68
78
 
69
79
  describe( 'Data Handling', () => {
@@ -0,0 +1,103 @@
1
+ .heatmap-chart {
2
+ width: 100%;
3
+ height: 100%;
4
+ min-height: 0;
5
+ font-size: var(--wpds-typography-font-size-sm, 12px);
6
+ }
7
+
8
+ .heatmap-chart__empty {
9
+ padding: var(--wpds-dimension-padding-lg, 16px);
10
+ color: var(--wpds-color-fg-content-neutral-weak, #707070);
11
+ }
12
+
13
+ // Track template is set inline because CSS `repeat()` won't take a `var()`
14
+ // count. ARIA rows are `display: contents` so their cells join this grid.
15
+ .heatmap-chart__grid {
16
+ display: grid;
17
+ gap: var(--heatmap-cell-gap, var(--wpds-dimension-gap-xs, 4px));
18
+ flex: 1 1 0;
19
+ width: 100%;
20
+ // Floor so the grid stays usable when the parent has no explicit height.
21
+ min-height: 200px;
22
+
23
+ &:focus-visible {
24
+ outline:
25
+ var(--wpds-border-width-focus, var(--wp-admin-border-width-focus, 2px)) solid
26
+ var(--wpds-color-stroke-focus-brand, var(--wp-admin-theme-color, #3858e9));
27
+ outline-offset: 2px;
28
+ border-radius: var(--wpds-border-radius-sm, 2px);
29
+ }
30
+ }
31
+
32
+ .heatmap-chart__grid--compact {
33
+ flex: 0 0 auto;
34
+ width: max-content;
35
+ height: max-content;
36
+ min-height: 0;
37
+ }
38
+
39
+ .heatmap-chart__row {
40
+ display: contents;
41
+ }
42
+
43
+ .heatmap-chart__col-label,
44
+ .heatmap-chart__row-label {
45
+ color: var(--wpds-color-fg-content-neutral-weak, #707070);
46
+ font-size: var(--wpds-typography-font-size-xs, 11px);
47
+ white-space: nowrap;
48
+ overflow: visible;
49
+ }
50
+
51
+ .heatmap-chart__col-label {
52
+ align-self: end;
53
+ text-align: left;
54
+ }
55
+
56
+ .heatmap-chart__row-label {
57
+ align-self: center;
58
+ justify-self: end;
59
+ text-align: right;
60
+ padding-inline-end: var(--wpds-dimension-padding-xs, 4px);
61
+ }
62
+
63
+ .heatmap-chart__cell {
64
+ display: flex;
65
+ align-items: center;
66
+ justify-content: center;
67
+ min-width: 0;
68
+ min-height: 0;
69
+ border-radius: var(--wpds-border-radius-sm, 2px);
70
+ // Empty (no-data) default; cells with a value override it below.
71
+ background: var(--wpds-color-bg-track-neutral-weak, #f0f0f0);
72
+ }
73
+
74
+ // Floor the mix at 15% so the lowest value stays visibly tinted, distinct from
75
+ // an empty cell.
76
+ .heatmap-chart__cell--filled {
77
+ background: color-mix(in sRGB, var(--heatmap-primary) calc((0.15 + 0.85 * var(--intensity)) * 100%), transparent);
78
+ }
79
+
80
+ .heatmap-chart__cell--selected {
81
+ outline:
82
+ var(--wpds-border-width-focus, var(--wp-admin-border-width-focus, 2px)) solid
83
+ var(--wpds-color-stroke-focus-brand, var(--wp-admin-theme-color, #3858e9));
84
+ outline-offset: calc(-1 * var(--wpds-border-width-focus, var(--wp-admin-border-width-focus, 2px)));
85
+ }
86
+
87
+ .heatmap-chart__cell-value {
88
+ color: var(--wpds-color-fg-content-neutral, #1e1e1e);
89
+ font-size: var(--wpds-typography-font-size-md, 13px);
90
+ }
91
+
92
+ // Light text on fills dark enough to out-contrast dark text (decided in JS from
93
+ // the blended fill luminance).
94
+ .heatmap-chart__cell--strong .heatmap-chart__cell-value {
95
+ color: var(--wpds-color-fg-interactive-neutral-strong, #f0f0f0);
96
+ }
97
+
98
+ .heatmap-chart__legend-swatch {
99
+ width: var(--wpds-dimension-size-3xs, 12px);
100
+ height: var(--wpds-dimension-size-3xs, 12px);
101
+ border-radius: var(--wpds-border-radius-sm, 2px);
102
+ background: color-mix(in sRGB, var(--heatmap-primary) calc((0.15 + 0.85 * var(--intensity)) * 100%), transparent);
103
+ }