@automattic/charts 4.12.0 → 4.14.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 (47) hide show
  1. package/CHANGELOG.md +35 -29
  2. package/TOKENS.md +3 -0
  3. package/dist/index.cjs +733 -2203
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +42 -13
  6. package/dist/index.d.cts +41 -125
  7. package/dist/index.d.ts +41 -125
  8. package/dist/index.js +756 -1881
  9. package/dist/index.js.map +1 -1
  10. package/package.json +9 -9
  11. package/src/charts/area-chart/area-chart.tsx +16 -6
  12. package/src/charts/area-chart/test/area-chart.test.tsx +42 -9
  13. package/src/charts/bar-chart/bar-chart.tsx +17 -12
  14. package/src/charts/bar-chart/private/index.ts +1 -1
  15. package/src/charts/bar-chart/private/slot-tooltip-members.tsx +4 -5
  16. package/src/charts/bar-chart/private/use-bar-stops.ts +1 -3
  17. package/src/charts/bar-chart/private/use-overlap-slots.ts +35 -16
  18. package/src/charts/bar-chart/test/bar-chart.test.tsx +115 -0
  19. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +24 -11
  20. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +235 -182
  21. package/src/charts/conversion-funnel-chart/index.ts +2 -0
  22. package/src/charts/conversion-funnel-chart/private/index.ts +1 -0
  23. package/src/charts/conversion-funnel-chart/private/use-funnel-selection.ts +9 -47
  24. package/src/charts/conversion-funnel-chart/private/use-roving-step-focus.ts +83 -0
  25. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +312 -92
  26. package/src/charts/conversion-funnel-chart/test/use-funnel-selection.test.ts +23 -188
  27. package/src/charts/conversion-funnel-chart/types.ts +22 -0
  28. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +42 -2
  29. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +124 -36
  30. package/src/charts/leaderboard-chart/private/resolve-leaderboard-entries.ts +53 -0
  31. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +88 -0
  32. package/src/charts/leaderboard-chart/test/resolve-leaderboard-entries.test.ts +146 -0
  33. package/src/charts/leaderboard-chart/types.ts +6 -0
  34. package/src/charts/line-chart/line-chart.tsx +10 -8
  35. package/src/charts/line-chart/test/line-chart.test.tsx +75 -0
  36. package/src/components/legend/private/base-legend.tsx +13 -8
  37. package/src/components/legend/test/legend.test.tsx +125 -0
  38. package/src/components/legend/types.ts +9 -1
  39. package/src/components/tooltip/private/at-nearest-date.ts +49 -0
  40. package/src/components/tooltip/private/keyboard-stops.ts +67 -0
  41. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +10 -2
  42. package/src/components/tooltip/test/keyboard-stops.test.ts +36 -0
  43. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +135 -1
  44. package/src/components/tooltip/xy-chart-tooltip.tsx +8 -12
  45. package/src/index.ts +2 -0
  46. package/src/styles/chart-scope.scss +1 -0
  47. package/src/types.ts +5 -6
@@ -81,6 +81,22 @@ describe( 'LeaderboardChart', () => {
81
81
  expect( screen.getByText( '-8%' ) ).toBeInTheDocument();
82
82
  } );
83
83
 
84
+ it( 'derives the delta of entries that pass only values', () => {
85
+ render(
86
+ <LeaderboardChart
87
+ data={ [
88
+ { id: 'up', label: 'Up', currentValue: 100, previousValue: 80 },
89
+ { id: 'down', label: 'Down', currentValue: 50, previousValue: 100 },
90
+ ] }
91
+ withComparison={ true }
92
+ deltaFormatter={ testDeltaFormatter }
93
+ />
94
+ );
95
+
96
+ expect( screen.getByText( '25delta' ) ).toBeInTheDocument();
97
+ expect( screen.getByText( '-50delta' ) ).toBeInTheDocument();
98
+ } );
99
+
84
100
  it( 'shows a placeholder when a row has no previous value', () => {
85
101
  render(
86
102
  <LeaderboardChart
@@ -152,6 +168,49 @@ describe( 'LeaderboardChart', () => {
152
168
  expect( container.querySelectorAll( '.bar' ) ).toHaveLength( 2 );
153
169
  } );
154
170
 
171
+ it.each( [
172
+ [ 'a current share is negative', { currentShare: -40 }, true, 2 ],
173
+ [ 'a comparison share is negative', { previousShare: -40 }, true, 2 ],
174
+ [ 'only a hidden comparison share is negative', { previousShare: -40 }, false, 0 ],
175
+ [
176
+ 'only a share without a comparison value is negative',
177
+ { previousShare: -40, previousValue: undefined },
178
+ true,
179
+ 0,
180
+ ],
181
+ [ 'every share is positive', {}, true, 0 ],
182
+ ] )( 'diverges the bars from zero when %s', ( _, override, withComparison, expected ) => {
183
+ const data = [ { ...mockData[ 0 ], ...override }, mockData[ 1 ] ];
184
+ render( <LeaderboardChart data={ data } withComparison={ withComparison } /> );
185
+
186
+ expect( screen.queryAllByTestId( 'leaderboard-chart-diverging-row' ) ).toHaveLength( expected );
187
+ } );
188
+
189
+ it( 'keeps every bar on one side when negative bars clamp', () => {
190
+ const data = [
191
+ { ...mockData[ 0 ], currentShare: undefined, currentValue: -40 },
192
+ mockData[ 1 ],
193
+ ];
194
+ render( <LeaderboardChart data={ data } withComparison negativeBars="clamp" /> );
195
+
196
+ expect( screen.queryAllByTestId( 'leaderboard-chart-diverging-row' ) ).toHaveLength( 0 );
197
+ } );
198
+
199
+ it.each( [
200
+ [ 'comparison bars draw', false, /^calc\(50%/ ],
201
+ [ 'the overlay label hides comparison bars', true, /^calc\(100%/ ],
202
+ ] )( 'sizes the current bar against the max when %s', ( _, withOverlayLabel, expected ) => {
203
+ const data = [
204
+ { id: 'a', label: 'A', currentValue: 50, previousValue: 100 },
205
+ { id: 'b', label: 'B', currentValue: 25, previousValue: 10 },
206
+ ] as LeaderboardEntry[];
207
+ render(
208
+ <LeaderboardChart data={ data } withComparison withOverlayLabel={ withOverlayLabel } />
209
+ );
210
+
211
+ expect( screen.getAllByTestId( 'leaderboard-chart-bar' )[ 0 ].style.width ).toMatch( expected );
212
+ } );
213
+
155
214
  it( 'shows custom label when provided', () => {
156
215
  render(
157
216
  <LeaderboardChart
@@ -748,6 +807,35 @@ describe( 'LeaderboardChart', () => {
748
807
  describe( 'grid gaps', () => {
749
808
  // eslint-disable-next-line testing-library/no-node-access -- The grid is rendered by `Grid` from @wordpress/components, which takes no test id.
750
809
  const grid = () => document.querySelector< HTMLElement >( '[data-leaderboard-grid]' );
810
+ const templateColumns = () =>
811
+ grid().style.getPropertyValue( '--wp-components-grid-template-columns' );
812
+
813
+ it( 'lays the rows out without a delta track when not comparing', () => {
814
+ render( <LeaderboardChart data={ mockData } withComparison={ false } /> );
815
+
816
+ expect( templateColumns() ).toBe( 'minmax(0, 1fr) auto' );
817
+ } );
818
+
819
+ it.each( [ [ 'Previous period' ], [ 'Current period' ] ] )(
820
+ 'adds a delta track when comparing and drops it while "%s" is hidden',
821
+ async legendItem => {
822
+ const user = userEvent.setup();
823
+ render(
824
+ <LeaderboardChart
825
+ data={ mockData }
826
+ withComparison={ true }
827
+ showLegend={ true }
828
+ legend={ { interactive: true } }
829
+ />
830
+ );
831
+
832
+ expect( templateColumns() ).toBe( 'minmax(0, 1fr) auto auto' );
833
+
834
+ await user.click( screen.getByRole( 'button', { name: new RegExp( legendItem ) } ) );
835
+
836
+ expect( templateColumns() ).toBe( 'minmax(0, 1fr) auto' );
837
+ }
838
+ );
751
839
 
752
840
  it( 'hands the catalog role to the grid', () => {
753
841
  render( <LeaderboardChart data={ mockData } /> );
@@ -0,0 +1,146 @@
1
+ import { resolveLeaderboardEntries } from '../private/resolve-leaderboard-entries';
2
+ import type { LeaderboardEntry } from '../../../types';
3
+
4
+ type EntryInput = Partial< LeaderboardEntry > & { currentValue: number };
5
+
6
+ const toEntries = ( rows: EntryInput[] ) =>
7
+ rows.map( ( row, index ) => ( {
8
+ id: `row-${ index }`,
9
+ label: `Row ${ index }`,
10
+ ...row,
11
+ } ) ) as LeaderboardEntry[];
12
+
13
+ describe( 'resolveLeaderboardEntries', () => {
14
+ const comparisonRows = [
15
+ { currentValue: 50, previousValue: 100 },
16
+ { currentValue: 25, previousValue: 40 },
17
+ ];
18
+
19
+ it( 'derives both shares and delta against one max across both periods', () => {
20
+ const result = resolveLeaderboardEntries( toEntries( comparisonRows ), {
21
+ includePrevious: true,
22
+ } );
23
+
24
+ expect(
25
+ result.map( ( { currentShare, previousShare, delta } ) => ( {
26
+ currentShare,
27
+ previousShare,
28
+ delta,
29
+ } ) )
30
+ ).toEqual( [
31
+ { currentShare: 50, previousShare: 100, delta: -50 },
32
+ { currentShare: 25, previousShare: 40, delta: -37.5 },
33
+ ] );
34
+ } );
35
+
36
+ it( 'lets a negative previous value set the max', () => {
37
+ const [ entry ] = resolveLeaderboardEntries(
38
+ toEntries( [ { currentValue: 10, previousValue: -40 } ] ),
39
+ { includePrevious: true }
40
+ );
41
+
42
+ expect( entry ).toMatchObject( { currentShare: 25, previousShare: -100 } );
43
+ } );
44
+
45
+ it( 'ignores previous values for the max when comparison bars are not drawn', () => {
46
+ const result = resolveLeaderboardEntries( toEntries( comparisonRows ), {
47
+ includePrevious: false,
48
+ } );
49
+
50
+ expect( result.map( ( { currentShare } ) => currentShare ) ).toEqual( [ 100, 50 ] );
51
+ } );
52
+
53
+ it( 'keeps passed fields and derives the rest', () => {
54
+ const [ entry ] = resolveLeaderboardEntries(
55
+ toEntries( [ { currentValue: 50, currentShare: 10, previousValue: 100, delta: 7 } ] ),
56
+ { includePrevious: true }
57
+ );
58
+
59
+ expect( entry ).toMatchObject( { currentShare: 10, delta: 7, previousShare: 100 } );
60
+ } );
61
+
62
+ it( 'derives a missing share from the max over every row, passed or not', () => {
63
+ const result = resolveLeaderboardEntries(
64
+ toEntries( [ { currentValue: 10, currentShare: 100 }, { currentValue: 20 } ] ),
65
+ { includePrevious: false }
66
+ );
67
+
68
+ expect( result.map( ( { currentShare } ) => currentShare ) ).toEqual( [ 100, 100 ] );
69
+ } );
70
+
71
+ it( 'keeps the sign of negative values', () => {
72
+ const result = resolveLeaderboardEntries(
73
+ toEntries( [ { currentValue: -30 }, { currentValue: 60 } ] ),
74
+ { includePrevious: false }
75
+ );
76
+
77
+ expect( result.map( ( { currentShare } ) => currentShare ) ).toEqual( [ -50, 100 ] );
78
+ } );
79
+
80
+ it( 'clamps negative shares to 0 and sizes the rest against the largest positive value', () => {
81
+ const result = resolveLeaderboardEntries(
82
+ toEntries( [
83
+ { currentValue: -90, previousValue: 30 },
84
+ { currentValue: 60, previousValue: -10, previousShare: -20 },
85
+ ] ),
86
+ { includePrevious: true, clampNegative: true }
87
+ );
88
+
89
+ expect(
90
+ result.map( ( { currentShare, previousShare, delta } ) => ( {
91
+ currentShare,
92
+ previousShare,
93
+ delta,
94
+ } ) )
95
+ ).toEqual( [
96
+ { currentShare: 0, previousShare: 50, delta: -400 },
97
+ { currentShare: 100, previousShare: 0, delta: 700 },
98
+ ] );
99
+ } );
100
+
101
+ it.each( [
102
+ { currentValue: 5, previousValue: -10, expected: 150 },
103
+ { currentValue: 0, previousValue: 0, expected: 0 },
104
+ { currentValue: 5, previousValue: 0, expected: undefined },
105
+ { currentValue: 5, previousValue: undefined, expected: undefined },
106
+ ] )(
107
+ 'derives delta $expected for current $currentValue and previous $previousValue',
108
+ ( { currentValue, previousValue, expected } ) => {
109
+ const [ entry ] = resolveLeaderboardEntries(
110
+ toEntries( [ { currentValue, previousValue } ] ),
111
+ { includePrevious: true }
112
+ );
113
+
114
+ expect( entry.delta ).toBe( expected );
115
+ }
116
+ );
117
+
118
+ it( 'sizes rows without a previous value against the shared max', () => {
119
+ const result = resolveLeaderboardEntries(
120
+ toEntries( [ { currentValue: 50, previousValue: 100 }, { currentValue: 25 } ] ),
121
+ { includePrevious: true }
122
+ );
123
+
124
+ expect( result.map( ( { currentShare } ) => currentShare ) ).toEqual( [ 50, 25 ] );
125
+ expect( result[ 1 ] ).not.toHaveProperty( 'previousShare' );
126
+ } );
127
+
128
+ it( 'gives shares of 0 when every value is 0', () => {
129
+ const [ entry ] = resolveLeaderboardEntries(
130
+ toEntries( [ { currentValue: 0, previousValue: 0 } ] ),
131
+ { includePrevious: true }
132
+ );
133
+
134
+ expect( entry ).toMatchObject( { currentShare: 0, previousShare: 0, delta: 0 } );
135
+ } );
136
+
137
+ it( 'leaves other fields untouched', () => {
138
+ const onClick = jest.fn();
139
+ const [ entry ] = resolveLeaderboardEntries(
140
+ toEntries( [ { id: 'a', label: 'A', currentValue: 1, onClick } ] ),
141
+ { includePrevious: false }
142
+ );
143
+
144
+ expect( entry ).toMatchObject( { id: 'a', label: 'A', onClick } );
145
+ } );
146
+ } );
@@ -30,6 +30,12 @@ export interface LeaderboardChartProps extends Pick<
30
30
  */
31
31
  withOverlayLabel?: boolean;
32
32
 
33
+ /**
34
+ * How a negative value draws: `diverge` puts every row on a shared zero point, `clamp` draws it as an empty bar.
35
+ * @default 'diverge'
36
+ */
37
+ negativeBars?: 'diverge' | 'clamp';
38
+
33
39
  /**
34
40
  * Primary color for current period bars
35
41
  */
@@ -16,6 +16,7 @@ import {
16
16
  } from 'react';
17
17
  import { Legend, useChartLegendItems } from '../../components/legend';
18
18
  import { XYChartTooltip, useKeyboardNavigation } from '../../components/tooltip';
19
+ import { getKeyboardStops } from '../../components/tooltip/private/keyboard-stops';
19
20
  import {
20
21
  useXYChartTheme,
21
22
  useChartDataTransform,
@@ -387,17 +388,18 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
387
388
  return min < max ? [ min, max ] : undefined;
388
389
  }, [ rescaleYOnVisibilityChange, dataSorted, isSeriesVisible, options?.yScale ] );
389
390
 
390
- // Keyboard navigation steps through x positions, and the grouped tooltip reads every
391
- // visible series at that position; the first visible series with a point there names it.
391
+ const stops = useMemo( () => getKeyboardStops( visibleSeries ), [ visibleSeries ] );
392
+
393
+ // The grouped tooltip reads every visible series at a stop; the first visible series with a point there names it.
392
394
  const activateSelectedPoint = useCallback(
393
- ( index: number ) => {
394
- const series = visibleSeries.find( candidate => candidate.data[ index ] );
395
+ ( stop: number ) => {
396
+ const match = stops.membersAt( stop )[ 0 ];
395
397
 
396
- if ( series ) {
397
- onDatumActivate?.( { datum: series.data[ index ], index, key: series.label } );
398
+ if ( match ) {
399
+ onDatumActivate?.( { datum: match.datum, index: match.index, key: match.series.label } );
398
400
  }
399
401
  },
400
- [ visibleSeries, onDatumActivate ]
402
+ [ stops, onDatumActivate ]
401
403
  );
402
404
 
403
405
  const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
@@ -406,7 +408,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
406
408
  isNavigating,
407
409
  setIsNavigating,
408
410
  chartRef,
409
- totalPoints: Math.max( 0, ...visibleSeries.map( series => series.data.length ) ),
411
+ totalPoints: stops.count,
410
412
  visibleSeriesKey,
411
413
  onActivate: activateSelectedPoint,
412
414
  preventTooltipScroll: tooltipPlacement === 'below-axis',
@@ -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',
@@ -217,24 +217,27 @@ export const BaseLegend: ForwardRefExoticComponent<
217
217
 
218
218
  // Create event handlers to avoid inline arrow functions
219
219
  const createClickHandler = useCallback(
220
- ( seriesLabels: string[], itemInteractive: boolean ) => {
220
+ ( seriesLabels: string[], itemInteractive: boolean, onToggle?: () => void ) => {
221
221
  if ( ! itemInteractive ) {
222
222
  return undefined;
223
223
  }
224
- return () => handleLegendClick( seriesLabels );
224
+ return onToggle ? () => onToggle() : () => handleLegendClick( seriesLabels );
225
225
  },
226
226
  [ handleLegendClick ]
227
227
  );
228
228
 
229
229
  const createKeyDownHandler = useCallback(
230
- ( seriesLabels: string[], itemInteractive: boolean ) => {
230
+ ( seriesLabels: string[], itemInteractive: boolean, onToggle?: () => void ) => {
231
231
  if ( ! itemInteractive ) {
232
232
  return undefined;
233
233
  }
234
+ const toggle = onToggle ?? ( () => handleLegendClick( seriesLabels ) );
234
235
  return ( event: KeyboardEvent ) => {
235
236
  if ( event.key === 'Enter' || event.key === ' ' ) {
236
237
  event.preventDefault();
237
- handleLegendClick( seriesLabels );
238
+ if ( ! event.repeat ) {
239
+ toggle();
240
+ }
238
241
  }
239
242
  };
240
243
  },
@@ -278,11 +281,13 @@ export const BaseLegend: ForwardRefExoticComponent<
278
281
  const seriesLabels = matchedItem?.seriesLabels?.length
279
282
  ? matchedItem.seriesLabels
280
283
  : [ label.text ];
281
- const visible =
282
- matchedItem?.interactive === false || isSeriesVisible( seriesLabels[ 0 ] );
283
284
  const itemInteractive = interactive && matchedItem?.interactive !== false;
284
- const handleClick = createClickHandler( seriesLabels, itemInteractive );
285
- const handleKeyDown = createKeyDownHandler( seriesLabels, itemInteractive );
285
+ const onToggle = itemInteractive ? matchedItem?.onToggle : undefined;
286
+ const visible = matchedItem?.onToggle
287
+ ? matchedItem.pressed
288
+ : matchedItem?.interactive === false || isSeriesVisible( seriesLabels[ 0 ] );
289
+ const handleClick = createClickHandler( seriesLabels, itemInteractive, onToggle );
290
+ const handleKeyDown = createKeyDownHandler( seriesLabels, itemInteractive, onToggle );
286
291
 
287
292
  return (
288
293
  <LegendItem
@@ -900,6 +900,131 @@ describe( 'BaseLegend', () => {
900
900
  } );
901
901
  } );
902
902
 
903
+ describe( 'Controlled items', () => {
904
+ const VisibilityProbe = () => {
905
+ const { isSeriesVisible } = useGlobalChartsContext();
906
+ return (
907
+ <output data-testid="views-visible">
908
+ { String( isSeriesVisible( 'test-chart', 'Views' ) ) }
909
+ </output>
910
+ );
911
+ };
912
+
913
+ it.each( [ false, true ] )( 'takes the pressed state from the item (%s)', pressed => {
914
+ render(
915
+ <GlobalChartsProvider>
916
+ <BaseLegend
917
+ items={ [ { label: 'Previous period', color: '#000', pressed, onToggle: jest.fn() } ] }
918
+ interactive
919
+ chartId="test-chart"
920
+ />
921
+ </GlobalChartsProvider>
922
+ );
923
+
924
+ expect( screen.getByRole( 'button', { name: /Previous period/ } ) ).toHaveAttribute(
925
+ 'aria-pressed',
926
+ String( pressed )
927
+ );
928
+ } );
929
+
930
+ it.each( [ 'click', 'Enter', ' ' ] )(
931
+ 'calls onToggle on %s and leaves series visibility alone',
932
+ async interaction => {
933
+ const user = userEvent.setup();
934
+ const onToggle = jest.fn();
935
+ render(
936
+ <GlobalChartsProvider>
937
+ <BaseLegend
938
+ items={ [
939
+ {
940
+ label: 'Previous period',
941
+ color: '#000',
942
+ seriesLabels: [ 'Views' ],
943
+ pressed: false,
944
+ onToggle,
945
+ },
946
+ ] }
947
+ interactive
948
+ chartId="test-chart"
949
+ />
950
+ <VisibilityProbe />
951
+ </GlobalChartsProvider>
952
+ );
953
+
954
+ const button = screen.getByRole( 'button', { name: /Previous period/ } );
955
+ if ( interaction === 'click' ) {
956
+ await user.click( button );
957
+ } else {
958
+ button.focus();
959
+ await user.keyboard( interaction === ' ' ? ' ' : '{Enter}' );
960
+ }
961
+
962
+ expect( onToggle ).toHaveBeenCalledTimes( 1 );
963
+ expect( onToggle ).toHaveBeenCalledWith();
964
+ expect( screen.getByTestId( 'views-visible' ) ).toHaveTextContent( 'true' );
965
+ }
966
+ );
967
+
968
+ it( 'ignores key repeats while Enter is held', async () => {
969
+ const user = userEvent.setup();
970
+ const onToggle = jest.fn();
971
+ render(
972
+ <GlobalChartsProvider>
973
+ <BaseLegend
974
+ items={ [ { label: 'Previous period', color: '#000', pressed: false, onToggle } ] }
975
+ interactive
976
+ chartId="test-chart"
977
+ />
978
+ </GlobalChartsProvider>
979
+ );
980
+
981
+ screen.getByRole( 'button', { name: /Previous period/ } ).focus();
982
+ await user.keyboard( '{Enter>3/}' );
983
+
984
+ expect( onToggle ).toHaveBeenCalledTimes( 1 );
985
+ } );
986
+
987
+ const renderStaticControlledItem = ( pressed: boolean ) => {
988
+ const onToggle = jest.fn();
989
+ render(
990
+ <GlobalChartsProvider>
991
+ <BaseLegend
992
+ items={ [
993
+ {
994
+ label: 'Previous period',
995
+ color: '#000',
996
+ seriesLabels: [ 'Views' ],
997
+ pressed,
998
+ onToggle,
999
+ },
1000
+ ] }
1001
+ interactive={ false }
1002
+ chartId="test-chart"
1003
+ />
1004
+ </GlobalChartsProvider>
1005
+ );
1006
+ return { item: screen.getByTestId( 'legend-item' ), onToggle };
1007
+ };
1008
+
1009
+ it( 'stays static in a legend that is not interactive', async () => {
1010
+ const user = userEvent.setup();
1011
+ const { item, onToggle } = renderStaticControlledItem( true );
1012
+
1013
+ expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument();
1014
+ expect( item ).not.toHaveClass( 'legend-item--inactive' );
1015
+ expect( item ).not.toHaveAttribute( 'aria-label' );
1016
+ await user.click( item );
1017
+ expect( onToggle ).not.toHaveBeenCalled();
1018
+ } );
1019
+
1020
+ it( 'dims an item that is not pressed in a legend that is not interactive', () => {
1021
+ const { item } = renderStaticControlledItem( false );
1022
+
1023
+ expect( item ).toHaveClass( 'legend-item--inactive' );
1024
+ expect( item ).toHaveAttribute( 'aria-label', 'Previous period: hidden' );
1025
+ } );
1026
+ } );
1027
+
903
1028
  describe( 'Non-interactive legend reflects hidden state', () => {
904
1029
  const HiddenSeriesHarness = ( { interactive }: { interactive: boolean } ) => {
905
1030
  const context = useGlobalChartsContext();
@@ -65,4 +65,12 @@ export type BaseLegendItem = {
65
65
  interactive?: boolean;
66
66
  /** Label of the series this static item describes; the item is left out while it is hidden. */
67
67
  describesSeries?: string;
68
- };
68
+ } & (
69
+ | {
70
+ /** Pressed state of the toggle. */
71
+ pressed: boolean;
72
+ /** Makes an interactive legend item a toggle the consumer controls, instead of a series toggle. */
73
+ onToggle: () => void;
74
+ }
75
+ | { pressed?: never; onToggle?: never }
76
+ );
@@ -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
+ };