@automattic/charts 3.0.0 → 3.1.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 (62) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/TOKENS.md +3 -3
  3. package/dist/index.cjs +235 -123
  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 +236 -125
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.tsx +24 -24
  12. package/src/charts/area-chart/private/overlays.tsx +3 -3
  13. package/src/charts/area-chart/test/area-chart.test.tsx +301 -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/test/bar-chart.test.tsx +255 -2
  17. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +6 -6
  18. package/src/charts/geo-chart/geo-chart.module.scss +8 -0
  19. package/src/charts/heatmap-chart/heatmap-chart.module.scss +9 -0
  20. package/src/charts/heatmap-chart/heatmap-chart.tsx +31 -8
  21. package/src/charts/heatmap-chart/private/build-calendar-data.ts +65 -5
  22. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +125 -0
  23. package/src/charts/heatmap-chart/types.ts +8 -0
  24. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +17 -22
  25. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +107 -1
  26. package/src/charts/line-chart/line-chart.module.scss +2 -2
  27. package/src/charts/line-chart/line-chart.tsx +31 -27
  28. package/src/charts/line-chart/private/line-chart-annotations-overlay.tsx +2 -2
  29. package/src/charts/line-chart/test/line-chart.test.tsx +262 -5
  30. package/src/charts/line-chart/types.ts +4 -3
  31. package/src/charts/pie-chart/pie-chart.tsx +8 -13
  32. package/src/charts/pie-chart/test/pie-chart.test.tsx +73 -1
  33. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +2 -2
  34. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +8 -13
  35. package/src/charts/private/{single-chart-context/single-chart-context.tsx → chart-instance-context/chart-instance-context.ts} +1 -5
  36. package/src/charts/private/chart-instance-context/index.ts +3 -0
  37. package/src/charts/private/{single-chart-context/use-single-chart-context.ts → chart-instance-context/use-chart-instance-context.ts} +1 -3
  38. package/src/charts/private/svg-empty-state/get-all-hidden-message.ts +41 -0
  39. package/src/charts/private/svg-empty-state/index.ts +1 -0
  40. package/src/charts/private/svg-empty-state/test/get-all-hidden-message.test.ts +15 -0
  41. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +2 -2
  42. package/src/charts/private/x-zoom/x-zoom.tsx +2 -2
  43. package/src/components/legend/legend.tsx +4 -4
  44. package/src/components/legend/private/base-legend.module.scss +1 -1
  45. package/src/components/legend/private/base-legend.tsx +67 -18
  46. package/src/components/legend/test/legend.test.tsx +100 -4
  47. package/src/components/tooltip/accessible-tooltip.tsx +13 -2
  48. package/src/components/trend-indicator/trend-indicator.module.scss +1 -1
  49. package/src/hooks/index.ts +1 -1
  50. package/src/hooks/{use-interactive-legend-data.ts → use-legend-visibility-data.ts} +29 -30
  51. package/src/index.ts +3 -1
  52. package/src/providers/chart-context/global-charts-provider.tsx +92 -18
  53. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +47 -0
  54. package/src/providers/chart-context/test/chart-context.test.tsx +142 -0
  55. package/src/providers/chart-context/test/use-default-hidden-series-paint.test.tsx +56 -0
  56. package/src/providers/chart-context/test/use-default-hidden-series.ssr.test.tsx +39 -0
  57. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +222 -0
  58. package/src/providers/chart-context/types.ts +7 -1
  59. package/src/styles/chart-scope.scss +2 -2
  60. package/src/styles/test/wpds-fallbacks.test.ts +131 -0
  61. package/src/types.ts +12 -0
  62. 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
 
@@ -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
@@ -1017,6 +1019,64 @@ describe( 'BarChart', () => {
1017
1019
  expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
1018
1020
  } );
1019
1021
 
1022
+ test( 'keeps the tooltip open once the chart re-renders under it', async () => {
1023
+ // visx hides its tooltip on a debounce and cancels only the most recently
1024
+ // scheduled hide, so anything that re-runs the tooltip effect while nothing
1025
+ // is shown leaves a hide pending that lands on the next tooltip. Seeding a
1026
+ // hidden series re-renders the chart right after mount, which is exactly
1027
+ // that shape.
1028
+ jest.useFakeTimers();
1029
+
1030
+ try {
1031
+ const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
1032
+ renderWithTheme( {
1033
+ withTooltips: true,
1034
+ defaultHiddenSeries: [ 'Series B' ],
1035
+ data: [
1036
+ {
1037
+ label: 'Series A',
1038
+ data: [
1039
+ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
1040
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
1041
+ ],
1042
+ options: {},
1043
+ },
1044
+ {
1045
+ label: 'Series B',
1046
+ data: [
1047
+ { date: new Date( '2024-01-01' ), value: 15, label: 'Jan 1' },
1048
+ { date: new Date( '2024-01-02' ), value: 25, label: 'Jan 2' },
1049
+ ],
1050
+ options: {},
1051
+ },
1052
+ ],
1053
+ } );
1054
+
1055
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
1056
+ chart.focus();
1057
+
1058
+ await user.keyboard( '{ArrowRight}' );
1059
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Series A' );
1060
+
1061
+ // Well past any pending hide.
1062
+ await act( async () => {
1063
+ jest.advanceTimersByTime( 5000 );
1064
+ } );
1065
+
1066
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toBeInTheDocument();
1067
+
1068
+ // Leaving navigation still closes it.
1069
+ await user.keyboard( '{Escape}' );
1070
+ await act( async () => {
1071
+ jest.advanceTimersByTime( 5000 );
1072
+ } );
1073
+
1074
+ expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
1075
+ } finally {
1076
+ jest.useRealTimers();
1077
+ }
1078
+ } );
1079
+
1020
1080
  test( 'left arrow key navigates to previous data point', async () => {
1021
1081
  const user = userEvent.setup();
1022
1082
  renderWithTheme( {
@@ -1918,6 +1978,133 @@ describe( 'BarChart', () => {
1918
1978
  screen.getByText( /all series are hidden.*click legend items to show data/i )
1919
1979
  ).toBeInTheDocument();
1920
1980
  } );
1981
+
1982
+ it( 'hides a series programmatically when the legend is not interactive', () => {
1983
+ let context: GlobalChartsContextValue;
1984
+ const Grab = () => {
1985
+ context = useGlobalChartsContext();
1986
+ return null;
1987
+ };
1988
+
1989
+ render(
1990
+ <GlobalChartsProvider>
1991
+ <Grab />
1992
+ <BarChartUnresponsive
1993
+ width={ 500 }
1994
+ height={ 300 }
1995
+ showLegend={ true }
1996
+ legend={ { interactive: false } }
1997
+ chartId="test-programmatic-bar"
1998
+ data={ [
1999
+ {
2000
+ label: 'Series A',
2001
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
2002
+ options: {},
2003
+ },
2004
+ {
2005
+ label: 'Series B',
2006
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
2007
+ options: {},
2008
+ },
2009
+ ] }
2010
+ />
2011
+ </GlobalChartsProvider>
2012
+ );
2013
+
2014
+ act( () => {
2015
+ context.toggleSeriesVisibility( 'test-programmatic-bar', 'Series A' );
2016
+ context.toggleSeriesVisibility( 'test-programmatic-bar', 'Series B' );
2017
+ } );
2018
+
2019
+ expect( screen.getByText( /all series are hidden/i ) ).toBeInTheDocument();
2020
+ } );
2021
+
2022
+ it( 'omits the click instruction when the legend cannot be clicked', () => {
2023
+ let context: GlobalChartsContextValue;
2024
+ const Grab = () => {
2025
+ context = useGlobalChartsContext();
2026
+ return null;
2027
+ };
2028
+
2029
+ render(
2030
+ <GlobalChartsProvider>
2031
+ <Grab />
2032
+ <BarChartUnresponsive
2033
+ width={ 500 }
2034
+ height={ 300 }
2035
+ showLegend={ true }
2036
+ legend={ { interactive: false } }
2037
+ chartId="test-empty-copy-bar"
2038
+ data={ [
2039
+ {
2040
+ label: 'Series A',
2041
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
2042
+ options: {},
2043
+ },
2044
+ ] }
2045
+ />
2046
+ </GlobalChartsProvider>
2047
+ );
2048
+
2049
+ act( () => {
2050
+ context.toggleSeriesVisibility( 'test-empty-copy-bar', 'Series A' );
2051
+ } );
2052
+
2053
+ expect( screen.getByText( 'All series are hidden.' ) ).toBeInTheDocument();
2054
+ expect( screen.queryByText( /click legend items/i ) ).not.toBeInTheDocument();
2055
+ } );
2056
+
2057
+ it( 'still renders the other series when only one is hidden programmatically', () => {
2058
+ // A test that hides every series would pass even if the chart still forced
2059
+ // all series visible right up until `allSeriesHidden` short-circuited it —
2060
+ // that shape of test masked a real bug in a sibling chart. Hiding exactly
2061
+ // one of two series exercises the partial-hide render path: the bar count
2062
+ // only drops to one if the hidden series' `BarSeries` is actually excluded
2063
+ // from the `BarGroup`, not merely rendered with zero opacity.
2064
+ let context: GlobalChartsContextValue;
2065
+ const Grab = () => {
2066
+ context = useGlobalChartsContext();
2067
+ return null;
2068
+ };
2069
+
2070
+ render(
2071
+ <GlobalChartsProvider>
2072
+ <Grab />
2073
+ <BarChartUnresponsive
2074
+ width={ 500 }
2075
+ height={ 300 }
2076
+ showLegend={ true }
2077
+ legend={ { interactive: false } }
2078
+ chartId="test-programmatic-partial-bar"
2079
+ data={ [
2080
+ {
2081
+ label: 'Series A',
2082
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
2083
+ options: {},
2084
+ },
2085
+ {
2086
+ label: 'Series B',
2087
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
2088
+ options: {},
2089
+ },
2090
+ ] }
2091
+ />
2092
+ </GlobalChartsProvider>
2093
+ );
2094
+
2095
+ // eslint-disable-next-line testing-library/no-node-access
2096
+ const svgElement = screen.getByRole( 'grid', { name: /bar chart/i } ).querySelector( 'svg' );
2097
+ // eslint-disable-next-line testing-library/no-node-access
2098
+ expect( svgElement?.querySelectorAll( '.visx-bar-group rect' ) ).toHaveLength( 2 );
2099
+
2100
+ act( () => {
2101
+ context.toggleSeriesVisibility( 'test-programmatic-partial-bar', 'Series B' );
2102
+ } );
2103
+
2104
+ expect( screen.queryByText( /all series are hidden/i ) ).not.toBeInTheDocument();
2105
+ // eslint-disable-next-line testing-library/no-node-access
2106
+ expect( svgElement?.querySelectorAll( '.visx-bar-group rect' ) ).toHaveLength( 1 );
2107
+ } );
1921
2108
  } );
1922
2109
 
1923
2110
  describe( 'Legend group collapsing', () => {
@@ -1996,4 +2183,70 @@ describe( 'BarChart', () => {
1996
2183
  ).toBeInTheDocument();
1997
2184
  } );
1998
2185
  } );
2186
+
2187
+ describe( 'defaultHiddenSeries', () => {
2188
+ it( 'renders a series hidden when named in defaultHiddenSeries', () => {
2189
+ render(
2190
+ <GlobalChartsProvider>
2191
+ <BarChartUnresponsive
2192
+ width={ 500 }
2193
+ height={ 300 }
2194
+ showLegend={ true }
2195
+ legend={ { interactive: true } }
2196
+ chartId="test-default-hidden-bar"
2197
+ defaultHiddenSeries={ [ 'Series B' ] }
2198
+ data={ [
2199
+ {
2200
+ label: 'Series A',
2201
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
2202
+ options: {},
2203
+ },
2204
+ {
2205
+ label: 'Series B',
2206
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
2207
+ options: {},
2208
+ },
2209
+ ] }
2210
+ />
2211
+ </GlobalChartsProvider>
2212
+ );
2213
+
2214
+ const items = screen.getAllByRole( 'button' );
2215
+ expect( items[ 0 ] ).toHaveAttribute( 'aria-pressed', 'true' );
2216
+ expect( items[ 1 ] ).toHaveAttribute( 'aria-pressed', 'false' );
2217
+ } );
2218
+
2219
+ it( 'lets the user reveal a series seeded hidden', async () => {
2220
+ const user = userEvent.setup();
2221
+
2222
+ render(
2223
+ <GlobalChartsProvider>
2224
+ <BarChartUnresponsive
2225
+ width={ 500 }
2226
+ height={ 300 }
2227
+ showLegend={ true }
2228
+ legend={ { interactive: true } }
2229
+ chartId="test-default-hidden-reveal-bar"
2230
+ defaultHiddenSeries={ [ 'Series B' ] }
2231
+ data={ [
2232
+ {
2233
+ label: 'Series A',
2234
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
2235
+ options: {},
2236
+ },
2237
+ {
2238
+ label: 'Series B',
2239
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
2240
+ options: {},
2241
+ },
2242
+ ] }
2243
+ />
2244
+ </GlobalChartsProvider>
2245
+ );
2246
+
2247
+ await user.click( screen.getAllByRole( 'button' )[ 1 ] );
2248
+
2249
+ expect( screen.getAllByRole( 'button' )[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
2250
+ } );
2251
+ } );
1999
2252
  } );
@@ -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
  };
@@ -10,6 +10,36 @@ export type CalendarHeatmapResult = {
10
10
  /** Rows that get a weekday label (Mon, Wed, Fri with a Monday week start). */
11
11
  const LABELLED_ROWS = [ 0, 2, 4 ];
12
12
 
13
+ /**
14
+ * Resolve one grid bound: an unparseable or narrowing value falls back to the
15
+ * series' own bound, so the grid can only ever be widened.
16
+ *
17
+ * @param bound - Requested bound as `yyyy-MM-dd`, if any.
18
+ * @param fallback - The series' own bound on this side.
19
+ * @param direction - Which way the bound is allowed to move.
20
+ * @return The bound to draw to.
21
+ */
22
+ const widenTo = (
23
+ bound: string | undefined,
24
+ fallback: Date,
25
+ direction: 'earlier' | 'later'
26
+ ): Date => {
27
+ if ( ! bound ) {
28
+ return fallback;
29
+ }
30
+
31
+ const parsed = parseISO( bound );
32
+ if ( isNaN( parsed.getTime() ) ) {
33
+ return fallback;
34
+ }
35
+
36
+ if ( direction === 'earlier' ) {
37
+ return parsed < fallback ? parsed : fallback;
38
+ }
39
+
40
+ return parsed > fallback ? parsed : fallback;
41
+ };
42
+
13
43
  const toDate = ( point: DataPointDate ): Date | null => {
14
44
  if ( point.date instanceof Date && ! isNaN( point.date.getTime() ) ) {
15
45
  return point.date;
@@ -34,6 +64,16 @@ export const buildCalendarHeatmapData = (
34
64
  * blank cells even when the series has no entry for them.
35
65
  */
36
66
  hideOutOfRangeDays?: boolean;
67
+ /**
68
+ * Draw the grid over this span (`yyyy-MM-dd` bounds) instead of the
69
+ * series' own. Days inside the grid but outside the series become
70
+ * placeholder cells: painted as empty slots so a short series still
71
+ * fills its container, but reporting nothing, since they were never
72
+ * measured. A start bound is drawn from the beginning of its week, so
73
+ * the grid always opens on a whole column. Bounds narrower than the
74
+ * series are ignored — the grid never drops a day that carries data.
75
+ */
76
+ gridSpan?: { start?: string; end?: string };
37
77
  } = {}
38
78
  ): CalendarHeatmapResult => {
39
79
  const weekStartsOn = options.weekStartsOn ?? 1;
@@ -60,14 +100,27 @@ export const buildCalendarHeatmapData = (
60
100
  }
61
101
  }
62
102
 
63
- const gridStart = startOfWeek( minDate, { weekStartsOn } );
64
- const weekCount = differenceInCalendarWeeks( maxDate, gridStart, { weekStartsOn } ) + 1;
65
-
66
103
  // Day-key bounds for the ragged-edge option: calendar-day comparison, so
67
104
  // entries carrying a time of day can't shift the span.
68
105
  const minDayKey = format( minDate, 'yyyy-MM-dd' );
69
106
  const maxDayKey = format( maxDate, 'yyyy-MM-dd' );
70
107
 
108
+ // The grid may run wider than the series, never narrower: a bound that would
109
+ // cut into the data is dropped rather than honoured.
110
+ const gridMinDate = widenTo( options.gridSpan?.start, minDate, 'earlier' );
111
+ const gridMaxDate = widenTo( options.gridSpan?.end, maxDate, 'later' );
112
+
113
+ const gridMaxDayKey = format( gridMaxDate, 'yyyy-MM-dd' );
114
+
115
+ const gridStart = startOfWeek( gridMinDate, { weekStartsOn } );
116
+
117
+ // A start bound rarely lands on a week start, and `gridStart` rounds it down.
118
+ // The days it rounds past were no more measured than the rest of the widened
119
+ // span, so they are filler too; treating them as a ragged edge instead would
120
+ // notch the first column by however far the bound sat into its week.
121
+ const gridMinDayKey = format( gridMinDate < minDate ? gridStart : gridMinDate, 'yyyy-MM-dd' );
122
+ const weekCount = differenceInCalendarWeeks( gridMaxDate, gridStart, { weekStartsOn } ) + 1;
123
+
71
124
  const rowLabels = Array.from( { length: 7 }, ( _, row ) =>
72
125
  LABELLED_ROWS.includes( row ) ? format( addDays( gridStart, row ), 'EEE' ) : ''
73
126
  );
@@ -104,8 +157,15 @@ export const buildCalendarHeatmapData = (
104
157
  label: format( day, 'EEE, MMM d, yyyy' ),
105
158
  value: valueByDay.has( key ) ? ( valueByDay.get( key ) as number | null ) : null,
106
159
  };
107
- if ( hideOutOfRangeDays && ( key < minDayKey || key > maxDayKey ) ) {
108
- cell.hidden = true;
160
+ if ( key < gridMinDayKey || key > gridMaxDayKey ) {
161
+ // The days completing the grid's first/last week: the calendar's
162
+ // ragged edge.
163
+ if ( hideOutOfRangeDays ) {
164
+ cell.hidden = true;
165
+ }
166
+ } else if ( key < minDayKey || key > maxDayKey ) {
167
+ // Inside the grid the caller asked for, outside the series: filler.
168
+ cell.placeholder = true;
109
169
  }
110
170
  cells.push( cell );
111
171
  }