@automattic/charts 4.11.1 → 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.
- package/CHANGELOG.md +24 -0
- package/dist/index.cjs +1412 -787
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +59 -25
- package/dist/index.d.cts +64 -120
- package/dist/index.d.ts +64 -120
- package/dist/index.js +1400 -765
- 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 +143 -142
- package/src/charts/bar-chart/index.ts +3 -0
- package/src/charts/bar-chart/private/band-tooltip.tsx +41 -15
- package/src/charts/bar-chart/private/bar-layer.tsx +132 -0
- package/src/charts/bar-chart/private/comparison-bars-geometry.ts +22 -34
- package/src/charts/bar-chart/private/comparison-bars.tsx +9 -18
- package/src/charts/bar-chart/private/comparison-constants.ts +4 -10
- package/src/charts/bar-chart/private/grouped-bars.ts +68 -0
- package/src/charts/bar-chart/private/index.ts +7 -1
- package/src/charts/bar-chart/private/overlap-bars.ts +183 -0
- package/src/charts/bar-chart/private/selected-bar-ring.tsx +84 -0
- package/src/charts/bar-chart/private/slot-tooltip-members.tsx +28 -0
- package/src/charts/bar-chart/private/slotted-bar-series.tsx +84 -0
- package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +4 -11
- package/src/charts/bar-chart/private/test/comparison-bars.test.tsx +2 -3
- package/src/charts/bar-chart/private/test/grouped-bars.test.ts +87 -0
- package/src/charts/bar-chart/private/test/overlap-bars.test.ts +159 -0
- package/src/charts/bar-chart/private/use-bar-stops.ts +87 -0
- package/src/charts/bar-chart/private/use-overlap-slots.ts +213 -0
- package/src/charts/bar-chart/private/value-domain.ts +0 -10
- package/src/charts/bar-chart/test/bar-chart-overlap.test.tsx +347 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +181 -19
- package/src/charts/bar-chart/test/use-bar-legend-items.test.tsx +81 -0
- package/src/charts/bar-chart/types.ts +3 -0
- package/src/charts/bar-chart/use-bar-colors.ts +46 -0
- package/src/charts/bar-chart/use-bar-legend-items.ts +87 -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 +41 -1
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +101 -30
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +47 -0
- package/src/charts/line-chart/line-chart.tsx +25 -18
- package/src/charts/line-chart/test/line-chart.test.tsx +240 -1
- package/src/charts/pie-chart/pie-chart.tsx +7 -2
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +7 -2
- package/src/charts/private/pie-keyboard-navigation/index.ts +0 -1
- package/src/charts/private/{pie-keyboard-navigation/pie-keyboard-navigation.module.scss → selected-ring/selected-ring.module.scss} +11 -6
- package/src/charts/private/selected-ring/selected-ring.tsx +73 -0
- package/src/charts/private/{pie-keyboard-navigation/test/selected-segment-ring.test.ts → selected-ring/test/selected-ring.test.ts} +1 -1
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +20 -0
- package/src/components/legend/hooks/use-chart-legend-items.ts +27 -3
- 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 +11 -14
- package/src/index.ts +10 -2
- package/src/providers/chart-context/global-charts-provider.tsx +2 -0
- package/src/providers/chart-context/test/chart-context.test.tsx +0 -13
- package/src/providers/chart-context/themes.ts +0 -1
- package/src/providers/chart-context/types.ts +2 -0
- package/src/test-utils/selected-bar-ring.ts +14 -0
- package/src/types.ts +14 -5
- package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +0 -47
|
@@ -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,
|
|
@@ -336,6 +337,15 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
336
337
|
} ) );
|
|
337
338
|
}, [ dataSorted, hiddenSeries ] );
|
|
338
339
|
|
|
340
|
+
const visibleSeries = useMemo(
|
|
341
|
+
() => dataSorted.filter( series => isSeriesVisible( series.label ) ),
|
|
342
|
+
[ dataSorted, isSeriesVisible ]
|
|
343
|
+
);
|
|
344
|
+
const visibleSeriesKey = useMemo(
|
|
345
|
+
() => JSON.stringify( visibleSeries.map( series => series.label ) ),
|
|
346
|
+
[ visibleSeries ]
|
|
347
|
+
);
|
|
348
|
+
|
|
339
349
|
// Check if all series are hidden
|
|
340
350
|
const allSeriesHidden = useMemo( () => {
|
|
341
351
|
return seriesWithVisibility.every( ( { isVisible } ) => ! isVisible );
|
|
@@ -378,18 +388,18 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
378
388
|
return min < max ? [ min, max ] : undefined;
|
|
379
389
|
}, [ rescaleYOnVisibilityChange, dataSorted, isSeriesVisible, options?.yScale ] );
|
|
380
390
|
|
|
381
|
-
|
|
382
|
-
|
|
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.
|
|
383
394
|
const activateSelectedPoint = useCallback(
|
|
384
|
-
(
|
|
385
|
-
const
|
|
386
|
-
const datum = series?.data[ index ];
|
|
395
|
+
( stop: number ) => {
|
|
396
|
+
const match = stops.membersAt( stop )[ 0 ];
|
|
387
397
|
|
|
388
|
-
if (
|
|
389
|
-
onDatumActivate?.( { datum, index, key: series.label } );
|
|
398
|
+
if ( match ) {
|
|
399
|
+
onDatumActivate?.( { datum: match.datum, index: match.index, key: match.series.label } );
|
|
390
400
|
}
|
|
391
401
|
},
|
|
392
|
-
[
|
|
402
|
+
[ stops, onDatumActivate ]
|
|
393
403
|
);
|
|
394
404
|
|
|
395
405
|
const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
|
|
@@ -398,7 +408,8 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
398
408
|
isNavigating,
|
|
399
409
|
setIsNavigating,
|
|
400
410
|
chartRef,
|
|
401
|
-
totalPoints:
|
|
411
|
+
totalPoints: stops.count,
|
|
412
|
+
visibleSeriesKey,
|
|
402
413
|
onActivate: activateSelectedPoint,
|
|
403
414
|
preventTooltipScroll: tooltipPlacement === 'below-axis',
|
|
404
415
|
} );
|
|
@@ -424,8 +435,8 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
424
435
|
}, [ dataSorted, isSeriesVisible ] );
|
|
425
436
|
|
|
426
437
|
const hasWholeNumberValues = useMemo(
|
|
427
|
-
() => hasOnlyWholeNumbers(
|
|
428
|
-
[
|
|
438
|
+
() => hasOnlyWholeNumbers( visibleSeries ),
|
|
439
|
+
[ visibleSeries ]
|
|
429
440
|
);
|
|
430
441
|
|
|
431
442
|
const chartOptions = useMemo( () => {
|
|
@@ -482,12 +493,8 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
482
493
|
// Classified from the rendered series, like the axis above: a hidden
|
|
483
494
|
// hourly line must not leave the tooltip naming an hour the axis dropped.
|
|
484
495
|
const bucketInfo = useMemo(
|
|
485
|
-
() =>
|
|
486
|
-
|
|
487
|
-
dataSorted.filter( series => isSeriesVisible( series.label ) ),
|
|
488
|
-
options?.axis?.x?.tickResolution
|
|
489
|
-
),
|
|
490
|
-
[ dataSorted, isSeriesVisible, options?.axis?.x?.tickResolution ]
|
|
496
|
+
() => getBucketInfo( visibleSeries, options?.axis?.x?.tickResolution ),
|
|
497
|
+
[ visibleSeries, options?.axis?.x?.tickResolution ]
|
|
491
498
|
);
|
|
492
499
|
|
|
493
500
|
const tooltipRenderGlyph = useMemo( () => {
|
|
@@ -809,7 +816,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
809
816
|
keyboardFocusedClassName={
|
|
810
817
|
styles[ 'line-chart__tooltip--keyboard-focused' ]
|
|
811
818
|
}
|
|
812
|
-
series={
|
|
819
|
+
series={ visibleSeries }
|
|
813
820
|
/>
|
|
814
821
|
) }
|
|
815
822
|
|
|
@@ -6,9 +6,10 @@ import { GlyphDiamond } from '@visx/glyph';
|
|
|
6
6
|
import { createElement, createRef, type ComponentProps } from 'react';
|
|
7
7
|
import { GlobalChartsProvider, defaultTheme } from '../../../providers';
|
|
8
8
|
import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
|
|
9
|
-
import LineChart, { LineChartUnresponsive } from '../line-chart';
|
|
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
|
|
|
@@ -1658,6 +1659,32 @@ describe( 'LineChart', () => {
|
|
|
1658
1659
|
} );
|
|
1659
1660
|
} );
|
|
1660
1661
|
|
|
1662
|
+
test.each( [
|
|
1663
|
+
[ 'the first is hidden', [ { ...SERIES_A, data: SERIES_A.data.slice( 0, 1 ) }, SERIES_B ] ],
|
|
1664
|
+
[
|
|
1665
|
+
'the first visible series is empty',
|
|
1666
|
+
[ SERIES_A, { ...SERIES_A, label: 'Empty', data: [] }, SERIES_B ],
|
|
1667
|
+
],
|
|
1668
|
+
] )( 'Enter reaches every visible point when %s', async ( _, data ) => {
|
|
1669
|
+
const user = userEvent.setup();
|
|
1670
|
+
const onDatumActivate = jest.fn();
|
|
1671
|
+
renderWithTheme( {
|
|
1672
|
+
data,
|
|
1673
|
+
defaultHiddenSeries: [ 'Series A' ],
|
|
1674
|
+
chartId: 'activation-hidden-first',
|
|
1675
|
+
onDatumActivate,
|
|
1676
|
+
} );
|
|
1677
|
+
|
|
1678
|
+
screen.getByRole( 'application', { name: /line chart/i } ).focus();
|
|
1679
|
+
await user.keyboard( '{ArrowRight}{ArrowRight}{Enter}' );
|
|
1680
|
+
|
|
1681
|
+
expect( onDatumActivate ).toHaveBeenCalledWith( {
|
|
1682
|
+
datum: SERIES_B.data[ 1 ],
|
|
1683
|
+
index: 1,
|
|
1684
|
+
key: 'Series B',
|
|
1685
|
+
} );
|
|
1686
|
+
} );
|
|
1687
|
+
|
|
1661
1688
|
test( 'Enter with no point selected activates nothing', async () => {
|
|
1662
1689
|
const user = userEvent.setup();
|
|
1663
1690
|
const onDatumActivate = jest.fn();
|
|
@@ -1670,6 +1697,80 @@ describe( 'LineChart', () => {
|
|
|
1670
1697
|
} );
|
|
1671
1698
|
} );
|
|
1672
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
|
+
|
|
1673
1774
|
describe( 'Arrow Key Navigation', () => {
|
|
1674
1775
|
test.each( [ 'auto', 'below-axis' ] as const )(
|
|
1675
1776
|
'right arrow key navigates with %s tooltips',
|
|
@@ -1859,6 +1960,144 @@ describe( 'LineChart', () => {
|
|
|
1859
1960
|
expect( legendItem ).toHaveAttribute( 'aria-pressed', 'false' );
|
|
1860
1961
|
} );
|
|
1861
1962
|
|
|
1963
|
+
it.each( [ 'Series A', 'Series B' ] )(
|
|
1964
|
+
'leaves %s out of the keyboard tooltip once it is hidden',
|
|
1965
|
+
async hiddenLabel => {
|
|
1966
|
+
const user = userEvent.setup();
|
|
1967
|
+
const visibleLabel = hiddenLabel === 'Series A' ? 'Series B' : 'Series A';
|
|
1968
|
+
const renderTooltip = jest.fn( renderDefaultTooltip );
|
|
1969
|
+
|
|
1970
|
+
render(
|
|
1971
|
+
<GlobalChartsProvider>
|
|
1972
|
+
<LineChartUnresponsive
|
|
1973
|
+
width={ 500 }
|
|
1974
|
+
height={ 300 }
|
|
1975
|
+
data={ [
|
|
1976
|
+
defaultProps.data[ 0 ],
|
|
1977
|
+
{
|
|
1978
|
+
label: 'Series B',
|
|
1979
|
+
data: [
|
|
1980
|
+
{ date: new Date( '2024-01-01' ), value: 15 },
|
|
1981
|
+
{ date: new Date( '2024-01-02' ), value: 25 },
|
|
1982
|
+
],
|
|
1983
|
+
},
|
|
1984
|
+
] }
|
|
1985
|
+
withGradientFill={ false }
|
|
1986
|
+
showLegend={ true }
|
|
1987
|
+
legend={ { interactive: true } }
|
|
1988
|
+
renderTooltip={ renderTooltip }
|
|
1989
|
+
chartId={ `test-keyboard-tooltip-hidden-${ hiddenLabel }` }
|
|
1990
|
+
/>
|
|
1991
|
+
</GlobalChartsProvider>
|
|
1992
|
+
);
|
|
1993
|
+
|
|
1994
|
+
await user.click( screen.getByRole( 'button', { name: new RegExp( hiddenLabel ) } ) );
|
|
1995
|
+
screen.getByRole( 'application', { name: /line chart/i } ).focus();
|
|
1996
|
+
await user.keyboard( '{ArrowRight}' );
|
|
1997
|
+
|
|
1998
|
+
const tooltip = await screen.findByTestId( 'line-chart-tooltip-content' );
|
|
1999
|
+
expect( tooltip ).toHaveTextContent( visibleLabel );
|
|
2000
|
+
expect( tooltip ).not.toHaveTextContent( hiddenLabel );
|
|
2001
|
+
expect( renderTooltip ).toHaveBeenLastCalledWith(
|
|
2002
|
+
expect.objectContaining( {
|
|
2003
|
+
tooltipData: expect.objectContaining( {
|
|
2004
|
+
nearestDatum: expect.objectContaining( { key: visibleLabel } ),
|
|
2005
|
+
} ),
|
|
2006
|
+
} )
|
|
2007
|
+
);
|
|
2008
|
+
}
|
|
2009
|
+
);
|
|
2010
|
+
|
|
2011
|
+
it( 'does not leave a series hidden mid-navigation in the keyboard tooltip', async () => {
|
|
2012
|
+
const user = userEvent.setup();
|
|
2013
|
+
|
|
2014
|
+
render(
|
|
2015
|
+
<GlobalChartsProvider>
|
|
2016
|
+
<LineChartUnresponsive
|
|
2017
|
+
width={ 500 }
|
|
2018
|
+
height={ 300 }
|
|
2019
|
+
data={ [
|
|
2020
|
+
defaultProps.data[ 0 ],
|
|
2021
|
+
{
|
|
2022
|
+
label: 'Series B',
|
|
2023
|
+
data: [
|
|
2024
|
+
{ date: new Date( '2024-01-01' ), value: 15 },
|
|
2025
|
+
{ date: new Date( '2024-01-02' ), value: 25 },
|
|
2026
|
+
],
|
|
2027
|
+
},
|
|
2028
|
+
] }
|
|
2029
|
+
withGradientFill={ false }
|
|
2030
|
+
showLegend={ true }
|
|
2031
|
+
legend={ { interactive: true } }
|
|
2032
|
+
chartId="test-keyboard-tooltip-hide-mid-navigation"
|
|
2033
|
+
/>
|
|
2034
|
+
</GlobalChartsProvider>
|
|
2035
|
+
);
|
|
2036
|
+
|
|
2037
|
+
screen.getByRole( 'application', { name: /line chart/i } ).focus();
|
|
2038
|
+
await user.keyboard( '{ArrowRight}' );
|
|
2039
|
+
await expect(
|
|
2040
|
+
screen.findByTestId( 'line-chart-tooltip-content' )
|
|
2041
|
+
).resolves.toHaveTextContent( 'Series A' );
|
|
2042
|
+
|
|
2043
|
+
await user.click( screen.getByRole( 'button', { name: /Series A/ } ) );
|
|
2044
|
+
|
|
2045
|
+
await waitFor( () =>
|
|
2046
|
+
expect( screen.queryByTestId( 'line-chart-tooltip-content' ) ).not.toBeInTheDocument()
|
|
2047
|
+
);
|
|
2048
|
+
} );
|
|
2049
|
+
|
|
2050
|
+
it( 'drops a series hidden programmatically from an open keyboard tooltip', async () => {
|
|
2051
|
+
const user = userEvent.setup();
|
|
2052
|
+
let context: GlobalChartsContextValue;
|
|
2053
|
+
const Grab = () => {
|
|
2054
|
+
context = useGlobalChartsContext();
|
|
2055
|
+
return null;
|
|
2056
|
+
};
|
|
2057
|
+
|
|
2058
|
+
render(
|
|
2059
|
+
<GlobalChartsProvider>
|
|
2060
|
+
<Grab />
|
|
2061
|
+
<LineChartUnresponsive
|
|
2062
|
+
width={ 500 }
|
|
2063
|
+
height={ 300 }
|
|
2064
|
+
withGradientFill={ false }
|
|
2065
|
+
chartId="test-keyboard-tooltip-programmatic-hide"
|
|
2066
|
+
data={ [
|
|
2067
|
+
{
|
|
2068
|
+
label: 'Series A',
|
|
2069
|
+
data: [
|
|
2070
|
+
{ date: new Date( '2024-01-01' ), value: 10 },
|
|
2071
|
+
{ date: new Date( '2024-01-02' ), value: 20 },
|
|
2072
|
+
{ date: new Date( '2024-01-03' ), value: 30 },
|
|
2073
|
+
],
|
|
2074
|
+
},
|
|
2075
|
+
{
|
|
2076
|
+
label: 'Series B',
|
|
2077
|
+
data: [
|
|
2078
|
+
{ date: new Date( '2024-01-01' ), value: 15 },
|
|
2079
|
+
{ date: new Date( '2024-01-02' ), value: 25 },
|
|
2080
|
+
],
|
|
2081
|
+
},
|
|
2082
|
+
] }
|
|
2083
|
+
/>
|
|
2084
|
+
</GlobalChartsProvider>
|
|
2085
|
+
);
|
|
2086
|
+
|
|
2087
|
+
screen.getByRole( 'application', { name: /line chart/i } ).focus();
|
|
2088
|
+
await user.keyboard( '{ArrowRight}{ArrowRight}' );
|
|
2089
|
+
await expect(
|
|
2090
|
+
screen.findByTestId( 'line-chart-tooltip-content' )
|
|
2091
|
+
).resolves.toHaveTextContent( 'Series A' );
|
|
2092
|
+
|
|
2093
|
+
act( () => {
|
|
2094
|
+
context.toggleSeriesVisibility( 'test-keyboard-tooltip-programmatic-hide', 'Series A' );
|
|
2095
|
+
} );
|
|
2096
|
+
|
|
2097
|
+
await waitFor( () => expect( screen.queryByText( 'Series A:' ) ).not.toBeInTheDocument() );
|
|
2098
|
+
expect( screen.getByTestId( 'line-chart-tooltip-content' ) ).toHaveTextContent( 'Series B' );
|
|
2099
|
+
} );
|
|
2100
|
+
|
|
1862
2101
|
it( 'does not filter series when legendInteractive is false', () => {
|
|
1863
2102
|
render(
|
|
1864
2103
|
<GlobalChartsProvider>
|
|
@@ -31,10 +31,10 @@ import { pickLabelTextColor, resolveLabelRoles } from '../private/label-text-col
|
|
|
31
31
|
import {
|
|
32
32
|
orderArcsForNavigation,
|
|
33
33
|
PieSelectionOutput,
|
|
34
|
-
SelectedSegmentRing,
|
|
35
34
|
usePieKeyboardNavigation,
|
|
36
35
|
} from '../private/pie-keyboard-navigation';
|
|
37
36
|
import { RadialWipeAnimation } from '../private/radial-wipe-animation/';
|
|
37
|
+
import { SelectedRing } from '../private/selected-ring/selected-ring';
|
|
38
38
|
import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
|
|
39
39
|
import { withResponsive, ResponsiveConfig } from '../private/with-responsive';
|
|
40
40
|
import styles from './pie-chart.module.scss';
|
|
@@ -503,7 +503,12 @@ const PieChartInternal = ( {
|
|
|
503
503
|
{ /* Unmasked so the entry wipe cannot hide the focus indicator. */ }
|
|
504
504
|
{ selectedArc && (
|
|
505
505
|
<Group top={ centerY } left={ centerX }>
|
|
506
|
-
<
|
|
506
|
+
<SelectedRing
|
|
507
|
+
chartId={ chartId }
|
|
508
|
+
d={ path( selectedArc ) || '' }
|
|
509
|
+
side="inside"
|
|
510
|
+
testId="pie-selected-ring"
|
|
511
|
+
/>
|
|
507
512
|
</Group>
|
|
508
513
|
) }
|
|
509
514
|
{ ! allSegmentsHidden && (
|
|
@@ -26,10 +26,10 @@ import { ChartLayout } from '../private/chart-layout';
|
|
|
26
26
|
import {
|
|
27
27
|
orderArcsForNavigation,
|
|
28
28
|
PieSelectionOutput,
|
|
29
|
-
SelectedSegmentRing,
|
|
30
29
|
usePieKeyboardNavigation,
|
|
31
30
|
} from '../private/pie-keyboard-navigation';
|
|
32
31
|
import { RadialWipeAnimation } from '../private/radial-wipe-animation';
|
|
32
|
+
import { SelectedRing } from '../private/selected-ring/selected-ring';
|
|
33
33
|
import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
|
|
34
34
|
import { withResponsive } from '../private/with-responsive';
|
|
35
35
|
import styles from './pie-semi-circle-chart.module.scss';
|
|
@@ -429,7 +429,12 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
|
|
|
429
429
|
{ /* Unmasked so the entry wipe cannot hide the focus indicator. */ }
|
|
430
430
|
{ selectedArc && (
|
|
431
431
|
<Group top={ height } left={ width / 2 }>
|
|
432
|
-
<
|
|
432
|
+
<SelectedRing
|
|
433
|
+
chartId={ chartId }
|
|
434
|
+
d={ path( selectedArc ) || '' }
|
|
435
|
+
side="inside"
|
|
436
|
+
testId="pie-selected-ring"
|
|
437
|
+
/>
|
|
433
438
|
</Group>
|
|
434
439
|
) }
|
|
435
440
|
{ ! allSegmentsHidden && (
|
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
// The clip
|
|
2
|
-
|
|
3
|
-
.selected-
|
|
4
|
-
.selected-ring__focus {
|
|
1
|
+
// The clip removes one half of each stroke, so widths are doubled.
|
|
2
|
+
.selected-ring__far,
|
|
3
|
+
.selected-ring__edge {
|
|
5
4
|
fill: none;
|
|
6
5
|
}
|
|
7
6
|
|
|
7
|
+
.selected-ring__far {
|
|
8
|
+
stroke-width: calc(4 * var(--wpds-border-width-focus));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.selected-ring__edge {
|
|
12
|
+
stroke-width: calc(2 * var(--wpds-border-width-focus));
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
.selected-ring__contrast {
|
|
9
16
|
stroke: var(--a8c-charts-color-background, #fff);
|
|
10
|
-
stroke-width: calc(4 * var(--wpds-border-width-focus));
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
.selected-ring__focus {
|
|
14
20
|
stroke: var(--wpds-color-stroke-focus);
|
|
15
|
-
stroke-width: calc(2 * var(--wpds-border-width-focus));
|
|
16
21
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import clsx from 'clsx';
|
|
2
|
+
import styles from './selected-ring.module.scss';
|
|
3
|
+
|
|
4
|
+
type Tone = 'contrast' | 'focus';
|
|
5
|
+
|
|
6
|
+
/** Which side of the shape's edge the ring is drawn on. */
|
|
7
|
+
export type SelectedRingSide = 'inside' | 'outside';
|
|
8
|
+
|
|
9
|
+
interface SelectedRingProps {
|
|
10
|
+
/** The chart's id, which keeps the clip path unique on a page with several charts. */
|
|
11
|
+
chartId: string;
|
|
12
|
+
/** The selected shape's path. */
|
|
13
|
+
d: string;
|
|
14
|
+
/** `inside` keeps the ring clear of the SVG edge; `outside` leaves the shape's fill uncovered. */
|
|
15
|
+
side: SelectedRingSide;
|
|
16
|
+
testId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The tone at the edge is drawn last, over the wider tone beyond it.
|
|
20
|
+
const TONES: Record< SelectedRingSide, [ far: Tone, edge: Tone ] > = {
|
|
21
|
+
inside: [ 'contrast', 'focus' ],
|
|
22
|
+
outside: [ 'focus', 'contrast' ],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const EVERYWHERE = 'M-1e6 -1e6H1e6V1e6H-1e6Z';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Encodes `value` for an SVG id, losslessly so distinct chart ids never share a clip path.
|
|
29
|
+
*
|
|
30
|
+
* @param value - Any string.
|
|
31
|
+
* @return `value` with each character outside `[A-Za-z0-9-]` written as `_<hex code point>_`.
|
|
32
|
+
*/
|
|
33
|
+
export const toSvgId = ( value: string ) =>
|
|
34
|
+
Array.from( value, character =>
|
|
35
|
+
/[A-Za-z0-9-]/.test( character )
|
|
36
|
+
? character
|
|
37
|
+
: `_${ character.codePointAt( 0 )?.toString( 16 ) }_`
|
|
38
|
+
).join( '' );
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Marks a selected shape with a focus-colored ring and a contrast ring, so it reads against any fill.
|
|
42
|
+
*
|
|
43
|
+
* @param {SelectedRingProps} props - Component props
|
|
44
|
+
* @return {JSX.Element} The ring
|
|
45
|
+
*/
|
|
46
|
+
export const SelectedRing = ( { chartId, d, side, testId }: SelectedRingProps ) => {
|
|
47
|
+
const clipId = `selected-ring-clip-${ toSvgId( chartId ) }`;
|
|
48
|
+
const clipPath = `url(#${ clipId })`;
|
|
49
|
+
const [ far, edge ] = TONES[ side ];
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<g pointerEvents="none" aria-hidden="true">
|
|
53
|
+
<clipPath id={ clipId }>
|
|
54
|
+
{ side === 'inside' ? (
|
|
55
|
+
<path d={ d } />
|
|
56
|
+
) : (
|
|
57
|
+
<path d={ `${ EVERYWHERE } ${ d }` } clipRule="evenodd" />
|
|
58
|
+
) }
|
|
59
|
+
</clipPath>
|
|
60
|
+
<path
|
|
61
|
+
d={ d }
|
|
62
|
+
clipPath={ clipPath }
|
|
63
|
+
className={ clsx( styles[ 'selected-ring__far' ], styles[ `selected-ring__${ far }` ] ) }
|
|
64
|
+
/>
|
|
65
|
+
<path
|
|
66
|
+
d={ d }
|
|
67
|
+
clipPath={ clipPath }
|
|
68
|
+
className={ clsx( styles[ 'selected-ring__edge' ], styles[ `selected-ring__${ edge }` ] ) }
|
|
69
|
+
data-testid={ testId }
|
|
70
|
+
/>
|
|
71
|
+
</g>
|
|
72
|
+
);
|
|
73
|
+
};
|
|
@@ -421,6 +421,26 @@ describe( 'useChartLegendItems', () => {
|
|
|
421
421
|
expect( result.current[ 2 ].color ).toBe( result.current[ 0 ].color );
|
|
422
422
|
} );
|
|
423
423
|
|
|
424
|
+
test( 'colors each swatch, comparison items included, from seriesColors by series index', () => {
|
|
425
|
+
const seriesColors = [ '#111111', '#222222', '#333333', '#444444' ];
|
|
426
|
+
const { result } = renderHook(
|
|
427
|
+
() =>
|
|
428
|
+
useChartLegendItems( twoMetricData, {
|
|
429
|
+
collapseGroups: true,
|
|
430
|
+
comparisonSeriesItems: perMetricLabel,
|
|
431
|
+
seriesColors,
|
|
432
|
+
} ),
|
|
433
|
+
{ wrapper }
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
expect( result.current.map( item => item.color ) ).toEqual( [
|
|
437
|
+
'#111111',
|
|
438
|
+
'#333333',
|
|
439
|
+
'#222222',
|
|
440
|
+
'#444444',
|
|
441
|
+
] );
|
|
442
|
+
} );
|
|
443
|
+
|
|
424
444
|
test( 'adds nothing when no series is a comparison', () => {
|
|
425
445
|
const { result } = renderHook(
|
|
426
446
|
() => useChartLegendItems( [ comparisonData[ 0 ] ], { comparisonItem: true } ),
|
|
@@ -41,6 +41,25 @@ export interface ChartLegendOptions {
|
|
|
41
41
|
comparisonItem?: SeriesChartLegendConfig[ 'comparisonItem' ];
|
|
42
42
|
/** One static item per comparison series instead; see `SeriesChartLegendConfig`. */
|
|
43
43
|
comparisonSeriesItems?: SeriesChartLegendConfig[ 'comparisonSeriesItems' ];
|
|
44
|
+
/** Each series' swatch color, by index, in place of its series color. */
|
|
45
|
+
seriesColors?: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Wraps `getElementStyles` so a swatch takes the color given for its series.
|
|
50
|
+
* @param getElementStyles - Function to get element styles
|
|
51
|
+
* @param seriesColors - Each series' swatch color, by index
|
|
52
|
+
* @return Element styles with the given color
|
|
53
|
+
*/
|
|
54
|
+
function withSeriesColors(
|
|
55
|
+
getElementStyles: ( params: GetElementStylesParams ) => ElementStyles,
|
|
56
|
+
seriesColors: string[]
|
|
57
|
+
): ( params: GetElementStylesParams ) => ElementStyles {
|
|
58
|
+
return params => {
|
|
59
|
+
const styles = getElementStyles( params );
|
|
60
|
+
|
|
61
|
+
return { ...styles, color: seriesColors[ params.index ] ?? styles.color };
|
|
62
|
+
};
|
|
44
63
|
}
|
|
45
64
|
|
|
46
65
|
/**
|
|
@@ -386,9 +405,10 @@ export function useChartLegendItems<
|
|
|
386
405
|
collapseGroups = false,
|
|
387
406
|
comparisonItem = false,
|
|
388
407
|
comparisonSeriesItems,
|
|
408
|
+
seriesColors,
|
|
389
409
|
renderGlyph,
|
|
390
410
|
} = options;
|
|
391
|
-
const { getElementStyles } = useGlobalChartsContext();
|
|
411
|
+
const { getElementStyles: getSeriesStyles } = useGlobalChartsContext();
|
|
392
412
|
|
|
393
413
|
return useMemo( () => {
|
|
394
414
|
if ( ! data || ! Array.isArray( data ) || data.length === 0 ) {
|
|
@@ -398,6 +418,9 @@ export function useChartLegendItems<
|
|
|
398
418
|
// Handle SeriesData (multiple series with data points)
|
|
399
419
|
if ( 'data' in data[ 0 ] ) {
|
|
400
420
|
const seriesData = data as SeriesData[];
|
|
421
|
+
const getElementStyles = seriesColors
|
|
422
|
+
? withSeriesColors( getSeriesStyles, seriesColors )
|
|
423
|
+
: getSeriesStyles;
|
|
401
424
|
const items = processSeriesData(
|
|
402
425
|
seriesData,
|
|
403
426
|
getElementStyles,
|
|
@@ -440,7 +463,7 @@ export function useChartLegendItems<
|
|
|
440
463
|
// Handle DataPointDate or DataPointPercentageCalculated (single data points)
|
|
441
464
|
return processPointData(
|
|
442
465
|
data as ( DataPointDate | DataPointPercentageCalculated )[],
|
|
443
|
-
|
|
466
|
+
getSeriesStyles,
|
|
444
467
|
showValues,
|
|
445
468
|
legendValueDisplay,
|
|
446
469
|
withGlyph,
|
|
@@ -450,7 +473,7 @@ export function useChartLegendItems<
|
|
|
450
473
|
);
|
|
451
474
|
}, [
|
|
452
475
|
data,
|
|
453
|
-
|
|
476
|
+
getSeriesStyles,
|
|
454
477
|
showValues,
|
|
455
478
|
legendValueDisplay,
|
|
456
479
|
withGlyph,
|
|
@@ -458,6 +481,7 @@ export function useChartLegendItems<
|
|
|
458
481
|
collapseGroups,
|
|
459
482
|
comparisonItem,
|
|
460
483
|
comparisonSeriesItems,
|
|
484
|
+
seriesColors,
|
|
461
485
|
renderGlyph,
|
|
462
486
|
legendShape,
|
|
463
487
|
] );
|
|
@@ -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
|
+
};
|