@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "1.3.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.7",
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.5",
103
- "@storybook/react": "10.3.5",
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.10",
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.5",
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, AreaSeries, AreaStack, Grid, Axis } from '@visx/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' ] ]: animation && ! prefersReducedMotion },
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
- <AreaStack
414
+ <AnimatedAreaStack
299
415
  curve={ curve }
300
416
  offset={ stackOffset }
301
417
  renderLine={ resolvedWithStroke }
302
418
  >
303
- { visibleSeries.map( ( { series: seriesData, index } ) => {
304
- const { color, lineStyles } = getElementStyles( {
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={ 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];
@@ -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,