@automattic/charts 2.0.1 → 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 (102) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/TOKENS.md +109 -50
  3. package/dist/index.cjs +529 -233
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +118 -55
  6. package/dist/index.d.cts +90 -48
  7. package/dist/index.d.ts +90 -48
  8. package/dist/index.js +527 -235
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.module.scss +8 -3
  12. package/src/charts/area-chart/area-chart.tsx +25 -25
  13. package/src/charts/area-chart/private/overlays.tsx +3 -3
  14. package/src/charts/area-chart/test/area-chart.test.tsx +301 -11
  15. package/src/charts/area-chart/types.ts +4 -3
  16. package/src/charts/bar-chart/bar-chart.module.scss +14 -6
  17. package/src/charts/bar-chart/bar-chart.tsx +21 -15
  18. package/src/charts/bar-chart/test/bar-chart.test.tsx +255 -2
  19. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +23 -18
  20. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +107 -86
  21. package/src/charts/conversion-funnel-chart/test/chart-scope.test.tsx +36 -0
  22. package/src/charts/geo-chart/geo-chart.module.scss +8 -0
  23. package/src/charts/geo-chart/geo-chart.tsx +12 -4
  24. package/src/charts/heatmap-chart/heatmap-chart.module.scss +22 -9
  25. package/src/charts/heatmap-chart/heatmap-chart.tsx +43 -13
  26. package/src/charts/heatmap-chart/private/build-calendar-data.ts +65 -5
  27. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +125 -0
  28. package/src/charts/heatmap-chart/types.ts +8 -0
  29. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +13 -7
  30. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +17 -22
  31. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +107 -1
  32. package/src/charts/leaderboard-chart/types.ts +0 -2
  33. package/src/charts/line-chart/line-chart.module.scss +12 -7
  34. package/src/charts/line-chart/line-chart.tsx +32 -28
  35. package/src/charts/line-chart/private/line-chart-annotation.tsx +4 -4
  36. package/src/charts/line-chart/private/line-chart-annotations-overlay.tsx +2 -2
  37. package/src/charts/line-chart/test/line-chart.test.tsx +262 -5
  38. package/src/charts/line-chart/types.ts +4 -3
  39. package/src/charts/pie-chart/pie-chart.tsx +12 -14
  40. package/src/charts/pie-chart/test/pie-chart.test.tsx +73 -1
  41. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +2 -2
  42. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +12 -14
  43. package/src/charts/private/{single-chart-context/single-chart-context.tsx → chart-instance-context/chart-instance-context.ts} +1 -5
  44. package/src/charts/private/chart-instance-context/index.ts +3 -0
  45. package/src/charts/private/{single-chart-context/use-single-chart-context.ts → chart-instance-context/use-chart-instance-context.ts} +1 -3
  46. package/src/charts/private/svg-empty-state/get-all-hidden-message.ts +41 -0
  47. package/src/charts/private/svg-empty-state/index.ts +1 -0
  48. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  49. package/src/charts/private/svg-empty-state/test/get-all-hidden-message.test.ts +15 -0
  50. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +16 -0
  51. package/src/charts/private/with-responsive/with-responsive.tsx +18 -11
  52. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +2 -2
  53. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  54. package/src/charts/private/x-zoom/x-zoom.tsx +2 -2
  55. package/src/components/legend/legend.tsx +4 -4
  56. package/src/components/legend/private/base-legend.module.scss +1 -1
  57. package/src/components/legend/private/base-legend.tsx +70 -19
  58. package/src/components/legend/test/legend.test.tsx +100 -4
  59. package/src/components/tooltip/accessible-tooltip.tsx +17 -4
  60. package/src/components/tooltip/base-tooltip.tsx +8 -1
  61. package/src/components/trend-indicator/test/trend-indicator.test.tsx +20 -0
  62. package/src/components/trend-indicator/trend-indicator.module.scss +4 -7
  63. package/src/components/trend-indicator/trend-indicator.tsx +3 -0
  64. package/src/hooks/index.ts +1 -1
  65. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +28 -0
  66. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +19 -13
  67. package/src/hooks/test/use-xychart-theme.test.tsx +25 -0
  68. package/src/hooks/{use-interactive-legend-data.ts → use-legend-visibility-data.ts} +29 -30
  69. package/src/hooks/use-xychart-theme.ts +6 -4
  70. package/src/index.ts +12 -3
  71. package/src/providers/chart-context/global-charts-provider.tsx +127 -30
  72. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +47 -0
  73. package/src/providers/chart-context/private/theme-override-vars.ts +87 -0
  74. package/src/providers/chart-context/private/with-catalog-pointers.ts +50 -0
  75. package/src/providers/chart-context/test/chart-context.test.tsx +142 -40
  76. package/src/providers/chart-context/test/chart-scope-class.test.tsx +19 -0
  77. package/src/providers/chart-context/test/sparse-theme.test.ts +35 -0
  78. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +29 -0
  79. package/src/providers/chart-context/test/theme-override-vars.test.ts +118 -0
  80. package/src/providers/chart-context/test/use-default-hidden-series-paint.test.tsx +56 -0
  81. package/src/providers/chart-context/test/use-default-hidden-series.ssr.test.tsx +39 -0
  82. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +222 -0
  83. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +47 -0
  84. package/src/providers/chart-context/themes.ts +18 -27
  85. package/src/providers/chart-context/types.ts +7 -4
  86. package/src/providers/chart-scope/chart-scope-context.ts +8 -0
  87. package/src/providers/chart-scope/index.ts +3 -0
  88. package/src/providers/chart-scope/test/chart-scope.test.tsx +24 -0
  89. package/src/providers/chart-scope/test/use-standalone-scope-class.test.tsx +20 -0
  90. package/src/providers/chart-scope/use-chart-scope-element.ts +9 -0
  91. package/src/providers/chart-scope/use-standalone-scope-class.ts +13 -0
  92. package/src/providers/index.ts +1 -0
  93. package/src/styles/chart-scope-class.ts +6 -0
  94. package/src/styles/chart-scope.scss +83 -0
  95. package/src/styles/test/chart-scope.test.ts +165 -0
  96. package/src/styles/test/wpds-fallbacks.test.ts +131 -0
  97. package/src/types.ts +12 -34
  98. package/src/charts/private/grid-control/grid-control.module.scss +0 -8
  99. package/src/charts/private/grid-control/grid-control.tsx +0 -26
  100. package/src/charts/private/grid-control/index.ts +0 -1
  101. package/src/charts/private/grid-control/test/grid-control.test.tsx +0 -56
  102. package/src/charts/private/single-chart-context/index.ts +0 -8
@@ -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
  }
@@ -142,3 +142,128 @@ describe( 'buildCalendarHeatmapData', () => {
142
142
  expect( rowLabels[ 4 ] ).toBe( 'Thu' );
143
143
  } );
144
144
  } );
145
+
146
+ describe( 'buildCalendarHeatmapData with a grid wider than the series', () => {
147
+ // Mon Jan 1 2024 through Sun Jan 7 — one whole week of data.
148
+ const oneWeek: DataPointDate[] = [
149
+ { dateString: '2024-01-01', value: 3 },
150
+ { dateString: '2024-01-07', value: 4 },
151
+ ];
152
+
153
+ test( 'draws the extra weeks and marks them placeholders, not blank cells', () => {
154
+ const { data } = buildCalendarHeatmapData( oneWeek, {
155
+ weekStartsOn: 1,
156
+ gridSpan: { start: '2023-12-04' }, // 4 weeks earlier
157
+ } );
158
+
159
+ expect( data ).toHaveLength( 5 );
160
+
161
+ // The filler weeks stand for days nothing was measured for.
162
+ expect( data[ 0 ].data.every( cell => cell.placeholder === true ) ).toBe( true );
163
+ expect( data[ 3 ].data.every( cell => cell.placeholder === true ) ).toBe( true );
164
+
165
+ // The data week is untouched: real values, no placeholder flag.
166
+ expect( data[ 4 ].data[ 0 ] ).toMatchObject( { value: 3 } );
167
+ expect( data[ 4 ].data[ 0 ].placeholder ).toBeUndefined();
168
+ expect( data[ 4 ].data[ 6 ] ).toMatchObject( { value: 4 } );
169
+ } );
170
+
171
+ test( 'labels the filler columns so the grid still reads as a calendar', () => {
172
+ const { data } = buildCalendarHeatmapData( oneWeek, {
173
+ weekStartsOn: 1,
174
+ gridSpan: { start: '2023-12-04' },
175
+ } );
176
+
177
+ expect( data.map( column => column.label ) ).toEqual( [ 'Dec', '', '', '', 'Jan' ] );
178
+ } );
179
+
180
+ test( 'ignores a bound that would cut into the series', () => {
181
+ const { data } = buildCalendarHeatmapData( oneWeek, {
182
+ weekStartsOn: 1,
183
+ gridSpan: { start: '2024-01-04', end: '2024-01-05' },
184
+ } );
185
+
186
+ expect( data ).toHaveLength( 1 );
187
+ expect( data[ 0 ].data[ 0 ].value ).toBe( 3 );
188
+ expect( data[ 0 ].data[ 6 ].value ).toBe( 4 );
189
+ } );
190
+
191
+ test( 'falls back to the series span for an unparseable bound', () => {
192
+ const { data } = buildCalendarHeatmapData( oneWeek, {
193
+ weekStartsOn: 1,
194
+ gridSpan: { start: 'not-a-date' },
195
+ } );
196
+
197
+ expect( data ).toHaveLength( 1 );
198
+ } );
199
+
200
+ test( 'separates the ragged edge from the filler', () => {
201
+ // The grid opens Mon Dec 25 and closes with the series on Wed Jan 3, so
202
+ // Thu Jan 4 onward only completes the last week.
203
+ const { data } = buildCalendarHeatmapData( [ { dateString: '2024-01-03', value: 5 } ], {
204
+ weekStartsOn: 1,
205
+ gridSpan: { start: '2023-12-25' },
206
+ } );
207
+
208
+ const lastColumn = data[ data.length - 1 ];
209
+
210
+ // Inside the requested grid, before the data: filler.
211
+ expect( lastColumn.data[ 0 ].placeholder ).toBe( true );
212
+ expect( lastColumn.data[ 0 ].hidden ).toBeUndefined();
213
+
214
+ expect( lastColumn.data[ 2 ].value ).toBe( 5 );
215
+
216
+ // Past the grid's end: the calendar's ragged edge, painted as nothing.
217
+ expect( lastColumn.data[ 3 ].hidden ).toBe( true );
218
+ expect( lastColumn.data[ 3 ].placeholder ).toBeUndefined();
219
+ } );
220
+
221
+ test( 'is a no-op when no grid span is given', () => {
222
+ expect( buildCalendarHeatmapData( oneWeek, { weekStartsOn: 1 } ) ).toEqual(
223
+ buildCalendarHeatmapData( oneWeek, { weekStartsOn: 1, gridSpan: {} } )
224
+ );
225
+ } );
226
+
227
+ // The caller sizes the grid in whole weeks back from the period's last day, so
228
+ // the bound lands on that weekday — a Monday only one year in seven.
229
+ test( 'opens on a whole column when the start bound falls mid-week', () => {
230
+ const { data } = buildCalendarHeatmapData( oneWeek, {
231
+ weekStartsOn: 1,
232
+ gridSpan: { start: '2023-12-07' }, // a Thursday
233
+ } );
234
+
235
+ expect( data ).toHaveLength( 5 );
236
+
237
+ // Mon Dec 4 through Wed Dec 6 precede the bound; they are as unmeasured as
238
+ // the rest of the filler, so they fill the column rather than notching it.
239
+ expect( data[ 0 ].data.every( cell => cell.placeholder === true ) ).toBe( true );
240
+ expect( data[ 0 ].data.some( cell => cell.hidden ) ).toBe( false );
241
+ } );
242
+
243
+ test( 'still draws the ragged edge past the grid end', () => {
244
+ // Wed Jan 3 ends the series, so Thu Jan 4 onward only completes the week.
245
+ const { data } = buildCalendarHeatmapData( [ { dateString: '2024-01-03', value: 5 } ], {
246
+ weekStartsOn: 1,
247
+ gridSpan: { start: '2023-12-20' }, // a Wednesday
248
+ } );
249
+
250
+ expect( data[ 0 ].data.every( cell => cell.placeholder === true ) ).toBe( true );
251
+
252
+ const lastColumn = data[ data.length - 1 ];
253
+ expect( lastColumn.data[ 2 ].value ).toBe( 5 );
254
+ expect( lastColumn.data[ 3 ].hidden ).toBe( true );
255
+ } );
256
+
257
+ test( 'keeps the ragged start edge when the bound is ignored', () => {
258
+ // A narrowing bound never applies, so the series' own first week stays
259
+ // ragged rather than being filled out.
260
+ const { data } = buildCalendarHeatmapData( [ { dateString: '2024-01-03', value: 5 } ], {
261
+ weekStartsOn: 1,
262
+ gridSpan: { start: '2024-01-05' },
263
+ } );
264
+
265
+ expect( data ).toHaveLength( 1 );
266
+ expect( data[ 0 ].data[ 0 ].hidden ).toBe( true );
267
+ expect( data[ 0 ].data[ 0 ].placeholder ).toBeUndefined();
268
+ } );
269
+ } );
@@ -13,6 +13,14 @@ export type HeatmapCell = {
13
13
  * days completing the first/last week fall outside the covered range.
14
14
  */
15
15
  hidden?: boolean;
16
+ /**
17
+ * Paint the cell as a faded empty slot that carries no claim about the day:
18
+ * it is set apart from a measured day that scored zero, and skipped by
19
+ * hover, tooltips, keyboard navigation and the accessibility tree. For grid
20
+ * filler — slots drawn only so a short range still fills its container,
21
+ * which were never measured.
22
+ */
23
+ placeholder?: boolean;
16
24
  };
17
25
 
18
26
  /** A heatmap column (rendered left→right); its cells render top→bottom. */
@@ -60,20 +60,26 @@
60
60
  // row's fixed-pixel hover inset from collapsing a narrow bar to nothing.
61
61
  min-width: var(--wpds-dimension-size-5xs, 4px);
62
62
 
63
- border-radius: var(--a8c-charts-border-radius-leaderboard-bar, var(--a8c--charts--leaderboard--bar--border-radius, 9999px));
63
+ border-radius: var(--a8c-charts-border-radius-leaderboard-bar);
64
64
  z-index: -1; /* places it behind the label */
65
65
 
66
+ // Keep the collapsed state in `from` and never on the element: a dropped
67
+ // animation then fails visible. `both`, not `forwards`, for the
68
+ // backwards fill.
66
69
  &--animated {
67
70
  transform-origin: left;
68
71
  transform-box: fill-box;
69
- transform: scaleX(0);
70
72
  animation:
71
- stretch var(--wpds-motion-duration-xl, 400ms)
72
- var(--wpds-motion-easing-expressive, cubic-bezier(0.25, 0, 0, 1)) forwards;
73
+ stretch var(--a8c-charts-motion-duration-entrance)
74
+ var(--a8c-charts-motion-easing-entrance) both;
73
75
  }
74
76
 
75
77
  @keyframes stretch {
76
78
 
79
+ from {
80
+ transform: scaleX(0);
81
+ }
82
+
77
83
  to {
78
84
  transform: scaleX(1);
79
85
  }
@@ -104,7 +110,7 @@
104
110
  .emptyState {
105
111
  padding: var(--wpds-dimension-padding-3xl, 32px) var(--wpds-dimension-padding-lg, 16px);
106
112
  text-align: center;
107
- color: var(--a8c-charts-color-label-secondary, var(--wpds-color-foreground-content-neutral-weak, #707070));
113
+ color: var(--a8c-charts-color-label-secondary);
108
114
  font-size: var(--wpds-typography-font-size-md, 13px);
109
115
  font-style: italic;
110
116
  }
@@ -205,7 +211,7 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
205
211
  border-radius: var(--wpds-border-radius-sm, 2px);
206
212
  outline:
207
213
  $focus-ring-width solid
208
- var(--a8c-charts-color-focus, var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9)));
214
+ var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9));
209
215
  // Inset by its own width so the outer edge hugs the row boundary: the
210
216
  // outline then sits in the shared row padding, clear of the bar, and stays
211
217
  // inside the overflow:auto container that would clip an outset ring.
@@ -220,7 +226,7 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
220
226
  bottom: 0;
221
227
  margin-block: auto;
222
228
  opacity: 0;
223
- color: var(--a8c-charts-color-label-secondary, var(--wpds-color-foreground-content-neutral-weak, #707070));
229
+ color: var(--a8c-charts-color-label-secondary);
224
230
  transition:
225
231
  opacity var(--wpds-motion-duration-sm, 100ms)
226
232
  var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
@@ -17,8 +17,9 @@ import {
17
17
  } from '../../providers';
18
18
  import { formatMetricValue, attachSubComponents } from '../../utils';
19
19
  import { useChartChildren } from '../private/chart-composition';
20
+ import { ChartInstanceContext } from '../private/chart-instance-context';
20
21
  import { ChartLayout } from '../private/chart-layout';
21
- import { SingleChartContext } from '../private/single-chart-context';
22
+ import { getAllHiddenMessage } from '../private/svg-empty-state';
22
23
  import { withResponsive } from '../private/with-responsive';
23
24
  import { useFittedRowCount, useLeaderboardLegendItems } from './hooks';
24
25
  import styles from './leaderboard-chart.module.scss';
@@ -225,35 +226,28 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
225
226
  legendLabels,
226
227
  } );
227
228
 
228
- // Track visibility of primary and comparison series for interactive legends
229
+ // Track visibility of primary and comparison series from the shared legend state.
229
230
  const isPrimaryVisible = useMemo( () => {
230
- if ( ! chartId || ! legendInteractive || legendItems.length === 0 ) {
231
+ if ( legendItems.length === 0 ) {
231
232
  return true;
232
233
  }
233
234
  return isSeriesVisible( chartId, legendItems[ 0 ].label );
234
- }, [ chartId, legendInteractive, legendItems, isSeriesVisible ] );
235
+ }, [ chartId, legendItems, isSeriesVisible ] );
235
236
 
236
237
  const isComparisonVisible = useMemo( () => {
237
- if ( ! chartId || ! legendInteractive || legendItems.length < 2 ) {
238
+ if ( legendItems.length < 2 ) {
238
239
  return true;
239
240
  }
240
241
  return isSeriesVisible( chartId, legendItems[ 1 ].label );
241
- }, [ chartId, legendInteractive, legendItems, isSeriesVisible ] );
242
+ }, [ chartId, legendItems, isSeriesVisible ] );
242
243
 
243
244
  // Check if all series are hidden
244
245
  const allSeriesHidden = useMemo( () => {
245
- if ( ! legendInteractive ) return false;
246
246
  if ( withComparison && ! withOverlayLabel ) {
247
247
  return ! isPrimaryVisible && ! isComparisonVisible;
248
248
  }
249
249
  return ! isPrimaryVisible;
250
- }, [
251
- legendInteractive,
252
- isPrimaryVisible,
253
- isComparisonVisible,
254
- withComparison,
255
- withOverlayLabel,
256
- ] );
250
+ }, [ isPrimaryVisible, isComparisonVisible, withComparison, withOverlayLabel ] );
257
251
 
258
252
  // Validate data
259
253
  const isDataValid = Boolean( data && data.length > 0 );
@@ -278,9 +272,10 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
278
272
 
279
273
  const prefersReducedMotion = usePrefersReducedMotion();
280
274
 
281
- // There are no rows to measure while an interactive legend has hidden every
282
- // series. Pausing fitting restores the full row count and, when a series is shown
283
- // again, re-runs the effect against the newly mounted grid.
275
+ // There are no rows to measure while every series is hidden, whether from an
276
+ // interactive legend click or a programmatic toggle. Pausing fitting restores
277
+ // the full row count and, when a series is shown again, re-runs the effect
278
+ // against the newly mounted grid.
284
279
  const { contentRef, fittedCount, isMeasurable } = useFittedRowCount(
285
280
  fitRows && ! allSeriesHidden,
286
281
  data?.length ?? 0,
@@ -291,7 +286,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
291
286
  // Handle empty or undefined data
292
287
  if ( ! data || data.length === 0 ) {
293
288
  return (
294
- <SingleChartContext.Provider value={ { chartId } }>
289
+ <ChartInstanceContext.Provider value={ { chartId } }>
295
290
  <ChartLayout
296
291
  legendPosition={ legendPosition }
297
292
  legendElement={ false }
@@ -315,7 +310,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
315
310
  : __( 'No data available', 'jetpack-charts' ) }
316
311
  </div>
317
312
  </ChartLayout>
318
- </SingleChartContext.Provider>
313
+ </ChartInstanceContext.Provider>
319
314
  );
320
315
  }
321
316
 
@@ -335,7 +330,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
335
330
  );
336
331
 
337
332
  return (
338
- <SingleChartContext.Provider value={ { chartId } }>
333
+ <ChartInstanceContext.Provider value={ { chartId } }>
339
334
  <ChartLayout
340
335
  legendPosition={ legendPosition }
341
336
  legendElement={ legendElement }
@@ -372,7 +367,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
372
367
  ) }
373
368
  { allSeriesHidden ? (
374
369
  <div className={ styles.emptyState }>
375
- { __( 'All series are hidden. Click legend items to show data.', 'jetpack-charts' ) }
370
+ { getAllHiddenMessage( legendInteractive, 'series' ) }
376
371
  </div>
377
372
  ) : (
378
373
  <Grid
@@ -476,7 +471,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
476
471
  ) }
477
472
  </div>
478
473
  </ChartLayout>
479
- </SingleChartContext.Provider>
474
+ </ChartInstanceContext.Provider>
480
475
  );
481
476
  };
482
477
 
@@ -1,6 +1,9 @@
1
- import { render, screen } from '@testing-library/react';
1
+ import { render, screen, act } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
+ import { GlobalChartsProvider } from '../../../providers';
4
+ import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
3
5
  import LeaderboardChart from '../leaderboard-chart';
6
+ import type { GlobalChartsContextValue } from '../../../providers/chart-context/types';
4
7
  import type { LeaderboardEntry } from '../../../types';
5
8
 
6
9
  const mockDefaultParentSize = () => ( {
@@ -418,6 +421,109 @@ describe( 'LeaderboardChart', () => {
418
421
  } );
419
422
  } );
420
423
 
424
+ describe( 'Programmatic visibility', () => {
425
+ it( 'hides the primary series programmatically when the legend is not interactive', () => {
426
+ let context: GlobalChartsContextValue;
427
+ const Grab = () => {
428
+ context = useGlobalChartsContext();
429
+ return null;
430
+ };
431
+
432
+ render(
433
+ <GlobalChartsProvider>
434
+ <Grab />
435
+ <LeaderboardChart
436
+ width={ 500 }
437
+ height={ 300 }
438
+ showLegend={ true }
439
+ legend={ { interactive: false } }
440
+ chartId="test-programmatic-leaderboard"
441
+ data={ mockData }
442
+ />
443
+ </GlobalChartsProvider>
444
+ );
445
+
446
+ const primaryLabel = screen.getAllByTestId( 'legend-item' )[ 0 ].textContent;
447
+
448
+ act( () => {
449
+ context.toggleSeriesVisibility( 'test-programmatic-leaderboard', primaryLabel );
450
+ } );
451
+
452
+ expect( screen.getByText( /all series are hidden/i ) ).toBeInTheDocument();
453
+ } );
454
+
455
+ it( 'omits the click instruction when the legend cannot be clicked', () => {
456
+ let context: GlobalChartsContextValue;
457
+ const Grab = () => {
458
+ context = useGlobalChartsContext();
459
+ return null;
460
+ };
461
+
462
+ render(
463
+ <GlobalChartsProvider>
464
+ <Grab />
465
+ <LeaderboardChart
466
+ width={ 500 }
467
+ height={ 300 }
468
+ showLegend={ true }
469
+ legend={ { interactive: false } }
470
+ chartId="test-empty-copy-leaderboard"
471
+ data={ mockData }
472
+ />
473
+ </GlobalChartsProvider>
474
+ );
475
+
476
+ const primaryLabel = screen.getAllByTestId( 'legend-item' )[ 0 ].textContent;
477
+
478
+ act( () => {
479
+ context.toggleSeriesVisibility( 'test-empty-copy-leaderboard', primaryLabel );
480
+ } );
481
+
482
+ expect( screen.getByText( 'All series are hidden.' ) ).toBeInTheDocument();
483
+ expect( screen.queryByText( /click legend items/i ) ).not.toBeInTheDocument();
484
+ } );
485
+
486
+ it( 'still renders the primary series when only the comparison is hidden programmatically', () => {
487
+ // Hiding every series routes through allSeriesHidden to the empty state and
488
+ // never exercises the per-series render path. Hiding only the comparison
489
+ // series exercises that path for the leaderboard's primary/comparison split.
490
+ let context: GlobalChartsContextValue;
491
+ const Grab = () => {
492
+ context = useGlobalChartsContext();
493
+ return null;
494
+ };
495
+
496
+ render(
497
+ <GlobalChartsProvider>
498
+ <Grab />
499
+ <LeaderboardChart
500
+ width={ 500 }
501
+ height={ 300 }
502
+ showLegend={ true }
503
+ withComparison={ true }
504
+ legend={ { interactive: false } }
505
+ chartId="test-programmatic-comparison-leaderboard"
506
+ data={ mockData }
507
+ />
508
+ </GlobalChartsProvider>
509
+ );
510
+
511
+ const comparisonLabel = screen.getAllByTestId( 'legend-item' )[ 1 ].textContent;
512
+
513
+ act( () => {
514
+ context.toggleSeriesVisibility(
515
+ 'test-programmatic-comparison-leaderboard',
516
+ comparisonLabel
517
+ );
518
+ } );
519
+
520
+ expect( screen.queryByText( /all series are hidden/i ) ).not.toBeInTheDocument();
521
+ expect( screen.getByText( 'Direct' ) ).toBeInTheDocument();
522
+ expect( screen.getByText( '12.5K' ) ).toBeInTheDocument();
523
+ expect( screen.queryByText( '+25%' ) ).not.toBeInTheDocument();
524
+ } );
525
+ } );
526
+
421
527
  describe( 'Responsive wrapper', () => {
422
528
  it( 'applies explicit width and height to chart container', () => {
423
529
  const { useParentSize } = jest.requireMock( '@visx/responsive' );
@@ -65,8 +65,6 @@ export interface LeaderboardChartProps
65
65
  */
66
66
  style?: React.CSSProperties & {
67
67
  '--a8c-charts-border-radius-leaderboard-bar'?: string;
68
- /** @deprecated Use `--a8c-charts-border-radius-leaderboard-bar`. */
69
- '--a8c--charts--leaderboard--bar--border-radius'?: string;
70
68
  };
71
69
 
72
70
  /**
@@ -1,14 +1,15 @@
1
1
  .line-chart {
2
2
  position: relative;
3
3
 
4
+ // Keep the collapsed state in `from` and never on the element: a dropped
5
+ // animation then fails visible. `both`, not `forwards`, for backwards fill.
4
6
  &--animated {
5
7
 
6
8
  path {
7
9
  transform-origin: 0 95%;
8
- transform: scaleY(0);
9
10
  animation:
10
- rise var(--wpds-motion-duration-xl, 400ms)
11
- var(--wpds-motion-easing-expressive, cubic-bezier(0.25, 0, 0, 1)) forwards;
11
+ rise var(--a8c-charts-motion-duration-entrance)
12
+ var(--a8c-charts-motion-easing-entrance) both;
12
13
  }
13
14
  }
14
15
 
@@ -18,13 +19,13 @@
18
19
 
19
20
  &__tooltip,
20
21
  &__annotation-label-popover {
21
- background: var(--a8c-charts-color-background, var(--wpds-color-background-surface-neutral-strong, #fff));
22
- color: var(--a8c-charts-color-label, var(--wpds-color-foreground-content-neutral, #1e1e1e));
22
+ background: var(--a8c-charts-color-surface);
23
+ color: var(--a8c-charts-color-label);
23
24
  padding: var(--wpds-dimension-padding-sm, 8px);
24
25
  }
25
26
 
26
27
  &__tooltip-date {
27
- font-weight: var(--wpds-typography-font-weight-emphasis, 499);
28
+ font-weight: var(--wpds-typography-font-weight-emphasis, 600);
28
29
  padding-bottom: var(--wpds-dimension-padding-md, 12px);
29
30
  }
30
31
 
@@ -33,7 +34,7 @@
33
34
  }
34
35
 
35
36
  &__tooltip-label {
36
- font-weight: var(--wpds-typography-font-weight-emphasis, 499);
37
+ font-weight: var(--wpds-typography-font-weight-emphasis, 600);
37
38
  padding-right: var(--wpds-dimension-padding-lg, 16px);
38
39
  }
39
40
 
@@ -110,6 +111,10 @@
110
111
 
111
112
  @keyframes rise {
112
113
 
114
+ from {
115
+ transform: scaleY(0);
116
+ }
117
+
113
118
  to {
114
119
  transform: scaleY(1);
115
120
  }