@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/index.cjs +1412 -787
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +59 -25
  5. package/dist/index.d.cts +64 -120
  6. package/dist/index.d.ts +64 -120
  7. package/dist/index.js +1400 -765
  8. package/dist/index.js.map +1 -1
  9. package/package.json +9 -9
  10. package/src/charts/area-chart/area-chart.tsx +16 -6
  11. package/src/charts/area-chart/test/area-chart.test.tsx +42 -9
  12. package/src/charts/bar-chart/bar-chart.tsx +143 -142
  13. package/src/charts/bar-chart/index.ts +3 -0
  14. package/src/charts/bar-chart/private/band-tooltip.tsx +41 -15
  15. package/src/charts/bar-chart/private/bar-layer.tsx +132 -0
  16. package/src/charts/bar-chart/private/comparison-bars-geometry.ts +22 -34
  17. package/src/charts/bar-chart/private/comparison-bars.tsx +9 -18
  18. package/src/charts/bar-chart/private/comparison-constants.ts +4 -10
  19. package/src/charts/bar-chart/private/grouped-bars.ts +68 -0
  20. package/src/charts/bar-chart/private/index.ts +7 -1
  21. package/src/charts/bar-chart/private/overlap-bars.ts +183 -0
  22. package/src/charts/bar-chart/private/selected-bar-ring.tsx +84 -0
  23. package/src/charts/bar-chart/private/slot-tooltip-members.tsx +28 -0
  24. package/src/charts/bar-chart/private/slotted-bar-series.tsx +84 -0
  25. package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +4 -11
  26. package/src/charts/bar-chart/private/test/comparison-bars.test.tsx +2 -3
  27. package/src/charts/bar-chart/private/test/grouped-bars.test.ts +87 -0
  28. package/src/charts/bar-chart/private/test/overlap-bars.test.ts +159 -0
  29. package/src/charts/bar-chart/private/use-bar-stops.ts +87 -0
  30. package/src/charts/bar-chart/private/use-overlap-slots.ts +213 -0
  31. package/src/charts/bar-chart/private/value-domain.ts +0 -10
  32. package/src/charts/bar-chart/test/bar-chart-overlap.test.tsx +347 -0
  33. package/src/charts/bar-chart/test/bar-chart.test.tsx +181 -19
  34. package/src/charts/bar-chart/test/use-bar-legend-items.test.tsx +81 -0
  35. package/src/charts/bar-chart/types.ts +3 -0
  36. package/src/charts/bar-chart/use-bar-colors.ts +46 -0
  37. package/src/charts/bar-chart/use-bar-legend-items.ts +87 -0
  38. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +24 -11
  39. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +235 -182
  40. package/src/charts/conversion-funnel-chart/index.ts +2 -0
  41. package/src/charts/conversion-funnel-chart/private/index.ts +1 -0
  42. package/src/charts/conversion-funnel-chart/private/use-funnel-selection.ts +9 -47
  43. package/src/charts/conversion-funnel-chart/private/use-roving-step-focus.ts +83 -0
  44. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +312 -92
  45. package/src/charts/conversion-funnel-chart/test/use-funnel-selection.test.ts +23 -188
  46. package/src/charts/conversion-funnel-chart/types.ts +22 -0
  47. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +41 -1
  48. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +101 -30
  49. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +47 -0
  50. package/src/charts/line-chart/line-chart.tsx +25 -18
  51. package/src/charts/line-chart/test/line-chart.test.tsx +240 -1
  52. package/src/charts/pie-chart/pie-chart.tsx +7 -2
  53. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +7 -2
  54. package/src/charts/private/pie-keyboard-navigation/index.ts +0 -1
  55. package/src/charts/private/{pie-keyboard-navigation/pie-keyboard-navigation.module.scss → selected-ring/selected-ring.module.scss} +11 -6
  56. package/src/charts/private/selected-ring/selected-ring.tsx +73 -0
  57. package/src/charts/private/{pie-keyboard-navigation/test/selected-segment-ring.test.ts → selected-ring/test/selected-ring.test.ts} +1 -1
  58. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +20 -0
  59. package/src/components/legend/hooks/use-chart-legend-items.ts +27 -3
  60. package/src/components/tooltip/private/at-nearest-date.ts +49 -0
  61. package/src/components/tooltip/private/keyboard-stops.ts +67 -0
  62. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +10 -2
  63. package/src/components/tooltip/test/keyboard-stops.test.ts +36 -0
  64. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +135 -1
  65. package/src/components/tooltip/xy-chart-tooltip.tsx +11 -14
  66. package/src/index.ts +10 -2
  67. package/src/providers/chart-context/global-charts-provider.tsx +2 -0
  68. package/src/providers/chart-context/test/chart-context.test.tsx +0 -13
  69. package/src/providers/chart-context/themes.ts +0 -1
  70. package/src/providers/chart-context/types.ts +2 -0
  71. package/src/test-utils/selected-bar-ring.ts +14 -0
  72. package/src/types.ts +14 -5
  73. 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
- // Keyboard navigation steps through x positions, and the grouped tooltip
382
- // reads every series at that position; the first series names the point.
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
- ( index: number ) => {
385
- const series = dataSorted[ 0 ];
386
- const datum = series?.data[ index ];
395
+ ( stop: number ) => {
396
+ const match = stops.membersAt( stop )[ 0 ];
387
397
 
388
- if ( series && datum ) {
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
- [ dataSorted, onDatumActivate ]
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: dataSorted[ 0 ]?.data.length || 0,
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( dataSorted.filter( series => isSeriesVisible( series.label ) ) ),
428
- [ dataSorted, isSeriesVisible ]
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
- getBucketInfo(
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={ dataSorted }
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
- <SelectedSegmentRing chartId={ chartId } d={ path( selectedArc ) || '' } />
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
- <SelectedSegmentRing chartId={ chartId } d={ path( selectedArc ) || '' } />
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,4 +1,3 @@
1
1
  export { orderArcsForNavigation } from './order-arcs-for-navigation';
2
2
  export { PieSelectionOutput } from './pie-selection-output';
3
- export { SelectedSegmentRing } from './selected-segment-ring';
4
3
  export { usePieKeyboardNavigation } from './use-pie-keyboard-navigation';
@@ -1,16 +1,21 @@
1
- // The clip to the segment removes the outer half of each stroke,
2
- // so widths are doubled.
3
- .selected-ring__contrast,
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
+ };
@@ -1,4 +1,4 @@
1
- import { toSvgId } from '../selected-segment-ring';
1
+ import { toSvgId } from '../selected-ring';
2
2
 
3
3
  describe( 'toSvgId', () => {
4
4
  it.each( [
@@ -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
- getElementStyles,
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
- getElementStyles,
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
+ };