@automattic/charts 3.0.0 → 3.1.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 (63) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/TOKENS.md +3 -3
  3. package/dist/index.cjs +250 -136
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +23 -14
  6. package/dist/index.d.cts +51 -14
  7. package/dist/index.d.ts +51 -14
  8. package/dist/index.js +251 -138
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.tsx +26 -26
  12. package/src/charts/area-chart/private/overlays.tsx +3 -3
  13. package/src/charts/area-chart/test/area-chart.test.tsx +346 -11
  14. package/src/charts/area-chart/types.ts +4 -3
  15. package/src/charts/bar-chart/bar-chart.tsx +21 -15
  16. package/src/charts/bar-chart/private/use-bar-chart-options.ts +19 -12
  17. package/src/charts/bar-chart/test/bar-chart.test.tsx +277 -2
  18. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +6 -6
  19. package/src/charts/geo-chart/geo-chart.module.scss +8 -0
  20. package/src/charts/heatmap-chart/heatmap-chart.module.scss +9 -0
  21. package/src/charts/heatmap-chart/heatmap-chart.tsx +31 -8
  22. package/src/charts/heatmap-chart/private/build-calendar-data.ts +65 -5
  23. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +125 -0
  24. package/src/charts/heatmap-chart/types.ts +8 -0
  25. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +17 -22
  26. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +107 -1
  27. package/src/charts/line-chart/line-chart.module.scss +2 -2
  28. package/src/charts/line-chart/line-chart.tsx +33 -29
  29. package/src/charts/line-chart/private/line-chart-annotations-overlay.tsx +2 -2
  30. package/src/charts/line-chart/test/line-chart.test.tsx +313 -5
  31. package/src/charts/line-chart/types.ts +4 -3
  32. package/src/charts/pie-chart/pie-chart.tsx +8 -13
  33. package/src/charts/pie-chart/test/pie-chart.test.tsx +73 -1
  34. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +2 -2
  35. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +8 -13
  36. package/src/charts/private/{single-chart-context/single-chart-context.tsx → chart-instance-context/chart-instance-context.ts} +1 -5
  37. package/src/charts/private/chart-instance-context/index.ts +3 -0
  38. package/src/charts/private/{single-chart-context/use-single-chart-context.ts → chart-instance-context/use-chart-instance-context.ts} +1 -3
  39. package/src/charts/private/svg-empty-state/get-all-hidden-message.ts +41 -0
  40. package/src/charts/private/svg-empty-state/index.ts +1 -0
  41. package/src/charts/private/svg-empty-state/test/get-all-hidden-message.test.ts +15 -0
  42. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +2 -2
  43. package/src/charts/private/x-zoom/x-zoom.tsx +2 -2
  44. package/src/components/legend/legend.tsx +4 -4
  45. package/src/components/legend/private/base-legend.module.scss +1 -1
  46. package/src/components/legend/private/base-legend.tsx +67 -18
  47. package/src/components/legend/test/legend.test.tsx +100 -4
  48. package/src/components/tooltip/accessible-tooltip.tsx +13 -2
  49. package/src/components/trend-indicator/trend-indicator.module.scss +1 -1
  50. package/src/hooks/index.ts +1 -1
  51. package/src/hooks/{use-interactive-legend-data.ts → use-legend-visibility-data.ts} +29 -30
  52. package/src/index.ts +3 -1
  53. package/src/providers/chart-context/global-charts-provider.tsx +92 -18
  54. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +47 -0
  55. package/src/providers/chart-context/test/chart-context.test.tsx +142 -0
  56. package/src/providers/chart-context/test/use-default-hidden-series-paint.test.tsx +56 -0
  57. package/src/providers/chart-context/test/use-default-hidden-series.ssr.test.tsx +39 -0
  58. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +222 -0
  59. package/src/providers/chart-context/types.ts +7 -1
  60. package/src/styles/chart-scope.scss +2 -2
  61. package/src/styles/test/wpds-fallbacks.test.ts +131 -0
  62. package/src/types.ts +12 -0
  63. package/src/charts/private/single-chart-context/index.ts +0 -8
@@ -20,11 +20,12 @@ import {
20
20
  useGlobalChartsContext,
21
21
  GlobalChartsContext,
22
22
  } from '../../providers';
23
+ import { useDefaultHiddenSeries } from '../../providers/chart-context/hooks/use-default-hidden-series';
23
24
  import { attachSubComponents } from '../../utils';
24
25
  import { useChartChildren } from '../private/chart-composition';
26
+ import { ChartInstanceContext } from '../private/chart-instance-context';
25
27
  import { ChartLayout } from '../private/chart-layout';
26
- import { SingleChartContext } from '../private/single-chart-context';
27
- import { SvgEmptyState } from '../private/svg-empty-state';
28
+ import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
28
29
  import { withResponsive } from '../private/with-responsive';
29
30
  import styles from './bar-chart.module.scss';
30
31
  import {
@@ -42,13 +43,14 @@ import type {
42
43
  DataPointDate,
43
44
  SeriesData,
44
45
  SeriesChartLegendConfig,
46
+ SeriesVisibilityProps,
45
47
  Optional,
46
48
  } from '../../types';
47
49
  import type { RenderTooltipParams } from '../../visx/types';
48
50
  import type { ResponsiveConfig } from '../private/with-responsive';
49
51
  import type { FC, ReactNode, ComponentType } from 'react';
50
52
 
51
- export interface BarChartProps extends BaseChartProps< SeriesData[] > {
53
+ export interface BarChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
52
54
  /**
53
55
  * Legend configuration. Supports `collapseGroups` on top of the shared options.
54
56
  */
@@ -122,6 +124,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
122
124
  orientation = 'vertical',
123
125
  withPatterns = false,
124
126
  showZeroValues = false,
127
+ defaultHiddenSeries,
125
128
  animation,
126
129
  children,
127
130
  gap = 'md',
@@ -130,6 +133,11 @@ const BarChartInternal: FC< BarChartProps > = ( {
130
133
  const legendCollapseGroups = legend.collapseGroups ?? false;
131
134
  const horizontal = orientation === 'horizontal';
132
135
  const chartId = useChartId( providedChartId );
136
+ const hiddenSeries = useDefaultHiddenSeries( chartId, defaultHiddenSeries );
137
+ const isSeriesVisible = useCallback(
138
+ ( seriesLabel: string ) => ! hiddenSeries.has( seriesLabel ),
139
+ [ hiddenSeries ]
140
+ );
133
141
  const theme = useXYChartTheme( data );
134
142
 
135
143
  const dataSorted = useChartDataTransform( data );
@@ -148,14 +156,14 @@ const BarChartInternal: FC< BarChartProps > = ( {
148
156
  );
149
157
  const legendItems = useChartLegendItems( dataSorted, legendOptions );
150
158
 
151
- const { getElementStyles, isSeriesVisible } = useGlobalChartsContext();
159
+ const { getElementStyles } = useGlobalChartsContext();
152
160
 
153
161
  // A hidden series is unmounted, so it is not on the band scale either — the
154
- // axis has to choose its tick values from what is left.
162
+ // axis has to choose its tick values from what is left. Visibility is owned by
163
+ // the provider, whether it changed through the legend or programmatically.
155
164
  const isSeriesRendered = useCallback(
156
- ( series: SeriesData ) =>
157
- ! chartId || ! legendInteractive || isSeriesVisible( chartId, series.label ),
158
- [ chartId, legendInteractive, isSeriesVisible ]
165
+ ( series: SeriesData ) => isSeriesVisible( series.label ),
166
+ [ isSeriesVisible ]
159
167
  );
160
168
 
161
169
  const chartOptions = useBarChartOptions(
@@ -198,7 +206,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
198
206
  totalPoints,
199
207
  } );
200
208
 
201
- // Add visibility information to series when using interactive legends
209
+ // Add visibility information from the shared legend state.
202
210
  const seriesWithVisibility = useMemo(
203
211
  () =>
204
212
  dataWithVisibleZeros.map( ( series, index ) => ( {
@@ -514,9 +522,10 @@ const BarChartInternal: FC< BarChartProps > = ( {
514
522
  );
515
523
 
516
524
  return (
517
- <SingleChartContext.Provider
525
+ <ChartInstanceContext.Provider
518
526
  value={ {
519
527
  chartId,
528
+ isSeriesVisible,
520
529
  chartWidth: width,
521
530
  chartHeight: measuredChartHeight || 0,
522
531
  } }
@@ -606,10 +615,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
606
615
  width={ width }
607
616
  height={ chartHeight }
608
617
  >
609
- { __(
610
- 'All series are hidden. Click legend items to show data.',
611
- 'jetpack-charts'
612
- ) }
618
+ { getAllHiddenMessage( legendInteractive, 'series' ) }
613
619
  </SvgEmptyState>
614
620
  ) : null }
615
621
 
@@ -673,7 +679,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
673
679
  );
674
680
  } }
675
681
  </ChartLayout>
676
- </SingleChartContext.Provider>
682
+ </ChartInstanceContext.Provider>
677
683
  );
678
684
  };
679
685
 
@@ -154,23 +154,29 @@ export function useBarChartOptions(
154
154
  const stableOptions = useDeepMemo( options );
155
155
 
156
156
  // `labelOverflow` and `tickResolution` are consumed by this hook rather than
157
- // forwarded — visx has an axis prop for neither — so they are split off the
158
- // caller's axis options once, here, and only the rest reaches visx below.
157
+ // forwarded — visx has an axis prop for neither — and `tickFormat` is merged
158
+ // with the derived default explicitly, so an `undefined` passed by a caller
159
+ // cannot clobber it through the spread. They are split off the caller's axis
160
+ // options once, here, and only the rest reaches visx below.
159
161
  const axisConfig = useMemo( () => {
160
162
  const {
161
163
  labelOverflow: xLabelOverflow,
162
164
  tickResolution: xTickResolution,
165
+ tickFormat: xTickFormat,
163
166
  ...xAxisOptions
164
167
  } = stableOptions.axis?.x || {};
165
168
  const {
166
169
  labelOverflow: yLabelOverflow,
167
170
  tickResolution: yTickResolution,
171
+ tickFormat: yTickFormat,
168
172
  ...yAxisOptions
169
173
  } = stableOptions.axis?.y || {};
170
174
 
171
175
  return {
172
176
  xLabelOverflow,
173
177
  yLabelOverflow,
178
+ xTickFormat,
179
+ yTickFormat,
174
180
  xAxisOptions,
175
181
  yAxisOptions,
176
182
  // The dates sit on the x axis normally, and on the y axis when the
@@ -243,8 +249,8 @@ export function useBarChartOptions(
243
249
  return useMemo( () => {
244
250
  const orientationKey = horizontal ? 'horizontal' : 'vertical';
245
251
  const {
246
- xTickFormat,
247
- yTickFormat,
252
+ xTickFormat: defaultXTickFormat,
253
+ yTickFormat: defaultYTickFormat,
248
254
  tooltipLabelFormatter: defaultTooltipLabelFormatter,
249
255
  xAccessor,
250
256
  yAccessor,
@@ -294,10 +300,11 @@ export function useBarChartOptions(
294
300
  ...( stableOptions.yScale || {} ),
295
301
  ...( ! horizontal ? valueScaleDomainOverride : {} ),
296
302
  };
297
- const { xLabelOverflow, yLabelOverflow, xAxisOptions, yAxisOptions } = axisConfig;
298
- const providedToolTipLabelFormatter = horizontal
299
- ? yAxisOptions.tickFormat
300
- : xAxisOptions.tickFormat;
303
+ const { xLabelOverflow, yLabelOverflow, xTickFormat, yTickFormat, xAxisOptions, yAxisOptions } =
304
+ axisConfig;
305
+ // The dates sit on the y axis of a horizontal chart, so the caller's format
306
+ // for them moves with the orientation. It also labels the tooltip.
307
+ const dateAxisTickFormat = horizontal ? yTickFormat : xTickFormat;
301
308
 
302
309
  // A band scale has no ticks of its own for visx to ask for, so it samples
303
310
  // the domain by index and can miss the tick that dates the day or names the
@@ -306,7 +313,7 @@ export function useBarChartOptions(
306
313
  const { timeAxis } = defaultOptions;
307
314
  const dateAxisOptions = horizontal ? yAxisOptions : xAxisOptions;
308
315
  const bandTickValues =
309
- timeAxis && ! dateAxisOptions.tickFormat
316
+ timeAxis && ! dateAxisTickFormat
310
317
  ? getBandTickValues(
311
318
  timeAxis.domain,
312
319
  timeAxis.tickFormatter,
@@ -327,7 +334,7 @@ export function useBarChartOptions(
327
334
  x: {
328
335
  orientation: 'bottom' as const,
329
336
  numTicks: DEFAULT_NUM_TICKS,
330
- tickFormat: xTickFormat,
337
+ tickFormat: xTickFormat || defaultXTickFormat,
331
338
  ...( horizontal ? {} : dateAxisTickValues ),
332
339
  ...( xLabelOverflow === 'ellipsis' ? { tickComponent: TruncatedXTickComponent } : {} ),
333
340
  ...xAxisOptions,
@@ -335,7 +342,7 @@ export function useBarChartOptions(
335
342
  y: {
336
343
  orientation: 'left' as const,
337
344
  numTicks: DEFAULT_NUM_TICKS,
338
- tickFormat: yTickFormat,
345
+ tickFormat: yTickFormat || defaultYTickFormat,
339
346
  ...( horizontal ? dateAxisTickValues : {} ),
340
347
  ...( yLabelOverflow === 'ellipsis' ? { tickComponent: TruncatedYTickComponent } : {} ),
341
348
  ...yAxisOptions,
@@ -345,7 +352,7 @@ export function useBarChartOptions(
345
352
  padding: getGroupPadding( horizontal ? yScale : xScale ),
346
353
  },
347
354
  tooltip: {
348
- labelFormatter: providedToolTipLabelFormatter || defaultTooltipLabelFormatter,
355
+ labelFormatter: dateAxisTickFormat || defaultTooltipLabelFormatter,
349
356
  },
350
357
  };
351
358
  }, [ defaultOptions, axisConfig, stableOptions, horizontal, data ] );
@@ -1,8 +1,10 @@
1
- import { render, renderHook, screen, within } from '@testing-library/react';
1
+ import { render, renderHook, screen, within, act } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { GlobalChartsProvider } from '../../../providers';
4
- import BarChart from '../bar-chart';
4
+ import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
5
+ import BarChart, { BarChartUnresponsive } from '../bar-chart';
5
6
  import { useBarChartOptions } from '../private';
7
+ import type { GlobalChartsContextValue } from '../../../providers/chart-context/types';
6
8
  import type { SeriesData } from '../../../types';
7
9
 
8
10
  // Mock useElementSize to return non-zero dimensions in jsdom so charts render
@@ -503,6 +505,28 @@ describe( 'BarChart', () => {
503
505
  expect( distinctTexts( ticks ).size ).toBeGreaterThan( 1 );
504
506
  } );
505
507
 
508
+ test( 'keeps the derived date formatter when tickFormat is passed as undefined', () => {
509
+ renderWithTheme( {
510
+ width: 800,
511
+ options: { axis: { x: { tickFormat: undefined } } },
512
+ data: [
513
+ {
514
+ label: 'Series A',
515
+ data: Array.from( { length: 14 }, ( _, i ) => ( {
516
+ date: new Date( 2024, 0, 1 + i ),
517
+ value: 10 + i,
518
+ } ) ),
519
+ options: {},
520
+ },
521
+ ],
522
+ } );
523
+
524
+ const ticks = screen.getAllByText( /^Jan \d+$/ );
525
+ expect( distinctTexts( ticks ).size ).toBeGreaterThan( 1 );
526
+ // A clobbered formatter falls back to raw `Date.toString()` ticks.
527
+ expect( screen.queryByText( /GMT/ ) ).not.toBeInTheDocument();
528
+ } );
529
+
506
530
  test( 'renders distinct hour ticks for sub-daily buckets in a single day', () => {
507
531
  renderWithTheme( {
508
532
  width: 800,
@@ -1017,6 +1041,64 @@ describe( 'BarChart', () => {
1017
1041
  expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
1018
1042
  } );
1019
1043
 
1044
+ test( 'keeps the tooltip open once the chart re-renders under it', async () => {
1045
+ // visx hides its tooltip on a debounce and cancels only the most recently
1046
+ // scheduled hide, so anything that re-runs the tooltip effect while nothing
1047
+ // is shown leaves a hide pending that lands on the next tooltip. Seeding a
1048
+ // hidden series re-renders the chart right after mount, which is exactly
1049
+ // that shape.
1050
+ jest.useFakeTimers();
1051
+
1052
+ try {
1053
+ const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
1054
+ renderWithTheme( {
1055
+ withTooltips: true,
1056
+ defaultHiddenSeries: [ 'Series B' ],
1057
+ data: [
1058
+ {
1059
+ label: 'Series A',
1060
+ data: [
1061
+ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
1062
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
1063
+ ],
1064
+ options: {},
1065
+ },
1066
+ {
1067
+ label: 'Series B',
1068
+ data: [
1069
+ { date: new Date( '2024-01-01' ), value: 15, label: 'Jan 1' },
1070
+ { date: new Date( '2024-01-02' ), value: 25, label: 'Jan 2' },
1071
+ ],
1072
+ options: {},
1073
+ },
1074
+ ],
1075
+ } );
1076
+
1077
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
1078
+ chart.focus();
1079
+
1080
+ await user.keyboard( '{ArrowRight}' );
1081
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Series A' );
1082
+
1083
+ // Well past any pending hide.
1084
+ await act( async () => {
1085
+ jest.advanceTimersByTime( 5000 );
1086
+ } );
1087
+
1088
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toBeInTheDocument();
1089
+
1090
+ // Leaving navigation still closes it.
1091
+ await user.keyboard( '{Escape}' );
1092
+ await act( async () => {
1093
+ jest.advanceTimersByTime( 5000 );
1094
+ } );
1095
+
1096
+ expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
1097
+ } finally {
1098
+ jest.useRealTimers();
1099
+ }
1100
+ } );
1101
+
1020
1102
  test( 'left arrow key navigates to previous data point', async () => {
1021
1103
  const user = userEvent.setup();
1022
1104
  renderWithTheme( {
@@ -1918,6 +2000,133 @@ describe( 'BarChart', () => {
1918
2000
  screen.getByText( /all series are hidden.*click legend items to show data/i )
1919
2001
  ).toBeInTheDocument();
1920
2002
  } );
2003
+
2004
+ it( 'hides a series programmatically when the legend is not interactive', () => {
2005
+ let context: GlobalChartsContextValue;
2006
+ const Grab = () => {
2007
+ context = useGlobalChartsContext();
2008
+ return null;
2009
+ };
2010
+
2011
+ render(
2012
+ <GlobalChartsProvider>
2013
+ <Grab />
2014
+ <BarChartUnresponsive
2015
+ width={ 500 }
2016
+ height={ 300 }
2017
+ showLegend={ true }
2018
+ legend={ { interactive: false } }
2019
+ chartId="test-programmatic-bar"
2020
+ data={ [
2021
+ {
2022
+ label: 'Series A',
2023
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
2024
+ options: {},
2025
+ },
2026
+ {
2027
+ label: 'Series B',
2028
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
2029
+ options: {},
2030
+ },
2031
+ ] }
2032
+ />
2033
+ </GlobalChartsProvider>
2034
+ );
2035
+
2036
+ act( () => {
2037
+ context.toggleSeriesVisibility( 'test-programmatic-bar', 'Series A' );
2038
+ context.toggleSeriesVisibility( 'test-programmatic-bar', 'Series B' );
2039
+ } );
2040
+
2041
+ expect( screen.getByText( /all series are hidden/i ) ).toBeInTheDocument();
2042
+ } );
2043
+
2044
+ it( 'omits the click instruction when the legend cannot be clicked', () => {
2045
+ let context: GlobalChartsContextValue;
2046
+ const Grab = () => {
2047
+ context = useGlobalChartsContext();
2048
+ return null;
2049
+ };
2050
+
2051
+ render(
2052
+ <GlobalChartsProvider>
2053
+ <Grab />
2054
+ <BarChartUnresponsive
2055
+ width={ 500 }
2056
+ height={ 300 }
2057
+ showLegend={ true }
2058
+ legend={ { interactive: false } }
2059
+ chartId="test-empty-copy-bar"
2060
+ data={ [
2061
+ {
2062
+ label: 'Series A',
2063
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
2064
+ options: {},
2065
+ },
2066
+ ] }
2067
+ />
2068
+ </GlobalChartsProvider>
2069
+ );
2070
+
2071
+ act( () => {
2072
+ context.toggleSeriesVisibility( 'test-empty-copy-bar', 'Series A' );
2073
+ } );
2074
+
2075
+ expect( screen.getByText( 'All series are hidden.' ) ).toBeInTheDocument();
2076
+ expect( screen.queryByText( /click legend items/i ) ).not.toBeInTheDocument();
2077
+ } );
2078
+
2079
+ it( 'still renders the other series when only one is hidden programmatically', () => {
2080
+ // A test that hides every series would pass even if the chart still forced
2081
+ // all series visible right up until `allSeriesHidden` short-circuited it —
2082
+ // that shape of test masked a real bug in a sibling chart. Hiding exactly
2083
+ // one of two series exercises the partial-hide render path: the bar count
2084
+ // only drops to one if the hidden series' `BarSeries` is actually excluded
2085
+ // from the `BarGroup`, not merely rendered with zero opacity.
2086
+ let context: GlobalChartsContextValue;
2087
+ const Grab = () => {
2088
+ context = useGlobalChartsContext();
2089
+ return null;
2090
+ };
2091
+
2092
+ render(
2093
+ <GlobalChartsProvider>
2094
+ <Grab />
2095
+ <BarChartUnresponsive
2096
+ width={ 500 }
2097
+ height={ 300 }
2098
+ showLegend={ true }
2099
+ legend={ { interactive: false } }
2100
+ chartId="test-programmatic-partial-bar"
2101
+ data={ [
2102
+ {
2103
+ label: 'Series A',
2104
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
2105
+ options: {},
2106
+ },
2107
+ {
2108
+ label: 'Series B',
2109
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
2110
+ options: {},
2111
+ },
2112
+ ] }
2113
+ />
2114
+ </GlobalChartsProvider>
2115
+ );
2116
+
2117
+ // eslint-disable-next-line testing-library/no-node-access
2118
+ const svgElement = screen.getByRole( 'grid', { name: /bar chart/i } ).querySelector( 'svg' );
2119
+ // eslint-disable-next-line testing-library/no-node-access
2120
+ expect( svgElement?.querySelectorAll( '.visx-bar-group rect' ) ).toHaveLength( 2 );
2121
+
2122
+ act( () => {
2123
+ context.toggleSeriesVisibility( 'test-programmatic-partial-bar', 'Series B' );
2124
+ } );
2125
+
2126
+ expect( screen.queryByText( /all series are hidden/i ) ).not.toBeInTheDocument();
2127
+ // eslint-disable-next-line testing-library/no-node-access
2128
+ expect( svgElement?.querySelectorAll( '.visx-bar-group rect' ) ).toHaveLength( 1 );
2129
+ } );
1921
2130
  } );
1922
2131
 
1923
2132
  describe( 'Legend group collapsing', () => {
@@ -1996,4 +2205,70 @@ describe( 'BarChart', () => {
1996
2205
  ).toBeInTheDocument();
1997
2206
  } );
1998
2207
  } );
2208
+
2209
+ describe( 'defaultHiddenSeries', () => {
2210
+ it( 'renders a series hidden when named in defaultHiddenSeries', () => {
2211
+ render(
2212
+ <GlobalChartsProvider>
2213
+ <BarChartUnresponsive
2214
+ width={ 500 }
2215
+ height={ 300 }
2216
+ showLegend={ true }
2217
+ legend={ { interactive: true } }
2218
+ chartId="test-default-hidden-bar"
2219
+ defaultHiddenSeries={ [ 'Series B' ] }
2220
+ data={ [
2221
+ {
2222
+ label: 'Series A',
2223
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
2224
+ options: {},
2225
+ },
2226
+ {
2227
+ label: 'Series B',
2228
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
2229
+ options: {},
2230
+ },
2231
+ ] }
2232
+ />
2233
+ </GlobalChartsProvider>
2234
+ );
2235
+
2236
+ const items = screen.getAllByRole( 'button' );
2237
+ expect( items[ 0 ] ).toHaveAttribute( 'aria-pressed', 'true' );
2238
+ expect( items[ 1 ] ).toHaveAttribute( 'aria-pressed', 'false' );
2239
+ } );
2240
+
2241
+ it( 'lets the user reveal a series seeded hidden', async () => {
2242
+ const user = userEvent.setup();
2243
+
2244
+ render(
2245
+ <GlobalChartsProvider>
2246
+ <BarChartUnresponsive
2247
+ width={ 500 }
2248
+ height={ 300 }
2249
+ showLegend={ true }
2250
+ legend={ { interactive: true } }
2251
+ chartId="test-default-hidden-reveal-bar"
2252
+ defaultHiddenSeries={ [ 'Series B' ] }
2253
+ data={ [
2254
+ {
2255
+ label: 'Series A',
2256
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
2257
+ options: {},
2258
+ },
2259
+ {
2260
+ label: 'Series B',
2261
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
2262
+ options: {},
2263
+ },
2264
+ ] }
2265
+ />
2266
+ </GlobalChartsProvider>
2267
+ );
2268
+
2269
+ await user.click( screen.getAllByRole( 'button' )[ 1 ] );
2270
+
2271
+ expect( screen.getAllByRole( 'button' )[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
2272
+ } );
2273
+ } );
1999
2274
  } );
@@ -14,9 +14,9 @@
14
14
  overflow: hidden;
15
15
  color: var(--a8c-charts-color-label);
16
16
  text-overflow: ellipsis;
17
- font-size: var(--wpds-typography-font-size-xl, 18px);
17
+ font-size: var(--wpds-typography-font-size-xl, 20px);
18
18
  font-style: normal;
19
- font-weight: var(--wpds-typography-font-weight-emphasis, 499);
19
+ font-weight: var(--wpds-typography-font-weight-emphasis, 600);
20
20
  line-height: var(--wpds-typography-line-height-sm, 20px);
21
21
  margin: 0;
22
22
  }
@@ -26,7 +26,7 @@
26
26
  text-overflow: ellipsis;
27
27
  font-size: var(--wpds-typography-font-size-md, 13px);
28
28
  font-style: normal;
29
- font-weight: var(--wpds-typography-font-weight-emphasis, 499);
29
+ font-weight: var(--wpds-typography-font-weight-emphasis, 600);
30
30
  line-height: var(--wpds-typography-line-height-sm, 20px);
31
31
  margin: 0;
32
32
  }
@@ -66,7 +66,7 @@
66
66
  .step-rate {
67
67
  color: var(--a8c-charts-color-label);
68
68
  font-size: var(--wpds-typography-font-size-md, 13px);
69
- font-weight: var(--wpds-typography-font-weight-emphasis, 499);
69
+ font-weight: var(--wpds-typography-font-weight-emphasis, 600);
70
70
  line-height: var(--wpds-typography-line-height-xs, 16px);
71
71
  }
72
72
 
@@ -126,12 +126,12 @@
126
126
  color: var(--a8c-charts-color-label);
127
127
  font-size: var(--wpds-typography-font-size-md, 13px);
128
128
  font-style: normal;
129
- font-weight: var(--wpds-typography-font-weight-emphasis, 499);
129
+ font-weight: var(--wpds-typography-font-weight-emphasis, 600);
130
130
  line-height: var(--wpds-typography-line-height-sm, 20px);
131
131
  }
132
132
 
133
133
  .empty-state {
134
134
  text-align: center;
135
135
  color: var(--a8c-charts-color-label-secondary);
136
- font-size: var(--wpds-typography-font-size-lg, 16px);
136
+ font-size: var(--wpds-typography-font-size-lg, 15px);
137
137
  }
@@ -1,3 +1,11 @@
1
1
  .container {
2
2
  position: relative;
3
+
4
+ // Google gives its HTML tooltip no width, so the box shrink-to-fits against
5
+ // whatever room is left beside the hovered feature. Near a map edge that
6
+ // breaks even a short label across lines. Sizing to content keeps each line
7
+ // whole and grows the box instead.
8
+ :global(.google-visualization-tooltip) {
9
+ white-space: nowrap;
10
+ }
3
11
  }
@@ -103,6 +103,15 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
103
103
  pointer-events: none;
104
104
  }
105
105
 
106
+ // Grid filler: keeps its slot so the grid reads as continuous, but stands for a
107
+ // day nothing was measured for — so it captures no pointer, and its track is
108
+ // faded to keep it apart from a measured day that simply scored zero. Mixed
109
+ // towards `transparent` rather than a lighter grey so it holds in either theme.
110
+ .heatmap-chart__cell--placeholder {
111
+ pointer-events: none;
112
+ background: color-mix(in sRGB, var(--a8c-charts-color-track) 45%, transparent);
113
+ }
114
+
106
115
  // Floor the mix at 15% so the lowest value stays visibly tinted, distinct from
107
116
  // an empty cell.
108
117
  .heatmap-chart__cell--filled {
@@ -21,8 +21,8 @@ import {
21
21
  import { resolveCssVariable } from '../../utils/resolve-css-var';
22
22
  import { Center } from '../private/center';
23
23
  import { useChartChildren } from '../private/chart-composition';
24
+ import { ChartInstanceContext } from '../private/chart-instance-context';
24
25
  import { ChartLayout } from '../private/chart-layout';
25
- import { SingleChartContext } from '../private/single-chart-context';
26
26
  import { withResponsive } from '../private/with-responsive';
27
27
  import styles from './heatmap-chart.module.scss';
28
28
  import {
@@ -136,8 +136,13 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
136
136
  hideTooltip();
137
137
  }, [ hideTooltip ] );
138
138
 
139
- const isCellHidden = useCallback(
140
- ( col: number, row: number ) => data[ col ]?.data[ row ]?.hidden === true,
139
+ // Both empty-slot kinds are skipped by navigation: `hidden` paints nothing,
140
+ // `placeholder` paints an empty cell, and neither has a value to report.
141
+ const isCellInert = useCallback(
142
+ ( col: number, row: number ) => {
143
+ const cell = data[ col ]?.data[ row ];
144
+ return cell?.hidden === true || cell?.placeholder === true;
145
+ },
141
146
  [ data ]
142
147
  );
143
148
 
@@ -163,7 +168,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
163
168
  // Start at the first navigable cell (a calendar's leading edge
164
169
  // slots may be hidden).
165
170
  for ( let index = 0; index < columns * rows; index++ ) {
166
- if ( ! isCellHidden( Math.floor( index / rows ), index % rows ) ) {
171
+ if ( ! isCellInert( Math.floor( index / rows ), index % rows ) ) {
167
172
  setSelectedIndex( index );
168
173
  return;
169
174
  }
@@ -191,7 +196,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
191
196
  do {
192
197
  col += stepCol;
193
198
  row += stepRow;
194
- } while ( col >= 0 && col < columns && row >= 0 && row < rows && isCellHidden( col, row ) );
199
+ } while ( col >= 0 && col < columns && row >= 0 && row < rows && isCellInert( col, row ) );
195
200
 
196
201
  if ( col < 0 || col >= columns || row < 0 || row >= rows ) {
197
202
  return;
@@ -199,7 +204,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
199
204
 
200
205
  setSelectedIndex( col * rows + row );
201
206
  },
202
- [ rows, columns, selectedIndex, hideTooltip, isCellHidden ]
207
+ [ rows, columns, selectedIndex, hideTooltip, isCellInert ]
203
208
  );
204
209
 
205
210
  const handleCellMouseMove = useCallback(
@@ -313,7 +318,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
313
318
 
314
319
  return (
315
320
  <HeatmapContext.Provider value={ heatmapContext }>
316
- <SingleChartContext.Provider value={ { chartId } }>
321
+ <ChartInstanceContext.Provider value={ { chartId } }>
317
322
  <ChartLayout
318
323
  legendPosition="bottom"
319
324
  // Legend renders via trailingContent, not the legend slot.
@@ -391,6 +396,24 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
391
396
  );
392
397
  }
393
398
 
399
+ // Filler: drawn like an empty cell so the grid fills its
400
+ // container, but it stands for a day nothing was measured
401
+ // for, so it reports nothing to a pointer or a screen
402
+ // reader either.
403
+ if ( cell?.placeholder ) {
404
+ return (
405
+ <div
406
+ key={ `cell-${ columnIndex }-${ rowIndex }` }
407
+ data-testid="heatmap-cell-placeholder"
408
+ aria-hidden="true"
409
+ className={ clsx(
410
+ styles[ 'heatmap-chart__cell' ],
411
+ styles[ 'heatmap-chart__cell--placeholder' ]
412
+ ) }
413
+ />
414
+ );
415
+ }
416
+
394
417
  const value = cell?.value ?? null;
395
418
  const present = isPresent( value );
396
419
  const normalized = present ? getNormalizedValue( value, extent ) : 0;
@@ -456,7 +479,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
456
479
  </TooltipInPortal>
457
480
  ) }
458
481
  </ChartLayout>
459
- </SingleChartContext.Provider>
482
+ </ChartInstanceContext.Provider>
460
483
  </HeatmapContext.Provider>
461
484
  );
462
485
  };