@automattic/charts 1.9.0 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/index.cjs +5661 -5466
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +40 -28
  5. package/dist/index.d.cts +31 -7
  6. package/dist/index.d.ts +32 -8
  7. package/dist/index.js +1239 -1046
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +7 -7
  11. package/src/charts/geo-chart/geo-chart.tsx +148 -3
  12. package/src/charts/geo-chart/index.ts +1 -1
  13. package/src/charts/geo-chart/test/geo-chart.test.tsx +122 -2
  14. package/src/charts/geo-chart/types.ts +11 -0
  15. package/src/charts/heatmap-chart/heatmap-chart.module.scss +11 -11
  16. package/src/charts/heatmap-chart/heatmap-chart.tsx +37 -36
  17. package/src/charts/heatmap-chart/private/build-calendar-data.ts +17 -1
  18. package/src/charts/heatmap-chart/private/heatmap-context.ts +10 -0
  19. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +3 -2
  20. package/src/charts/heatmap-chart/private/index.ts +2 -0
  21. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +23 -1
  22. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +10 -3
  23. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +15 -3
  24. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +65 -37
  25. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +46 -0
  26. package/src/charts/line-chart/line-chart.module.scss +2 -2
  27. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  28. package/src/charts/private/x-zoom.module.scss +2 -2
  29. package/src/components/trend-indicator/trend-indicator.module.scss +3 -3
  30. package/src/hooks/test/use-chart-margin.test.tsx +8 -5
  31. package/src/hooks/use-chart-margin.tsx +11 -1
  32. package/src/index.ts +1 -1
  33. package/src/providers/chart-context/global-charts-provider.tsx +7 -0
  34. package/src/providers/chart-context/test/chart-context.test.tsx +40 -0
  35. package/src/providers/chart-context/themes.ts +17 -14
  36. package/src/providers/chart-context/types.ts +3 -0
  37. package/src/types.ts +9 -6
  38. package/src/utils/color-utils.ts +28 -0
  39. package/src/utils/test/color-utils.test.ts +29 -0
@@ -2,15 +2,7 @@ import { formatNumber, formatNumberCompact } from '@automattic/number-formatters
2
2
  import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
3
3
  import { __ } from '@wordpress/i18n';
4
4
  import clsx from 'clsx';
5
- import {
6
- createContext,
7
- useCallback,
8
- useContext,
9
- useEffect,
10
- useMemo,
11
- useRef,
12
- useState,
13
- } from 'react';
5
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
14
6
  import {
15
7
  GlobalChartsProvider,
16
8
  useChartId,
@@ -20,7 +12,7 @@ import {
20
12
  import { attachSubComponents } from '../../utils';
21
13
  import {
22
14
  isValidHexColor,
23
- lightenHexColor,
15
+ mixHexColors,
24
16
  normalizeColorToHex,
25
17
  prefersLightText,
26
18
  } from '../../utils/color-utils';
@@ -30,19 +22,18 @@ import { ChartLayout } from '../private/chart-layout';
30
22
  import { SingleChartContext } from '../private/single-chart-context';
31
23
  import { withResponsive } from '../private/with-responsive';
32
24
  import styles from './heatmap-chart.module.scss';
33
- import { getValueExtent, getNormalizedValue, HeatmapLegend, isPresent } from './private';
25
+ import {
26
+ getValueExtent,
27
+ getNormalizedValue,
28
+ HeatmapContext,
29
+ HeatmapLegend,
30
+ isPresent,
31
+ } from './private';
32
+ import type { HeatmapContextValue } from './private';
34
33
  import type { HeatmapChartProps, HeatmapTooltipData } from './types';
35
34
  import type { ResponsiveConfig } from '../private/with-responsive';
36
35
  import type { CSSProperties, FC } from 'react';
37
36
 
38
- export type HeatmapContextValue = {
39
- extent: [ number, number ];
40
- /** The resolved primary color (full intensity); the legend mixes toward it in CSS. */
41
- primaryColorHex: string;
42
- };
43
-
44
- export const HeatmapContext = createContext< HeatmapContextValue | null >( null );
45
-
46
37
  // Mirrors the color-mix floor in heatmap-chart.module.scss (.heatmap-chart__cell--filled):
47
38
  // the rendered fill is the primary mixed over the chart background at 0.15 + 0.85 * intensity.
48
39
  const CELL_MIX_FLOOR = 0.15;
@@ -63,7 +54,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
63
54
  children,
64
55
  } ) => {
65
56
  const chartId = useChartId( providedChartId );
66
- const { getElementStyles, theme } = useGlobalChartsContext();
57
+ const { getElementStyles, resolveThemeColor, theme } = useGlobalChartsContext();
67
58
  const { heatmapChart: heatmapChartSettings } = theme;
68
59
  const { nonLegendChildren } = useChartChildren( children, 'HeatmapChart' );
69
60
 
@@ -84,14 +75,22 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
84
75
  overrideColor: primaryColor || heatmapChartSettings.primaryColor,
85
76
  } );
86
77
 
87
- // Pick the in-cell text color from the cell's actual blended fill luminance (not the data
88
- // value), so light text is only used where it out-contrasts dark text. Falls back to dark
89
- // text when the primary isn't a resolvable hex (e.g. a bare CSS token).
78
+ // Resolve the background in the provider's theme scope so the blended-fill text
79
+ // color tracks a themed (e.g. dark) background.
80
+ const chartBackgroundHex = resolveThemeColor( theme.backgroundColor );
81
+
82
+ // Choose text color from the blended fill, not the raw value.
83
+ // If either color cannot resolve to hex, keep dark text.
90
84
  const primaryHex = normalizeColorToHex( primaryColorHex );
91
85
  const cellHasLightText = ( intensity: number ): boolean =>
92
86
  isValidHexColor( primaryHex ) &&
87
+ isValidHexColor( chartBackgroundHex ) &&
93
88
  prefersLightText(
94
- lightenHexColor( primaryHex, 1 - ( CELL_MIX_FLOOR + ( 1 - CELL_MIX_FLOOR ) * intensity ) )
89
+ mixHexColors(
90
+ primaryHex,
91
+ chartBackgroundHex,
92
+ 1 - ( CELL_MIX_FLOOR + ( 1 - CELL_MIX_FLOOR ) * intensity )
93
+ )
95
94
  );
96
95
 
97
96
  const extent = useMemo( () => getValueExtent( data ), [ data ] );
@@ -247,6 +246,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
247
246
  const trackSize = compact ? 'var(--heatmap-cell-size)' : 'minmax(0, 1fr)';
248
247
  const gridStyle: Record< string, string | number > = {
249
248
  '--heatmap-primary': primaryColorHex,
249
+ '--heatmap-bg': theme.backgroundColor,
250
250
  gridTemplateColumns: `auto repeat(${ columns }, ${ trackSize })`,
251
251
  gridTemplateRows: `auto repeat(${ rows }, ${ trackSize })`,
252
252
  };
@@ -292,17 +292,18 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
292
292
  } ) }
293
293
  style={ gridStyle as CSSProperties }
294
294
  >
295
- { /* Corner gutter + column labels; aria-hidden, since each cell's label carries the text. */ }
296
- <span aria-hidden="true" />
297
- { data.map( ( column, columnIndex ) => (
298
- <span
299
- key={ `col-${ columnIndex }` }
300
- aria-hidden="true"
301
- className={ styles[ 'heatmap-chart__col-label' ] }
302
- >
303
- { column.label }
304
- </span>
305
- ) ) }
295
+ { /* Header row preserves the grid structure; cell aria-labels include this text. */ }
296
+ <div role="row" aria-hidden="true" className={ styles[ 'heatmap-chart__row' ] }>
297
+ <span />
298
+ { data.map( ( column, columnIndex ) => (
299
+ <span
300
+ key={ `col-${ columnIndex }` }
301
+ className={ styles[ 'heatmap-chart__col-label' ] }
302
+ >
303
+ { column.label }
304
+ </span>
305
+ ) ) }
306
+ </div>
306
307
 
307
308
  { Array.from( { length: rows } ).map( ( _row, rowIndex ) => {
308
309
  const labelVisible = ! compact || rowIndex % 2 === 0;
@@ -360,7 +361,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
360
361
  >
361
362
  { drawValues && present && (
362
363
  <span className={ styles[ 'heatmap-chart__cell-value' ] }>
363
- { /* Compact so large values fit the cell; tooltip + aria-label keep full precision. */ }
364
+ { /* Compact display; tooltip and aria-label keep full precision. */ }
364
365
  { formatNumberCompact( value ) }
365
366
  </span>
366
367
  ) }
@@ -57,12 +57,28 @@ export const buildCalendarHeatmapData = (
57
57
  LABELLED_ROWS.includes( row ) ? format( addDays( gridStart, row ), 'EEE' ) : ''
58
58
  );
59
59
 
60
+ // Hide short partial first-month labels when a later month follows; compact
61
+ // cells make adjacent labels collide. Keep the label for single-month ranges.
62
+ const MIN_FIRST_MONTH_WEEKS = 2;
63
+ const firstMonth = gridStart.getMonth();
64
+ let firstMonthWeeks = 0;
65
+ while (
66
+ firstMonthWeeks < weekCount &&
67
+ addDays( gridStart, firstMonthWeeks * 7 ).getMonth() === firstMonth
68
+ ) {
69
+ firstMonthWeeks++;
70
+ }
71
+ const spansLaterMonth = firstMonthWeeks < weekCount;
72
+ const showFirstMonthLabel = ! spansLaterMonth || firstMonthWeeks >= MIN_FIRST_MONTH_WEEKS;
73
+
60
74
  const data: HeatmapColumn[] = [];
61
75
  let previousMonth = -1;
62
76
  for ( let week = 0; week < weekCount; week++ ) {
63
77
  const columnStart = addDays( gridStart, week * 7 );
64
78
  const month = columnStart.getMonth();
65
- const label = month !== previousMonth ? format( columnStart, 'MMM' ) : '';
79
+ const isNewMonth = month !== previousMonth;
80
+ const label =
81
+ isNewMonth && ( week !== 0 || showFirstMonthLabel ) ? format( columnStart, 'MMM' ) : '';
66
82
  previousMonth = month;
67
83
 
68
84
  const cells: HeatmapCell[] = [];
@@ -0,0 +1,10 @@
1
+ import { createContext } from 'react';
2
+
3
+ export type HeatmapContextValue = {
4
+ extent: [ number, number ];
5
+ /** The resolved primary color (full intensity); the legend mixes toward it in CSS. */
6
+ primaryColorHex: string;
7
+ };
8
+
9
+ /** Shared by the chart and legend without importing back from `heatmap-chart.tsx`. */
10
+ export const HeatmapContext = createContext< HeatmapContextValue | null >( null );
@@ -2,8 +2,8 @@ import { __ } from '@wordpress/i18n';
2
2
  import { Stack, Text } from '@wordpress/ui';
3
3
  import { useContext } from 'react';
4
4
  import { useGlobalChartsTheme } from '../../../providers';
5
- import { HeatmapContext } from '../heatmap-chart';
6
5
  import styles from '../heatmap-chart.module.scss';
6
+ import { HeatmapContext } from './heatmap-context';
7
7
  import type { CSSProperties, FC } from 'react';
8
8
 
9
9
  export interface HeatmapLegendProps {
@@ -15,7 +15,7 @@ export interface HeatmapLegendProps {
15
15
 
16
16
  export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel, moreLabel } ) => {
17
17
  const context = useContext( HeatmapContext );
18
- const { legend } = useGlobalChartsTheme();
18
+ const { legend, backgroundColor } = useGlobalChartsTheme();
19
19
  if ( ! context ) {
20
20
  return null;
21
21
  }
@@ -38,6 +38,7 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel,
38
38
  style={
39
39
  {
40
40
  '--heatmap-primary': primaryColorHex,
41
+ '--heatmap-bg': backgroundColor,
41
42
  '--intensity': intensity,
42
43
  } as CSSProperties
43
44
  }
@@ -1,5 +1,7 @@
1
1
  export { getValueExtent, getNormalizedValue, isPresent } from './use-heatmap-colors';
2
2
  export { buildCalendarHeatmapData } from './build-calendar-data';
3
3
  export { HeatmapLegend } from './heatmap-legend';
4
+ export { HeatmapContext } from './heatmap-context';
4
5
  export type { CalendarHeatmapResult } from './build-calendar-data';
5
6
  export type { HeatmapLegendProps } from './heatmap-legend';
7
+ export type { HeatmapContextValue } from './heatmap-context';
@@ -35,7 +35,7 @@ describe( 'buildCalendarHeatmapData', () => {
35
35
 
36
36
  test( 'labels only the first column of each month', () => {
37
37
  const multiMonth: DataPointDate[] = [
38
- { dateString: '2024-01-29', value: 1 },
38
+ { dateString: '2024-01-01', value: 1 },
39
39
  { dateString: '2024-02-05', value: 1 },
40
40
  ];
41
41
  const { data } = buildCalendarHeatmapData( multiMonth );
@@ -44,6 +44,28 @@ describe( 'buildCalendarHeatmapData', () => {
44
44
  expect( labels ).toContain( 'Feb' );
45
45
  } );
46
46
 
47
+ test( 'suppresses a partial first month label so it cannot collide with the next', () => {
48
+ // Jan 29 is the only January column; February should be the first visible label.
49
+ const partialFirstMonth: DataPointDate[] = [
50
+ { dateString: '2024-01-29', value: 1 },
51
+ { dateString: '2024-02-05', value: 1 },
52
+ ];
53
+ const { data } = buildCalendarHeatmapData( partialFirstMonth, { weekStartsOn: 1 } );
54
+ expect( data[ 0 ].label ).toBe( '' );
55
+ expect( data.map( c => c.label ).filter( Boolean )[ 0 ] ).toBe( 'Feb' );
56
+ } );
57
+
58
+ test( 'keeps the first month label when the range never reaches a second month', () => {
59
+ // Single-month ranges keep their lone month label.
60
+ const singleMonth: DataPointDate[] = [
61
+ { dateString: '2024-01-01', value: 1 },
62
+ { dateString: '2024-01-03', value: 2 },
63
+ ];
64
+ const { data } = buildCalendarHeatmapData( singleMonth, { weekStartsOn: 1 } );
65
+ expect( data ).toHaveLength( 1 );
66
+ expect( data[ 0 ].label ).toBe( 'Jan' );
67
+ } );
68
+
47
69
  test( 'filters out entries with unparseable or missing dates', () => {
48
70
  const mixed: DataPointDate[] = [
49
71
  { dateString: '2024-01-01', value: 3 },
@@ -222,12 +222,19 @@ describe( 'HeatmapChart', () => {
222
222
  } );
223
223
  } );
224
224
 
225
- test( 'leaves cell gap and radius to WPDS tokens (no inline overrides)', () => {
225
+ test( 'keeps a strict grid → row structure (column labels live in a row, not directly under the grid)', () => {
226
+ renderChart();
227
+ // The header row is aria-hidden, so include hidden elements when querying for it.
228
+ const rows = screen.getAllByRole( 'row', { hidden: true } );
229
+ const headerRow = rows.find( row => within( row ).queryByText( 'W1' ) );
230
+ expect( headerRow ).toBeDefined();
231
+ } );
232
+
233
+ test( 'leaves the cell gap to WPDS tokens in non-compact mode (no inline override)', () => {
226
234
  renderChart();
227
235
  const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
228
- // Non-compact sets no inline gap/radius — the SCSS falls back to the WPDS tokens.
236
+ // Non-compact sets no inline gap — the SCSS falls back to the WPDS token.
229
237
  expect( grid.style.getPropertyValue( '--heatmap-cell-gap' ) ).toBe( '' );
230
- expect( grid.style.getPropertyValue( '--heatmap-cell-radius' ) ).toBe( '' );
231
238
  } );
232
239
 
233
240
  test( 'applies the compact gap inline from the theme compactCellGap', () => {
@@ -73,9 +73,21 @@
73
73
  }
74
74
 
75
75
  .valueContainer {
76
+ align-items: center;
76
77
  justify-content: flex-end;
77
78
  }
78
79
 
80
+ .deltaValue {
81
+ display: inline-block;
82
+ font-variant-numeric: tabular-nums;
83
+ min-width: 3ch;
84
+ text-align: end;
85
+ }
86
+
87
+ .deltaPlaceholder {
88
+ text-align: center;
89
+ }
90
+
79
91
  .overlayLabel {
80
92
  align-items: center;
81
93
  }
@@ -83,7 +95,7 @@
83
95
  .emptyState {
84
96
  padding: var(--wpds-dimension-padding-3xl, 32px) var(--wpds-dimension-padding-lg, 16px);
85
97
  text-align: center;
86
- color: var(--wpds-color-fg-content-neutral-weak, #707070);
98
+ color: var(--wpds-color-foreground-content-neutral-weak, #707070);
87
99
  font-size: var(--wpds-typography-font-size-md, 13px);
88
100
  font-style: italic;
89
101
  }
@@ -165,7 +177,7 @@
165
177
  border-radius: var(--wpds-border-radius-sm, 2px);
166
178
  outline:
167
179
  var(--focus-ring-width) solid
168
- var(--wpds-color-stroke-focus-brand, var(--wp-admin-theme-color, #3858e9));
180
+ var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9));
169
181
  // Inset by its own width so the outer edge hugs the row boundary: the
170
182
  // outline then sits in the row's padding (also a ring width), clear of the
171
183
  // bar, and stays inside the overflow:auto container that would clip an
@@ -181,7 +193,7 @@
181
193
  bottom: 0;
182
194
  margin-block: auto;
183
195
  opacity: 0;
184
- color: var(--wpds-color-fg-content-neutral-weak, #707070);
196
+ color: var(--wpds-color-foreground-content-neutral-weak, #707070);
185
197
  transition:
186
198
  opacity var(--wpds-motion-duration-sm, 100ms)
187
199
  var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable @wordpress/no-unsafe-wp-apis */
2
- import { __experimentalGrid as Grid } from '@wordpress/components';
2
+ import { __experimentalGrid as Grid, VisuallyHidden } from '@wordpress/components';
3
3
  import { Fragment } from '@wordpress/element';
4
4
  import { __ } from '@wordpress/i18n';
5
5
  import { Icon, chevronRight } from '@wordpress/icons';
@@ -65,6 +65,14 @@ const defaultDeltaFormatter = ( value: number ): string => {
65
65
  const getBarWidth = ( share: number ): string =>
66
66
  `calc(${ share }% - var(--a8c--charts--leaderboard--bar--hover-inset, 0px) * ${ share } / 100)`;
67
67
 
68
+ const hasComparisonValue = (
69
+ entry: LeaderboardEntry
70
+ ): entry is LeaderboardEntry & {
71
+ previousValue: number;
72
+ previousShare: number;
73
+ delta: number;
74
+ } => entry.previousValue != null && entry.previousShare != null && entry.delta != null;
75
+
68
76
  const BarLabel = ( { label }: { label: LeaderboardEntry[ 'label' ] } ) => (
69
77
  <>{ typeof label === 'string' ? <Text className={ styles.label }>{ label }</Text> : label }</>
70
78
  );
@@ -87,39 +95,43 @@ const BarWithLabel = ( {
87
95
  isPrimaryVisible?: boolean;
88
96
  isComparisonVisible?: boolean;
89
97
  animation?: boolean;
90
- } ) => (
91
- <div
92
- className={ clsx( styles.barWithLabelContainer, {
93
- [ styles[ 'is-overlay' ] ]: withOverlayLabel,
94
- } ) }
95
- >
96
- <BarLabel label={ entry.label } />
97
-
98
- { isPrimaryVisible && (
99
- <div
100
- className={ clsx( styles.bar, {
101
- [ styles[ 'bar--animated' ] ]: animation,
102
- } ) }
103
- style={ {
104
- width: getBarWidth( entry.currentShare ),
105
- backgroundColor: primaryColor,
106
- } }
107
- ></div>
108
- ) }
109
-
110
- { withComparison && ! withOverlayLabel && isComparisonVisible && (
111
- <div
112
- className={ clsx( styles.bar, {
113
- [ styles[ 'bar--animated' ] ]: animation,
114
- } ) }
115
- style={ {
116
- width: getBarWidth( entry.previousShare ),
117
- backgroundColor: secondaryColor,
118
- } }
119
- ></div>
120
- ) }
121
- </div>
122
- );
98
+ } ) => {
99
+ const showComparisonBar = withComparison && ! withOverlayLabel && isComparisonVisible;
100
+
101
+ return (
102
+ <div
103
+ className={ clsx( styles.barWithLabelContainer, {
104
+ [ styles[ 'is-overlay' ] ]: withOverlayLabel,
105
+ } ) }
106
+ >
107
+ <BarLabel label={ entry.label } />
108
+
109
+ { isPrimaryVisible && (
110
+ <div
111
+ className={ clsx( styles.bar, {
112
+ [ styles[ 'bar--animated' ] ]: animation,
113
+ } ) }
114
+ style={ {
115
+ width: getBarWidth( entry.currentShare ),
116
+ backgroundColor: primaryColor,
117
+ } }
118
+ ></div>
119
+ ) }
120
+
121
+ { showComparisonBar && hasComparisonValue( entry ) && (
122
+ <div
123
+ className={ clsx( styles.bar, {
124
+ [ styles[ 'bar--animated' ] ]: animation,
125
+ } ) }
126
+ style={ {
127
+ width: getBarWidth( entry.previousShare ),
128
+ backgroundColor: secondaryColor,
129
+ } }
130
+ ></div>
131
+ ) }
132
+ </div>
133
+ );
134
+ };
123
135
 
124
136
  /**
125
137
  * LeaderboardChart component displays a ranked list of data with progress bars
@@ -335,7 +347,11 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
335
347
  ) : (
336
348
  <Grid templateColumns="minmax(0, 1fr) auto" rowGap={ rowGap } columnGap={ columnGap }>
337
349
  { data.map( entry => {
338
- const colorIndex = Math.sign( entry.delta ) + 1;
350
+ const showComparisonColumn = withComparison && isComparisonVisible;
351
+ const hasDeltaValue = hasComparisonValue( entry );
352
+ const showComparisonValue = showComparisonColumn && hasDeltaValue;
353
+ const showComparisonPlaceholder = showComparisonColumn && ! hasDeltaValue;
354
+ const colorIndex = showComparisonValue ? Math.sign( entry.delta ) + 1 : 1;
339
355
  const deltaColor = deltaColors[ colorIndex ];
340
356
 
341
357
  const rowCells = (
@@ -362,11 +378,23 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
362
378
  >
363
379
  { isPrimaryVisible && <Text>{ valueFormatter( entry.currentValue ) }</Text> }
364
380
 
365
- { withComparison && isComparisonVisible && (
366
- <Text style={ { color: deltaColor } }>
381
+ { showComparisonValue && (
382
+ <Text className={ styles.deltaValue } style={ { color: deltaColor } }>
367
383
  { deltaFormatter( entry.delta ) }
368
384
  </Text>
369
385
  ) }
386
+
387
+ { showComparisonPlaceholder && (
388
+ <Text
389
+ className={ clsx( styles.deltaValue, styles.deltaPlaceholder ) }
390
+ style={ { color: deltaColor } }
391
+ >
392
+ <span aria-hidden="true">-</span>
393
+ <VisuallyHidden as="span">
394
+ { __( 'No comparison data', 'jetpack-charts' ) }
395
+ </VisuallyHidden>
396
+ </Text>
397
+ ) }
370
398
  </Stack>
371
399
  </>
372
400
  );
@@ -78,6 +78,30 @@ describe( 'LeaderboardChart', () => {
78
78
  expect( screen.getByText( '-8%' ) ).toBeInTheDocument();
79
79
  } );
80
80
 
81
+ it( 'shows a placeholder when a row has no previous value', () => {
82
+ render(
83
+ <LeaderboardChart
84
+ data={ [
85
+ ...mockData,
86
+ {
87
+ id: 'new',
88
+ label: 'New Source',
89
+ currentValue: 100,
90
+ currentShare: 1,
91
+ },
92
+ ] }
93
+ withComparison={ true }
94
+ />
95
+ );
96
+
97
+ expect( screen.getByText( '+25%' ) ).toBeInTheDocument();
98
+ expect( screen.getByText( '-8%' ) ).toBeInTheDocument();
99
+ expect( screen.getByText( 'New Source' ) ).toBeInTheDocument();
100
+ expect( screen.getByText( '-' ) ).toHaveAttribute( 'aria-hidden', 'true' );
101
+ expect( screen.getByText( 'No comparison data' ) ).toBeInTheDocument();
102
+ expect( screen.queryByText( '+100%' ) ).not.toBeInTheDocument();
103
+ } );
104
+
81
105
  it( 'shows custom label when provided', () => {
82
106
  render(
83
107
  <LeaderboardChart
@@ -403,6 +427,28 @@ describe( 'LeaderboardChart', () => {
403
427
  expect( screen.getByRole( 'button' ) ).toHaveAccessibleName( /Direct.*12\.5K/ );
404
428
  } );
405
429
 
430
+ it( 'does not include the missing comparison dash in interactive row names', () => {
431
+ render(
432
+ <LeaderboardChart
433
+ data={ [
434
+ {
435
+ id: 'new',
436
+ label: 'New Source',
437
+ currentValue: 100,
438
+ currentShare: 1,
439
+ onClick: jest.fn(),
440
+ },
441
+ ] }
442
+ withComparison={ true }
443
+ />
444
+ );
445
+
446
+ expect( screen.getByRole( 'button' ) ).toHaveAccessibleName(
447
+ /New Source.*100.*No comparison data/
448
+ );
449
+ expect( screen.getByRole( 'button' ) ).not.toHaveAccessibleName( /-/ );
450
+ } );
451
+
406
452
  it( 'derives the accessible name from an image label via its alt text', () => {
407
453
  render(
408
454
  <LeaderboardChart
@@ -18,8 +18,8 @@
18
18
 
19
19
  &__tooltip,
20
20
  &__annotation-label-popover {
21
- background: var(--wpds-color-bg-surface-neutral-strong, #fff);
22
- color: var(--wpds-color-fg-content-neutral, #1e1e1e);
21
+ background: var(--wpds-color-background-surface-neutral-strong, #fff);
22
+ color: var(--wpds-color-foreground-content-neutral, #1e1e1e);
23
23
  padding: var(--wpds-dimension-padding-sm, 8px);
24
24
  }
25
25
 
@@ -1,5 +1,5 @@
1
1
  .svg-empty-state {
2
2
  text-align: center;
3
3
  font-size: var(--wpds-typography-font-size-md, 13px);
4
- color: var(--wpds-color-fg-content-neutral-weak, #707070);
4
+ color: var(--wpds-color-foreground-content-neutral-weak, #707070);
5
5
  }
@@ -22,7 +22,7 @@
22
22
  height: 28px;
23
23
  padding: 0;
24
24
  background: var(--charts-zoom-reset-bg, rgba(255, 255, 255, 0.92));
25
- color: var(--charts-zoom-reset-fg, var(--wpds-color-fg-content-neutral, #1e1e1e));
25
+ color: var(--charts-zoom-reset-fg, var(--wpds-color-foreground-content-neutral, #1e1e1e));
26
26
  border:
27
27
  var(--wpds-border-width-xs, 1px) solid
28
28
  var(--charts-zoom-reset-border, rgba(0, 0, 0, 0.16));
@@ -35,7 +35,7 @@
35
35
  }
36
36
 
37
37
  &:focus-visible {
38
- outline: 2px solid var(--charts-zoom-reset-focus, var(--wpds-color-stroke-focus-brand, #3858e9));
38
+ outline: 2px solid var(--charts-zoom-reset-focus, var(--wpds-color-stroke-focus, #3858e9));
39
39
  outline-offset: 1px;
40
40
  }
41
41
  }
@@ -9,15 +9,15 @@
9
9
  // Trend colors use the documented --charts-trend-* override API, falling
10
10
  // back to the accessible WPDS semantic tokens.
11
11
  &--up {
12
- color: var(--charts-trend-up-color, var(--wpds-color-fg-content-success-weak, #008030));
12
+ color: var(--charts-trend-up-color, var(--wpds-color-foreground-content-success-weak, #008030));
13
13
  }
14
14
 
15
15
  &--down {
16
- color: var(--charts-trend-down-color, var(--wpds-color-fg-content-error-weak, #cc1818));
16
+ color: var(--charts-trend-down-color, var(--wpds-color-foreground-content-error-weak, #cc1818));
17
17
  }
18
18
 
19
19
  &--neutral {
20
- color: var(--charts-trend-neutral-color, var(--wpds-color-fg-content-neutral-weak, #707070));
20
+ color: var(--charts-trend-neutral-color, var(--wpds-color-foreground-content-neutral-weak, #707070));
21
21
  }
22
22
 
23
23
  &__icon {
@@ -13,8 +13,8 @@ describe( 'useChartMargin', () => {
13
13
  const baseTheme = {
14
14
  axisStyles: {
15
15
  y: {
16
- left: { axisLabel: { fontSize: 11 }, tickLength: 8 },
17
- right: { axisLabel: { fontSize: 11 }, tickLength: 8 },
16
+ left: { axisLabel: { fontSize: 12 }, tickLabel: { fontSize: 11 }, tickLength: 8 },
17
+ right: { axisLabel: { fontSize: 12 }, tickLabel: { fontSize: 11 }, tickLength: 8 },
18
18
  },
19
19
  },
20
20
  } as XYChartTheme;
@@ -64,7 +64,8 @@ describe( 'useChartMargin', () => {
64
64
  options.axis.y.tickFormat,
65
65
  theme.axisStyles.y.left.axisLabel
66
66
  );
67
- expect( result.current.left ).toBe( 48 ); // 40 + 8
67
+ // 40 label width + 8 tick length + ceil(11 * 0.25) label dx offset
68
+ expect( result.current.left ).toBe( 51 );
68
69
  } );
69
70
 
70
71
  it( 'calculates right margin for right y axis', () => {
@@ -86,7 +87,8 @@ describe( 'useChartMargin', () => {
86
87
  options.axis.y.tickFormat,
87
88
  theme.axisStyles.y.right.axisLabel
88
89
  );
89
- expect( result.current.right ).toBe( 48 ); // 40 + 8
90
+ // 40 label width + 8 tick length + ceil(11 * 0.25) label dx offset
91
+ expect( result.current.right ).toBe( 51 );
90
92
  } );
91
93
 
92
94
  it( 'uses explicit y tickValues when provided', () => {
@@ -133,7 +135,8 @@ describe( 'useChartMargin', () => {
133
135
  const height = 300;
134
136
  const theme = baseTheme;
135
137
  const { result } = renderHook( () => useChartMargin( height, options, data, theme ) );
136
- expect( result.current.left ).toBe( 48 );
138
+ // 40 label width + 8 tick length + ceil(11 * 0.25) label dx offset
139
+ expect( result.current.left ).toBe( 51 );
137
140
  expect( result.current.top ).toBe( 10 );
138
141
  // 12px font + 8 tick length = 20
139
142
  expect( result.current.bottom ).toBe( 20 );
@@ -114,7 +114,17 @@ export const useChartMargin = (
114
114
  options.axis?.y?.tickFormat,
115
115
  yAxisStyles.axisLabel
116
116
  );
117
- const yMarginValue = ( yTickWidth ?? DEFAULT_Y_TICK_WIDTH ) + ( yAxisStyles?.tickLength ?? 0 );
117
+ // visx's default axis theme pushes y-axis tick labels a further 0.25em
118
+ // away from the axis (dx of -0.25em on the left, 0.25em on the right), so
119
+ // reserve that on top of the measured label width — without it the widest
120
+ // label clips at the SVG edge. The em resolves against the tick label's own
121
+ // font size (theme tickLabel), not the axis-title font size (axisLabel).
122
+ const yTickLabelFontSize =
123
+ resolveFontSize( yAxisStyles?.tickLabel?.fontSize ) || DEFAULT_FONT_SIZE;
124
+ const yMarginValue =
125
+ ( yTickWidth ?? DEFAULT_Y_TICK_WIDTH ) +
126
+ ( yAxisStyles?.tickLength ?? 0 ) +
127
+ Math.ceil( yTickLabelFontSize * 0.25 );
118
128
 
119
129
  if ( yAxisOrientation === 'right' ) {
120
130
  defaultMargin.right = yMarginValue;
package/src/index.ts CHANGED
@@ -72,7 +72,7 @@ export type {
72
72
  PieSemiCircleChartProps,
73
73
  PieSemiCircleChartRenderTooltipParams,
74
74
  } from './charts/pie-semi-circle-chart';
75
- export type { GeoChartProps, GeoRegion, GeoResolution } from './charts/geo-chart';
75
+ export type { GeoChartProps, GeoRegion, GeoResolution, GeoChartError } from './charts/geo-chart';
76
76
  export type { LegendValueDisplay, BaseLegendItem } from './components/legend';
77
77
  export type { TrendIndicatorProps, TrendDirection } from './components/trend-indicator';
78
78
  export type { LineStyles, GridStyles, EventHandlerParams } from '@visx/xychart';