@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.
- package/CHANGELOG.md +35 -29
- package/TOKENS.md +3 -0
- package/dist/index.cjs +733 -2203
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +42 -13
- package/dist/index.d.cts +41 -125
- package/dist/index.d.ts +41 -125
- package/dist/index.js +756 -1881
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/charts/area-chart/area-chart.tsx +16 -6
- package/src/charts/area-chart/test/area-chart.test.tsx +42 -9
- package/src/charts/bar-chart/bar-chart.tsx +17 -12
- package/src/charts/bar-chart/private/index.ts +1 -1
- package/src/charts/bar-chart/private/slot-tooltip-members.tsx +4 -5
- package/src/charts/bar-chart/private/use-bar-stops.ts +1 -3
- package/src/charts/bar-chart/private/use-overlap-slots.ts +35 -16
- package/src/charts/bar-chart/test/bar-chart.test.tsx +115 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +24 -11
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +235 -182
- package/src/charts/conversion-funnel-chart/index.ts +2 -0
- package/src/charts/conversion-funnel-chart/private/index.ts +1 -0
- package/src/charts/conversion-funnel-chart/private/use-funnel-selection.ts +9 -47
- package/src/charts/conversion-funnel-chart/private/use-roving-step-focus.ts +83 -0
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +312 -92
- package/src/charts/conversion-funnel-chart/test/use-funnel-selection.test.ts +23 -188
- package/src/charts/conversion-funnel-chart/types.ts +22 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +42 -2
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +124 -36
- package/src/charts/leaderboard-chart/private/resolve-leaderboard-entries.ts +53 -0
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +88 -0
- package/src/charts/leaderboard-chart/test/resolve-leaderboard-entries.test.ts +146 -0
- package/src/charts/leaderboard-chart/types.ts +6 -0
- package/src/charts/line-chart/line-chart.tsx +10 -8
- package/src/charts/line-chart/test/line-chart.test.tsx +75 -0
- package/src/components/legend/private/base-legend.tsx +13 -8
- package/src/components/legend/test/legend.test.tsx +125 -0
- package/src/components/legend/types.ts +9 -1
- package/src/components/tooltip/private/at-nearest-date.ts +49 -0
- package/src/components/tooltip/private/keyboard-stops.ts +67 -0
- package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +10 -2
- package/src/components/tooltip/test/keyboard-stops.test.ts +36 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +135 -1
- package/src/components/tooltip/xy-chart-tooltip.tsx +8 -12
- package/src/index.ts +2 -0
- package/src/styles/chart-scope.scss +1 -0
- 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
|
-
|
|
391
|
-
|
|
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
|
-
(
|
|
394
|
-
const
|
|
395
|
+
( stop: number ) => {
|
|
396
|
+
const match = stops.membersAt( stop )[ 0 ];
|
|
395
397
|
|
|
396
|
-
if (
|
|
397
|
-
onDatumActivate?.( { datum:
|
|
398
|
+
if ( match ) {
|
|
399
|
+
onDatumActivate?.( { datum: match.datum, index: match.index, key: match.series.label } );
|
|
398
400
|
}
|
|
399
401
|
},
|
|
400
|
-
[
|
|
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:
|
|
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
|
-
|
|
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
|
|
285
|
-
const
|
|
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
|
+
};
|