@automattic/charts 4.12.0 → 4.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/index.cjs +641 -636
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +40 -12
  5. package/dist/index.d.cts +26 -121
  6. package/dist/index.d.ts +26 -121
  7. package/dist/index.js +630 -613
  8. package/dist/index.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/charts/area-chart/area-chart.tsx +16 -6
  11. package/src/charts/area-chart/test/area-chart.test.tsx +42 -9
  12. package/src/charts/bar-chart/bar-chart.tsx +17 -12
  13. package/src/charts/bar-chart/private/index.ts +1 -1
  14. package/src/charts/bar-chart/private/slot-tooltip-members.tsx +4 -5
  15. package/src/charts/bar-chart/private/use-bar-stops.ts +1 -3
  16. package/src/charts/bar-chart/private/use-overlap-slots.ts +35 -16
  17. package/src/charts/bar-chart/test/bar-chart.test.tsx +115 -0
  18. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +24 -11
  19. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +235 -182
  20. package/src/charts/conversion-funnel-chart/index.ts +2 -0
  21. package/src/charts/conversion-funnel-chart/private/index.ts +1 -0
  22. package/src/charts/conversion-funnel-chart/private/use-funnel-selection.ts +9 -47
  23. package/src/charts/conversion-funnel-chart/private/use-roving-step-focus.ts +83 -0
  24. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +312 -92
  25. package/src/charts/conversion-funnel-chart/test/use-funnel-selection.test.ts +23 -188
  26. package/src/charts/conversion-funnel-chart/types.ts +22 -0
  27. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +41 -1
  28. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +101 -30
  29. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +47 -0
  30. package/src/charts/line-chart/line-chart.tsx +10 -8
  31. package/src/charts/line-chart/test/line-chart.test.tsx +75 -0
  32. package/src/components/tooltip/private/at-nearest-date.ts +49 -0
  33. package/src/components/tooltip/private/keyboard-stops.ts +67 -0
  34. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +10 -2
  35. package/src/components/tooltip/test/keyboard-stops.test.ts +36 -0
  36. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +135 -1
  37. package/src/components/tooltip/xy-chart-tooltip.tsx +8 -12
  38. package/src/index.ts +2 -0
  39. package/src/types.ts +4 -3
@@ -9,6 +9,7 @@ import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/u
9
9
  import LineChart, { LineChartUnresponsive, renderDefaultTooltip } from '../line-chart';
10
10
  import type { GlobalChartsContextValue } from '../../../providers/chart-context/types';
11
11
  import type { DataPointDate, SeriesData } from '../../../types';
12
+ import type { RenderTooltipParams } from '../../../visx/types';
12
13
  import type { ChartInstanceRef } from '../../private/chart-instance-context';
13
14
  import type { RenderLineGlyphProps } from '../types';
14
15
 
@@ -1696,6 +1697,80 @@ describe( 'LineChart', () => {
1696
1697
  } );
1697
1698
  } );
1698
1699
 
1700
+ describe( 'Keyboard stops by date', () => {
1701
+ const point = ( day: number, value: number | null ) => ( {
1702
+ date: new Date( `2024-01-0${ day }` ),
1703
+ value,
1704
+ } );
1705
+ const rowsTooltip = ( { tooltipData }: RenderTooltipParams< DataPointDate > ) => (
1706
+ <div data-testid="rows-tooltip">
1707
+ { Object.values( tooltipData?.datumByKey ?? {} )
1708
+ .map( ( { key, datum } ) => `${ key }=${ datum.value }` )
1709
+ .sort()
1710
+ .join( ';' ) }
1711
+ </div>
1712
+ );
1713
+ const renderRows = ( data: SeriesData[], props = {} ) => {
1714
+ renderWithTheme( { data, withTooltips: true, renderTooltip: rowsTooltip, ...props } );
1715
+ screen.getByRole( 'application', { name: /line chart/i } ).focus();
1716
+ };
1717
+
1718
+ it( 'stops at every date any series has and reads each series there', async () => {
1719
+ const user = userEvent.setup();
1720
+ renderRows( [
1721
+ { label: 'A', data: [ point( 1, 1 ), point( 2, 2 ), point( 3, 3 ) ] },
1722
+ { label: 'B', data: [ point( 2, 20 ), point( 4, 40 ) ] },
1723
+ ] );
1724
+
1725
+ const expected = [
1726
+ { text: 'A=1', stop: 0 },
1727
+ { text: 'A=2;B=20', stop: 1 },
1728
+ { text: 'A=3', stop: 2 },
1729
+ { text: 'B=40', stop: 3 },
1730
+ { text: 'B=40', stop: 3 },
1731
+ ];
1732
+ for ( const { text, stop } of expected ) {
1733
+ await user.keyboard( '{ArrowRight}' );
1734
+ await waitFor( () => {
1735
+ expect( screen.getByTestId( `chart-tooltip-${ stop }` ) ).toBeInTheDocument();
1736
+ expect( screen.getByTestId( 'rows-tooltip' ) ).toHaveTextContent(
1737
+ new RegExp( `^${ text }$` )
1738
+ );
1739
+ } );
1740
+ }
1741
+ } );
1742
+
1743
+ it( 'keeps a null point at the selected date', async () => {
1744
+ const user = userEvent.setup();
1745
+ renderRows( [
1746
+ { label: 'A', data: [ point( 1, 1 ), point( 2, 2 ) ] },
1747
+ { label: 'B', data: [ point( 1, null ), point( 2, 20 ) ] },
1748
+ ] );
1749
+
1750
+ await user.keyboard( '{ArrowRight}' );
1751
+ await expect( screen.findByTestId( 'rows-tooltip' ) ).resolves.toHaveTextContent(
1752
+ /^A=1;B=null$/
1753
+ );
1754
+ } );
1755
+
1756
+ it( 'activates the first visible series with a point at the selected date', async () => {
1757
+ const user = userEvent.setup();
1758
+ const onDatumActivate = jest.fn();
1759
+ const seriesB = { label: 'Series B', data: [ point( 1, 10 ), point( 2, 20 ) ] };
1760
+ renderRows( [ { label: 'Series A', data: [ point( 2, 2 ) ] }, seriesB ], {
1761
+ onDatumActivate,
1762
+ } );
1763
+
1764
+ await user.keyboard( '{ArrowRight}{Enter}' );
1765
+
1766
+ expect( onDatumActivate ).toHaveBeenCalledWith( {
1767
+ key: 'Series B',
1768
+ index: 0,
1769
+ datum: seriesB.data[ 0 ],
1770
+ } );
1771
+ } );
1772
+ } );
1773
+
1699
1774
  describe( 'Arrow Key Navigation', () => {
1700
1775
  test.each( [ 'auto', 'below-axis' ] as const )(
1701
1776
  'right arrow key navigates with %s tooltips',
@@ -0,0 +1,49 @@
1
+ import type { AxisScale } from '@visx/axis';
2
+ import type { DataRegistryEntry, TooltipContextType } from '@visx/xychart';
3
+
4
+ type TooltipData< Datum extends object > = TooltipContextType< Datum >[ 'tooltipData' ];
5
+
6
+ type Registry< Datum extends object > = {
7
+ entries: () => DataRegistryEntry< AxisScale, AxisScale, Datum >[];
8
+ };
9
+
10
+ const timeOf = ( datum: object ): number | undefined =>
11
+ ( datum as { date?: Date } ).date?.getTime();
12
+
13
+ /**
14
+ * Lists every registered series at the nearest datum's date, each with its own point there.
15
+ *
16
+ * The pointer hands each series its own nearest point, which for a series that skips that date, or that the pointer sits nearer another date of, is a neighbor's.
17
+ *
18
+ * @param tooltipData - The tooltip data visx collected.
19
+ * @param registry - The chart's data registry.
20
+ * @return The tooltip data, unchanged when the nearest datum has no date.
21
+ */
22
+ export const atNearestDate = < Datum extends object >(
23
+ tooltipData: TooltipData< Datum >,
24
+ registry: Registry< Datum > | undefined
25
+ ): TooltipData< Datum > => {
26
+ const nearest = tooltipData?.nearestDatum?.datum;
27
+ const time = nearest ? timeOf( nearest ) : undefined;
28
+
29
+ if ( ! tooltipData || time === undefined ) {
30
+ return tooltipData;
31
+ }
32
+
33
+ const datumByKey: NonNullable< TooltipData< Datum > >[ 'datumByKey' ] = {};
34
+ for ( const { key, data } of registry?.entries() ?? [] ) {
35
+ const index = data.findIndex( datum => timeOf( datum ) === time );
36
+ if ( index !== -1 ) {
37
+ datumByKey[ key ] = { key, index, datum: data[ index ] };
38
+ }
39
+ }
40
+
41
+ // Stacked series register points without a date, so their own nearest point is kept when it is at that date.
42
+ for ( const [ key, entry ] of Object.entries( tooltipData.datumByKey ) ) {
43
+ if ( ! datumByKey[ key ] && timeOf( entry.datum ) === time ) {
44
+ datumByKey[ key ] = entry;
45
+ }
46
+ }
47
+
48
+ return { ...tooltipData, datumByKey };
49
+ };
@@ -0,0 +1,67 @@
1
+ import type { DataPointDate, SeriesData } from '../../../types';
2
+
3
+ /** A series' point at a keyboard stop. */
4
+ export type StopMember = { series: SeriesData; datum: DataPointDate; index: number };
5
+
6
+ export type KeyboardStops = {
7
+ count: number;
8
+ membersAt: ( stop: number ) => StopMember[];
9
+ };
10
+
11
+ const timeOf = ( point: unknown ): number | undefined =>
12
+ ( point as DataPointDate | undefined )?.date?.getTime();
13
+
14
+ const getXPositions = ( series: SeriesData[] ): number[] => {
15
+ const positions = new Set< number >();
16
+
17
+ for ( const { data } of series ) {
18
+ for ( const point of data ) {
19
+ const time = timeOf( point );
20
+ if ( time !== undefined ) {
21
+ positions.add( time );
22
+ }
23
+ }
24
+ }
25
+
26
+ return [ ...positions ].sort( ( a, b ) => a - b );
27
+ };
28
+
29
+ /**
30
+ * Keyboard stops over the series: the sorted union of their dates, or array indexes when no point has a date.
31
+ *
32
+ * Once any point has a date, a series of undated points has no stop.
33
+ *
34
+ * @param series - The series to step through.
35
+ * @return The stop count, and each series' point at a stop.
36
+ */
37
+ export const getKeyboardStops = ( series: SeriesData[] ): KeyboardStops => {
38
+ const positions = getXPositions( series );
39
+
40
+ if ( positions.length === 0 ) {
41
+ return {
42
+ count: Math.max( 0, ...series.map( ( { data } ) => data.length ) ),
43
+ membersAt: stop =>
44
+ series.flatMap( s =>
45
+ stop < s.data.length
46
+ ? [ { series: s, datum: s.data[ stop ] as DataPointDate, index: stop } ]
47
+ : []
48
+ ),
49
+ };
50
+ }
51
+
52
+ return {
53
+ count: positions.length,
54
+ membersAt: stop => {
55
+ const time = positions[ stop ];
56
+ if ( time === undefined ) {
57
+ return [];
58
+ }
59
+ return series.flatMap( s => {
60
+ const index = s.data.findIndex( point => timeOf( point ) === time );
61
+ return index === -1
62
+ ? []
63
+ : [ { series: s, datum: s.data[ index ] as DataPointDate, index } ];
64
+ } );
65
+ },
66
+ };
67
+ };
@@ -2,13 +2,15 @@ import { DataContext, TooltipContext } from '@visx/xychart';
2
2
  import { useCallback, useContext, useState } from 'react';
3
3
  import { createPortal } from 'react-dom';
4
4
  import { TooltipBox } from '../tooltip-box';
5
+ import { atNearestDate } from './at-nearest-date';
5
6
  import { BoundedTooltip, TOOLTIP_Z_INDEX } from './bounded-tooltip';
6
7
  import type {
7
8
  CrosshairStyle,
8
9
  RenderTooltipGlyphProps,
9
10
  XyChartTooltipProps,
10
11
  } from '../../../visx/types';
11
- import type { TooltipContextType } from '@visx/xychart';
12
+ import type { AxisScale } from '@visx/axis';
13
+ import type { DataContextType, TooltipContextType } from '@visx/xychart';
12
14
  import type { CSSProperties, ReactNode } from 'react';
13
15
 
14
16
  const CROSSHAIR_STROKE_WIDTH = 1.5;
@@ -314,6 +316,9 @@ export const XyChartTooltipOverlay = < Datum extends object >(
314
316
  props: XyChartTooltipProps< Datum >
315
317
  ) => {
316
318
  const tooltipContext = useContext( TooltipContext ) as TooltipContextType< Datum > | null;
319
+ const dataRegistry = (
320
+ useContext( DataContext ) as DataContextType< AxisScale, AxisScale, Datum >
321
+ )?.dataRegistry;
317
322
  const [ container, setContainer ] = useState< HTMLElement | null >( null );
318
323
 
319
324
  // Children of XYChart render inside its SVG, and the box needs an HTML parent.
@@ -327,7 +332,10 @@ export const XyChartTooltipOverlay = < Datum extends object >(
327
332
  { tooltipContext?.tooltipOpen && (
328
333
  <XyChartTooltipContent
329
334
  { ...props }
330
- tooltipContext={ tooltipContext }
335
+ tooltipContext={ {
336
+ ...tooltipContext,
337
+ tooltipData: atNearestDate( tooltipContext.tooltipData, dataRegistry ),
338
+ } }
331
339
  container={ container }
332
340
  />
333
341
  ) }
@@ -0,0 +1,36 @@
1
+ import { getKeyboardStops } from '../private/keyboard-stops';
2
+
3
+ describe( 'getKeyboardStops', () => {
4
+ it( 'gives a stop past the last date no members, even for undated points', () => {
5
+ const stops = getKeyboardStops( [
6
+ { label: 'Dated', data: [ { date: new Date( '2025-04-01T00:00:00Z' ), value: 1 } ] },
7
+ { label: 'Undated', data: [ { label: 'Apr', value: 2 } ] },
8
+ ] );
9
+
10
+ expect( stops.count ).toBe( 1 );
11
+ expect( stops.membersAt( 1 ) ).toEqual( [] );
12
+ } );
13
+
14
+ it( 'steps through array indexes when no point has a date', () => {
15
+ const first = {
16
+ label: 'First',
17
+ data: [
18
+ { label: 'Jan', value: 1 },
19
+ { label: 'Feb', value: 2 },
20
+ ],
21
+ };
22
+ const second = { label: 'Second', data: [ { label: 'Jan', value: 3 } ] };
23
+ const stops = getKeyboardStops( [ first, second ] );
24
+
25
+ expect( stops.count ).toBe( 2 );
26
+ expect( stops.membersAt( 0 ).map( ( { series, index } ) => [ series.label, index ] ) ).toEqual(
27
+ [
28
+ [ 'First', 0 ],
29
+ [ 'Second', 0 ],
30
+ ]
31
+ );
32
+ expect( stops.membersAt( 1 ).map( ( { series, index } ) => [ series.label, index ] ) ).toEqual(
33
+ [ [ 'First', 1 ] ]
34
+ );
35
+ } );
36
+ } );
@@ -1,10 +1,13 @@
1
1
  import { act, render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
- import { useCallback, useRef, useState } from 'react';
3
+ import { BarSeries, LineSeries, TooltipContext, XYChart } from '@visx/xychart';
4
+ import { useCallback, useContext, useEffect, useRef, useState } from 'react';
4
5
  import { LineChartUnresponsive } from '../../../charts/line-chart/line-chart';
5
6
  import { GlobalChartsProvider } from '../../../providers';
6
7
  import { ChartScopeContext } from '../../../providers/chart-scope';
7
8
  import { AccessibleTooltip, XYChartTooltip, useKeyboardNavigation } from '../xy-chart-tooltip';
9
+ import type { DataPointDate } from '../../../types';
10
+ import type { RenderTooltipParams } from '../../../visx/types';
8
11
  import type { ReactNode } from 'react';
9
12
 
10
13
  // A real chart is the harness rather than the subject: the crosshairs render only once visx has a data context and an open tooltip. The unresponsive export is what lets the test own the scope element — `withResponsive` otherwise provides its own wrapper as the scope.
@@ -48,6 +51,46 @@ const openTooltip = async () => {
48
51
  return screen.getByTestId( 'xy-chart-tooltip-crosshair-vertical' );
49
52
  };
50
53
 
54
+ const individualItems = [ 'First', 'Second' ].map( label => ( {
55
+ label,
56
+ data: [ { label: 'Jan', value: 10 } ],
57
+ options: {},
58
+ } ) );
59
+ const xAccessor = ( d: DataPointDate ) => d.label;
60
+ const yAccessor = ( d: DataPointDate ) => d.value;
61
+ const xScale = { type: 'band' } as const;
62
+ const yScale = { type: 'linear' } as const;
63
+ const renderKeys = ( { tooltipData }: RenderTooltipParams< DataPointDate > ) => (
64
+ <div data-testid="keys">{ Object.keys( tooltipData?.datumByKey ?? {} ).join( ',' ) }</div>
65
+ );
66
+
67
+ const IndividualHarness = () => {
68
+ const [ selectedIndex, setSelectedIndex ] = useState( 0 );
69
+ const selectSecond = useCallback( () => setSelectedIndex( 1 ), [] );
70
+ return (
71
+ <GlobalChartsProvider>
72
+ <button onClick={ selectSecond }>next</button>
73
+ <XYChart width={ 300 } height={ 200 } xScale={ xScale } yScale={ yScale }>
74
+ { individualItems.map( item => (
75
+ <BarSeries
76
+ key={ item.label }
77
+ dataKey={ item.label }
78
+ data={ item.data }
79
+ xAccessor={ xAccessor }
80
+ yAccessor={ yAccessor }
81
+ />
82
+ ) ) }
83
+ <XYChartTooltip
84
+ mode="individual"
85
+ series={ individualItems }
86
+ selectedIndex={ selectedIndex }
87
+ renderTooltip={ renderKeys }
88
+ />
89
+ </XYChart>
90
+ </GlobalChartsProvider>
91
+ );
92
+ };
93
+
51
94
  describe( 'XYChartTooltip', () => {
52
95
  it( 'keeps AccessibleTooltip as an alias', () => {
53
96
  expect( AccessibleTooltip ).toBe( XYChartTooltip );
@@ -129,8 +172,99 @@ describe( 'XYChartTooltip', () => {
129
172
 
130
173
  await expect( openTooltip() ).resolves.toHaveAttribute( 'stroke', '#dbdbdb' );
131
174
  } );
175
+
176
+ it( 'lists only the selected item in individual mode as the selection moves', async () => {
177
+ const user = userEvent.setup();
178
+
179
+ render( <IndividualHarness /> );
180
+ await expect( screen.findByTestId( 'keys' ) ).resolves.toHaveTextContent( /^First$/ );
181
+
182
+ await user.click( screen.getByRole( 'button', { name: 'next' } ) );
183
+
184
+ await expect( screen.findByTestId( 'keys' ) ).resolves.toHaveTextContent( /^Second$/ );
185
+ } );
186
+
187
+ // Between April and May, visx hands Views its April point and Previous period, which has no April, its May point.
188
+ it.each( [
189
+ [ 'leaves out a series with no point at the nearest date', 0, 90, 'Views=10', false ],
190
+ [
191
+ 'reads every series at the nearest date, not at its own nearest point',
192
+ 90,
193
+ 0,
194
+ 'Views=40,Previous period=30',
195
+ true,
196
+ ],
197
+ ] )( '%s', async ( _name, viewsDistance, previousDistance, rows, previousGlyph ) => {
198
+ render(
199
+ <PointerHarness viewsDistance={ viewsDistance } previousDistance={ previousDistance } />
200
+ );
201
+
202
+ await expect( screen.findByTestId( 'rows' ) ).resolves.toHaveTextContent( rows );
203
+ expect( screen.getByTestId( 'xy-chart-tooltip-glyph-Views' ) ).toBeInTheDocument();
204
+ expect( !! screen.queryByTestId( 'xy-chart-tooltip-glyph-Previous period' ) ).toBe(
205
+ previousGlyph
206
+ );
207
+ } );
132
208
  } );
133
209
 
210
+ const april = { date: new Date( '2025-04-01' ), value: 10 };
211
+ const may = { date: new Date( '2025-05-01' ), value: 40 };
212
+ const previousMay = { date: new Date( '2025-05-01' ), value: 30 };
213
+ const dateAccessor = ( d: DataPointDate ) => d.date;
214
+ const timeScale = { type: 'time' } as const;
215
+ const renderRows = ( { tooltipData }: RenderTooltipParams< DataPointDate > ) => (
216
+ <div data-testid="rows">
217
+ { Object.entries( tooltipData?.datumByKey ?? {} )
218
+ .map( ( [ key, { datum } ] ) => `${ key }=${ datum.value }` )
219
+ .join( ',' ) }
220
+ </div>
221
+ );
222
+
223
+ type PointerDistances = { viewsDistance: number; previousDistance: number };
224
+
225
+ const ReportPointer = ( { viewsDistance, previousDistance }: PointerDistances ) => {
226
+ const tooltipContext = useContext( TooltipContext );
227
+ useEffect( () => {
228
+ tooltipContext?.showTooltip( {
229
+ key: 'Views',
230
+ datum: april,
231
+ index: 0,
232
+ distanceX: viewsDistance,
233
+ distanceY: 0,
234
+ } );
235
+ tooltipContext?.showTooltip( {
236
+ key: 'Previous period',
237
+ datum: previousMay,
238
+ index: 0,
239
+ distanceX: previousDistance,
240
+ distanceY: 0,
241
+ } );
242
+ // eslint-disable-next-line react-hooks/exhaustive-deps
243
+ }, [] );
244
+ return null;
245
+ };
246
+
247
+ const PointerHarness = ( distances: PointerDistances ) => (
248
+ <GlobalChartsProvider>
249
+ <XYChart width={ 300 } height={ 200 } xScale={ timeScale } yScale={ yScale }>
250
+ <LineSeries
251
+ dataKey="Views"
252
+ data={ [ april, may ] }
253
+ xAccessor={ dateAccessor }
254
+ yAccessor={ yAccessor }
255
+ />
256
+ <LineSeries
257
+ dataKey="Previous period"
258
+ data={ [ previousMay ] }
259
+ xAccessor={ dateAccessor }
260
+ yAccessor={ yAccessor }
261
+ />
262
+ <ReportPointer { ...distances } />
263
+ <XYChartTooltip renderTooltip={ renderRows } showSeriesGlyphs />
264
+ </XYChart>
265
+ </GlobalChartsProvider>
266
+ );
267
+
134
268
  // Mirrors the charts' markup: the focusable chart container wraps the element `chartRef` points at.
135
269
  const NavigationHarness = ( {
136
270
  totalPoints,
@@ -6,6 +6,7 @@ import { useGlobalChartsContext } from '../../providers/chart-context/hooks/use-
6
6
  import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
7
7
  import { useChartScopeElement, useStandaloneScopeClass } from '../../providers/chart-scope';
8
8
  import { resolveCssVariable } from '../../utils';
9
+ import { getKeyboardStops } from './private/keyboard-stops';
9
10
  import { XyChartTooltipOverlay } from './private/xy-chart-tooltip-overlay';
10
11
  import type { SeriesData, DataPointDate } from '../../types';
11
12
  import type { RenderTooltipParams, XyChartTooltipProps } from '../../visx/types';
@@ -34,7 +35,7 @@ interface XYChartTooltipProps extends Omit<
34
35
  */
35
36
  tooltipData?: FlattenedTooltipData[];
36
37
  /**
37
- * For line charts: series data to show all series at selected data point
38
+ * For line charts: series data to show every series at the selected date
38
39
  * When provided, shows all series instead of individual tooltips
39
40
  */
40
41
  series?: SeriesData[];
@@ -99,6 +100,8 @@ export const XYChartTooltip: React.FC< XYChartTooltipProps > = ( {
99
100
  return flattened;
100
101
  }, [ series, mode ] );
101
102
 
103
+ const stops = useMemo( () => getKeyboardStops( series ), [ series ] );
104
+
102
105
  // Tracks whether this effect opened a tooltip, so it only closes its own.
103
106
  const hasKeyboardSelection = useRef( false );
104
107
 
@@ -154,22 +157,15 @@ export const XYChartTooltip: React.FC< XYChartTooltipProps > = ( {
154
157
  if ( mode === 'group' ) {
155
158
  // visx merges each shown datum into the previous tooltip data, so start empty or a series dropped from `series` keeps its row.
156
159
  closeTooltipNow();
157
- series.forEach( ( s, index ) => {
158
- if ( selectedIndex < s.data.length ) {
159
- const datum = s.data[ selectedIndex ];
160
-
161
- tooltipContext?.showTooltip( {
162
- datum,
163
- key: s.label,
164
- index,
165
- } );
166
- }
160
+ stops.membersAt( selectedIndex ).forEach( ( { series: member, datum, index } ) => {
161
+ tooltipContext?.showTooltip( { datum, key: member.label, index } );
167
162
  } );
168
163
  } else if ( mode === 'individual' ) {
169
164
  // Show individual tooltips for each datapoint from each series.
170
165
  if ( selectedIndex < tooltipData.length ) {
171
166
  const tooltipItem = tooltipData[ selectedIndex ];
172
167
 
168
+ tooltipContext?.updateTooltip( state => ( { ...state, tooltipData: undefined } ) );
173
169
  tooltipContext?.showTooltip( {
174
170
  datum: tooltipItem.datum,
175
171
  key: tooltipItem.seriesLabel,
@@ -180,7 +176,7 @@ export const XYChartTooltip: React.FC< XYChartTooltipProps > = ( {
180
176
 
181
177
  // Don't include tooltipContext in the dependency array to avoid loop.
182
178
  // eslint-disable-next-line react-hooks/exhaustive-deps
183
- }, [ selectedIndex, tooltipData, series ] );
179
+ }, [ selectedIndex, tooltipData, stops ] );
184
180
 
185
181
  // Create a focusable renderTooltip that includes accessibility features
186
182
  const focusableRenderTooltip = useMemo( () => {
package/src/index.ts CHANGED
@@ -124,6 +124,8 @@ export type {
124
124
  export type {
125
125
  ConversionFunnelChartProps,
126
126
  FunnelStep,
127
+ FunnelTooltipContent,
128
+ FunnelTooltipRow,
127
129
  StepLabelRenderProps,
128
130
  StepRateRenderProps,
129
131
  MainMetricRenderProps,
package/src/types.ts CHANGED
@@ -214,13 +214,14 @@ type LeaderboardEntryFields = {
214
214
  previousValue?: number;
215
215
 
216
216
  /**
217
- * Width of current bar, as % of the current value
217
+ * Width of current bar, as % of the largest absolute value across rows. A negative share
218
+ * extends the bar from a shared zero point toward the inline start, for every row.
218
219
  */
219
220
  currentShare: number;
220
221
 
221
222
  /**
222
- * Width of previous bar, as % of the current value. Omit when this row has
223
- * no matching comparison-period value.
223
+ * Width of previous bar, as % of the largest absolute value across rows. Omit when this
224
+ * row has no matching comparison-period value.
224
225
  */
225
226
  previousShare?: number;
226
227