@automattic/charts 1.4.0 → 1.4.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 +15 -5
- package/dist/index.cjs +162 -114
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +120 -72
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/charts/area-chart/area-chart.tsx +124 -54
- package/src/charts/area-chart/test/area-chart.test.tsx +203 -0
- package/AGENTS.md +0 -78
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
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.10",
|
|
67
67
|
"@babel/runtime": "7.29.2",
|
|
68
68
|
"@react-spring/web": "9.7.5",
|
|
69
69
|
"@visx/annotation": "^3.12.0",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"@visx/xychart": "^3.12.0",
|
|
85
85
|
"@wordpress/i18n": "^6.0.0",
|
|
86
86
|
"@wordpress/icons": "^12.0.0",
|
|
87
|
-
"@wordpress/theme": "0.
|
|
87
|
+
"@wordpress/theme": "0.13.0",
|
|
88
88
|
"@wordpress/ui": "0.11.0",
|
|
89
89
|
"clsx": "2.1.1",
|
|
90
90
|
"date-fns": "^4.1.0",
|
|
@@ -110,16 +110,16 @@
|
|
|
110
110
|
"@types/react-dom": "18.3.7",
|
|
111
111
|
"@typescript/native-preview": "7.0.0-dev.20260225.1",
|
|
112
112
|
"@visx/glyph": "3.12.0",
|
|
113
|
-
"@wordpress/components": "
|
|
114
|
-
"@wordpress/element": "6.
|
|
115
|
-
"@wordpress/private-apis": "1.
|
|
116
|
-
"babel-jest": "30.
|
|
113
|
+
"@wordpress/components": "33.1.0",
|
|
114
|
+
"@wordpress/element": "6.46.0",
|
|
115
|
+
"@wordpress/private-apis": "1.46.0",
|
|
116
|
+
"babel-jest": "30.4.1",
|
|
117
117
|
"babel-plugin-react-remove-properties": "^0.3.1",
|
|
118
118
|
"esbuild": "0.27.4",
|
|
119
119
|
"esbuild-plugin-babel": "^0.2.3",
|
|
120
120
|
"esbuild-sass-plugin": "^3.1.0",
|
|
121
121
|
"identity-obj-proxy": "^3.0.0",
|
|
122
|
-
"jest": "30.
|
|
122
|
+
"jest": "30.4.2",
|
|
123
123
|
"jest-extended": "7.0.0",
|
|
124
124
|
"postcss": "8.5.14",
|
|
125
125
|
"postcss-modules": "6.0.1",
|
|
@@ -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/AGENTS.md
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
# AGENTS.md
|
|
2
|
-
|
|
3
|
-
Package-specific guidance for AI agents working in `projects/js-packages/charts`.
|
|
4
|
-
|
|
5
|
-
## CRITICAL Rules
|
|
6
|
-
|
|
7
|
-
- Do not invent behavior in docs. If unsure, verify implementation and stories first.
|
|
8
|
-
- Do not assume wildcard exports like `./*` or `./providers/*` — they don't exist. Check the explicit exports in `package.json`.
|
|
9
|
-
|
|
10
|
-
## Changelog
|
|
11
|
-
|
|
12
|
-
Run from monorepo root:
|
|
13
|
-
|
|
14
|
-
```bash
|
|
15
|
-
jp changelog add js-packages/charts -s patch -t changed -e "Charts: <user-facing change>."
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
## Architecture Decisions (Do Not "Fix" These)
|
|
19
|
-
|
|
20
|
-
- Accessibility behavior (keyboard navigation, accessible tooltips) is core chart behavior, not optional polish.
|
|
21
|
-
- Charts are responsive by default — do not add external responsive wrappers that conflict with built-in sizing semantics.
|
|
22
|
-
|
|
23
|
-
## WordPress UI + Theme Integration
|
|
24
|
-
|
|
25
|
-
The package is migrating to WordPress UI and Theme as its defaults. When adding or changing code, follow these defaults unless the task explicitly says otherwise:
|
|
26
|
-
|
|
27
|
-
- **Design tokens (WPDS).** In SCSS, use `var(--wpds-dimension-*, <fallback>)`, `var(--wpds-border-*, <fallback>)`, and `var(--wpds-typography-*, <fallback>)` instead of hardcoded px values for spacing, padding, margins, border radius, border width, font size, and font weight. Fallbacks must match the WPDS spec value for that token — do not invent fallback values.
|
|
28
|
-
- **UI primitives.** Prefer `Stack` and the stable `Text` from `@wordpress/ui` over ad-hoc flexbox or raw `<span>`/`<div>` for layout and text. Do not use `__experimental*` exports from `@wordpress/components` (e.g. `__experimentalText`, `__experimentalHStack`) — use the stable `@wordpress/ui` equivalents. Exception: `__experimentalGrid` has no stable alternative yet and is acceptable to use for now.
|
|
29
|
-
- **Theming.** Theming flows through `@wordpress/theme`'s `ThemeProvider` (unlocked via private APIs in Storybook; see `src/stories/chart-decorator.tsx`). Do not manually override DS tokens in stories or components to achieve theming — pass a color through `ThemeProvider` instead.
|
|
30
|
-
- **Chart element styles.** Read chart element styles via `getElementStyles` from `GlobalChartsProvider`, not directly from `theme`. This is the supported path for color/style resolution across themes.
|
|
31
|
-
|
|
32
|
-
## Documentation Workflow
|
|
33
|
-
|
|
34
|
-
- For docs tasks agents should use the skill at `.agents/skills/charts-docs.md`.
|
|
35
|
-
- For public chart/component docs, maintain the standard set when applicable: `[feature-name].stories.tsx` + `.docs.mdx` + `.api.mdx`. Some docs are intentionally guide-only and skip the full triplet.
|
|
36
|
-
- Only include animation docs when the component actually supports an `animation` prop.
|
|
37
|
-
|
|
38
|
-
## Conventions
|
|
39
|
-
|
|
40
|
-
- Preserve backward compatibility for existing public APIs unless a breaking change is explicitly requested.
|
|
41
|
-
- Prefer extending existing chart components/patterns over introducing new surface area.
|
|
42
|
-
- Reuse existing hooks/providers/utilities before adding new abstractions.
|
|
43
|
-
- Avoid `!important` unless there is no viable alternative and the rationale is documented.
|
|
44
|
-
- Add focused behavioral tests for changed behavior; avoid speculative tests for unimplemented behavior.
|
|
45
|
-
- Verify behavior/UI changes in Storybook using browser automation, not only unit tests.
|
|
46
|
-
- Prefer charts-scoped PR titles (e.g. `Charts: ...`, `CHARTS-###: ...`).
|
|
47
|
-
- Include test steps and visual evidence (screenshots/GIFs) in PR descriptions for UI changes.
|
|
48
|
-
|
|
49
|
-
## Common Pitfalls
|
|
50
|
-
|
|
51
|
-
- Claiming Rollup is used for builds (it's tsup).
|
|
52
|
-
- Documenting props or behavior not present in stories and implementation.
|
|
53
|
-
- Refactoring core composition/provider patterns as if they are accidental complexity.
|
|
54
|
-
- Defining new chart prop interfaces that diverge from established base chart contracts (for example, not aligning with `BaseChartProps` when appropriate).
|
|
55
|
-
- Using ad-hoc flexbox layouts where established layout primitives (e.g. `Stack` from `@wordpress/ui`) should be preferred.
|
|
56
|
-
- Accessing colors/styles directly from `theme` rather than using `getElementStyles` from `GlobalChartsProvider`.
|
|
57
|
-
- Hardcoding px values in SCSS for spacing, borders, or typography where a WPDS token (`--wpds-dimension-*`, `--wpds-border-*`, `--wpds-typography-*`) exists.
|
|
58
|
-
- CSS variable fallback values that diverge from the WPDS spec for that token.
|
|
59
|
-
- Using `__experimental*` exports from `@wordpress/components` (e.g. `__experimentalText`, `__experimentalHStack`) instead of the stable `@wordpress/ui` equivalents. (`__experimentalGrid` is excepted — no stable alternative exists yet.)
|
|
60
|
-
- Manually overriding DS tokens in stories or components to achieve theming instead of passing a color through `@wordpress/theme`'s `ThemeProvider`.
|
|
61
|
-
- Responsive wrappers that conflict with component sizing semantics (fixed-height charts, resize behavior, aspect-ratio assumptions).
|
|
62
|
-
- Updating `.docs.mdx` without the corresponding `.api.mdx` when API docs are affected.
|
|
63
|
-
- Not checking CSF file references in `.docs.mdx` when changing or removing stories.
|
|
64
|
-
- Stories that don't visibly demonstrate documented behavior/props, or render clipped due to container sizing.
|
|
65
|
-
- Breaking MDX `<Source code={\`...\` } />` rendering by malformed/flattened indentation inside template literals.
|
|
66
|
-
- Tooltip styles/positioning that only work on default backgrounds or fail at chart edges.
|
|
67
|
-
- Using mock/placeholder series data in production code.
|
|
68
|
-
- Avoidable multi-pass data transformations in render paths when a single pass suffices.
|
|
69
|
-
- CSS layout/overflow workarounds without documenting why they're needed.
|
|
70
|
-
|
|
71
|
-
## Definition of Done
|
|
72
|
-
|
|
73
|
-
- Behavior verified in Storybook and/or tests, not only by static checks.
|
|
74
|
-
- Edits remain in package boundaries; avoid unrelated refactors.
|
|
75
|
-
|
|
76
|
-
## References
|
|
77
|
-
|
|
78
|
-
- Published Storybook: `https://automattic.github.io/jetpack-storybook/?path=/docs/js-packages-charts-library`
|