@automattic/charts 1.8.0 → 1.9.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 (49) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/index.cjs +541 -84
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +122 -34
  5. package/dist/index.d.cts +97 -7
  6. package/dist/index.d.ts +97 -7
  7. package/dist/index.js +539 -87
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/charts/area-chart/area-chart.module.scss +3 -1
  11. package/src/charts/area-chart/area-chart.tsx +5 -2
  12. package/src/charts/bar-chart/bar-chart.module.scss +6 -2
  13. package/src/charts/bar-chart/bar-chart.tsx +29 -22
  14. package/src/charts/bar-chart/private/comparison-bars.tsx +7 -0
  15. package/src/charts/bar-chart/private/use-bar-chart-options.ts +4 -1
  16. package/src/charts/bar-chart/test/bar-chart.test.tsx +141 -3
  17. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +15 -12
  18. package/src/charts/geo-chart/geo-chart.tsx +6 -1
  19. package/src/charts/geo-chart/test/geo-chart.test.tsx +11 -1
  20. package/src/charts/heatmap-chart/heatmap-chart.module.scss +103 -0
  21. package/src/charts/heatmap-chart/heatmap-chart.tsx +422 -0
  22. package/src/charts/heatmap-chart/index.ts +4 -0
  23. package/src/charts/heatmap-chart/private/build-calendar-data.ts +81 -0
  24. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +53 -0
  25. package/src/charts/heatmap-chart/private/index.ts +5 -0
  26. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +45 -0
  27. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +88 -0
  28. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +301 -0
  29. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +34 -0
  30. package/src/charts/heatmap-chart/types.ts +42 -0
  31. package/src/charts/index.ts +1 -0
  32. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +18 -6
  33. package/src/charts/line-chart/line-chart.module.scss +6 -4
  34. package/src/charts/line-chart/line-chart.tsx +6 -2
  35. package/src/charts/line-chart/private/line-chart-annotation.tsx +16 -3
  36. package/src/charts/private/grid-control/grid-control.module.scss +1 -4
  37. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  38. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +14 -0
  39. package/src/charts/private/with-responsive/with-responsive.tsx +12 -0
  40. package/src/charts/private/x-zoom.module.scss +6 -3
  41. package/src/components/legend/private/base-legend.module.scss +3 -1
  42. package/src/components/tooltip/base-tooltip.module.scss +4 -1
  43. package/src/components/trend-indicator/trend-indicator.module.scss +5 -3
  44. package/src/hooks/use-xychart-theme.ts +24 -0
  45. package/src/index.ts +12 -0
  46. package/src/providers/chart-context/themes.ts +29 -16
  47. package/src/types.ts +19 -2
  48. package/src/utils/color-utils.ts +36 -0
  49. package/src/utils/test/color-utils.test.ts +33 -0
@@ -0,0 +1,88 @@
1
+ import { buildCalendarHeatmapData } from '../private/build-calendar-data';
2
+ import type { DataPointDate } from '../../../types';
3
+
4
+ const series: DataPointDate[] = [
5
+ { dateString: '2024-01-01', value: 3 }, // Mon
6
+ { dateString: '2024-01-03', value: 5 }, // Wed
7
+ { dateString: '2024-01-15', value: 2 }, // Mon (3rd week)
8
+ ];
9
+
10
+ describe( 'buildCalendarHeatmapData', () => {
11
+ test( 'returns empty result for empty input', () => {
12
+ expect( buildCalendarHeatmapData( [] ) ).toEqual( { data: [], rowLabels: [] } );
13
+ } );
14
+
15
+ test( 'groups days into week columns of 7 rows', () => {
16
+ const { data } = buildCalendarHeatmapData( series );
17
+ expect( data ).toHaveLength( 3 ); // weeks containing Jan 1, Jan 8, Jan 15
18
+ data.forEach( column => expect( column.data ).toHaveLength( 7 ) );
19
+ } );
20
+
21
+ test( 'Monday week start places Jan 1 (Mon) in row 0', () => {
22
+ const { data, rowLabels } = buildCalendarHeatmapData( series, { weekStartsOn: 1 } );
23
+ expect( data[ 0 ].data[ 0 ].value ).toBe( 3 );
24
+ expect( data[ 0 ].data[ 2 ].value ).toBe( 5 ); // Wed
25
+ expect( rowLabels[ 0 ] ).toBe( 'Mon' );
26
+ expect( rowLabels[ 2 ] ).toBe( 'Wed' );
27
+ expect( rowLabels[ 4 ] ).toBe( 'Fri' );
28
+ expect( rowLabels[ 1 ] ).toBe( '' );
29
+ } );
30
+
31
+ test( 'fills missing days with null', () => {
32
+ const { data } = buildCalendarHeatmapData( series );
33
+ expect( data[ 0 ].data[ 1 ].value ).toBeNull(); // Tue Jan 2 has no datum
34
+ } );
35
+
36
+ test( 'labels only the first column of each month', () => {
37
+ const multiMonth: DataPointDate[] = [
38
+ { dateString: '2024-01-29', value: 1 },
39
+ { dateString: '2024-02-05', value: 1 },
40
+ ];
41
+ const { data } = buildCalendarHeatmapData( multiMonth );
42
+ expect( data[ 0 ].label ).toBe( 'Jan' );
43
+ const labels = data.map( c => c.label ).filter( Boolean );
44
+ expect( labels ).toContain( 'Feb' );
45
+ } );
46
+
47
+ test( 'filters out entries with unparseable or missing dates', () => {
48
+ const mixed: DataPointDate[] = [
49
+ { dateString: '2024-01-01', value: 3 },
50
+ { dateString: 'not-a-date', value: 9 },
51
+ { date: new Date( NaN ), value: 7 },
52
+ { value: 1 }, // neither date nor dateString
53
+ ];
54
+ const { data } = buildCalendarHeatmapData( mixed );
55
+ expect( data ).toHaveLength( 1 ); // only Jan 1 survives -> one week column
56
+ const values = data.flatMap( column => column.data.map( cell => cell.value ) );
57
+ expect( values ).toContain( 3 );
58
+ expect( values ).not.toContain( 9 );
59
+ expect( values ).not.toContain( 7 );
60
+ } );
61
+
62
+ test( 'returns empty result when every entry has an invalid date', () => {
63
+ const allInvalid: DataPointDate[] = [
64
+ { dateString: 'nope', value: 1 },
65
+ { date: new Date( NaN ), value: 2 },
66
+ ];
67
+ expect( buildCalendarHeatmapData( allInvalid ) ).toEqual( { data: [], rowLabels: [] } );
68
+ } );
69
+
70
+ test( 'duplicate days keep the last value (no aggregation)', () => {
71
+ const dupes: DataPointDate[] = [
72
+ { dateString: '2024-01-01', value: 3 },
73
+ { dateString: '2024-01-01', value: 8 },
74
+ ];
75
+ const { data } = buildCalendarHeatmapData( dupes, { weekStartsOn: 1 } );
76
+ expect( data[ 0 ].data[ 0 ].value ).toBe( 8 ); // last write wins, not summed to 11
77
+ } );
78
+
79
+ test( 'Sunday week start shifts rows and labels (Sun/Tue/Thu)', () => {
80
+ // gridStart snaps to Sun Dec 31 2023, so Mon Jan 1 lands in row 1.
81
+ const { data, rowLabels } = buildCalendarHeatmapData( series, { weekStartsOn: 0 } );
82
+ expect( data[ 0 ].data[ 1 ].value ).toBe( 3 ); // Mon Jan 1
83
+ expect( data[ 0 ].data[ 3 ].value ).toBe( 5 ); // Wed Jan 3
84
+ expect( rowLabels[ 0 ] ).toBe( 'Sun' );
85
+ expect( rowLabels[ 2 ] ).toBe( 'Tue' );
86
+ expect( rowLabels[ 4 ] ).toBe( 'Thu' );
87
+ } );
88
+ } );
@@ -0,0 +1,301 @@
1
+ import { render, screen, within } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { GlobalChartsProvider } from '../../../providers';
4
+ import HeatmapChart, { HeatmapChartUnresponsive } from '../heatmap-chart';
5
+ import type { HeatmapColumn } from '../types';
6
+
7
+ const mockRefCallback = jest.fn();
8
+ jest.mock( '../../../hooks/use-element-size', () => ( {
9
+ useElementSize: () => [ mockRefCallback, 500, 300 ],
10
+ } ) );
11
+
12
+ const data: HeatmapColumn[] = [
13
+ { label: 'W1', data: [ { value: 1 }, { value: 2 }, { value: null } ] },
14
+ { label: 'W2', data: [ { value: 3 }, { value: 0 }, { value: 4 } ] },
15
+ ];
16
+
17
+ const renderChart = ( props = {} ) =>
18
+ render(
19
+ <GlobalChartsProvider>
20
+ <HeatmapChart width={ 500 } height={ 300 } data={ data } { ...props } />
21
+ </GlobalChartsProvider>
22
+ );
23
+
24
+ describe( 'HeatmapChart', () => {
25
+ test( 'renders a grid with an accessible label', () => {
26
+ renderChart();
27
+ expect( screen.getByRole( 'grid', { name: /heatmap/i } ) ).toBeInTheDocument();
28
+ } );
29
+
30
+ test( 'renders one cell per data point', () => {
31
+ renderChart();
32
+ // 2 columns x 3 rows = 6 cells
33
+ expect( screen.getAllByTestId( 'heatmap-cell' ) ).toHaveLength( 6 );
34
+ } );
35
+
36
+ test( 'shows an empty-state message for empty data', () => {
37
+ renderChart( { data: [] } );
38
+ expect( screen.getByText( /no data available/i ) ).toBeInTheDocument();
39
+ } );
40
+
41
+ test( 'renders column and row labels', () => {
42
+ renderChart( { rowLabels: [ 'Mon', '', 'Wed' ] } );
43
+ expect( screen.getAllByText( 'W1' ).length ).toBeGreaterThan( 0 );
44
+ expect( screen.getAllByText( 'Mon' ).length ).toBeGreaterThan( 0 );
45
+ expect( screen.getAllByText( 'Wed' ).length ).toBeGreaterThan( 0 );
46
+ } );
47
+
48
+ test( 'shows in-cell values by default and hides them in compact mode', () => {
49
+ const { rerender } = renderChart();
50
+ // value 3 appears in a cell
51
+ expect( screen.getAllByText( '3' ).length ).toBeGreaterThan( 0 );
52
+
53
+ rerender(
54
+ <GlobalChartsProvider>
55
+ <HeatmapChart width={ 500 } height={ 300 } data={ data } compact />
56
+ </GlobalChartsProvider>
57
+ );
58
+ expect( screen.queryByText( '3' ) ).not.toBeInTheDocument();
59
+ } );
60
+
61
+ test( 'formats large in-cell values compactly', () => {
62
+ render(
63
+ <GlobalChartsProvider>
64
+ <HeatmapChart
65
+ width={ 500 }
66
+ height={ 300 }
67
+ data={ [ { label: 'W1', data: [ { value: 748500 } ] } ] }
68
+ />
69
+ </GlobalChartsProvider>
70
+ );
71
+ expect( screen.getByText( /748\.5\s?K/i ) ).toBeInTheDocument();
72
+ } );
73
+
74
+ test( 'gives each cell an accessible name for screen readers', () => {
75
+ renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
76
+ // The gridcell's accessible name is its aria-label (column + row + value).
77
+ expect( screen.getByRole( 'gridcell', { name: 'W1 Mon: 1' } ) ).toBeInTheDocument();
78
+ } );
79
+
80
+ test( 'shows a tooltip on cell hover when withTooltips is set', async () => {
81
+ renderChart( { withTooltips: true, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
82
+ const cell = screen.getAllByTestId( 'heatmap-cell' )[ 0 ];
83
+ await userEvent.setup().hover( cell );
84
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
85
+ } );
86
+
87
+ test( 'shows a tooltip on keyboard navigation when withTooltips is set', async () => {
88
+ renderChart( { withTooltips: true, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
89
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
90
+ grid.focus();
91
+ await userEvent.setup().keyboard( '{ArrowDown}' );
92
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
93
+ } );
94
+
95
+ test( 'hides the keyboard tooltip on Escape', async () => {
96
+ renderChart( { withTooltips: true, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
97
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
98
+ const user = userEvent.setup();
99
+ grid.focus();
100
+ await user.keyboard( '{ArrowDown}' );
101
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
102
+ await user.keyboard( '{Escape}' );
103
+ expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
104
+ } );
105
+
106
+ test( 'renders a composition legend with Less/More labels', () => {
107
+ render(
108
+ <GlobalChartsProvider>
109
+ <HeatmapChart width={ 500 } height={ 300 } data={ data }>
110
+ <HeatmapChart.Legend />
111
+ </HeatmapChart>
112
+ </GlobalChartsProvider>
113
+ );
114
+ expect( screen.getByText( /less/i ) ).toBeInTheDocument();
115
+ expect( screen.getByText( /more/i ) ).toBeInTheDocument();
116
+ } );
117
+
118
+ test( 'ArrowDown moves focus within a column, ArrowRight moves to next column', async () => {
119
+ renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
120
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
121
+ const user = userEvent.setup();
122
+
123
+ // First ArrowDown lands on cell (0,0)
124
+ grid.focus();
125
+ await user.keyboard( '{ArrowDown}' );
126
+ expect( grid ).toHaveAttribute(
127
+ 'aria-activedescendant',
128
+ expect.stringMatching( /-cell-0-0$/ )
129
+ );
130
+
131
+ // Second ArrowDown moves row 0→1 in col 0
132
+ await user.keyboard( '{ArrowDown}' );
133
+ expect( grid ).toHaveAttribute(
134
+ 'aria-activedescendant',
135
+ expect.stringMatching( /-cell-0-1$/ )
136
+ );
137
+
138
+ // ArrowRight moves to next column, same row (col 0→1, row 1)
139
+ await user.keyboard( '{ArrowRight}' );
140
+ expect( grid ).toHaveAttribute(
141
+ 'aria-activedescendant',
142
+ expect.stringMatching( /-cell-1-1$/ )
143
+ );
144
+ } );
145
+
146
+ test( 'ArrowUp and ArrowLeft move focus in the opposite direction', async () => {
147
+ renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
148
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
149
+ const user = userEvent.setup();
150
+
151
+ // Navigate to col 1, row 2 (bottom-right of a 2-col × 3-row grid)
152
+ // First ArrowDown lands on (0,0); subsequent presses move from there.
153
+ grid.focus();
154
+ await user.keyboard( '{ArrowDown}{ArrowDown}{ArrowDown}{ArrowRight}' );
155
+ expect( grid ).toHaveAttribute(
156
+ 'aria-activedescendant',
157
+ expect.stringMatching( /-cell-1-2$/ )
158
+ );
159
+
160
+ // ArrowUp moves row 2→1 within col 1
161
+ await user.keyboard( '{ArrowUp}' );
162
+ expect( grid ).toHaveAttribute(
163
+ 'aria-activedescendant',
164
+ expect.stringMatching( /-cell-1-1$/ )
165
+ );
166
+
167
+ // ArrowLeft moves col 1→0, same row
168
+ await user.keyboard( '{ArrowLeft}' );
169
+ expect( grid ).toHaveAttribute(
170
+ 'aria-activedescendant',
171
+ expect.stringMatching( /-cell-0-1$/ )
172
+ );
173
+ } );
174
+
175
+ test( 'ArrowLeft at column 0 and ArrowUp at row 0 clamp (no wrap)', async () => {
176
+ renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
177
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
178
+ const user = userEvent.setup();
179
+
180
+ // Start at col 0, row 0 (first ArrowDown sets index to row 1; press ArrowUp back to row 0)
181
+ grid.focus();
182
+ await user.keyboard( '{ArrowDown}{ArrowUp}' );
183
+ expect( grid ).toHaveAttribute(
184
+ 'aria-activedescendant',
185
+ expect.stringMatching( /-cell-0-0$/ )
186
+ );
187
+
188
+ // ArrowUp at row 0 stays at row 0
189
+ await user.keyboard( '{ArrowUp}' );
190
+ expect( grid ).toHaveAttribute(
191
+ 'aria-activedescendant',
192
+ expect.stringMatching( /-cell-0-0$/ )
193
+ );
194
+
195
+ // ArrowLeft at col 0 stays at col 0
196
+ await user.keyboard( '{ArrowLeft}' );
197
+ expect( grid ).toHaveAttribute(
198
+ 'aria-activedescendant',
199
+ expect.stringMatching( /-cell-0-0$/ )
200
+ );
201
+ } );
202
+
203
+ test( 'Escape clears the selection (aria-activedescendant removed)', async () => {
204
+ renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
205
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
206
+ const user = userEvent.setup();
207
+
208
+ grid.focus();
209
+ await user.keyboard( '{ArrowDown}' );
210
+ expect( grid ).toHaveAttribute( 'aria-activedescendant' );
211
+
212
+ await user.keyboard( '{Escape}' );
213
+ expect( grid ).not.toHaveAttribute( 'aria-activedescendant' );
214
+ } );
215
+
216
+ test( 'rows contain gridcell children in the ARIA hierarchy', () => {
217
+ renderChart();
218
+ const rows = screen.getAllByRole( 'row' );
219
+ expect( rows.length ).toBeGreaterThan( 0 );
220
+ rows.forEach( row => {
221
+ expect( within( row ).getAllByRole( 'gridcell' ).length ).toBeGreaterThan( 0 );
222
+ } );
223
+ } );
224
+
225
+ test( 'leaves cell gap and radius to WPDS tokens (no inline overrides)', () => {
226
+ renderChart();
227
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
228
+ // Non-compact sets no inline gap/radius — the SCSS falls back to the WPDS tokens.
229
+ expect( grid.style.getPropertyValue( '--heatmap-cell-gap' ) ).toBe( '' );
230
+ expect( grid.style.getPropertyValue( '--heatmap-cell-radius' ) ).toBe( '' );
231
+ } );
232
+
233
+ test( 'applies the compact gap inline from the theme compactCellGap', () => {
234
+ render(
235
+ <GlobalChartsProvider theme={ { heatmapChart: { compactCellGap: 3 } } }>
236
+ <HeatmapChart width={ 500 } height={ 300 } data={ data } compact />
237
+ </GlobalChartsProvider>
238
+ );
239
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
240
+ expect( grid.style.getPropertyValue( '--heatmap-cell-gap' ) ).toBe( '3px' );
241
+ } );
242
+
243
+ test( 'sizes compact cells to the theme compactCellSize', () => {
244
+ render(
245
+ <GlobalChartsProvider theme={ { heatmapChart: { compactCellSize: 20 } } }>
246
+ <HeatmapChart width={ 500 } height={ 300 } data={ data } compact />
247
+ </GlobalChartsProvider>
248
+ );
249
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
250
+ expect( grid.style.getPropertyValue( '--heatmap-cell-size' ) ).toBe( '20px' );
251
+ // Compact track template is built from the fixed cell size.
252
+ expect( grid.style.gridTemplateColumns ).toContain( 'var(--heatmap-cell-size)' );
253
+ } );
254
+
255
+ test( 'applies the primaryColor prop as the cell-scale color', () => {
256
+ renderChart( { primaryColor: '#abcdef' } );
257
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
258
+ expect( grid.style.getPropertyValue( '--heatmap-primary' ) ).toBe( '#abcdef' );
259
+ } );
260
+
261
+ test( 'resolves primaryColor from the chart theme', () => {
262
+ render(
263
+ <GlobalChartsProvider theme={ { heatmapChart: { primaryColor: '#0a0b0c' } } }>
264
+ <HeatmapChart width={ 500 } height={ 300 } data={ data } />
265
+ </GlobalChartsProvider>
266
+ );
267
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
268
+ expect( grid.style.getPropertyValue( '--heatmap-primary' ) ).toBe( '#0a0b0c' );
269
+ } );
270
+
271
+ test( 'falls back to the palette colors[0] when no prop or theme primaryColor is set', () => {
272
+ render(
273
+ <GlobalChartsProvider theme={ { colors: [ '#0a0b0c' ] } }>
274
+ <HeatmapChart width={ 500 } height={ 300 } data={ data } />
275
+ </GlobalChartsProvider>
276
+ );
277
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
278
+ expect( grid.style.getPropertyValue( '--heatmap-primary' ) ).toBe( '#0a0b0c' );
279
+ } );
280
+
281
+ test( 'the unresponsive export pins explicit width and height', () => {
282
+ render(
283
+ <GlobalChartsProvider>
284
+ <HeatmapChartUnresponsive width={ 480 } height={ 240 } data={ data } />
285
+ </GlobalChartsProvider>
286
+ );
287
+ const chart = screen.getByTestId( 'heatmap-chart' );
288
+ expect( chart ).toHaveStyle( { width: '480px', height: '240px' } );
289
+ } );
290
+
291
+ test( 'the responsive export leaves the chart unpinned so it fills its container', () => {
292
+ render(
293
+ <GlobalChartsProvider>
294
+ <HeatmapChart width={ 500 } height={ 300 } data={ data } />
295
+ </GlobalChartsProvider>
296
+ );
297
+ const chart = screen.getByTestId( 'heatmap-chart' );
298
+ expect( chart ).not.toHaveStyle( { width: '500px' } );
299
+ expect( chart ).not.toHaveStyle( { height: '300px' } );
300
+ } );
301
+ } );
@@ -0,0 +1,34 @@
1
+ import { getValueExtent, getNormalizedValue } from '../private/use-heatmap-colors';
2
+ import type { HeatmapColumn } from '../types';
3
+
4
+ const data: HeatmapColumn[] = [
5
+ { label: 'A', data: [ { value: 0 }, { value: null }, { value: 10 } ] },
6
+ { label: 'B', data: [ { value: 5 }, { value: 20 }, { value: null } ] },
7
+ ];
8
+
9
+ describe( 'getValueExtent', () => {
10
+ test( 'returns [min, max] ignoring null/NaN', () => {
11
+ expect( getValueExtent( data ) ).toEqual( [ 0, 20 ] );
12
+ } );
13
+
14
+ test( 'returns [0, 0] for all-empty data', () => {
15
+ expect( getValueExtent( [ { data: [ { value: null } ] } ] ) ).toEqual( [ 0, 0 ] );
16
+ } );
17
+ } );
18
+
19
+ describe( 'getNormalizedValue', () => {
20
+ test( 'returns 0 at min and 1 at max', () => {
21
+ expect( getNormalizedValue( 0, [ 0, 20 ] ) ).toBe( 0 );
22
+ expect( getNormalizedValue( 20, [ 0, 20 ] ) ).toBe( 1 );
23
+ } );
24
+
25
+ test( 'returns a clamped value for points inside and outside the extent', () => {
26
+ expect( getNormalizedValue( 10, [ 0, 20 ] ) ).toBe( 0.5 );
27
+ expect( getNormalizedValue( 30, [ 0, 20 ] ) ).toBe( 1 );
28
+ expect( getNormalizedValue( -5, [ 0, 20 ] ) ).toBe( 0 );
29
+ } );
30
+
31
+ test( 'returns 1 when min === max', () => {
32
+ expect( getNormalizedValue( 7, [ 7, 7 ] ) ).toBe( 1 );
33
+ } );
34
+ } );
@@ -0,0 +1,42 @@
1
+ import type { BaseChartProps } from '../../types';
2
+ import type { ReactNode } from 'react';
3
+
4
+ /** A single heatmap cell. `value: null` marks an empty cell. */
5
+ export type HeatmapCell = {
6
+ /** Per-cell label used in the tooltip / accessible name. */
7
+ label?: string;
8
+ value: number | null;
9
+ };
10
+
11
+ /** A heatmap column (rendered left→right); its cells render top→bottom. */
12
+ export type HeatmapColumn = {
13
+ /** x-axis label for this column. Empty/omitted renders blank. */
14
+ label?: string;
15
+ data: HeatmapCell[];
16
+ };
17
+
18
+ export type HeatmapTooltipData = {
19
+ value: number | null;
20
+ rowLabel?: string;
21
+ columnLabel?: string;
22
+ cellLabel?: string;
23
+ row: number;
24
+ column: number;
25
+ };
26
+
27
+ export interface HeatmapChartProps
28
+ extends Omit< BaseChartProps< HeatmapColumn[] >, 'showLegend' | 'legend' | 'gridVisibility' > {
29
+ /** y-axis labels by row index. Empty entries render blank. */
30
+ rowLabels?: string[];
31
+ /** Compact mode: hide in-cell values, tighten gap, thin axis labels. Default false. */
32
+ compact?: boolean;
33
+ /** Render the numeric value inside each cell. Default `! compact`. */
34
+ showValues?: boolean;
35
+ /**
36
+ * Color the cell scale interpolates toward at the highest value
37
+ * (this prop > theme `heatmapChart.primaryColor` > palette `colors[0]`).
38
+ */
39
+ primaryColor?: string;
40
+ renderTooltip?: ( data: HeatmapTooltipData ) => ReactNode;
41
+ children?: ReactNode;
42
+ }
@@ -1,6 +1,7 @@
1
1
  export * from './bar-chart';
2
2
  export * from './bar-list-chart';
3
3
  export * from './conversion-funnel-chart';
4
+ export * from './heatmap-chart';
4
5
  export * from './leaderboard-chart';
5
6
  export * from './line-chart';
6
7
  export * from './pie-chart';
@@ -1,5 +1,7 @@
1
1
  .leaderboardChart {
2
- transition: opacity 0.3s ease-in-out;
2
+ transition:
3
+ opacity var(--wpds-motion-duration-lg, 300ms)
4
+ var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
3
5
 
4
6
  &--responsive {
5
7
  height: 100%;
@@ -56,7 +58,9 @@
56
58
  transform-origin: left;
57
59
  transform-box: fill-box;
58
60
  transform: scaleX(0);
59
- animation: stretch 1s ease-out forwards;
61
+ animation:
62
+ stretch var(--wpds-motion-duration-xl, 400ms)
63
+ var(--wpds-motion-easing-expressive, cubic-bezier(0.25, 0, 0, 1)) forwards;
60
64
  }
61
65
 
62
66
  @keyframes stretch {
@@ -79,7 +83,7 @@
79
83
  .emptyState {
80
84
  padding: var(--wpds-dimension-padding-3xl, 32px) var(--wpds-dimension-padding-lg, 16px);
81
85
  text-align: center;
82
- color: #666;
86
+ color: var(--wpds-color-fg-content-neutral-weak, #707070);
83
87
  font-size: var(--wpds-typography-font-size-md, 13px);
84
88
  font-style: italic;
85
89
  }
@@ -118,11 +122,17 @@
118
122
  // a grid item aligned to the inline start, so its inset mirrors in RTL; the
119
123
  // value slide is given an RTL override below. No row background.
120
124
  .bar {
121
- transition: opacity 0.15s ease, width 0.15s ease;
125
+ transition:
126
+ opacity var(--wpds-motion-duration-sm, 100ms)
127
+ var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1)),
128
+ width var(--wpds-motion-duration-sm, 100ms)
129
+ var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
122
130
  }
123
131
 
124
132
  .valueContainer {
125
- transition: transform 0.15s ease;
133
+ transition:
134
+ transform var(--wpds-motion-duration-sm, 100ms)
135
+ var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
126
136
  }
127
137
 
128
138
  &:hover,
@@ -172,7 +182,9 @@
172
182
  margin-block: auto;
173
183
  opacity: 0;
174
184
  color: var(--wpds-color-fg-content-neutral-weak, #707070);
175
- transition: opacity 0.15s ease;
185
+ transition:
186
+ opacity var(--wpds-motion-duration-sm, 100ms)
187
+ var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
176
188
  pointer-events: none;
177
189
 
178
190
  // The glyph points toward the inline end, so flip it to point left in RTL.
@@ -6,7 +6,9 @@
6
6
  path {
7
7
  transform-origin: 0 95%;
8
8
  transform: scaleY(0);
9
- animation: rise 1s ease-out forwards;
9
+ animation:
10
+ rise var(--wpds-motion-duration-xl, 400ms)
11
+ var(--wpds-motion-easing-expressive, cubic-bezier(0.25, 0, 0, 1)) forwards;
10
12
  }
11
13
  }
12
14
 
@@ -16,7 +18,8 @@
16
18
 
17
19
  &__tooltip,
18
20
  &__annotation-label-popover {
19
- background: #fff;
21
+ background: var(--wpds-color-bg-surface-neutral-strong, #fff);
22
+ color: var(--wpds-color-fg-content-neutral, #1e1e1e);
20
23
  padding: var(--wpds-dimension-padding-sm, 8px);
21
24
  }
22
25
 
@@ -59,11 +62,10 @@
59
62
 
60
63
  &__annotation-label-popover {
61
64
  min-width: 125px;
62
- background: #fff;
63
65
  border: none;
64
66
  border-radius: var(--wpds-border-radius-md, 4px);
65
67
  font-size: var(--wpds-typography-font-size-md, 13px);
66
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
68
+ box-shadow: var(--wpds-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
67
69
  position: fixed;
68
70
  // Without margin set, the popover has margin:auto which upsets the
69
71
  // positioning relative to the trigger button. A gap token is appropriate
@@ -29,7 +29,7 @@ import {
29
29
  useGlobalChartsContext,
30
30
  useGlobalChartsTheme,
31
31
  } from '../../providers';
32
- import { attachSubComponents } from '../../utils';
32
+ import { attachSubComponents, resolveCssVariable } from '../../utils';
33
33
  import { useChartChildren } from '../private/chart-composition';
34
34
  import { ChartLayout } from '../private/chart-layout';
35
35
  import { DefaultGlyph } from '../private/default-glyph';
@@ -190,6 +190,10 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
190
190
  const legendPosition = legend.position ?? 'bottom';
191
191
 
192
192
  const providerTheme = useGlobalChartsTheme();
193
+ // Gradient stops apply this as an SVG attribute, where CSS var() cannot
194
+ // resolve, so resolve the WPDS token to a concrete value first.
195
+ const resolvedBackgroundColor =
196
+ resolveCssVariable( providerTheme.backgroundColor ) ?? providerTheme.backgroundColor;
193
197
  const theme = useXYChartTheme( data );
194
198
  const chartId = useChartId( providedChartId );
195
199
  const chartRef = useRef< HTMLDivElement >( null );
@@ -485,7 +489,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
485
489
  from={ color }
486
490
  fromOpacity={ 0.4 }
487
491
  toOpacity={ 0.1 }
488
- to={ providerTheme.backgroundColor }
492
+ to={ resolvedBackgroundColor }
489
493
  { ...seriesData.options?.gradient }
490
494
  data-testid="line-gradient"
491
495
  >
@@ -10,7 +10,7 @@ import { DataContext } from '@visx/xychart';
10
10
  import merge from 'deepmerge';
11
11
  import { useContext, useRef, useEffect, useState, useMemo } from 'react';
12
12
  import { useGlobalChartsTheme } from '../../../providers';
13
- import { isSafari } from '../../../utils';
13
+ import { isSafari, resolveCssVariable } from '../../../utils';
14
14
  import LineChartAnnotationLabelWithPopover, {
15
15
  POPOVER_BUTTON_SIZE,
16
16
  } from './line-chart-annotation-label-popover';
@@ -159,6 +159,11 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
159
159
  // Deep merge styles to preserve nested object properties
160
160
  const styles = merge( providerTheme.annotationStyles ?? {}, datumStyles ?? {} );
161
161
 
162
+ // visx annotation parts apply these colors as SVG presentation attributes,
163
+ // where CSS var() cannot resolve. Resolve WPDS tokens to concrete values first.
164
+ const resolveColor = ( value?: string ): string | undefined =>
165
+ value ? resolveCssVariable( value ) ?? value : value;
166
+
162
167
  // Measure the label height once after initial render
163
168
  useEffect( () => {
164
169
  if ( labelRef.current?.getBBox ) {
@@ -264,8 +269,14 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
264
269
  return (
265
270
  <g data-testid={ testId }>
266
271
  <Annotation x={ x } y={ y } dx={ dx } dy={ dy }>
267
- <Connector { ...styles?.connector } />
268
- { subjectType === 'circle' && <CircleSubject { ...styles?.circleSubject } /> }
272
+ <Connector { ...styles?.connector } stroke={ resolveColor( styles?.connector?.stroke ) } />
273
+ { subjectType === 'circle' && (
274
+ <CircleSubject
275
+ { ...styles?.circleSubject }
276
+ fill={ resolveColor( styles?.circleSubject?.fill ) }
277
+ stroke={ resolveColor( styles?.circleSubject?.stroke ) }
278
+ />
279
+ ) }
269
280
  { subjectType === 'line-vertical' && (
270
281
  <LineSubject
271
282
  min={ yMax }
@@ -301,6 +312,8 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
301
312
  title={ title }
302
313
  subtitle={ subtitle }
303
314
  { ...styles?.label }
315
+ anchorLineStroke={ resolveColor( styles?.label?.anchorLineStroke ) }
316
+ backgroundFill={ resolveColor( styles?.label?.backgroundFill ) }
304
317
  { ...labelPosition }
305
318
  horizontalAnchor={ getHorizontalAnchor( subjectType, isFlippedHorizontally ) }
306
319
  verticalAnchor={ getVerticalAnchor(
@@ -1,10 +1,7 @@
1
- // Variables
2
- $grid-line-color: #d7d6d6;
3
-
4
1
  .grid-control {
5
2
 
6
3
  :global(.visx-line) {
7
- stroke: $grid-line-color;
4
+ stroke: var(--wpds-color-stroke-surface-neutral, #dbdbdb);
8
5
  stroke-width: 1px;
9
6
  shape-rendering: crispEdges;
10
7
  }
@@ -1,5 +1,5 @@
1
1
  .svg-empty-state {
2
2
  text-align: center;
3
3
  font-size: var(--wpds-typography-font-size-md, 13px);
4
- color: var(--wpds-color-fg-content-neutral-weak, #6d6d6d);
4
+ color: var(--wpds-color-fg-content-neutral-weak, #707070);
5
5
  }