@automattic/charts 3.0.0 → 3.1.1

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 (63) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/TOKENS.md +3 -3
  3. package/dist/index.cjs +250 -136
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +23 -14
  6. package/dist/index.d.cts +51 -14
  7. package/dist/index.d.ts +51 -14
  8. package/dist/index.js +251 -138
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.tsx +26 -26
  12. package/src/charts/area-chart/private/overlays.tsx +3 -3
  13. package/src/charts/area-chart/test/area-chart.test.tsx +346 -11
  14. package/src/charts/area-chart/types.ts +4 -3
  15. package/src/charts/bar-chart/bar-chart.tsx +21 -15
  16. package/src/charts/bar-chart/private/use-bar-chart-options.ts +19 -12
  17. package/src/charts/bar-chart/test/bar-chart.test.tsx +277 -2
  18. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +6 -6
  19. package/src/charts/geo-chart/geo-chart.module.scss +8 -0
  20. package/src/charts/heatmap-chart/heatmap-chart.module.scss +9 -0
  21. package/src/charts/heatmap-chart/heatmap-chart.tsx +31 -8
  22. package/src/charts/heatmap-chart/private/build-calendar-data.ts +65 -5
  23. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +125 -0
  24. package/src/charts/heatmap-chart/types.ts +8 -0
  25. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +17 -22
  26. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +107 -1
  27. package/src/charts/line-chart/line-chart.module.scss +2 -2
  28. package/src/charts/line-chart/line-chart.tsx +33 -29
  29. package/src/charts/line-chart/private/line-chart-annotations-overlay.tsx +2 -2
  30. package/src/charts/line-chart/test/line-chart.test.tsx +313 -5
  31. package/src/charts/line-chart/types.ts +4 -3
  32. package/src/charts/pie-chart/pie-chart.tsx +8 -13
  33. package/src/charts/pie-chart/test/pie-chart.test.tsx +73 -1
  34. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +2 -2
  35. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +8 -13
  36. package/src/charts/private/{single-chart-context/single-chart-context.tsx → chart-instance-context/chart-instance-context.ts} +1 -5
  37. package/src/charts/private/chart-instance-context/index.ts +3 -0
  38. package/src/charts/private/{single-chart-context/use-single-chart-context.ts → chart-instance-context/use-chart-instance-context.ts} +1 -3
  39. package/src/charts/private/svg-empty-state/get-all-hidden-message.ts +41 -0
  40. package/src/charts/private/svg-empty-state/index.ts +1 -0
  41. package/src/charts/private/svg-empty-state/test/get-all-hidden-message.test.ts +15 -0
  42. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +2 -2
  43. package/src/charts/private/x-zoom/x-zoom.tsx +2 -2
  44. package/src/components/legend/legend.tsx +4 -4
  45. package/src/components/legend/private/base-legend.module.scss +1 -1
  46. package/src/components/legend/private/base-legend.tsx +67 -18
  47. package/src/components/legend/test/legend.test.tsx +100 -4
  48. package/src/components/tooltip/accessible-tooltip.tsx +13 -2
  49. package/src/components/trend-indicator/trend-indicator.module.scss +1 -1
  50. package/src/hooks/index.ts +1 -1
  51. package/src/hooks/{use-interactive-legend-data.ts → use-legend-visibility-data.ts} +29 -30
  52. package/src/index.ts +3 -1
  53. package/src/providers/chart-context/global-charts-provider.tsx +92 -18
  54. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +47 -0
  55. package/src/providers/chart-context/test/chart-context.test.tsx +142 -0
  56. package/src/providers/chart-context/test/use-default-hidden-series-paint.test.tsx +56 -0
  57. package/src/providers/chart-context/test/use-default-hidden-series.ssr.test.tsx +39 -0
  58. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +222 -0
  59. package/src/providers/chart-context/types.ts +7 -1
  60. package/src/styles/chart-scope.scss +2 -2
  61. package/src/styles/test/wpds-fallbacks.test.ts +131 -0
  62. package/src/types.ts +12 -0
  63. package/src/charts/private/single-chart-context/index.ts +0 -8
@@ -0,0 +1,41 @@
1
+ import { _x } from '@wordpress/i18n';
2
+
3
+ export type HiddenChartItemType = 'series' | 'segments';
4
+
5
+ /**
6
+ * Return the empty-state message for a chart whose items are all hidden.
7
+ *
8
+ * Each translation call has a distinct gettext context. This prevents Terser
9
+ * from folding conditional calls into one call with a conditional msgid, which
10
+ * would make the strings unavailable to static translation extraction.
11
+ *
12
+ * @param interactive - Whether the legend can be used to show hidden items.
13
+ * @param itemType - The chart-specific name for its data items.
14
+ * @return The translated empty-state message.
15
+ */
16
+ export const getAllHiddenMessage = (
17
+ interactive: boolean,
18
+ itemType: HiddenChartItemType
19
+ ): string => {
20
+ if ( itemType === 'segments' ) {
21
+ if ( interactive ) {
22
+ return _x(
23
+ 'All segments are hidden. Click legend items to show data.',
24
+ 'chart empty state: interactive segments',
25
+ 'jetpack-charts'
26
+ );
27
+ }
28
+
29
+ return _x( 'All segments are hidden.', 'chart empty state: segments', 'jetpack-charts' );
30
+ }
31
+
32
+ if ( interactive ) {
33
+ return _x(
34
+ 'All series are hidden. Click legend items to show data.',
35
+ 'chart empty state: interactive series',
36
+ 'jetpack-charts'
37
+ );
38
+ }
39
+
40
+ return _x( 'All series are hidden.', 'chart empty state: series', 'jetpack-charts' );
41
+ };
@@ -1 +1,2 @@
1
1
  export { SvgEmptyState } from './svg-empty-state';
2
+ export { getAllHiddenMessage } from './get-all-hidden-message';
@@ -0,0 +1,15 @@
1
+ import { getAllHiddenMessage } from '../get-all-hidden-message';
2
+
3
+ describe( 'getAllHiddenMessage', () => {
4
+ it.each( [
5
+ [ false, 'series', 'All series are hidden.' ],
6
+ [ true, 'series', 'All series are hidden. Click legend items to show data.' ],
7
+ [ false, 'segments', 'All segments are hidden.' ],
8
+ [ true, 'segments', 'All segments are hidden. Click legend items to show data.' ],
9
+ ] as const )(
10
+ 'returns the expected message for interactive=%s %s',
11
+ ( interactive, type, message ) => {
12
+ expect( getAllHiddenMessage( interactive, type ) ).toBe( message );
13
+ }
14
+ );
15
+ } );
@@ -2,7 +2,7 @@ import { act, render, renderHook, screen, waitFor } from '@testing-library/react
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { useCallback, useRef, useState } from 'react';
4
4
  import { useXZoom, ZoomResetButton } from '../index';
5
- import type { SingleChartRef } from '../../single-chart-context';
5
+ import type { ChartInstanceRef } from '../../chart-instance-context';
6
6
  import type { EventHandlerParams } from '@visx/xychart';
7
7
  import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
8
8
 
@@ -23,7 +23,7 @@ const makeChartRef = (
23
23
  getScales: () => ( scale ? { xScale: scale, yScale: scale } : null ),
24
24
  getChartDimensions: () => ( { width: 0, height: 0, margin: {} } ),
25
25
  },
26
- } ) as unknown as ReturnType< typeof useRef< SingleChartRef > >;
26
+ } ) as unknown as ReturnType< typeof useRef< ChartInstanceRef > >;
27
27
 
28
28
  const makeParams = ( x: number ): EventHandlerParams< object > =>
29
29
  ( {
@@ -3,7 +3,7 @@ import { __ } from '@wordpress/i18n';
3
3
  import { IconButton } from '@wordpress/ui';
4
4
  import { useCallback, useContext, useMemo, useState } from 'react';
5
5
  import styles from './x-zoom.module.scss';
6
- import type { SingleChartRef } from '../single-chart-context';
6
+ import type { ChartInstanceRef } from '../chart-instance-context';
7
7
  import type { AxisScale } from '@visx/axis';
8
8
  import type { EventHandlerParams } from '@visx/xychart';
9
9
  import type { KeyboardEvent, ReactNode, RefObject } from 'react';
@@ -38,7 +38,7 @@ export function useXZoom< T extends Date | number = Date >( {
38
38
  userHandlers,
39
39
  }: {
40
40
  enabled: boolean;
41
- chartRef: RefObject< SingleChartRef | null >;
41
+ chartRef: RefObject< ChartInstanceRef | null >;
42
42
  userHandlers?: {
43
43
  onPointerDown?: PointerHandler;
44
44
  onPointerMove?: PointerHandler;
@@ -1,5 +1,5 @@
1
1
  import { useContext, useMemo, forwardRef } from 'react';
2
- import { SingleChartContext } from '../../charts/private/single-chart-context';
2
+ import { ChartInstanceContext } from '../../charts/private/chart-instance-context';
3
3
  import { GlobalChartsContext } from '../../providers';
4
4
  import { BaseLegend } from './private';
5
5
  import type { LegendProps } from './types';
@@ -20,11 +20,11 @@ export const Legend = forwardRef< HTMLDivElement, LegendProps >(
20
20
  ( { chartId, items, shape, ...props }, ref ) => {
21
21
  // Get context but don't throw if it doesn't exist
22
22
  const context = useContext( GlobalChartsContext );
23
- const singleChartContext = useContext( SingleChartContext );
23
+ const chartInstanceContext = useContext( ChartInstanceContext );
24
24
 
25
25
  // When chartId is used, it is standalone mode
26
- // When chartId is not provided, we use the context's chartId, meaning it is in a single chart context
27
- const contextChartId = chartId ?? singleChartContext?.chartId;
26
+ // When chartId is not provided, we use the context's chartId, meaning it is inside a chart instance
27
+ const contextChartId = chartId ?? chartInstanceContext?.chartId;
28
28
 
29
29
  const chartData = useMemo(
30
30
  () => ( contextChartId && context ? context.getChartData( contextChartId ) : undefined ),
@@ -54,6 +54,6 @@
54
54
  }
55
55
 
56
56
  .legend-item-value {
57
- font-weight: var(--wpds-typography-font-weight-emphasis, 499);
57
+ font-weight: var(--wpds-typography-font-weight-emphasis, 600);
58
58
  flex-shrink: 0; // Prevent value from shrinking when text is ellipsized
59
59
  }
@@ -1,6 +1,7 @@
1
1
  import { Group } from '@visx/group';
2
2
  import { LegendItem, LegendLabel, LegendOrdinal, LegendShape } from '@visx/legend';
3
3
  import { scaleOrdinal } from '@visx/scale';
4
+ import { _x, sprintf } from '@wordpress/i18n';
4
5
  import { Stack } from '@wordpress/ui';
5
6
  import clsx from 'clsx';
6
7
  import {
@@ -11,12 +12,13 @@ import {
11
12
  useCallback,
12
13
  useContext,
13
14
  } from 'react';
15
+ import { ChartInstanceContext } from '../../../charts/private/chart-instance-context';
14
16
  import { useTextTruncation } from '../../../hooks';
15
17
  import { GlobalChartsContext, useGlobalChartsTheme } from '../../../providers';
16
18
  import { useStandaloneScopeClass } from '../../../providers/chart-scope';
17
19
  import { valueOrIdentity, valueOrIdentityString, labelTransformFactory } from '../utils';
18
20
  import styles from './base-legend.module.scss';
19
- import type { BaseLegendProps } from '../types';
21
+ import type { BaseLegendItem, BaseLegendProps } from '../types';
20
22
 
21
23
  const ALIGNMENT_TO_FLEX = {
22
24
  start: 'flex-start',
@@ -58,6 +60,52 @@ const LegendText = ( {
58
60
  );
59
61
  };
60
62
 
63
+ // Interactive items get a toggle affordance; non-interactive items only need a label
64
+ // when hidden, since a visible item's own text already serves as its accessible name.
65
+ const getLegendItemAriaLabel = (
66
+ text: string,
67
+ value: BaseLegendItem[ 'value' ],
68
+ visible: boolean,
69
+ interactive: boolean
70
+ ) => {
71
+ const accessibleText =
72
+ value != null && value !== ''
73
+ ? sprintf(
74
+ /* translators: 1: legend item label; 2: legend item value. */
75
+ _x( '%1$s, %2$s', 'legend item label and value', 'jetpack-charts' ),
76
+ text,
77
+ String( value )
78
+ )
79
+ : text;
80
+
81
+ if ( interactive ) {
82
+ if ( visible ) {
83
+ return sprintf(
84
+ /* translators: %s: legend item label (e.g. a series or segment name) */
85
+ _x(
86
+ '%s: visible. Toggle visibility.',
87
+ 'visible interactive legend item',
88
+ 'jetpack-charts'
89
+ ),
90
+ accessibleText
91
+ );
92
+ }
93
+ return sprintf(
94
+ /* translators: %s: legend item label (e.g. a series or segment name) */
95
+ _x( '%s: hidden. Toggle visibility.', 'hidden interactive legend item', 'jetpack-charts' ),
96
+ accessibleText
97
+ );
98
+ }
99
+ if ( visible ) {
100
+ return undefined;
101
+ }
102
+ return sprintf(
103
+ /* translators: %s: legend item label (e.g. a series or segment name) */
104
+ _x( '%s: hidden', 'hidden non-interactive legend item', 'jetpack-charts' ),
105
+ accessibleText
106
+ );
107
+ };
108
+
61
109
  /*
62
110
  * Base legend component that displays color-coded items with labels based on visx LegendOrdinal.
63
111
  * We avoid using LegendOrdinal directly to enable support for advanced features such as interactivity.
@@ -103,6 +151,7 @@ export const BaseLegend: ForwardRefExoticComponent<
103
151
 
104
152
  const theme = useGlobalChartsTheme();
105
153
  const context = useContext( GlobalChartsContext );
154
+ const chartInstanceContext = useContext( ChartInstanceContext );
106
155
  const standaloneScopeClass = useStandaloneScopeClass();
107
156
 
108
157
  const legendScale = scaleOrdinal( {
@@ -120,29 +169,28 @@ export const BaseLegend: ForwardRefExoticComponent<
120
169
  const handleLegendClick = useCallback(
121
170
  ( seriesLabels: string[] ) => {
122
171
  if ( interactive && chartId && context ) {
123
- // Converge the whole group on the representative's next state: only toggle series that
124
- // currently match it, so a desynced group (e.g. one member hidden programmatically)
125
- // ends up uniformly hidden/shown after one click.
126
172
  const representativeVisible = context.isSeriesVisible( chartId, seriesLabels[ 0 ] );
127
- seriesLabels.forEach( label => {
128
- if ( context.isSeriesVisible( chartId, label ) === representativeVisible ) {
129
- context.toggleSeriesVisibility( chartId, label );
130
- }
131
- } );
173
+ seriesLabels.forEach( label =>
174
+ context.setSeriesVisibility( chartId, label, ! representativeVisible )
175
+ );
132
176
  }
133
177
  },
134
178
  [ interactive, chartId, context ]
135
179
  );
136
180
 
137
- // Check if a series is visible
181
+ // Visibility is display state, not interaction state: a series hidden
182
+ // programmatically must read as hidden even when the legend cannot be clicked.
138
183
  const isSeriesVisible = useCallback(
139
184
  ( seriesLabel: string ) => {
140
- if ( ! interactive || ! chartId || ! context ) {
185
+ if ( chartInstanceContext?.isSeriesVisible ) {
186
+ return chartInstanceContext.isSeriesVisible( seriesLabel );
187
+ }
188
+ if ( ! chartId || ! context ) {
141
189
  return true;
142
190
  }
143
191
  return context.isSeriesVisible( chartId, seriesLabel );
144
192
  },
145
- [ interactive, chartId, context ]
193
+ [ chartId, chartInstanceContext, context ]
146
194
  );
147
195
 
148
196
  // Create event handlers to avoid inline arrow functions
@@ -189,7 +237,7 @@ export const BaseLegend: ForwardRefExoticComponent<
189
237
  align={ orientation === 'vertical' ? flexAlignment : undefined }
190
238
  justify={ orientation === 'horizontal' ? flexAlignment : undefined }
191
239
  wrap={ orientation === 'horizontal' ? 'wrap' : undefined }
192
- role="list"
240
+ role={ interactive ? undefined : 'list' }
193
241
  data-testid={ `legend-${ orientation }` }
194
242
  className={ clsx( standaloneScopeClass, styles.legend, className ) }
195
243
  style={ theme.legend?.containerStyles }
@@ -223,14 +271,15 @@ export const BaseLegend: ForwardRefExoticComponent<
223
271
  }
224
272
  onClick={ handleClick }
225
273
  onKeyDown={ handleKeyDown }
226
- role={ interactive ? 'button' : undefined }
274
+ role={ interactive ? 'button' : 'listitem' }
227
275
  tabIndex={ interactive ? 0 : undefined }
228
276
  aria-pressed={ interactive ? visible : undefined }
229
- aria-label={
277
+ aria-label={ getLegendItemAriaLabel(
278
+ label.text,
279
+ matchedItem?.value,
280
+ visible,
230
281
  interactive
231
- ? `${ label.text }: ${ visible ? 'visible' : 'hidden' }. Toggle visibility.`
232
- : undefined
233
- }
282
+ ) }
234
283
  >
235
284
  { items[ i ]?.renderGlyph ? (
236
285
  <svg
@@ -1,8 +1,8 @@
1
1
  /* eslint-disable react/jsx-no-bind */
2
2
  import { render, screen } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
- import { useMemo } from 'react';
5
- import { SingleChartContext } from '../../../charts/private/single-chart-context';
4
+ import { useEffect, useMemo } from 'react';
5
+ import { ChartInstanceContext } from '../../../charts/private/chart-instance-context';
6
6
  import {
7
7
  GlobalChartsProvider,
8
8
  useChartId,
@@ -473,9 +473,9 @@ describe( 'BaseLegend', () => {
473
473
  return render(
474
474
  <GlobalChartsProvider>
475
475
  <ChartRegistrar chartType={ chartType } chartId={ chartId } />
476
- <SingleChartContext.Provider value={ { chartId } }>
476
+ <ChartInstanceContext.Provider value={ { chartId } }>
477
477
  <Legend shape={ explicitShape } />
478
- </SingleChartContext.Provider>
478
+ </ChartInstanceContext.Provider>
479
479
  </GlobalChartsProvider>
480
480
  );
481
481
  };
@@ -535,6 +535,7 @@ describe( 'BaseLegend', () => {
535
535
  expect( legendItems ).toHaveLength( 2 );
536
536
  expect( legendItems[ 0 ] ).toHaveAttribute( 'tabIndex', '0' );
537
537
  expect( legendItems[ 0 ] ).toHaveAttribute( 'aria-pressed', 'true' );
538
+ expect( screen.queryByRole( 'list' ) ).not.toBeInTheDocument();
538
539
  } );
539
540
 
540
541
  it( 'handles click events to toggle visibility', async () => {
@@ -621,6 +622,24 @@ describe( 'BaseLegend', () => {
621
622
  expect( buttons ).toHaveLength( 0 );
622
623
  } );
623
624
 
625
+ it( 'uses chart-instance visibility before provider state', () => {
626
+ render(
627
+ <GlobalChartsProvider>
628
+ <ChartInstanceContext.Provider
629
+ value={ {
630
+ chartId: 'test-chart',
631
+ isSeriesVisible: label => label !== 'Item 1',
632
+ } }
633
+ >
634
+ <BaseLegend items={ defaultItems } chartId="test-chart" />
635
+ </ChartInstanceContext.Provider>
636
+ </GlobalChartsProvider>
637
+ );
638
+
639
+ expect( screen.getByRole( 'listitem', { name: /Item 1.*hidden/ } ) ).toBeInTheDocument();
640
+ expect( screen.getByText( 'Item 2' ) ).toBeInTheDocument();
641
+ } );
642
+
624
643
  it( 'works without chartId but does not toggle', async () => {
625
644
  const user = userEvent.setup();
626
645
 
@@ -754,4 +773,81 @@ describe( 'BaseLegend', () => {
754
773
  expect( legendButton ).toHaveAttribute( 'aria-pressed', 'false' );
755
774
  } );
756
775
  } );
776
+
777
+ describe( 'Non-interactive legend reflects hidden state', () => {
778
+ const HiddenSeriesHarness = ( { interactive }: { interactive: boolean } ) => {
779
+ const context = useGlobalChartsContext();
780
+ useEffect( () => {
781
+ context.toggleSeriesVisibility( 'legend-a11y-chart', 'Series A' );
782
+ // eslint-disable-next-line react-hooks/exhaustive-deps
783
+ }, [] );
784
+ return (
785
+ <BaseLegend
786
+ chartId="legend-a11y-chart"
787
+ interactive={ interactive }
788
+ items={ [
789
+ { label: 'Series A', value: '10', color: '#ff0000' },
790
+ { label: 'Series B', value: '20', color: '#00ff00' },
791
+ ] }
792
+ />
793
+ );
794
+ };
795
+
796
+ it( 'marks a programmatically hidden series inactive on a non-interactive legend', () => {
797
+ render(
798
+ <GlobalChartsProvider>
799
+ <HiddenSeriesHarness interactive={ false } />
800
+ </GlobalChartsProvider>
801
+ );
802
+
803
+ const items = screen.getAllByTestId( 'legend-item' );
804
+ expect( items[ 0 ] ).toHaveClass( 'legend-item--inactive' );
805
+ expect( items[ 1 ] ).not.toHaveClass( 'legend-item--inactive' );
806
+ } );
807
+
808
+ it( 'does not make a non-interactive legend item operable', () => {
809
+ render(
810
+ <GlobalChartsProvider>
811
+ <HiddenSeriesHarness interactive={ false } />
812
+ </GlobalChartsProvider>
813
+ );
814
+
815
+ expect( screen.queryAllByRole( 'button' ) ).toHaveLength( 0 );
816
+ const items = screen.getAllByTestId( 'legend-item' );
817
+ expect( items[ 0 ] ).not.toHaveAttribute( 'aria-pressed' );
818
+ expect( items[ 0 ] ).not.toHaveAttribute( 'tabindex' );
819
+ expect( items[ 0 ] ).toHaveAttribute( 'role', 'listitem' );
820
+ } );
821
+
822
+ it( 'names the hidden state accessibly on a non-interactive legend', () => {
823
+ render(
824
+ <GlobalChartsProvider>
825
+ <HiddenSeriesHarness interactive={ false } />
826
+ </GlobalChartsProvider>
827
+ );
828
+
829
+ const items = screen.getAllByTestId( 'legend-item' );
830
+ expect( items[ 0 ] ).toHaveAttribute( 'aria-label', 'Series A, 10: hidden' );
831
+ expect( items[ 1 ] ).not.toHaveAttribute( 'aria-label' );
832
+ } );
833
+
834
+ it( 'includes the item value in interactive accessible names', () => {
835
+ render(
836
+ <GlobalChartsProvider>
837
+ <HiddenSeriesHarness interactive={ true } />
838
+ </GlobalChartsProvider>
839
+ );
840
+
841
+ expect(
842
+ screen.getByRole( 'button', {
843
+ name: 'Series A, 10: hidden. Toggle visibility.',
844
+ } )
845
+ ).toBeInTheDocument();
846
+ expect(
847
+ screen.getByRole( 'button', {
848
+ name: 'Series B, 20: visible. Toggle visibility.',
849
+ } )
850
+ ).toBeInTheDocument();
851
+ } );
852
+ } );
757
853
  } );
@@ -1,6 +1,6 @@
1
1
  import { Tooltip, TooltipContext } from '@visx/xychart';
2
2
  import clsx from 'clsx';
3
- import { useContext, useEffect, useCallback, useMemo } from 'react';
3
+ import { useContext, useEffect, useCallback, useMemo, useRef } from 'react';
4
4
  import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
5
5
  import type { SeriesData, DataPointDate } from '../../types';
6
6
  import type { RenderTooltipParams, XyChartTooltipProps } from '../../visx/types';
@@ -79,13 +79,24 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
79
79
  return flattened;
80
80
  }, [ series, mode ] );
81
81
 
82
+ // Tracks whether this effect opened a tooltip, so it only closes its own.
83
+ const hasKeyboardSelection = useRef( false );
84
+
82
85
  // Handle tooltip highlighting for keyboard navigation
83
86
  useEffect( () => {
84
87
  if ( selectedIndex === undefined ) {
85
- tooltipContext?.hideTooltip();
88
+ // visx debounces the hide and cancels only the most recently scheduled one,
89
+ // so hiding on every run leaves earlier hides pending. One of those lands
90
+ // mid-navigation and closes the tooltip the user is reading.
91
+ if ( hasKeyboardSelection.current ) {
92
+ hasKeyboardSelection.current = false;
93
+ tooltipContext?.hideTooltip();
94
+ }
86
95
  return;
87
96
  }
88
97
 
98
+ hasKeyboardSelection.current = true;
99
+
89
100
  if ( mode === 'group' ) {
90
101
  // Show all series at the selected data point index in single tooltip.
91
102
  series.forEach( ( s, index ) => {
@@ -3,7 +3,7 @@
3
3
  align-items: center;
4
4
  gap: 0.125em;
5
5
  font-size: var(--wpds-typography-font-size-md, 13px);
6
- font-weight: var(--wpds-typography-font-weight-emphasis, 499);
6
+ font-weight: var(--wpds-typography-font-weight-emphasis, 600);
7
7
  line-height: 1;
8
8
 
9
9
  &--up {
@@ -7,5 +7,5 @@ export { useElementSize } from './use-element-size';
7
7
  export { useTextTruncation } from './use-text-truncation';
8
8
  export { useZeroValueDisplay } from './use-zero-value-display';
9
9
  export { useDataWithPercentages } from './use-data-with-percentages';
10
- export { useInteractiveLegendData } from './use-interactive-legend-data';
10
+ export { useLegendVisibilityData } from './use-legend-visibility-data';
11
11
  export { usePrefersReducedMotion } from './use-prefers-reduced-motion';
@@ -1,7 +1,7 @@
1
1
  import { useMemo } from 'react';
2
2
 
3
3
  /**
4
- * Data point interface for charts with interactive legends.
4
+ * Data point interface for charts with visibility-aware legends.
5
5
  * Requires label for series identification, value for calculations,
6
6
  * and percentage (should be pre-calculated by the chart component).
7
7
  */
@@ -12,23 +12,21 @@ interface DataPointWithPercentage {
12
12
  }
13
13
 
14
14
  /**
15
- * Parameters for the useInteractiveLegendData hook.
15
+ * Parameters for the useLegendVisibilityData hook.
16
16
  */
17
- interface UseInteractiveLegendDataParams< T extends DataPointWithPercentage > {
17
+ interface UseLegendVisibilityDataParams< T extends DataPointWithPercentage > {
18
18
  /** The chart data with pre-calculated percentages */
19
19
  data: T[];
20
- /** Unique chart identifier, required for interactive legends */
20
+ /** Unique chart identifier, required for filtering */
21
21
  chartId: string | undefined;
22
- /** Whether interactive legend filtering is enabled */
23
- legendInteractive: boolean;
24
22
  /** Function to check if a series is visible in the legend */
25
23
  isSeriesVisible: ( chartId: string, label: string ) => boolean;
26
24
  }
27
25
 
28
26
  /**
29
- * Return value from the useInteractiveLegendData hook.
27
+ * Return value from the useLegendVisibilityData hook.
30
28
  */
31
- interface UseInteractiveLegendDataResult< T extends DataPointWithPercentage > {
29
+ interface UseLegendVisibilityDataResult< T extends DataPointWithPercentage > {
32
30
  /** Filtered data array containing only visible segments with recalculated percentages */
33
31
  visibleData: T[];
34
32
  /** Boolean indicating if all segments are hidden */
@@ -42,10 +40,10 @@ interface UseInteractiveLegendDataResult< T extends DataPointWithPercentage > {
42
40
  }
43
41
 
44
42
  /**
45
- * Custom hook to filter and recalculate chart data for interactive legends.
43
+ * Custom hook to filter and recalculate chart data based on series visibility.
46
44
  *
47
- * When interactive legends are enabled, this hook:
48
- * 1. Filters data to show only visible series based on legend selection
45
+ * Regardless of whether the legend is interactive (clickable), this hook:
46
+ * 1. Filters data to show only visible series (visibility can also be set programmatically)
49
47
  * 2. Recalculates percentages so visible segments total 100%
50
48
  * 3. Tracks whether all segments are hidden to show empty state
51
49
  *
@@ -54,10 +52,9 @@ interface UseInteractiveLegendDataResult< T extends DataPointWithPercentage > {
54
52
  *
55
53
  * @example
56
54
  * ```tsx
57
- * const { visibleData, allSegmentsHidden, legendData } = useInteractiveLegendData({
55
+ * const { visibleData, allSegmentsHidden, legendData } = useLegendVisibilityData({
58
56
  * data: chartData,
59
57
  * chartId: 'my-pie-chart',
60
- * legendInteractive: true,
61
58
  * isSeriesVisible: (id, label) => context.isSeriesVisible(id, label),
62
59
  * });
63
60
  *
@@ -72,24 +69,24 @@ interface UseInteractiveLegendDataResult< T extends DataPointWithPercentage > {
72
69
  * return <PieChart data={visibleData} />;
73
70
  * ```
74
71
  *
75
- * @param params - Configuration object for the hook
76
- * @param params.data - The chart data to filter
77
- * @param params.chartId - Unique identifier for the chart (required for interactive mode)
78
- * @param params.legendInteractive - Whether to enable interactive filtering
79
- * @param params.isSeriesVisible - Function to check series visibility
72
+ * @param params - Configuration object for the hook
73
+ * @param params.data - The chart data to filter
74
+ * @param params.chartId - Unique identifier for the chart (required for filtering)
75
+ * @param params.isSeriesVisible - Function to check series visibility
80
76
  * @return Object containing visibleData, allSegmentsHidden flag, and legendData with recalculated percentages
81
77
  */
82
- export const useInteractiveLegendData = < T extends DataPointWithPercentage >( {
78
+ export const useLegendVisibilityData = < T extends DataPointWithPercentage >( {
83
79
  data,
84
80
  chartId,
85
- legendInteractive,
86
81
  isSeriesVisible,
87
- }: UseInteractiveLegendDataParams< T > ): UseInteractiveLegendDataResult< T > => {
88
- // Filter and recalculate data for interactive legends
82
+ }: UseLegendVisibilityDataParams< T > ): UseLegendVisibilityDataResult< T > => {
83
+ // Filter and recalculate data based on shared legend visibility state.
89
84
  // Note: data should already have percentages calculated by the chart component
90
85
  const visibleData = useMemo( () => {
91
- // If interactive mode is disabled or no chartId, return data as-is
92
- if ( ! chartId || ! legendInteractive ) {
86
+ // Visibility filtering applies regardless of whether the legend is
87
+ // interactive: interactivity only controls whether clicking the legend
88
+ // can change visibility, not whether the chart honors it.
89
+ if ( ! chartId ) {
93
90
  return data;
94
91
  }
95
92
 
@@ -107,18 +104,20 @@ export const useInteractiveLegendData = < T extends DataPointWithPercentage >( {
107
104
  ...segment,
108
105
  percentage: totalValue > 0 ? ( segment.value / totalValue ) * 100 : 0,
109
106
  } ) );
110
- }, [ data, chartId, isSeriesVisible, legendInteractive ] );
107
+ }, [ data, chartId, isSeriesVisible ] );
111
108
 
112
- // Check if all segments are hidden (only relevant in interactive mode)
109
+ // Check if all segments are hidden. Guard on the source data being non-empty:
110
+ // unlike line/area's `.every()`, an empty `visibleData` alone can't distinguish
111
+ // "everything was hidden" from "there was nothing to show" for an empty dataset.
113
112
  const allSegmentsHidden = useMemo( () => {
114
- return legendInteractive && visibleData.length === 0;
115
- }, [ legendInteractive, visibleData ] );
113
+ return visibleData.length === 0 && data.length > 0;
114
+ }, [ visibleData, data ] );
116
115
 
117
116
  // Prepare legend data with percentages
118
117
  // Hidden items keep their original percentage (calculated from all values)
119
118
  // Visible items show recalculated percentages (totaling 100%)
120
119
  const legendData = useMemo( () => {
121
- if ( ! legendInteractive || ! chartId ) {
120
+ if ( ! chartId ) {
122
121
  return data;
123
122
  }
124
123
 
@@ -135,7 +134,7 @@ export const useInteractiveLegendData = < T extends DataPointWithPercentage >( {
135
134
  // For visible items, get the recalculated percentage from visibleData
136
135
  return visibleDataMap.get( segment.label ) || segment;
137
136
  } );
138
- }, [ data, visibleData, legendInteractive, chartId, isSeriesVisible ] );
137
+ }, [ data, visibleData, chartId, isSeriesVisible ] );
139
138
 
140
139
  return { visibleData, allSegmentsHidden, legendData };
141
140
  };
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@ export { BarChart, BarChartUnresponsive } from './charts/bar-chart';
4
4
  export { BarListChart, BarListChartUnresponsive } from './charts/bar-list-chart';
5
5
  export { ConversionFunnelChart } from './charts/conversion-funnel-chart';
6
6
  export { GeoChart, GeoChartUnresponsive } from './charts/geo-chart';
7
+ // A value export, not a type: building a tooltip column header needs the enum member itself.
8
+ export { GoogleDataTableColumnRoleType } from './types';
7
9
  export {
8
10
  HeatmapChart,
9
11
  HeatmapChartUnresponsive,
@@ -61,9 +63,9 @@ export type {
61
63
  LegendPosition,
62
64
  ChartLegendConfig,
63
65
  SeriesChartLegendConfig,
66
+ SeriesVisibilityProps,
64
67
  BaseChartProps,
65
68
  GoogleDataTableColumn,
66
- GoogleDataTableColumnRoleType,
67
69
  GoogleDataTableRow,
68
70
  LegendShape,
69
71
  LegendShapeLabel,