@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.
- package/CHANGELOG.md +21 -0
- package/dist/index.cjs +541 -84
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +122 -34
- package/dist/index.d.cts +97 -7
- package/dist/index.d.ts +97 -7
- package/dist/index.js +539 -87
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/charts/area-chart/area-chart.module.scss +3 -1
- package/src/charts/area-chart/area-chart.tsx +5 -2
- package/src/charts/bar-chart/bar-chart.module.scss +6 -2
- package/src/charts/bar-chart/bar-chart.tsx +29 -22
- package/src/charts/bar-chart/private/comparison-bars.tsx +7 -0
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +4 -1
- package/src/charts/bar-chart/test/bar-chart.test.tsx +141 -3
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +15 -12
- package/src/charts/geo-chart/geo-chart.tsx +6 -1
- package/src/charts/geo-chart/test/geo-chart.test.tsx +11 -1
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +103 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +422 -0
- package/src/charts/heatmap-chart/index.ts +4 -0
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +81 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +53 -0
- package/src/charts/heatmap-chart/private/index.ts +5 -0
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +45 -0
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +88 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +301 -0
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +34 -0
- package/src/charts/heatmap-chart/types.ts +42 -0
- package/src/charts/index.ts +1 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +18 -6
- package/src/charts/line-chart/line-chart.module.scss +6 -4
- package/src/charts/line-chart/line-chart.tsx +6 -2
- package/src/charts/line-chart/private/line-chart-annotation.tsx +16 -3
- package/src/charts/private/grid-control/grid-control.module.scss +1 -4
- package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
- package/src/charts/private/with-responsive/test/with-responsive.test.tsx +14 -0
- package/src/charts/private/with-responsive/with-responsive.tsx +12 -0
- package/src/charts/private/x-zoom.module.scss +6 -3
- package/src/components/legend/private/base-legend.module.scss +3 -1
- package/src/components/tooltip/base-tooltip.module.scss +4 -1
- package/src/components/trend-indicator/trend-indicator.module.scss +5 -3
- package/src/hooks/use-xychart-theme.ts +24 -0
- package/src/index.ts +12 -0
- package/src/providers/chart-context/themes.ts +29 -16
- package/src/types.ts +19 -2
- package/src/utils/color-utils.ts +36 -0
- 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
|
+
}
|
package/src/charts/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
.leaderboardChart {
|
|
2
|
-
transition:
|
|
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:
|
|
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: #
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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={
|
|
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' &&
|
|
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(
|