@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.
- package/CHANGELOG.md +21 -0
- package/dist/index.cjs +5917 -5783
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +44 -3
- package/dist/index.d.cts +66 -13
- package/dist/index.d.ts +67 -14
- package/dist/index.js +1159 -1015
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +27 -4
- package/src/charts/heatmap-chart/heatmap-chart.tsx +115 -49
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +39 -4
- package/src/charts/heatmap-chart/private/heatmap-context.ts +10 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +3 -2
- package/src/charts/heatmap-chart/private/index.ts +2 -0
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +6 -2
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +57 -1
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +91 -3
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +4 -0
- package/src/charts/heatmap-chart/types.ts +28 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +12 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +65 -37
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +46 -6
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +0 -6
- package/src/charts/private/with-responsive/test/with-responsive.test.tsx +229 -18
- package/src/charts/private/with-responsive/with-responsive.module.scss +20 -1
- package/src/charts/private/with-responsive/with-responsive.tsx +114 -65
- package/src/hooks/test/use-chart-margin.test.tsx +8 -5
- package/src/hooks/use-chart-margin.tsx +11 -1
- package/src/providers/chart-context/global-charts-provider.tsx +7 -0
- package/src/providers/chart-context/test/chart-context.test.tsx +40 -0
- package/src/providers/chart-context/types.ts +3 -0
- package/src/types.ts +9 -6
- package/src/utils/color-utils.ts +28 -0
- package/src/utils/test/color-utils.test.ts +29 -0
|
@@ -5,7 +5,7 @@ import type { BaseChartProps } from '../../../../types';
|
|
|
5
5
|
// Mock the useParentSize hook
|
|
6
6
|
jest.mock( '@visx/responsive', () => ( {
|
|
7
7
|
useParentSize: jest.fn( () => ( {
|
|
8
|
-
parentRef:
|
|
8
|
+
parentRef: () => {},
|
|
9
9
|
width: 600, // Default width for tests
|
|
10
10
|
height: 300, // Default height for tests
|
|
11
11
|
} ) ),
|
|
@@ -20,6 +20,32 @@ describe( 'withResponsive', () => {
|
|
|
20
20
|
|
|
21
21
|
const ResponsiveComponent = withResponsive( MockComponent );
|
|
22
22
|
|
|
23
|
+
const { useParentSize } = jest.requireMock( '@visx/responsive' );
|
|
24
|
+
const DEFAULT_SIZE = { parentRef: () => {}, width: 600, height: 300 };
|
|
25
|
+
|
|
26
|
+
// jsdom has no layout engine, so the wrapper's clientHeight (which the contain
|
|
27
|
+
// logic reads from real layout) is always 0. Mock it to simulate the height the
|
|
28
|
+
// parent makes available: 0 means unconstrained (the chart derives its height from
|
|
29
|
+
// width), a positive value simulates a parent that limits the height.
|
|
30
|
+
let mockClientHeight = 0;
|
|
31
|
+
beforeAll( () => {
|
|
32
|
+
Object.defineProperty( window.HTMLElement.prototype, 'clientHeight', {
|
|
33
|
+
configurable: true,
|
|
34
|
+
get() {
|
|
35
|
+
return mockClientHeight;
|
|
36
|
+
},
|
|
37
|
+
} );
|
|
38
|
+
} );
|
|
39
|
+
|
|
40
|
+
// Fully clear the mock (queued returns and implementation) after each test, then
|
|
41
|
+
// re-establish the default implementation, so a per-test override can't bleed
|
|
42
|
+
// into the next test.
|
|
43
|
+
afterEach( () => {
|
|
44
|
+
mockClientHeight = 0;
|
|
45
|
+
useParentSize.mockReset();
|
|
46
|
+
useParentSize.mockImplementation( () => DEFAULT_SIZE );
|
|
47
|
+
} );
|
|
48
|
+
|
|
23
49
|
describe( 'component dimensions', () => {
|
|
24
50
|
test( 'passes measured parent width to component', () => {
|
|
25
51
|
render( <ResponsiveComponent data={ [] } /> );
|
|
@@ -34,11 +60,35 @@ describe( 'withResponsive', () => {
|
|
|
34
60
|
expect( component ).toHaveStyle( { height: '300px' } );
|
|
35
61
|
} );
|
|
36
62
|
|
|
37
|
-
test( '
|
|
63
|
+
test( 'derives height from width when the parent is taller than the derived height', () => {
|
|
64
|
+
// Parent allows 300px of height; aspectRatio 0.4 derives 600 * 0.4 = 240,
|
|
65
|
+
// which fits, so the chart keeps the full measured width.
|
|
66
|
+
mockClientHeight = 300;
|
|
67
|
+
render( <ResponsiveComponent data={ [] } aspectRatio={ 0.4 } /> );
|
|
68
|
+
const component = screen.getByTestId( 'mock-component' );
|
|
69
|
+
expect( component ).toHaveStyle( { width: '600px', height: '240px' } );
|
|
70
|
+
} );
|
|
71
|
+
|
|
72
|
+
test( 'contains within the parent height when the parent limits the available height', () => {
|
|
73
|
+
// Parent makes 300px of height available; aspectRatio 0.75 would derive
|
|
74
|
+
// 600 * 0.75 = 450, so both axes shrink to fit: 300 / 0.75 = 400.
|
|
75
|
+
mockClientHeight = 300;
|
|
38
76
|
render( <ResponsiveComponent data={ [] } aspectRatio={ 0.75 } /> );
|
|
39
77
|
const component = screen.getByTestId( 'mock-component' );
|
|
40
|
-
|
|
41
|
-
|
|
78
|
+
expect( component ).toHaveStyle( { width: '400px', height: '300px' } );
|
|
79
|
+
} );
|
|
80
|
+
|
|
81
|
+
test( 'derives height from width when the parent has no measured height', () => {
|
|
82
|
+
// parentHeight 0 (unconstrained or not-yet-measured parent): the contain
|
|
83
|
+
// clamp must not run, so the height stays width-derived (600 * 0.4 = 240).
|
|
84
|
+
useParentSize.mockImplementation( () => ( {
|
|
85
|
+
parentRef: () => {},
|
|
86
|
+
width: 600,
|
|
87
|
+
height: 0,
|
|
88
|
+
} ) );
|
|
89
|
+
render( <ResponsiveComponent data={ [] } aspectRatio={ 0.4 } /> );
|
|
90
|
+
const component = screen.getByTestId( 'mock-component' );
|
|
91
|
+
expect( component ).toHaveStyle( { width: '600px', height: '240px' } );
|
|
42
92
|
} );
|
|
43
93
|
|
|
44
94
|
test( 'respects maxWidth configuration', () => {
|
|
@@ -52,13 +102,28 @@ describe( 'withResponsive', () => {
|
|
|
52
102
|
const component = screen.getByTestId( 'mock-component' );
|
|
53
103
|
expect( component ).toHaveAttribute( 'data-size', '200' );
|
|
54
104
|
} );
|
|
105
|
+
|
|
106
|
+
test( 'assigns the wrapper to an object-shaped parentRef from useParentSize', () => {
|
|
107
|
+
// useParentSize hands back a callback ref in practice, but older versions
|
|
108
|
+
// return a RefObject; the wrapper must populate that shape too.
|
|
109
|
+
const objectRef: { current: HTMLElement | null } = { current: null };
|
|
110
|
+
useParentSize.mockImplementation( () => ( {
|
|
111
|
+
parentRef: objectRef,
|
|
112
|
+
width: 600,
|
|
113
|
+
height: 300,
|
|
114
|
+
} ) );
|
|
115
|
+
render( <ResponsiveComponent data={ [] } /> );
|
|
116
|
+
expect( objectRef.current ).toBe( screen.getByTestId( 'responsive-wrapper' ) );
|
|
117
|
+
} );
|
|
55
118
|
} );
|
|
56
119
|
|
|
57
120
|
describe( 'wrapper dimensions', () => {
|
|
58
|
-
test( 'wrapper
|
|
121
|
+
test( 'wrapper has no inline dimensions by default (fills the parent via CSS)', () => {
|
|
59
122
|
render( <ResponsiveComponent data={ [] } /> );
|
|
60
123
|
const wrapper = screen.getByTestId( 'responsive-wrapper' );
|
|
61
|
-
expect( wrapper ).
|
|
124
|
+
expect( wrapper ).toHaveClass( 'container' );
|
|
125
|
+
expect( wrapper ).not.toHaveClass( 'isContained' );
|
|
126
|
+
expect( wrapper ).not.toHaveAttribute( 'style' );
|
|
62
127
|
} );
|
|
63
128
|
|
|
64
129
|
test( 'wrapper uses explicit width/height for dimensions when provided', () => {
|
|
@@ -67,24 +132,170 @@ describe( 'withResponsive', () => {
|
|
|
67
132
|
expect( wrapper ).toHaveStyle( { width: '200px', height: '200px' } );
|
|
68
133
|
} );
|
|
69
134
|
|
|
70
|
-
test( 'wrapper
|
|
135
|
+
test( 'wrapper gets the contained class with aspectRatio (centering lives in CSS)', () => {
|
|
71
136
|
render( <ResponsiveComponent data={ [] } aspectRatio={ 0.5 } /> );
|
|
72
137
|
const wrapper = screen.getByTestId( 'responsive-wrapper' );
|
|
73
|
-
expect( wrapper ).
|
|
138
|
+
expect( wrapper ).toHaveClass( 'isContained' );
|
|
74
139
|
} );
|
|
75
140
|
|
|
76
|
-
test( '
|
|
77
|
-
render( <ResponsiveComponent data={ [] }
|
|
78
|
-
|
|
79
|
-
// CSS aspect-ratio is width/height, so 1 / 0.5 = 2.
|
|
80
|
-
expect( wrapper ).toHaveStyle( { aspectRatio: '2', maxWidth: '800px' } );
|
|
141
|
+
test( 'no content box is rendered without an aspectRatio', () => {
|
|
142
|
+
render( <ResponsiveComponent data={ [] } /> );
|
|
143
|
+
expect( screen.queryByTestId( 'responsive-content' ) ).not.toBeInTheDocument();
|
|
81
144
|
} );
|
|
82
145
|
|
|
83
|
-
test( '
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
expect(
|
|
146
|
+
test( 'renders the contained chart in an inner content box sized to the aspect ratio', () => {
|
|
147
|
+
// aspectRatio 0.4: 600 * 0.4 = 240 fits the 300px parent.
|
|
148
|
+
render( <ResponsiveComponent data={ [] } aspectRatio={ 0.4 } /> );
|
|
149
|
+
const content = screen.getByTestId( 'responsive-content' );
|
|
150
|
+
expect( content ).toHaveStyle( { width: '600px', height: '240px' } );
|
|
151
|
+
} );
|
|
152
|
+
|
|
153
|
+
test( 'content box contains to the parent height when the parent limits the available height', () => {
|
|
154
|
+
// aspectRatio 0.75 derives 450 > the 300px the parent allows, so both axes
|
|
155
|
+
// shrink to fit: width 400 (300 / 0.75) × height 300.
|
|
156
|
+
mockClientHeight = 300;
|
|
157
|
+
render( <ResponsiveComponent data={ [] } aspectRatio={ 0.75 } /> );
|
|
158
|
+
const content = screen.getByTestId( 'responsive-content' );
|
|
159
|
+
expect( content ).toHaveStyle( { width: '400px', height: '300px' } );
|
|
160
|
+
} );
|
|
161
|
+
} );
|
|
162
|
+
|
|
163
|
+
describe( 'resize behavior', () => {
|
|
164
|
+
test( 'grows on widen in a fluid (unconstrained-height) parent', () => {
|
|
165
|
+
// Unconstrained parent (mockClientHeight 0): the chart derives its height
|
|
166
|
+
// from width and must grow when the parent widens, not deadlock.
|
|
167
|
+
useParentSize.mockImplementation( () => ( {
|
|
168
|
+
parentRef: () => {},
|
|
169
|
+
width: 600,
|
|
170
|
+
height: 0,
|
|
171
|
+
} ) );
|
|
172
|
+
const { rerender } = render( <ResponsiveComponent data={ [] } aspectRatio={ 0.4 } /> );
|
|
173
|
+
expect( screen.getByTestId( 'mock-component' ) ).toHaveStyle( {
|
|
174
|
+
width: '600px',
|
|
175
|
+
height: '240px',
|
|
176
|
+
} );
|
|
177
|
+
|
|
178
|
+
useParentSize.mockImplementation( () => ( {
|
|
179
|
+
parentRef: () => {},
|
|
180
|
+
width: 900,
|
|
181
|
+
height: 0,
|
|
182
|
+
} ) );
|
|
183
|
+
rerender( <ResponsiveComponent data={ [] } aspectRatio={ 0.4 } /> );
|
|
184
|
+
expect( screen.getByTestId( 'mock-component' ) ).toHaveStyle( {
|
|
185
|
+
width: '900px',
|
|
186
|
+
height: '360px',
|
|
187
|
+
} );
|
|
188
|
+
} );
|
|
189
|
+
|
|
190
|
+
test( 'stays contained on widen when the parent limits the height', () => {
|
|
191
|
+
// Parent allows 150px of height. aspectRatio 0.4 derives 240 at width 600 and
|
|
192
|
+
// 360 at width 900 — both exceed 150 — so the chart stays contained at
|
|
193
|
+
// 375 × 150 (150 / 0.4 = 375) across the widen rather than overflowing.
|
|
194
|
+
mockClientHeight = 150;
|
|
195
|
+
useParentSize.mockImplementation( () => ( {
|
|
196
|
+
parentRef: () => {},
|
|
197
|
+
width: 600,
|
|
198
|
+
height: 150,
|
|
199
|
+
} ) );
|
|
200
|
+
const { rerender } = render( <ResponsiveComponent data={ [] } aspectRatio={ 0.4 } /> );
|
|
201
|
+
expect( screen.getByTestId( 'mock-component' ) ).toHaveStyle( {
|
|
202
|
+
width: '375px',
|
|
203
|
+
height: '150px',
|
|
204
|
+
} );
|
|
205
|
+
|
|
206
|
+
useParentSize.mockImplementation( () => ( {
|
|
207
|
+
parentRef: () => {},
|
|
208
|
+
width: 900,
|
|
209
|
+
height: 150,
|
|
210
|
+
} ) );
|
|
211
|
+
rerender( <ResponsiveComponent data={ [] } aspectRatio={ 0.4 } /> );
|
|
212
|
+
expect( screen.getByTestId( 'mock-component' ) ).toHaveStyle( {
|
|
213
|
+
width: '375px',
|
|
214
|
+
height: '150px',
|
|
215
|
+
} );
|
|
216
|
+
} );
|
|
217
|
+
|
|
218
|
+
test( 'releases containment when the parent grows tall enough', () => {
|
|
219
|
+
// Parent starts at 150px of height, so aspectRatio 0.4 (derives 240 at width
|
|
220
|
+
// 600) is contained to 375 × 150.
|
|
221
|
+
mockClientHeight = 150;
|
|
222
|
+
useParentSize.mockImplementation( () => ( {
|
|
223
|
+
parentRef: () => {},
|
|
224
|
+
width: 600,
|
|
225
|
+
height: 150,
|
|
226
|
+
} ) );
|
|
227
|
+
const { rerender } = render( <ResponsiveComponent data={ [] } aspectRatio={ 0.4 } /> );
|
|
228
|
+
expect( screen.getByTestId( 'mock-component' ) ).toHaveStyle( {
|
|
229
|
+
width: '375px',
|
|
230
|
+
height: '150px',
|
|
231
|
+
} );
|
|
232
|
+
|
|
233
|
+
// Parent grows to 300px — now taller than the 240px derived height — so
|
|
234
|
+
// containment releases and the chart returns to its full width-derived size.
|
|
235
|
+
mockClientHeight = 300;
|
|
236
|
+
useParentSize.mockImplementation( () => ( {
|
|
237
|
+
parentRef: () => {},
|
|
238
|
+
width: 600,
|
|
239
|
+
height: 300,
|
|
240
|
+
} ) );
|
|
241
|
+
rerender( <ResponsiveComponent data={ [] } aspectRatio={ 0.4 } /> );
|
|
242
|
+
expect( screen.getByTestId( 'mock-component' ) ).toHaveStyle( {
|
|
243
|
+
width: '600px',
|
|
244
|
+
height: '240px',
|
|
245
|
+
} );
|
|
246
|
+
} );
|
|
247
|
+
|
|
248
|
+
test( 'and re-contains to the new height when the parent shrinks further', () => {
|
|
249
|
+
// Parent allows 300px; aspectRatio 0.75 derives 450, so it contains to 400 × 300.
|
|
250
|
+
mockClientHeight = 300;
|
|
251
|
+
useParentSize.mockImplementation( () => ( {
|
|
252
|
+
parentRef: () => {},
|
|
253
|
+
width: 600,
|
|
254
|
+
height: 300,
|
|
255
|
+
} ) );
|
|
256
|
+
const { rerender } = render( <ResponsiveComponent data={ [] } aspectRatio={ 0.75 } /> );
|
|
257
|
+
expect( screen.getByTestId( 'mock-component' ) ).toHaveStyle( {
|
|
258
|
+
width: '400px',
|
|
259
|
+
height: '300px',
|
|
260
|
+
} );
|
|
261
|
+
|
|
262
|
+
// Parent shrinks to 150px — still shorter than the 450px derived height — so the
|
|
263
|
+
// chart re-contains to the new available height: 150 / 0.75 = 200 × 150.
|
|
264
|
+
mockClientHeight = 150;
|
|
265
|
+
useParentSize.mockImplementation( () => ( {
|
|
266
|
+
parentRef: () => {},
|
|
267
|
+
width: 600,
|
|
268
|
+
height: 150,
|
|
269
|
+
} ) );
|
|
270
|
+
rerender( <ResponsiveComponent data={ [] } aspectRatio={ 0.75 } /> );
|
|
271
|
+
expect( screen.getByTestId( 'mock-component' ) ).toHaveStyle( {
|
|
272
|
+
width: '200px',
|
|
273
|
+
height: '150px',
|
|
274
|
+
} );
|
|
275
|
+
} );
|
|
276
|
+
|
|
277
|
+
test( 'releases containment when aspectRatio is removed', () => {
|
|
278
|
+
// Contained first (derived 450 > the 300px parent → 400 × 300).
|
|
279
|
+
mockClientHeight = 300;
|
|
280
|
+
useParentSize.mockImplementation( () => ( {
|
|
281
|
+
parentRef: () => {},
|
|
282
|
+
width: 600,
|
|
283
|
+
height: 300,
|
|
284
|
+
} ) );
|
|
285
|
+
const { rerender } = render( <ResponsiveComponent data={ [] } aspectRatio={ 0.75 } /> );
|
|
286
|
+
expect( screen.getByTestId( 'mock-component' ) ).toHaveStyle( {
|
|
287
|
+
width: '400px',
|
|
288
|
+
height: '300px',
|
|
289
|
+
} );
|
|
290
|
+
|
|
291
|
+
// Dropping aspectRatio clears the containment and fills the parent again; the
|
|
292
|
+
// inner content box is gone.
|
|
293
|
+
rerender( <ResponsiveComponent data={ [] } /> );
|
|
294
|
+
expect( screen.queryByTestId( 'responsive-content' ) ).not.toBeInTheDocument();
|
|
295
|
+
expect( screen.getByTestId( 'mock-component' ) ).toHaveStyle( {
|
|
296
|
+
width: '600px',
|
|
297
|
+
height: '300px',
|
|
298
|
+
} );
|
|
88
299
|
} );
|
|
89
300
|
} );
|
|
90
301
|
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
.container {
|
|
2
|
-
//
|
|
2
|
+
// Fill the parent by default so the wrapper's live height reflects the
|
|
3
|
+
// real available space. JS sets width/height only when passed explicitly.
|
|
4
|
+
width: 100%;
|
|
5
|
+
height: 100%;
|
|
6
|
+
// Allow the wrapper to shrink in flex layouts
|
|
7
|
+
min-width: 0;
|
|
8
|
+
min-height: 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// With an aspectRatio, center the contained box horizontally and top-align it.
|
|
12
|
+
.isContained {
|
|
13
|
+
display: flex;
|
|
14
|
+
justify-content: center;
|
|
15
|
+
align-items: flex-start;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The contain box. Its width/height come from JS; keep it from being stretched
|
|
19
|
+
// or shrunk by the flex parent.
|
|
20
|
+
.content {
|
|
21
|
+
flex: 0 0 auto;
|
|
3
22
|
min-width: 0;
|
|
4
23
|
min-height: 0;
|
|
5
24
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { useParentSize } from '@visx/responsive';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
4
|
import styles from './with-responsive.module.scss';
|
|
3
5
|
import type { BaseChartProps } from '../../../types';
|
|
4
6
|
import type { ComponentType } from 'react';
|
|
@@ -16,7 +18,11 @@ export type ResponsiveConfig = {
|
|
|
16
18
|
maxWidth?: number;
|
|
17
19
|
/**
|
|
18
20
|
* The aspect ratio of the chart (height = width * aspectRatio).
|
|
19
|
-
* When provided,
|
|
21
|
+
* When provided, the chart keeps this ratio and is contained within the
|
|
22
|
+
* parent on both axes: it fills the available width and derives its height,
|
|
23
|
+
* but if the parent is shorter than that derived height it shrinks both axes
|
|
24
|
+
* to fit rather than overflowing. When it is narrower than the parent (the
|
|
25
|
+
* height-constrained case) it is centered horizontally.
|
|
20
26
|
* When omitted, the chart fills the parent container's height.
|
|
21
27
|
*/
|
|
22
28
|
aspectRatio?: number;
|
|
@@ -26,35 +32,9 @@ export type ResponsiveConfig = {
|
|
|
26
32
|
resizeDebounceTime?: number;
|
|
27
33
|
};
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
aspectRatio,
|
|
33
|
-
}: ResponsiveConfig ) => {
|
|
34
|
-
const {
|
|
35
|
-
parentRef,
|
|
36
|
-
width: parentWidth,
|
|
37
|
-
height: parentHeight,
|
|
38
|
-
} = useParentSize( {
|
|
39
|
-
debounceTime: resizeDebounceTime,
|
|
40
|
-
enableDebounceLeadingCall: true,
|
|
41
|
-
} );
|
|
42
|
-
|
|
43
|
-
const containerWidth = parentWidth > 0 ? Math.min( parentWidth, maxWidth ) : 0;
|
|
44
|
-
const containerHeight = aspectRatio !== undefined ? containerWidth * aspectRatio : parentHeight;
|
|
45
|
-
|
|
46
|
-
return {
|
|
47
|
-
parentRef,
|
|
48
|
-
width: containerWidth,
|
|
49
|
-
height: containerHeight,
|
|
50
|
-
/**
|
|
51
|
-
* Whether an aspectRatio was provided. Used to determine container
|
|
52
|
-
* height styling: 'auto' when true (height derived from width),
|
|
53
|
-
* '100%' when false (fill parent container).
|
|
54
|
-
*/
|
|
55
|
-
hasAspectRatio: aspectRatio !== undefined,
|
|
56
|
-
};
|
|
57
|
-
};
|
|
35
|
+
// useLayoutEffect on the client (so containment is resolved before paint, no flash),
|
|
36
|
+
// useEffect on the server to avoid React's SSR warning.
|
|
37
|
+
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
|
58
38
|
|
|
59
39
|
/**
|
|
60
40
|
* A higher-order component that provides responsive dimensions
|
|
@@ -77,51 +57,120 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
|
|
|
77
57
|
}: Omit< T, 'width' | 'height' | 'size' > & DimensionProps & ResponsiveConfig ) {
|
|
78
58
|
const {
|
|
79
59
|
parentRef,
|
|
80
|
-
width:
|
|
81
|
-
height:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
maxWidth,
|
|
86
|
-
aspectRatio,
|
|
60
|
+
width: parentWidth,
|
|
61
|
+
height: parentHeight,
|
|
62
|
+
} = useParentSize( {
|
|
63
|
+
debounceTime: resizeDebounceTime,
|
|
64
|
+
enableDebounceLeadingCall: true,
|
|
87
65
|
} );
|
|
88
66
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
67
|
+
const hasAspectRatio = aspectRatio !== undefined && aspectRatio > 0;
|
|
68
|
+
|
|
69
|
+
// Keep our own handle on the wrapper so we can read its live height below, and
|
|
70
|
+
// still hand the node to useParentSize's ref (a callback ref in practice; guard
|
|
71
|
+
// the object-ref shape too).
|
|
72
|
+
const wrapperRef = useRef< HTMLDivElement | null >( null );
|
|
73
|
+
const setWrapperRef = useCallback(
|
|
74
|
+
( node: HTMLDivElement | null ) => {
|
|
75
|
+
wrapperRef.current = node;
|
|
76
|
+
if ( typeof parentRef === 'function' ) {
|
|
77
|
+
parentRef( node );
|
|
78
|
+
} else if ( parentRef ) {
|
|
79
|
+
( parentRef as { current: HTMLDivElement | null } ).current = node;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
[ parentRef ]
|
|
83
|
+
);
|
|
94
84
|
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
85
|
+
// The parent's available height when it actually constrains the chart, else
|
|
86
|
+
// null (the chart derives its height from width and grows freely).
|
|
87
|
+
const [ containedHeight, setContainedHeight ] = useState< number | null >( null );
|
|
88
|
+
|
|
89
|
+
// Cap the available width at maxWidth unless an explicit width pins it. Before
|
|
90
|
+
// measurement resolves, fall back to the explicit width.
|
|
91
|
+
const cap = width === undefined ? maxWidth : Infinity;
|
|
92
|
+
const availableWidth = parentWidth > 0 ? Math.min( parentWidth, cap ) : width ?? 0;
|
|
93
|
+
|
|
94
|
+
let boxWidth = availableWidth;
|
|
95
|
+
let boxHeight: number;
|
|
96
|
+
if ( hasAspectRatio ) {
|
|
97
|
+
const derivedHeight = availableWidth * aspectRatio;
|
|
98
|
+
if ( containedHeight !== null && derivedHeight > containedHeight ) {
|
|
99
|
+
boxHeight = containedHeight;
|
|
100
|
+
boxWidth = boxHeight / aspectRatio;
|
|
101
|
+
} else {
|
|
102
|
+
boxHeight = derivedHeight;
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
boxHeight = parentHeight > 0 ? parentHeight : height ?? 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Decide containment from the wrapper's real height, measured after layout.
|
|
109
|
+
// useParentSize's height is no good here: with height:100% and no definite
|
|
110
|
+
// parent height it just echoes our own content back, so clamping against it
|
|
111
|
+
// would freeze the chart at its current width when the container widens.
|
|
112
|
+
// Compare the post-layout clientHeight to the height we drew: shorter means the
|
|
113
|
+
// parent really constrains us → contain to it; equal means it is only our own
|
|
114
|
+
// content → let it grow. Once contained, stay contained until the parent is tall
|
|
115
|
+
// enough for the full derived height, so a now-fitting chart doesn't oscillate.
|
|
116
|
+
// (parentHeight is in the deps only to re-run this; the value used is clientHeight.)
|
|
117
|
+
useIsomorphicLayoutEffect( () => {
|
|
118
|
+
if ( ! hasAspectRatio ) {
|
|
119
|
+
if ( containedHeight !== null ) {
|
|
120
|
+
setContainedHeight( null );
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const available = wrapperRef.current?.clientHeight ?? 0;
|
|
125
|
+
const derivedHeight = availableWidth * aspectRatio;
|
|
126
|
+
if ( containedHeight === null ) {
|
|
127
|
+
if ( available > 0 && derivedHeight > available + 1 ) {
|
|
128
|
+
setContainedHeight( available );
|
|
129
|
+
}
|
|
130
|
+
} else if ( available >= derivedHeight - 1 ) {
|
|
131
|
+
setContainedHeight( null );
|
|
132
|
+
} else if ( Math.abs( available - containedHeight ) > 1 ) {
|
|
133
|
+
setContainedHeight( available );
|
|
134
|
+
}
|
|
135
|
+
}, [ hasAspectRatio, availableWidth, aspectRatio, containedHeight, parentHeight ] );
|
|
136
|
+
|
|
137
|
+
const wrappedComponent = (
|
|
138
|
+
<WrappedComponent
|
|
139
|
+
width={ boxWidth }
|
|
140
|
+
height={ boxHeight }
|
|
141
|
+
size={ size }
|
|
142
|
+
{ ...( chartProps as T ) }
|
|
143
|
+
/>
|
|
144
|
+
);
|
|
107
145
|
|
|
146
|
+
// The outer element fills the parent (via CSS) so its live height reflects the
|
|
147
|
+
// real available space; JS sets width/height only when they are passed
|
|
148
|
+
// explicitly. Static layout (fill, flex centering) lives in the stylesheet. With
|
|
149
|
+
// an aspectRatio the chart is placed in an inner box sized to the contained
|
|
150
|
+
// dimensions (centered by CSS) so it keeps its proportions and fits within the
|
|
151
|
+
// parent on both axes. Charts that fill their container via CSS (e.g. the heatmap
|
|
152
|
+
// grid) then track that box. Without an aspectRatio the chart fills the parent.
|
|
108
153
|
return (
|
|
109
154
|
<div
|
|
110
|
-
ref={
|
|
155
|
+
ref={ setWrapperRef }
|
|
111
156
|
data-testid="responsive-wrapper"
|
|
112
|
-
className={ styles.container }
|
|
157
|
+
className={ clsx( styles.container, hasAspectRatio && styles.isContained ) }
|
|
113
158
|
style={ {
|
|
114
|
-
width
|
|
115
|
-
height
|
|
116
|
-
...aspectRatioStyle,
|
|
159
|
+
...( width !== undefined ? { width } : null ),
|
|
160
|
+
...( height !== undefined ? { height } : null ),
|
|
117
161
|
} }
|
|
118
162
|
>
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
163
|
+
{ hasAspectRatio ? (
|
|
164
|
+
<div
|
|
165
|
+
data-testid="responsive-content"
|
|
166
|
+
className={ styles.content }
|
|
167
|
+
style={ { width: boxWidth, height: boxHeight } }
|
|
168
|
+
>
|
|
169
|
+
{ wrappedComponent }
|
|
170
|
+
</div>
|
|
171
|
+
) : (
|
|
172
|
+
wrappedComponent
|
|
173
|
+
) }
|
|
125
174
|
</div>
|
|
126
175
|
);
|
|
127
176
|
};
|
|
@@ -13,8 +13,8 @@ describe( 'useChartMargin', () => {
|
|
|
13
13
|
const baseTheme = {
|
|
14
14
|
axisStyles: {
|
|
15
15
|
y: {
|
|
16
|
-
left: { axisLabel: { fontSize: 11 }, tickLength: 8 },
|
|
17
|
-
right: { axisLabel: { fontSize: 11 }, tickLength: 8 },
|
|
16
|
+
left: { axisLabel: { fontSize: 12 }, tickLabel: { fontSize: 11 }, tickLength: 8 },
|
|
17
|
+
right: { axisLabel: { fontSize: 12 }, tickLabel: { fontSize: 11 }, tickLength: 8 },
|
|
18
18
|
},
|
|
19
19
|
},
|
|
20
20
|
} as XYChartTheme;
|
|
@@ -64,7 +64,8 @@ describe( 'useChartMargin', () => {
|
|
|
64
64
|
options.axis.y.tickFormat,
|
|
65
65
|
theme.axisStyles.y.left.axisLabel
|
|
66
66
|
);
|
|
67
|
-
|
|
67
|
+
// 40 label width + 8 tick length + ceil(11 * 0.25) label dx offset
|
|
68
|
+
expect( result.current.left ).toBe( 51 );
|
|
68
69
|
} );
|
|
69
70
|
|
|
70
71
|
it( 'calculates right margin for right y axis', () => {
|
|
@@ -86,7 +87,8 @@ describe( 'useChartMargin', () => {
|
|
|
86
87
|
options.axis.y.tickFormat,
|
|
87
88
|
theme.axisStyles.y.right.axisLabel
|
|
88
89
|
);
|
|
89
|
-
|
|
90
|
+
// 40 label width + 8 tick length + ceil(11 * 0.25) label dx offset
|
|
91
|
+
expect( result.current.right ).toBe( 51 );
|
|
90
92
|
} );
|
|
91
93
|
|
|
92
94
|
it( 'uses explicit y tickValues when provided', () => {
|
|
@@ -133,7 +135,8 @@ describe( 'useChartMargin', () => {
|
|
|
133
135
|
const height = 300;
|
|
134
136
|
const theme = baseTheme;
|
|
135
137
|
const { result } = renderHook( () => useChartMargin( height, options, data, theme ) );
|
|
136
|
-
|
|
138
|
+
// 40 label width + 8 tick length + ceil(11 * 0.25) label dx offset
|
|
139
|
+
expect( result.current.left ).toBe( 51 );
|
|
137
140
|
expect( result.current.top ).toBe( 10 );
|
|
138
141
|
// 12px font + 8 tick length = 20
|
|
139
142
|
expect( result.current.bottom ).toBe( 20 );
|
|
@@ -114,7 +114,17 @@ export const useChartMargin = (
|
|
|
114
114
|
options.axis?.y?.tickFormat,
|
|
115
115
|
yAxisStyles.axisLabel
|
|
116
116
|
);
|
|
117
|
-
|
|
117
|
+
// visx's default axis theme pushes y-axis tick labels a further 0.25em
|
|
118
|
+
// away from the axis (dx of -0.25em on the left, 0.25em on the right), so
|
|
119
|
+
// reserve that on top of the measured label width — without it the widest
|
|
120
|
+
// label clips at the SVG edge. The em resolves against the tick label's own
|
|
121
|
+
// font size (theme tickLabel), not the axis-title font size (axisLabel).
|
|
122
|
+
const yTickLabelFontSize =
|
|
123
|
+
resolveFontSize( yAxisStyles?.tickLabel?.fontSize ) || DEFAULT_FONT_SIZE;
|
|
124
|
+
const yMarginValue =
|
|
125
|
+
( yTickWidth ?? DEFAULT_Y_TICK_WIDTH ) +
|
|
126
|
+
( yAxisStyles?.tickLength ?? 0 ) +
|
|
127
|
+
Math.ceil( yTickLabelFontSize * 0.25 );
|
|
118
128
|
|
|
119
129
|
if ( yAxisOrientation === 'right' ) {
|
|
120
130
|
defaultMargin.right = yMarginValue;
|
|
@@ -186,6 +186,11 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
|
|
|
186
186
|
[ colorCache, groupToColorMap ]
|
|
187
187
|
);
|
|
188
188
|
|
|
189
|
+
const resolveThemeColor = useCallback< GlobalChartsContextValue[ 'resolveThemeColor' ] >(
|
|
190
|
+
value => ( value ? normalizeColorToHex( value, wrapperRef.current, resolveCssVariable ) : '' ),
|
|
191
|
+
[]
|
|
192
|
+
);
|
|
193
|
+
|
|
189
194
|
const getElementStyles = useCallback< GlobalChartsContextValue[ 'getElementStyles' ] >(
|
|
190
195
|
( { data, index, overrideColor, legendShape } ) => {
|
|
191
196
|
const isSeriesData = data && typeof data === 'object' && 'data' in data && 'options' in data;
|
|
@@ -264,6 +269,7 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
|
|
|
264
269
|
getChartData,
|
|
265
270
|
theme: providerTheme,
|
|
266
271
|
getElementStyles,
|
|
272
|
+
resolveThemeColor,
|
|
267
273
|
toggleSeriesVisibility,
|
|
268
274
|
isSeriesVisible,
|
|
269
275
|
getHiddenSeries,
|
|
@@ -276,6 +282,7 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
|
|
|
276
282
|
getChartData,
|
|
277
283
|
providerTheme,
|
|
278
284
|
getElementStyles,
|
|
285
|
+
resolveThemeColor,
|
|
279
286
|
toggleSeriesVisibility,
|
|
280
287
|
isSeriesVisible,
|
|
281
288
|
getHiddenSeries,
|
|
@@ -2536,6 +2536,46 @@ describe( 'ChartContext', () => {
|
|
|
2536
2536
|
expect( afterRerenderColor ).toBe( '#ff0000' );
|
|
2537
2537
|
} );
|
|
2538
2538
|
} );
|
|
2539
|
+
|
|
2540
|
+
describe( 'resolveThemeColor', () => {
|
|
2541
|
+
let contextValue: GlobalChartsContextValue;
|
|
2542
|
+
|
|
2543
|
+
const TestComponent = () => {
|
|
2544
|
+
contextValue = useGlobalChartsContext();
|
|
2545
|
+
return <div>Test</div>;
|
|
2546
|
+
};
|
|
2547
|
+
|
|
2548
|
+
const mountProvider = () =>
|
|
2549
|
+
render(
|
|
2550
|
+
<GlobalChartsProvider>
|
|
2551
|
+
<TestComponent />
|
|
2552
|
+
</GlobalChartsProvider>
|
|
2553
|
+
);
|
|
2554
|
+
|
|
2555
|
+
it( 'resolves a CSS variable against the provider scope, not the document root', () => {
|
|
2556
|
+
window.getComputedStyle = jest.fn( ( element: Element ) => ( {
|
|
2557
|
+
getPropertyValue: ( prop: string ) =>
|
|
2558
|
+
prop === '--surface' && element !== document.documentElement ? '#1e1e1e' : '#ffffff',
|
|
2559
|
+
} ) ) as unknown as typeof window.getComputedStyle;
|
|
2560
|
+
|
|
2561
|
+
mountProvider();
|
|
2562
|
+
|
|
2563
|
+
expect( contextValue.resolveThemeColor( 'var(--surface, #fff)' ) ).toBe( '#1e1e1e' );
|
|
2564
|
+
} );
|
|
2565
|
+
|
|
2566
|
+
it( 'passes a resolved color through to hex', () => {
|
|
2567
|
+
mountProvider();
|
|
2568
|
+
|
|
2569
|
+
expect( contextValue.resolveThemeColor( '#abcdef' ) ).toBe( '#abcdef' );
|
|
2570
|
+
expect( contextValue.resolveThemeColor( 'rgb(255, 0, 0)' ) ).toBe( '#ff0000' );
|
|
2571
|
+
} );
|
|
2572
|
+
|
|
2573
|
+
it( 'returns an empty string for an empty value', () => {
|
|
2574
|
+
mountProvider();
|
|
2575
|
+
|
|
2576
|
+
expect( contextValue.resolveThemeColor( '' ) ).toBe( '' );
|
|
2577
|
+
} );
|
|
2578
|
+
} );
|
|
2539
2579
|
} );
|
|
2540
2580
|
|
|
2541
2581
|
describe( 'defaultTheme', () => {
|