@automattic/charts 1.3.1 → 1.4.1
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 +15 -0
- package/dist/index.cjs +158 -209
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +0 -9
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +11 -10
- package/dist/index.d.ts +11 -10
- package/dist/index.js +125 -176
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- package/src/charts/area-chart/area-chart.tsx +124 -54
- package/src/charts/area-chart/test/area-chart.test.tsx +203 -0
- package/src/hooks/index.ts +0 -1
- package/src/hooks/test/use-chart-margin.test.tsx +21 -0
- package/src/hooks/use-chart-margin.tsx +4 -0
- package/src/providers/chart-context/global-charts-provider.tsx +1 -18
- package/src/style.css +10 -0
- package/src/types.ts +10 -0
- package/tsup.config.ts +3 -2
- package/AGENTS.md +0 -78
- package/src/hooks/test/use-tooltip-portal-relocator.test.ts +0 -216
- package/src/hooks/use-tooltip-portal-relocator.module.scss +0 -7
- package/src/hooks/use-tooltip-portal-relocator.ts +0 -188
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Display charts within Automattic products.",
|
|
5
5
|
"homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"typecheck": "tsgo --noEmit"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@automattic/number-formatters": "^1.1.
|
|
66
|
+
"@automattic/number-formatters": "^1.1.8",
|
|
67
67
|
"@babel/runtime": "7.29.2",
|
|
68
68
|
"@react-spring/web": "9.7.5",
|
|
69
69
|
"@visx/annotation": "^3.12.0",
|
|
@@ -99,8 +99,8 @@
|
|
|
99
99
|
"@babel/core": "7.29.0",
|
|
100
100
|
"@babel/preset-react": "7.28.5",
|
|
101
101
|
"@babel/preset-typescript": "7.28.5",
|
|
102
|
-
"@storybook/addon-docs": "10.3.
|
|
103
|
-
"@storybook/react": "10.3.
|
|
102
|
+
"@storybook/addon-docs": "10.3.6",
|
|
103
|
+
"@storybook/react": "10.3.6",
|
|
104
104
|
"@testing-library/dom": "^10.0.0",
|
|
105
105
|
"@testing-library/jest-dom": "^6.0.0",
|
|
106
106
|
"@testing-library/react": "^16.0.0",
|
|
@@ -121,12 +121,12 @@
|
|
|
121
121
|
"identity-obj-proxy": "^3.0.0",
|
|
122
122
|
"jest": "30.3.0",
|
|
123
123
|
"jest-extended": "7.0.0",
|
|
124
|
-
"postcss": "8.5.
|
|
124
|
+
"postcss": "8.5.14",
|
|
125
125
|
"postcss-modules": "6.0.1",
|
|
126
126
|
"react": "18.3.1",
|
|
127
127
|
"react-dom": "18.3.1",
|
|
128
128
|
"sass-embedded": "1.97.3",
|
|
129
|
-
"storybook": "10.3.
|
|
129
|
+
"storybook": "10.3.6",
|
|
130
130
|
"tsup": "8.5.1",
|
|
131
131
|
"typescript": "5.9.3"
|
|
132
132
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { formatNumberCompact } from '@automattic/number-formatters';
|
|
2
|
-
import { XYChart,
|
|
2
|
+
import { XYChart, AnimatedAreaSeries, AnimatedAreaStack, Grid, Axis } from '@visx/xychart';
|
|
3
3
|
import { __ } from '@wordpress/i18n';
|
|
4
4
|
import clsx from 'clsx';
|
|
5
5
|
import {
|
|
@@ -135,6 +135,57 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
135
135
|
totalPoints: dataSorted[ 0 ]?.data.length || 0,
|
|
136
136
|
} );
|
|
137
137
|
|
|
138
|
+
// Computed from the full data set (ignoring legend visibility) so the y-axis stays
|
|
139
|
+
// fixed when series are toggled off — otherwise visx auto-fits to the remaining data
|
|
140
|
+
// and the chart appears to rescale. Skipped for non-default stack offsets, which
|
|
141
|
+
// reshape the y-extent (`expand` → [0,1], `wiggle`/`silhouette` → centred around
|
|
142
|
+
// zero) — letting visx derive the domain is correct there.
|
|
143
|
+
const fixedYDomain = useMemo< [ number, number ] | undefined >( () => {
|
|
144
|
+
if (
|
|
145
|
+
! legendInteractive ||
|
|
146
|
+
! dataSorted.length ||
|
|
147
|
+
! dataSorted[ 0 ].data.length ||
|
|
148
|
+
( stacked && stackOffset !== 'none' )
|
|
149
|
+
) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if ( stacked ) {
|
|
154
|
+
// d3-stack with `offset: 'none'` stacks positives upward from 0 and
|
|
155
|
+
// negatives downward from 0, so we need both extremes.
|
|
156
|
+
const numPoints = Math.max( ...dataSorted.map( s => s.data.length ) );
|
|
157
|
+
let posMax = 0;
|
|
158
|
+
let negMin = 0;
|
|
159
|
+
for ( let i = 0; i < numPoints; i++ ) {
|
|
160
|
+
let posSum = 0;
|
|
161
|
+
let negSum = 0;
|
|
162
|
+
for ( const series of dataSorted ) {
|
|
163
|
+
const v = Number( series.data[ i ]?.value );
|
|
164
|
+
if ( Number.isNaN( v ) ) continue;
|
|
165
|
+
if ( v >= 0 ) posSum += v;
|
|
166
|
+
else negSum += v;
|
|
167
|
+
}
|
|
168
|
+
if ( posSum > posMax ) posMax = posSum;
|
|
169
|
+
if ( negSum < negMin ) negMin = negSum;
|
|
170
|
+
}
|
|
171
|
+
return [ negMin, posMax ];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let max = -Infinity;
|
|
175
|
+
let min = Infinity;
|
|
176
|
+
for ( const series of dataSorted ) {
|
|
177
|
+
for ( const point of series.data ) {
|
|
178
|
+
const v = Number( point?.value );
|
|
179
|
+
if ( ! Number.isNaN( v ) ) {
|
|
180
|
+
if ( v > max ) max = v;
|
|
181
|
+
if ( v < min ) min = v;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if ( max === -Infinity ) return undefined;
|
|
186
|
+
return [ Math.min( 0, min ), max ];
|
|
187
|
+
}, [ dataSorted, stacked, stackOffset, legendInteractive ] );
|
|
188
|
+
|
|
138
189
|
const chartOptions = useMemo( () => {
|
|
139
190
|
const formatter = options?.axis?.x?.tickFormat || getFormatter( dataSorted );
|
|
140
191
|
|
|
@@ -164,10 +215,11 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
164
215
|
nice: true,
|
|
165
216
|
// Stacked areas should always include zero so the baseline is meaningful.
|
|
166
217
|
zero: stacked,
|
|
218
|
+
...( fixedYDomain ? { domain: fixedYDomain } : {} ),
|
|
167
219
|
...options?.yScale,
|
|
168
220
|
},
|
|
169
221
|
};
|
|
170
|
-
}, [ options, dataSorted, width, stacked ] );
|
|
222
|
+
}, [ options, dataSorted, width, stacked, fixedYDomain ] );
|
|
171
223
|
|
|
172
224
|
const defaultMargin = useChartMargin( height, chartOptions, dataSorted, theme );
|
|
173
225
|
|
|
@@ -191,11 +243,48 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
191
243
|
} );
|
|
192
244
|
|
|
193
245
|
const prefersReducedMotion = usePrefersReducedMotion();
|
|
246
|
+
const animationEnabled = !! animation && ! prefersReducedMotion;
|
|
194
247
|
|
|
195
248
|
const accessors = {
|
|
196
249
|
xAccessor: ( d: DataPointDate ) => d?.date,
|
|
197
250
|
yAccessor: ( d: DataPointDate ) => d?.value,
|
|
198
251
|
};
|
|
252
|
+
const zeroYAccessor = useCallback( () => 0, [] );
|
|
253
|
+
|
|
254
|
+
// Hidden series are still registered with visx (so paths can interpolate), but their
|
|
255
|
+
// data points should not appear in the tooltip.
|
|
256
|
+
const visibleLabels = useMemo(
|
|
257
|
+
() => new Set( seriesWithVisibility.filter( s => s.isVisible ).map( s => s.series.label ) ),
|
|
258
|
+
[ seriesWithVisibility ]
|
|
259
|
+
);
|
|
260
|
+
const filteredRenderTooltip = useCallback(
|
|
261
|
+
( params: Parameters< typeof renderTooltip >[ 0 ] ) => {
|
|
262
|
+
if ( ! legendInteractive ) return renderTooltip( params );
|
|
263
|
+
const datumByKey = params?.tooltipData?.datumByKey;
|
|
264
|
+
if ( ! datumByKey ) return renderTooltip( params );
|
|
265
|
+
const filtered = Object.fromEntries(
|
|
266
|
+
Object.entries( datumByKey ).filter( ( [ key ] ) => visibleLabels.has( key ) )
|
|
267
|
+
);
|
|
268
|
+
if ( Object.keys( filtered ).length === 0 ) return null;
|
|
269
|
+
// `nearestDatum` may still point at a hidden series; re-point it at the first
|
|
270
|
+
// visible entry so consumers that read it (e.g. for the tooltip heading) don't
|
|
271
|
+
// surface hidden-series state.
|
|
272
|
+
const nearestDatum = params?.tooltipData?.nearestDatum;
|
|
273
|
+
const nextNearest =
|
|
274
|
+
nearestDatum && visibleLabels.has( nearestDatum.key )
|
|
275
|
+
? nearestDatum
|
|
276
|
+
: { ...Object.values( filtered )[ 0 ], distance: nearestDatum?.distance ?? 0 };
|
|
277
|
+
return renderTooltip( {
|
|
278
|
+
...params,
|
|
279
|
+
tooltipData: {
|
|
280
|
+
...params.tooltipData,
|
|
281
|
+
datumByKey: filtered,
|
|
282
|
+
nearestDatum: nextNearest,
|
|
283
|
+
} as typeof params.tooltipData,
|
|
284
|
+
} );
|
|
285
|
+
},
|
|
286
|
+
[ renderTooltip, legendInteractive, visibleLabels ]
|
|
287
|
+
);
|
|
199
288
|
|
|
200
289
|
// Defaults that depend on stacked vs overlapping mode.
|
|
201
290
|
const resolvedFillOpacity = fillOpacity ?? ( stacked ? 0.85 : 0.4 );
|
|
@@ -224,6 +313,33 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
224
313
|
const visibleSeries = seriesWithVisibility.filter( ( { isVisible } ) => isVisible );
|
|
225
314
|
const curve = getCurveType( curveType, smoothing );
|
|
226
315
|
|
|
316
|
+
// Visx's `<AreaStack>` keys each `<Area>` by `stackIndex-dataKey`, so filtering out
|
|
317
|
+
// a hidden series would shift indexes and remount every path. Instead we keep every
|
|
318
|
+
// series mounted and zero out hidden ones via `yAccessor` — react-spring then
|
|
319
|
+
// interpolates the `d` attribute, giving a smooth "going down" effect for the
|
|
320
|
+
// hidden area and a smooth re-flow for the rest.
|
|
321
|
+
const renderSeries = ( {
|
|
322
|
+
series: seriesData,
|
|
323
|
+
index,
|
|
324
|
+
isVisible,
|
|
325
|
+
}: ( typeof seriesWithVisibility )[ number ] ) => {
|
|
326
|
+
const { color, lineStyles } = getElementStyles( { data: seriesData, index } );
|
|
327
|
+
return (
|
|
328
|
+
<AnimatedAreaSeries
|
|
329
|
+
key={ seriesData?.label || index }
|
|
330
|
+
dataKey={ seriesData?.label }
|
|
331
|
+
data={ seriesData.data as DataPointDate[] }
|
|
332
|
+
xAccessor={ accessors.xAccessor }
|
|
333
|
+
yAccessor={ isVisible || ! legendInteractive ? accessors.yAccessor : zeroYAccessor }
|
|
334
|
+
fill={ color }
|
|
335
|
+
fillOpacity={ resolvedFillOpacity }
|
|
336
|
+
{ ...( stacked ? {} : { renderLine: resolvedWithStroke, curve } ) }
|
|
337
|
+
lineProps={ { stroke: color, ...lineStyles } }
|
|
338
|
+
data-testid={ `area-chart-series-${ index }` }
|
|
339
|
+
/>
|
|
340
|
+
);
|
|
341
|
+
};
|
|
342
|
+
|
|
227
343
|
return (
|
|
228
344
|
<SingleChartContext.Provider
|
|
229
345
|
value={ {
|
|
@@ -241,7 +357,7 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
241
357
|
className={ clsx(
|
|
242
358
|
'area-chart',
|
|
243
359
|
styles[ 'area-chart' ],
|
|
244
|
-
{ [ styles[ 'area-chart--animated' ] ]:
|
|
360
|
+
{ [ styles[ 'area-chart--animated' ] ]: animationEnabled },
|
|
245
361
|
className
|
|
246
362
|
) }
|
|
247
363
|
style={ { width, height } }
|
|
@@ -295,62 +411,16 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
295
411
|
) : null }
|
|
296
412
|
|
|
297
413
|
{ ! allSeriesHidden && stacked && (
|
|
298
|
-
<
|
|
414
|
+
<AnimatedAreaStack
|
|
299
415
|
curve={ curve }
|
|
300
416
|
offset={ stackOffset }
|
|
301
417
|
renderLine={ resolvedWithStroke }
|
|
302
418
|
>
|
|
303
|
-
{
|
|
304
|
-
|
|
305
|
-
data: seriesData,
|
|
306
|
-
index,
|
|
307
|
-
} );
|
|
308
|
-
|
|
309
|
-
return (
|
|
310
|
-
<AreaSeries
|
|
311
|
-
key={ seriesData?.label || index }
|
|
312
|
-
dataKey={ seriesData?.label }
|
|
313
|
-
data={ seriesData.data as DataPointDate[] }
|
|
314
|
-
{ ...accessors }
|
|
315
|
-
fill={ color }
|
|
316
|
-
fillOpacity={ resolvedFillOpacity }
|
|
317
|
-
lineProps={ {
|
|
318
|
-
stroke: color,
|
|
319
|
-
...lineStyles,
|
|
320
|
-
} }
|
|
321
|
-
data-testid={ `area-chart-series-${ index }` }
|
|
322
|
-
/>
|
|
323
|
-
);
|
|
324
|
-
} ) }
|
|
325
|
-
</AreaStack>
|
|
419
|
+
{ seriesWithVisibility.map( renderSeries ) }
|
|
420
|
+
</AnimatedAreaStack>
|
|
326
421
|
) }
|
|
327
422
|
|
|
328
|
-
{ ! allSeriesHidden &&
|
|
329
|
-
! stacked &&
|
|
330
|
-
visibleSeries.map( ( { series: seriesData, index } ) => {
|
|
331
|
-
const { color, lineStyles } = getElementStyles( {
|
|
332
|
-
data: seriesData,
|
|
333
|
-
index,
|
|
334
|
-
} );
|
|
335
|
-
|
|
336
|
-
return (
|
|
337
|
-
<AreaSeries
|
|
338
|
-
key={ seriesData?.label || index }
|
|
339
|
-
dataKey={ seriesData?.label }
|
|
340
|
-
data={ seriesData.data as DataPointDate[] }
|
|
341
|
-
{ ...accessors }
|
|
342
|
-
fill={ color }
|
|
343
|
-
fillOpacity={ resolvedFillOpacity }
|
|
344
|
-
renderLine={ resolvedWithStroke }
|
|
345
|
-
curve={ curve }
|
|
346
|
-
lineProps={ {
|
|
347
|
-
stroke: color,
|
|
348
|
-
...lineStyles,
|
|
349
|
-
} }
|
|
350
|
-
data-testid={ `area-chart-series-${ index }` }
|
|
351
|
-
/>
|
|
352
|
-
);
|
|
353
|
-
} ) }
|
|
423
|
+
{ ! allSeriesHidden && ! stacked && seriesWithVisibility.map( renderSeries ) }
|
|
354
424
|
|
|
355
425
|
{ withTooltips && (
|
|
356
426
|
<>
|
|
@@ -359,7 +429,7 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
359
429
|
snapTooltipToDatumX
|
|
360
430
|
// Stacked mode: yAccessor returns raw value, not stacked y — snapping mispositions.
|
|
361
431
|
snapTooltipToDatumY={ ! stacked }
|
|
362
|
-
renderTooltip={
|
|
432
|
+
renderTooltip={ filteredRenderTooltip }
|
|
363
433
|
showVerticalCrosshair={ withTooltipCrosshairs?.showVertical }
|
|
364
434
|
showHorizontalCrosshair={ withTooltipCrosshairs?.showHorizontal }
|
|
365
435
|
selectedIndex={ selectedIndex }
|
|
@@ -148,6 +148,196 @@ describe( 'AreaChart', () => {
|
|
|
148
148
|
} );
|
|
149
149
|
} );
|
|
150
150
|
|
|
151
|
+
describe( 'Interactive legend', () => {
|
|
152
|
+
test( 'keeps every series mounted after hiding one (stacked)', async () => {
|
|
153
|
+
const user = userEvent.setup();
|
|
154
|
+
renderWithProvider( {
|
|
155
|
+
showLegend: true,
|
|
156
|
+
chartId: 'test-interactive-stacked',
|
|
157
|
+
legend: { interactive: true },
|
|
158
|
+
} );
|
|
159
|
+
|
|
160
|
+
expect( screen.getByTestId( 'area-chart-series-0' ) ).toBeInTheDocument();
|
|
161
|
+
expect( screen.getByTestId( 'area-chart-series-1' ) ).toBeInTheDocument();
|
|
162
|
+
|
|
163
|
+
await user.click( screen.getByText( 'Series A' ) );
|
|
164
|
+
|
|
165
|
+
// Both series remain in the DOM (hidden one is zeroed via yAccessor,
|
|
166
|
+
// not unmounted) so that visx stack indices stay stable.
|
|
167
|
+
expect( screen.getByTestId( 'area-chart-series-0' ) ).toBeInTheDocument();
|
|
168
|
+
expect( screen.getByTestId( 'area-chart-series-1' ) ).toBeInTheDocument();
|
|
169
|
+
} );
|
|
170
|
+
|
|
171
|
+
test( 'keeps every series mounted after hiding one (unstacked)', async () => {
|
|
172
|
+
const user = userEvent.setup();
|
|
173
|
+
renderWithProvider( {
|
|
174
|
+
stacked: false,
|
|
175
|
+
showLegend: true,
|
|
176
|
+
chartId: 'test-interactive-unstacked',
|
|
177
|
+
legend: { interactive: true },
|
|
178
|
+
} );
|
|
179
|
+
|
|
180
|
+
await user.click( screen.getByText( 'Series A' ) );
|
|
181
|
+
|
|
182
|
+
expect( screen.getByTestId( 'area-chart-series-0' ) ).toBeInTheDocument();
|
|
183
|
+
expect( screen.getByTestId( 'area-chart-series-1' ) ).toBeInTheDocument();
|
|
184
|
+
} );
|
|
185
|
+
|
|
186
|
+
test( 'tooltip omits hidden series after toggle', async () => {
|
|
187
|
+
const user = userEvent.setup();
|
|
188
|
+
renderWithProvider( {
|
|
189
|
+
showLegend: true,
|
|
190
|
+
chartId: 'test-interactive-tooltip',
|
|
191
|
+
legend: { interactive: true },
|
|
192
|
+
} );
|
|
193
|
+
|
|
194
|
+
await user.click( screen.getByText( 'Series A' ) );
|
|
195
|
+
|
|
196
|
+
// Open a tooltip via keyboard navigation, then verify the hidden
|
|
197
|
+
// series' label is absent from the rendered tooltip rows.
|
|
198
|
+
const chart = screen.getByRole( 'grid', { name: /area chart/i } );
|
|
199
|
+
chart.focus();
|
|
200
|
+
await user.keyboard( '{ArrowRight}' );
|
|
201
|
+
|
|
202
|
+
const tooltip = await screen.findByRole( 'tooltip' );
|
|
203
|
+
expect( tooltip ).not.toHaveTextContent( 'Series A' );
|
|
204
|
+
expect( tooltip ).toHaveTextContent( 'Series B' );
|
|
205
|
+
} );
|
|
206
|
+
|
|
207
|
+
test( 'renderTooltip receives only visible series in datumByKey', async () => {
|
|
208
|
+
const user = userEvent.setup();
|
|
209
|
+
const renderTooltip = jest.fn( () => <div>tooltip</div> );
|
|
210
|
+
renderWithProvider( {
|
|
211
|
+
showLegend: true,
|
|
212
|
+
chartId: 'test-interactive-render-tooltip',
|
|
213
|
+
legend: { interactive: true },
|
|
214
|
+
renderTooltip,
|
|
215
|
+
} );
|
|
216
|
+
|
|
217
|
+
await user.click( screen.getByText( 'Series A' ) );
|
|
218
|
+
const chart = screen.getByRole( 'grid', { name: /area chart/i } );
|
|
219
|
+
chart.focus();
|
|
220
|
+
await user.keyboard( '{ArrowRight}' );
|
|
221
|
+
|
|
222
|
+
// `renderTooltip` may be called for non-keyboard events too, but the
|
|
223
|
+
// keyboard-driven call must have filtered datumByKey down to visible series.
|
|
224
|
+
const calls = renderTooltip.mock.calls as unknown as Array<
|
|
225
|
+
[
|
|
226
|
+
{
|
|
227
|
+
tooltipData?: {
|
|
228
|
+
datumByKey?: Record< string, unknown >;
|
|
229
|
+
nearestDatum?: { key: string };
|
|
230
|
+
};
|
|
231
|
+
},
|
|
232
|
+
]
|
|
233
|
+
>;
|
|
234
|
+
const keyboardCall = calls.find( ( [ params ] ) => params?.tooltipData?.datumByKey );
|
|
235
|
+
expect( keyboardCall ).toBeDefined();
|
|
236
|
+
const keys = Object.keys( keyboardCall![ 0 ].tooltipData!.datumByKey! );
|
|
237
|
+
expect( keys ).toContain( 'Series B' );
|
|
238
|
+
expect( keys ).not.toContain( 'Series A' );
|
|
239
|
+
// And `nearestDatum` should never point at a hidden series.
|
|
240
|
+
expect( keyboardCall![ 0 ].tooltipData?.nearestDatum?.key ).not.toBe( 'Series A' );
|
|
241
|
+
} );
|
|
242
|
+
|
|
243
|
+
test( 'y-axis domain stays fixed across legend toggles', async () => {
|
|
244
|
+
const user = userEvent.setup();
|
|
245
|
+
const ref = createRef< SingleChartRef >();
|
|
246
|
+
render(
|
|
247
|
+
<GlobalChartsProvider>
|
|
248
|
+
<AreaChartUnresponsive
|
|
249
|
+
{ ...defaultProps }
|
|
250
|
+
showLegend
|
|
251
|
+
chartId="test-interactive-domain"
|
|
252
|
+
legend={ { interactive: true } }
|
|
253
|
+
ref={ ref }
|
|
254
|
+
/>
|
|
255
|
+
</GlobalChartsProvider>
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const initialDomain = (
|
|
259
|
+
ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
|
|
260
|
+
)?.domain();
|
|
261
|
+
expect( initialDomain ).toBeDefined();
|
|
262
|
+
|
|
263
|
+
await user.click( screen.getByText( 'Series A' ) );
|
|
264
|
+
|
|
265
|
+
const afterToggleDomain = (
|
|
266
|
+
ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
|
|
267
|
+
)?.domain();
|
|
268
|
+
expect( afterToggleDomain ).toEqual( initialDomain );
|
|
269
|
+
} );
|
|
270
|
+
|
|
271
|
+
test( 'supports negative stacked values without clipping', () => {
|
|
272
|
+
const ref = createRef< SingleChartRef >();
|
|
273
|
+
render(
|
|
274
|
+
<GlobalChartsProvider>
|
|
275
|
+
<AreaChartUnresponsive
|
|
276
|
+
width={ 500 }
|
|
277
|
+
height={ 300 }
|
|
278
|
+
chartId="test-interactive-negative"
|
|
279
|
+
showLegend
|
|
280
|
+
legend={ { interactive: true } }
|
|
281
|
+
data={ [
|
|
282
|
+
{
|
|
283
|
+
label: 'Pos',
|
|
284
|
+
data: [
|
|
285
|
+
{ date: new Date( '2024-01-01' ), value: 10 },
|
|
286
|
+
{ date: new Date( '2024-01-02' ), value: 20 },
|
|
287
|
+
],
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
label: 'Neg',
|
|
291
|
+
data: [
|
|
292
|
+
{ date: new Date( '2024-01-01' ), value: -5 },
|
|
293
|
+
{ date: new Date( '2024-01-02' ), value: -15 },
|
|
294
|
+
],
|
|
295
|
+
},
|
|
296
|
+
] }
|
|
297
|
+
ref={ ref }
|
|
298
|
+
/>
|
|
299
|
+
</GlobalChartsProvider>
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const [ min, max ] =
|
|
303
|
+
( ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined )?.domain() ??
|
|
304
|
+
[];
|
|
305
|
+
expect( min ).toBeLessThanOrEqual( -15 );
|
|
306
|
+
expect( max ).toBeGreaterThanOrEqual( 20 );
|
|
307
|
+
} );
|
|
308
|
+
|
|
309
|
+
test( 'does not pin domain for non-default stack offsets', () => {
|
|
310
|
+
const ref = createRef< SingleChartRef >();
|
|
311
|
+
render(
|
|
312
|
+
<GlobalChartsProvider>
|
|
313
|
+
<AreaChartUnresponsive
|
|
314
|
+
{ ...defaultProps }
|
|
315
|
+
chartId="test-interactive-expand"
|
|
316
|
+
showLegend
|
|
317
|
+
legend={ { interactive: true } }
|
|
318
|
+
stackOffset="expand"
|
|
319
|
+
ref={ ref }
|
|
320
|
+
/>
|
|
321
|
+
</GlobalChartsProvider>
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// `expand` normalises to [0,1]; if we accidentally pinned the raw-sum
|
|
325
|
+
// domain (e.g. [0, 35]), the top of the domain would be far above 1.
|
|
326
|
+
const [ min, max ] =
|
|
327
|
+
( ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined )?.domain() ??
|
|
328
|
+
[];
|
|
329
|
+
expect( min ).toBeGreaterThanOrEqual( 0 );
|
|
330
|
+
expect( max ).toBeLessThanOrEqual( 1.001 );
|
|
331
|
+
} );
|
|
332
|
+
} );
|
|
333
|
+
|
|
334
|
+
describe( 'Without GlobalChartsProvider', () => {
|
|
335
|
+
test( 'self-wraps in a provider when none is present', () => {
|
|
336
|
+
render( <AreaChartUnresponsive { ...defaultProps } /> );
|
|
337
|
+
expect( screen.getByRole( 'grid', { name: /area chart/i } ) ).toBeInTheDocument();
|
|
338
|
+
} );
|
|
339
|
+
} );
|
|
340
|
+
|
|
151
341
|
describe( 'Accessibility', () => {
|
|
152
342
|
test( 'chart container has expected ARIA attributes', () => {
|
|
153
343
|
renderWithProvider();
|
|
@@ -233,6 +423,19 @@ describe( 'AreaChart', () => {
|
|
|
233
423
|
expect( glyphs ).toHaveLength( 2 );
|
|
234
424
|
} );
|
|
235
425
|
|
|
426
|
+
test( 'renders glyphs in unstacked mode with interactive legend', async () => {
|
|
427
|
+
renderWithProvider( {
|
|
428
|
+
stacked: false,
|
|
429
|
+
showLegend: true,
|
|
430
|
+
chartId: 'test-unstacked-interactive',
|
|
431
|
+
legend: { interactive: true },
|
|
432
|
+
} );
|
|
433
|
+
await focusFirstDatum();
|
|
434
|
+
|
|
435
|
+
const glyphs = screen.getAllByTestId( /^area-chart-hover-glyph-/ );
|
|
436
|
+
expect( glyphs ).toHaveLength( 2 );
|
|
437
|
+
} );
|
|
438
|
+
|
|
236
439
|
test( 'renders glyphs only for series with a datum at the hovered x (mismatched x-domains)', async () => {
|
|
237
440
|
// Each series' data[0] is a different date. Keyboard nav fires
|
|
238
441
|
// showTooltip for every series at its own data[selectedIndex];
|
package/src/hooks/index.ts
CHANGED
|
@@ -9,4 +9,3 @@ export { useZeroValueDisplay } from './use-zero-value-display';
|
|
|
9
9
|
export { useDataWithPercentages } from './use-data-with-percentages';
|
|
10
10
|
export { useInteractiveLegendData } from './use-interactive-legend-data';
|
|
11
11
|
export { usePrefersReducedMotion } from './use-prefers-reduced-motion';
|
|
12
|
-
export { useTooltipPortalRelocator } from './use-tooltip-portal-relocator';
|
|
@@ -89,6 +89,27 @@ describe( 'useChartMargin', () => {
|
|
|
89
89
|
expect( result.current.right ).toBe( 48 ); // 40 + 8
|
|
90
90
|
} );
|
|
91
91
|
|
|
92
|
+
it( 'uses explicit y tickValues when provided', () => {
|
|
93
|
+
const options = {
|
|
94
|
+
...optionsBase,
|
|
95
|
+
axis: {
|
|
96
|
+
...optionsBase.axis,
|
|
97
|
+
y: {
|
|
98
|
+
...optionsBase.axis.y,
|
|
99
|
+
tickValues: [ 0, 1000 ],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
const height = 300;
|
|
104
|
+
const theme = baseTheme;
|
|
105
|
+
renderHook( () => useChartMargin( height, options, data, theme ) );
|
|
106
|
+
expect( mockGetLongestTickWidth ).toHaveBeenCalledWith(
|
|
107
|
+
[ 0, 1000 ],
|
|
108
|
+
options.axis.y.tickFormat,
|
|
109
|
+
theme.axisStyles.y.left.axisLabel
|
|
110
|
+
);
|
|
111
|
+
} );
|
|
112
|
+
|
|
92
113
|
it( 'sets top and bottom margin for top x axis', () => {
|
|
93
114
|
const options = {
|
|
94
115
|
...optionsBase,
|
|
@@ -81,6 +81,10 @@ export const useChartMargin = (
|
|
|
81
81
|
);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
if ( options.axis?.y?.tickValues?.length ) {
|
|
85
|
+
return options.axis.y.tickValues;
|
|
86
|
+
}
|
|
87
|
+
|
|
84
88
|
const minY = Math.min( ...allDataPoints.map( d => d.value ) );
|
|
85
89
|
const maxY = Math.max( ...allDataPoints.map( d => d.value ) );
|
|
86
90
|
const yScale = createScale( {
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
useLayoutEffect,
|
|
9
9
|
useRef,
|
|
10
10
|
} from 'react';
|
|
11
|
-
import { useTooltipPortalRelocator } from '../../hooks/use-tooltip-portal-relocator';
|
|
12
11
|
import {
|
|
13
12
|
getItemShapeStyles,
|
|
14
13
|
getSeriesLineStyles,
|
|
@@ -27,22 +26,9 @@ export const GlobalChartsContext = createContext< GlobalChartsContextValue | nul
|
|
|
27
26
|
export interface GlobalChartsProviderProps {
|
|
28
27
|
children: ReactNode;
|
|
29
28
|
theme?: Partial< ChartTheme >;
|
|
30
|
-
/**
|
|
31
|
-
* Optional ref to an element that chart tooltip portals should be relocated into.
|
|
32
|
-
* When provided, visx tooltip portals (normally appended to document.body) will be
|
|
33
|
-
* moved into this container so they participate in the same effective CSS stacking context.
|
|
34
|
-
* The element referenced here, or one of its ancestors, should establish the desired
|
|
35
|
-
* stacking context (for example by using `position` and `z-index`) so that tooltips
|
|
36
|
-
* appear above the relevant chart content.
|
|
37
|
-
*/
|
|
38
|
-
portalContainer?: React.RefObject< HTMLElement | null >;
|
|
39
29
|
}
|
|
40
30
|
|
|
41
|
-
export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
|
|
42
|
-
children,
|
|
43
|
-
theme,
|
|
44
|
-
portalContainer,
|
|
45
|
-
} ) => {
|
|
31
|
+
export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { children, theme } ) => {
|
|
46
32
|
const [ charts, setCharts ] = useState< Map< string, ChartRegistration > >( () => new Map() );
|
|
47
33
|
// Track hidden series per chart: chartId -> Set<seriesLabel>
|
|
48
34
|
const [ hiddenSeries, setHiddenSeries ] = useState< Map< string, Set< string > > >(
|
|
@@ -52,9 +38,6 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
|
|
|
52
38
|
// Ref to the wrapper element for resolving scoped CSS variables
|
|
53
39
|
const wrapperRef = useRef< HTMLDivElement >( null );
|
|
54
40
|
|
|
55
|
-
// Relocate tooltip portals into the wrapper (or a consumer-provided container) for z-index control.
|
|
56
|
-
useTooltipPortalRelocator( portalContainer ?? wrapperRef );
|
|
57
|
-
|
|
58
41
|
const providerTheme: CompleteChartTheme = useMemo( () => {
|
|
59
42
|
return theme ? mergeThemes( defaultTheme, theme ) : defaultTheme;
|
|
60
43
|
}, [ theme ] );
|
package/src/style.css
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Placeholder for the `jetpack:src` arm of the `./style.css` package export.
|
|
3
|
+
*
|
|
4
|
+
* In-monorepo consumers resolving via source already pull each chart's
|
|
5
|
+
* `.module.scss` styles through the JS module graph, so no aggregated
|
|
6
|
+
* stylesheet is needed at this path.
|
|
7
|
+
*
|
|
8
|
+
* Published consumers resolve via the `default` condition, which points at
|
|
9
|
+
* the aggregated `./dist/index.css` produced by tsup.
|
|
10
|
+
*/
|
package/src/types.ts
CHANGED
|
@@ -322,6 +322,11 @@ export type CompleteChartTheme = Required< ChartTheme > & {
|
|
|
322
322
|
export type AxisOptions = {
|
|
323
323
|
orientation?: OrientationType;
|
|
324
324
|
numTicks?: number;
|
|
325
|
+
/**
|
|
326
|
+
* Explicit tick values for the axis. When set, takes precedence over `numTicks`
|
|
327
|
+
* so callers can force a specific axis (e.g. integer-only steps on a sparse chart).
|
|
328
|
+
*/
|
|
329
|
+
tickValues?: ScaleInput< AxisScale >[];
|
|
325
330
|
axisClassName?: string;
|
|
326
331
|
axisLineClassName?: string;
|
|
327
332
|
labelClassName?: string;
|
|
@@ -352,6 +357,11 @@ export type AxisOptions = {
|
|
|
352
357
|
export type ScaleOptions = {
|
|
353
358
|
type?: ScaleType;
|
|
354
359
|
zero?: boolean;
|
|
360
|
+
/**
|
|
361
|
+
* Extends the scale's domain to nice round values. Pass `false` together with
|
|
362
|
+
* an explicit `domain` to keep the tick values you set exactly.
|
|
363
|
+
*/
|
|
364
|
+
nice?: boolean;
|
|
355
365
|
domain?: [ number, number ];
|
|
356
366
|
range?: [ number, number ];
|
|
357
367
|
/**
|
package/tsup.config.ts
CHANGED
|
@@ -3,10 +3,11 @@ import { sassPlugin, postcssModules } from 'esbuild-sass-plugin';
|
|
|
3
3
|
import { defineConfig } from 'tsup';
|
|
4
4
|
import pkg from './package.json';
|
|
5
5
|
|
|
6
|
-
// Extract entries from package exports
|
|
6
|
+
// Extract JS/TS entries from package exports. Non-JS source paths (e.g. the
|
|
7
|
+
// `./style.css` placeholder) are skipped so tsup doesn't try to bundle them.
|
|
7
8
|
const entry = Object.values( pkg.exports )
|
|
8
9
|
.map( $export => ( typeof $export === 'object' ? $export[ 'jetpack:src' ] : '' ) )
|
|
9
|
-
.filter( ( path ): path is string => Boolean( path ) );
|
|
10
|
+
.filter( ( path ): path is string => Boolean( path ) && /\.[cm]?[jt]sx?$/.test( path ) );
|
|
10
11
|
|
|
11
12
|
export default defineConfig( {
|
|
12
13
|
entry,
|