@automattic/charts 1.10.0 → 1.10.2

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 (35) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/index.cjs +5917 -5783
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +44 -3
  5. package/dist/index.d.cts +66 -13
  6. package/dist/index.d.ts +67 -14
  7. package/dist/index.js +1159 -1015
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/charts/heatmap-chart/heatmap-chart.module.scss +27 -4
  11. package/src/charts/heatmap-chart/heatmap-chart.tsx +115 -49
  12. package/src/charts/heatmap-chart/private/build-calendar-data.ts +39 -4
  13. package/src/charts/heatmap-chart/private/heatmap-context.ts +10 -0
  14. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +3 -2
  15. package/src/charts/heatmap-chart/private/index.ts +2 -0
  16. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +6 -2
  17. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +57 -1
  18. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +91 -3
  19. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +4 -0
  20. package/src/charts/heatmap-chart/types.ts +28 -0
  21. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +12 -0
  22. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +65 -37
  23. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +46 -6
  24. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +0 -6
  25. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +229 -18
  26. package/src/charts/private/with-responsive/with-responsive.module.scss +20 -1
  27. package/src/charts/private/with-responsive/with-responsive.tsx +114 -65
  28. package/src/hooks/test/use-chart-margin.test.tsx +8 -5
  29. package/src/hooks/use-chart-margin.tsx +11 -1
  30. package/src/providers/chart-context/global-charts-provider.tsx +7 -0
  31. package/src/providers/chart-context/test/chart-context.test.tsx +40 -0
  32. package/src/providers/chart-context/types.ts +3 -0
  33. package/src/types.ts +9 -6
  34. package/src/utils/color-utils.ts +28 -0
  35. package/src/utils/test/color-utils.test.ts +29 -0
@@ -213,6 +213,57 @@ describe( 'HeatmapChart', () => {
213
213
  expect( grid ).not.toHaveAttribute( 'aria-activedescendant' );
214
214
  } );
215
215
 
216
+ // Calendar ragged edges: hidden cells keep their grid slot but paint
217
+ // nothing and take no interaction.
218
+ const raggedData: HeatmapColumn[] = [
219
+ { label: 'W1', data: [ { value: null, hidden: true }, { value: 1 }, { value: 2 } ] },
220
+ { label: 'W2', data: [ { value: 3 }, { value: null, hidden: true }, { value: 4 } ] },
221
+ { label: 'W3', data: [ { value: 5 }, { value: 6 }, { value: null, hidden: true } ] },
222
+ ];
223
+
224
+ test( 'hidden cells render as empty slots outside the accessibility tree', () => {
225
+ renderChart( { data: raggedData } );
226
+ expect( screen.getAllByTestId( 'heatmap-cell' ) ).toHaveLength( 6 );
227
+ expect( screen.getAllByTestId( 'heatmap-cell-hidden' ) ).toHaveLength( 3 );
228
+ expect( screen.getAllByRole( 'gridcell' ) ).toHaveLength( 6 );
229
+ } );
230
+
231
+ test( 'keyboard navigation skips hidden cells and never lands on them', async () => {
232
+ renderChart( { data: raggedData, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
233
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
234
+ const user = userEvent.setup();
235
+
236
+ // The initial selection starts past the hidden leading slot (0,0).
237
+ grid.focus();
238
+ await user.keyboard( '{ArrowDown}' );
239
+ expect( grid ).toHaveAttribute(
240
+ 'aria-activedescendant',
241
+ expect.stringMatching( /-cell-0-1$/ )
242
+ );
243
+
244
+ // ArrowRight steps over the hidden (1,1) to (2,1).
245
+ await user.keyboard( '{ArrowRight}' );
246
+ expect( grid ).toHaveAttribute(
247
+ 'aria-activedescendant',
248
+ expect.stringMatching( /-cell-2-1$/ )
249
+ );
250
+
251
+ // Only a hidden slot (2,2) lies below — the selection stays put.
252
+ await user.keyboard( '{ArrowDown}' );
253
+ expect( grid ).toHaveAttribute(
254
+ 'aria-activedescendant',
255
+ expect.stringMatching( /-cell-2-1$/ )
256
+ );
257
+
258
+ // ArrowLeft steps back over the hidden (1,1) to (0,1); ArrowUp has
259
+ // only the hidden (0,0) above, so the selection stays again.
260
+ await user.keyboard( '{ArrowLeft}{ArrowUp}' );
261
+ expect( grid ).toHaveAttribute(
262
+ 'aria-activedescendant',
263
+ expect.stringMatching( /-cell-0-1$/ )
264
+ );
265
+ } );
266
+
216
267
  test( 'rows contain gridcell children in the ARIA hierarchy', () => {
217
268
  renderChart();
218
269
  const rows = screen.getAllByRole( 'row' );
@@ -222,12 +273,19 @@ describe( 'HeatmapChart', () => {
222
273
  } );
223
274
  } );
224
275
 
225
- test( 'leaves cell gap and radius to WPDS tokens (no inline overrides)', () => {
276
+ test( 'keeps a strict grid → row structure (column labels live in a row, not directly under the grid)', () => {
277
+ renderChart();
278
+ // The header row is aria-hidden, so include hidden elements when querying for it.
279
+ const rows = screen.getAllByRole( 'row', { hidden: true } );
280
+ const headerRow = rows.find( row => within( row ).queryByText( 'W1' ) );
281
+ expect( headerRow ).toBeDefined();
282
+ } );
283
+
284
+ test( 'leaves the cell gap to WPDS tokens in non-compact mode (no inline override)', () => {
226
285
  renderChart();
227
286
  const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
228
- // Non-compact sets no inline gap/radius — the SCSS falls back to the WPDS tokens.
287
+ // Non-compact sets no inline gap — the SCSS falls back to the WPDS token.
229
288
  expect( grid.style.getPropertyValue( '--heatmap-cell-gap' ) ).toBe( '' );
230
- expect( grid.style.getPropertyValue( '--heatmap-cell-radius' ) ).toBe( '' );
231
289
  } );
232
290
 
233
291
  test( 'applies the compact gap inline from the theme compactCellGap', () => {
@@ -252,6 +310,36 @@ describe( 'HeatmapChart', () => {
252
310
  expect( grid.style.gridTemplateColumns ).toContain( 'var(--heatmap-cell-size)' );
253
311
  } );
254
312
 
313
+ test( 'caps cell width without changing the normal vertical layout', () => {
314
+ renderChart( { maxCellWidth: 64 } );
315
+ const chart = screen.getByTestId( 'heatmap-chart' );
316
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
317
+
318
+ expect( grid.style.gridTemplateColumns ).toContain( 'minmax(0px, 64px)' );
319
+ expect( grid.style.gridTemplateRows ).toContain( 'minmax(0px, 1fr)' );
320
+ expect( chart ).not.toHaveClass( 'heatmap-chart--height-capped' );
321
+ expect( grid ).not.toHaveClass( 'heatmap-chart__grid--height-capped' );
322
+ } );
323
+
324
+ test( 'content-sizes the vertical layout when cell height is capped', () => {
325
+ renderChart( { maxCellHeight: 42 } );
326
+ const chart = screen.getByTestId( 'heatmap-chart' );
327
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
328
+
329
+ expect( grid.style.gridTemplateColumns ).toContain( 'minmax(0px, 1fr)' );
330
+ expect( grid.style.gridTemplateRows ).toContain( 'minmax(0px, 42px)' );
331
+ expect( chart ).toHaveClass( 'heatmap-chart--height-capped' );
332
+ expect( grid ).toHaveClass( 'heatmap-chart__grid--height-capped' );
333
+ } );
334
+
335
+ test( 'applies independent minimum cell width and height floors', () => {
336
+ renderChart( { minCellWidth: 44, minCellHeight: 32 } );
337
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
338
+
339
+ expect( grid.style.gridTemplateColumns ).toContain( 'minmax(44px, 1fr)' );
340
+ expect( grid.style.gridTemplateRows ).toContain( 'minmax(32px, 1fr)' );
341
+ } );
342
+
255
343
  test( 'applies the primaryColor prop as the cell-scale color', () => {
256
344
  renderChart( { primaryColor: '#abcdef' } );
257
345
  const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
@@ -31,4 +31,8 @@ describe( 'getNormalizedValue', () => {
31
31
  test( 'returns 1 when min === max', () => {
32
32
  expect( getNormalizedValue( 7, [ 7, 7 ] ) ).toBe( 1 );
33
33
  } );
34
+
35
+ test( 'returns 0 for an all-zero extent so a no-activity grid stays at the scale bottom', () => {
36
+ expect( getNormalizedValue( 0, [ 0, 0 ] ) ).toBe( 0 );
37
+ } );
34
38
  } );
@@ -6,6 +6,13 @@ export type HeatmapCell = {
6
6
  /** Per-cell label used in the tooltip / accessible name. */
7
7
  label?: string;
8
8
  value: number | null;
9
+ /**
10
+ * Leave the cell's grid slot empty: nothing is painted and the cell is
11
+ * skipped by hover and keyboard navigation, while the slot keeps its
12
+ * place so the rest of the grid doesn't shift. For calendar edges, where
13
+ * days completing the first/last week fall outside the covered range.
14
+ */
15
+ hidden?: boolean;
9
16
  };
10
17
 
11
18
  /** A heatmap column (rendered left→right); its cells render top→bottom. */
@@ -32,6 +39,27 @@ export interface HeatmapChartProps
32
39
  compact?: boolean;
33
40
  /** Render the numeric value inside each cell. Default `! compact`. */
34
41
  showValues?: boolean;
42
+ /**
43
+ * Cap a cell's width (px) in non-compact mode. Cells grow up to the cap
44
+ * and stop instead of splitting the whole container width, so sparse
45
+ * ranges don't produce oversized cells; narrow containers still shrink
46
+ * them. Ignored in compact mode, which uses a fixed cell size.
47
+ */
48
+ maxCellWidth?: number;
49
+ /**
50
+ * Cap a cell's height (px) in non-compact mode. Applying this cap
51
+ * content-sizes the chart vertically so rows do not absorb unused height.
52
+ * Ignored in compact mode, which uses a fixed cell size.
53
+ */
54
+ maxCellHeight?: number;
55
+ /**
56
+ * Floor a cell's width (px) in non-compact mode. Below it the grid stops
57
+ * shrinking and overflows its container instead, so a scrollable wrapper
58
+ * can take over for long ranges. Default 0 (cells shrink freely).
59
+ */
60
+ minCellWidth?: number;
61
+ /** Floor a cell's height (px) in non-compact mode; see `minCellWidth`. */
62
+ minCellHeight?: number;
35
63
  /**
36
64
  * Color the cell scale interpolates toward at the highest value
37
65
  * (this prop > theme `heatmapChart.primaryColor` > palette `colors[0]`).
@@ -73,9 +73,21 @@
73
73
  }
74
74
 
75
75
  .valueContainer {
76
+ align-items: center;
76
77
  justify-content: flex-end;
77
78
  }
78
79
 
80
+ .deltaValue {
81
+ display: inline-block;
82
+ font-variant-numeric: tabular-nums;
83
+ min-width: 3ch;
84
+ text-align: end;
85
+ }
86
+
87
+ .deltaPlaceholder {
88
+ text-align: center;
89
+ }
90
+
79
91
  .overlayLabel {
80
92
  align-items: center;
81
93
  }
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable @wordpress/no-unsafe-wp-apis */
2
- import { __experimentalGrid as Grid } from '@wordpress/components';
2
+ import { __experimentalGrid as Grid, VisuallyHidden } from '@wordpress/components';
3
3
  import { Fragment } from '@wordpress/element';
4
4
  import { __ } from '@wordpress/i18n';
5
5
  import { Icon, chevronRight } from '@wordpress/icons';
@@ -65,6 +65,14 @@ const defaultDeltaFormatter = ( value: number ): string => {
65
65
  const getBarWidth = ( share: number ): string =>
66
66
  `calc(${ share }% - var(--a8c--charts--leaderboard--bar--hover-inset, 0px) * ${ share } / 100)`;
67
67
 
68
+ const hasComparisonValue = (
69
+ entry: LeaderboardEntry
70
+ ): entry is LeaderboardEntry & {
71
+ previousValue: number;
72
+ previousShare: number;
73
+ delta: number;
74
+ } => entry.previousValue != null && entry.previousShare != null && entry.delta != null;
75
+
68
76
  const BarLabel = ( { label }: { label: LeaderboardEntry[ 'label' ] } ) => (
69
77
  <>{ typeof label === 'string' ? <Text className={ styles.label }>{ label }</Text> : label }</>
70
78
  );
@@ -87,39 +95,43 @@ const BarWithLabel = ( {
87
95
  isPrimaryVisible?: boolean;
88
96
  isComparisonVisible?: boolean;
89
97
  animation?: boolean;
90
- } ) => (
91
- <div
92
- className={ clsx( styles.barWithLabelContainer, {
93
- [ styles[ 'is-overlay' ] ]: withOverlayLabel,
94
- } ) }
95
- >
96
- <BarLabel label={ entry.label } />
97
-
98
- { isPrimaryVisible && (
99
- <div
100
- className={ clsx( styles.bar, {
101
- [ styles[ 'bar--animated' ] ]: animation,
102
- } ) }
103
- style={ {
104
- width: getBarWidth( entry.currentShare ),
105
- backgroundColor: primaryColor,
106
- } }
107
- ></div>
108
- ) }
109
-
110
- { withComparison && ! withOverlayLabel && isComparisonVisible && (
111
- <div
112
- className={ clsx( styles.bar, {
113
- [ styles[ 'bar--animated' ] ]: animation,
114
- } ) }
115
- style={ {
116
- width: getBarWidth( entry.previousShare ),
117
- backgroundColor: secondaryColor,
118
- } }
119
- ></div>
120
- ) }
121
- </div>
122
- );
98
+ } ) => {
99
+ const showComparisonBar = withComparison && ! withOverlayLabel && isComparisonVisible;
100
+
101
+ return (
102
+ <div
103
+ className={ clsx( styles.barWithLabelContainer, {
104
+ [ styles[ 'is-overlay' ] ]: withOverlayLabel,
105
+ } ) }
106
+ >
107
+ <BarLabel label={ entry.label } />
108
+
109
+ { isPrimaryVisible && (
110
+ <div
111
+ className={ clsx( styles.bar, {
112
+ [ styles[ 'bar--animated' ] ]: animation,
113
+ } ) }
114
+ style={ {
115
+ width: getBarWidth( entry.currentShare ),
116
+ backgroundColor: primaryColor,
117
+ } }
118
+ ></div>
119
+ ) }
120
+
121
+ { showComparisonBar && hasComparisonValue( entry ) && (
122
+ <div
123
+ className={ clsx( styles.bar, {
124
+ [ styles[ 'bar--animated' ] ]: animation,
125
+ } ) }
126
+ style={ {
127
+ width: getBarWidth( entry.previousShare ),
128
+ backgroundColor: secondaryColor,
129
+ } }
130
+ ></div>
131
+ ) }
132
+ </div>
133
+ );
134
+ };
123
135
 
124
136
  /**
125
137
  * LeaderboardChart component displays a ranked list of data with progress bars
@@ -335,7 +347,11 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
335
347
  ) : (
336
348
  <Grid templateColumns="minmax(0, 1fr) auto" rowGap={ rowGap } columnGap={ columnGap }>
337
349
  { data.map( entry => {
338
- const colorIndex = Math.sign( entry.delta ) + 1;
350
+ const showComparisonColumn = withComparison && isComparisonVisible;
351
+ const hasDeltaValue = hasComparisonValue( entry );
352
+ const showComparisonValue = showComparisonColumn && hasDeltaValue;
353
+ const showComparisonPlaceholder = showComparisonColumn && ! hasDeltaValue;
354
+ const colorIndex = showComparisonValue ? Math.sign( entry.delta ) + 1 : 1;
339
355
  const deltaColor = deltaColors[ colorIndex ];
340
356
 
341
357
  const rowCells = (
@@ -362,11 +378,23 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
362
378
  >
363
379
  { isPrimaryVisible && <Text>{ valueFormatter( entry.currentValue ) }</Text> }
364
380
 
365
- { withComparison && isComparisonVisible && (
366
- <Text style={ { color: deltaColor } }>
381
+ { showComparisonValue && (
382
+ <Text className={ styles.deltaValue } style={ { color: deltaColor } }>
367
383
  { deltaFormatter( entry.delta ) }
368
384
  </Text>
369
385
  ) }
386
+
387
+ { showComparisonPlaceholder && (
388
+ <Text
389
+ className={ clsx( styles.deltaValue, styles.deltaPlaceholder ) }
390
+ style={ { color: deltaColor } }
391
+ >
392
+ <span aria-hidden="true">-</span>
393
+ <VisuallyHidden as="span">
394
+ { __( 'No comparison data', 'jetpack-charts' ) }
395
+ </VisuallyHidden>
396
+ </Text>
397
+ ) }
370
398
  </Stack>
371
399
  </>
372
400
  );
@@ -78,6 +78,30 @@ describe( 'LeaderboardChart', () => {
78
78
  expect( screen.getByText( '-8%' ) ).toBeInTheDocument();
79
79
  } );
80
80
 
81
+ it( 'shows a placeholder when a row has no previous value', () => {
82
+ render(
83
+ <LeaderboardChart
84
+ data={ [
85
+ ...mockData,
86
+ {
87
+ id: 'new',
88
+ label: 'New Source',
89
+ currentValue: 100,
90
+ currentShare: 1,
91
+ },
92
+ ] }
93
+ withComparison={ true }
94
+ />
95
+ );
96
+
97
+ expect( screen.getByText( '+25%' ) ).toBeInTheDocument();
98
+ expect( screen.getByText( '-8%' ) ).toBeInTheDocument();
99
+ expect( screen.getByText( 'New Source' ) ).toBeInTheDocument();
100
+ expect( screen.getByText( '-' ) ).toHaveAttribute( 'aria-hidden', 'true' );
101
+ expect( screen.getByText( 'No comparison data' ) ).toBeInTheDocument();
102
+ expect( screen.queryByText( '+100%' ) ).not.toBeInTheDocument();
103
+ } );
104
+
81
105
  it( 'shows custom label when provided', () => {
82
106
  render(
83
107
  <LeaderboardChart
@@ -348,12 +372,6 @@ describe( 'LeaderboardChart', () => {
348
372
  } );
349
373
 
350
374
  describe( 'Responsive wrapper', () => {
351
- it( 'fills parent container (height:100%) by default', () => {
352
- render( <LeaderboardChart data={ mockData } /> );
353
- const wrapper = screen.getByTestId( 'responsive-wrapper' );
354
- expect( wrapper ).toHaveStyle( { height: '100%' } );
355
- } );
356
-
357
375
  it( 'applies explicit width and height to chart container', () => {
358
376
  const { useParentSize } = jest.requireMock( '@visx/responsive' );
359
377
  useParentSize.mockReturnValue( {
@@ -403,6 +421,28 @@ describe( 'LeaderboardChart', () => {
403
421
  expect( screen.getByRole( 'button' ) ).toHaveAccessibleName( /Direct.*12\.5K/ );
404
422
  } );
405
423
 
424
+ it( 'does not include the missing comparison dash in interactive row names', () => {
425
+ render(
426
+ <LeaderboardChart
427
+ data={ [
428
+ {
429
+ id: 'new',
430
+ label: 'New Source',
431
+ currentValue: 100,
432
+ currentShare: 1,
433
+ onClick: jest.fn(),
434
+ },
435
+ ] }
436
+ withComparison={ true }
437
+ />
438
+ );
439
+
440
+ expect( screen.getByRole( 'button' ) ).toHaveAccessibleName(
441
+ /New Source.*100.*No comparison data/
442
+ );
443
+ expect( screen.getByRole( 'button' ) ).not.toHaveAccessibleName( /-/ );
444
+ } );
445
+
406
446
  it( 'derives the accessible name from an image label via its alt text', () => {
407
447
  render(
408
448
  <LeaderboardChart
@@ -226,12 +226,6 @@ describe( 'PieSemiCircleChart', () => {
226
226
  } );
227
227
 
228
228
  describe( 'Responsive wrapper', () => {
229
- it( 'fills parent container (height:100%) by default', () => {
230
- render( <PieSemiCircleChart data={ mockData } /> );
231
- const wrapper = screen.getByTestId( 'responsive-wrapper' );
232
- expect( wrapper ).toHaveStyle( { height: '100%' } );
233
- } );
234
-
235
229
  it( 'constrains chart to 2:1 ratio from measured dimensions', () => {
236
230
  // Mock returns width:400, height:200, so chart renders at 400×200 (2:1 ratio)
237
231
  render( <PieSemiCircleChart data={ mockData } /> );