@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
@@ -44,6 +44,10 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
44
44
  const [ hiddenSeries, setHiddenSeries ] = useState< Map< string, Set< string > > >(
45
45
  () => new Map()
46
46
  );
47
+ // Which charts have had their defaults seeded. Kept apart from `hiddenSeries`
48
+ // because that map drops a chart's entry once its hidden set empties, so an
49
+ // absent entry cannot tell "never seeded" from "the reader revealed the lot".
50
+ const seededCharts = useRef< Set< string > >( new Set() );
47
51
 
48
52
  // Ref to the wrapper element for resolving scoped CSS variables
49
53
  const wrapperRef = useRef< HTMLDivElement >( null );
@@ -242,28 +246,90 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
242
246
  [ providerTheme, resolveColor ]
243
247
  );
244
248
 
245
- // Series visibility management methods
246
- const toggleSeriesVisibility = useCallback( ( chartId: string, seriesLabel: string ) => {
247
- setHiddenSeries( prev => {
248
- const newMap = new Map( prev );
249
- const chartHidden = newMap.get( chartId ) || new Set();
250
- const newSet = new Set( chartHidden );
249
+ // Keep hidden-series updates and no-op detection consistent across setters.
250
+ const updateHiddenSeries = useCallback(
251
+ ( chartId: string, update: ( current: Set< string > ) => Set< string > ) => {
252
+ setHiddenSeries( prev => {
253
+ const current = prev.get( chartId );
254
+ const next = update( new Set( current ?? [] ) );
251
255
 
252
- if ( newSet.has( seriesLabel ) ) {
253
- newSet.delete( seriesLabel );
254
- } else {
255
- newSet.add( seriesLabel );
256
- }
256
+ if ( ! current && next.size === 0 ) {
257
+ return prev;
258
+ }
259
+
260
+ // Preserve identity for no-op updates.
261
+ if (
262
+ current &&
263
+ current.size === next.size &&
264
+ [ ...next ].every( label => current.has( label ) )
265
+ ) {
266
+ return prev;
267
+ }
268
+
269
+ const newMap = new Map( prev );
270
+ if ( next.size === 0 ) {
271
+ newMap.delete( chartId );
272
+ } else {
273
+ newMap.set( chartId, next );
274
+ }
275
+
276
+ return newMap;
277
+ } );
278
+ },
279
+ []
280
+ );
281
+
282
+ const toggleSeriesVisibility = useCallback(
283
+ ( chartId: string, seriesLabel: string ) => {
284
+ updateHiddenSeries( chartId, current => {
285
+ if ( current.has( seriesLabel ) ) {
286
+ current.delete( seriesLabel );
287
+ } else {
288
+ current.add( seriesLabel );
289
+ }
290
+ return current;
291
+ } );
292
+ },
293
+ [ updateHiddenSeries ]
294
+ );
257
295
 
258
- if ( newSet.size === 0 ) {
259
- newMap.delete( chartId );
260
- } else {
261
- newMap.set( chartId, newSet );
296
+ const setSeriesVisibility = useCallback(
297
+ ( chartId: string, seriesLabel: string, visible: boolean ) => {
298
+ updateHiddenSeries( chartId, current => {
299
+ if ( visible ) {
300
+ current.delete( seriesLabel );
301
+ } else {
302
+ current.add( seriesLabel );
303
+ }
304
+ return current;
305
+ } );
306
+ },
307
+ [ updateHiddenSeries ]
308
+ );
309
+
310
+ const setChartHiddenSeries = useCallback(
311
+ ( chartId: string, seriesLabels: readonly string[] ) => {
312
+ updateHiddenSeries( chartId, () => new Set( seriesLabels ) );
313
+ },
314
+ [ updateHiddenSeries ]
315
+ );
316
+
317
+ const hasSeededChart = useCallback(
318
+ ( chartId: string ) => seededCharts.current.has( chartId ),
319
+ []
320
+ );
321
+
322
+ const seedChartHiddenSeries = useCallback(
323
+ ( chartId: string, seriesLabels: readonly string[] ) => {
324
+ if ( seededCharts.current.has( chartId ) ) {
325
+ return;
262
326
  }
263
327
 
264
- return newMap;
265
- } );
266
- }, [] );
328
+ seededCharts.current.add( chartId );
329
+ setChartHiddenSeries( chartId, seriesLabels );
330
+ },
331
+ [ setChartHiddenSeries ]
332
+ );
267
333
 
268
334
  const isSeriesVisible = useCallback(
269
335
  ( chartId: string, seriesLabel: string ) => {
@@ -290,6 +356,10 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
290
356
  theme: providerTheme,
291
357
  getElementStyles,
292
358
  toggleSeriesVisibility,
359
+ setSeriesVisibility,
360
+ setChartHiddenSeries,
361
+ seedChartHiddenSeries,
362
+ hasSeededChart,
293
363
  isSeriesVisible,
294
364
  getHiddenSeries,
295
365
  isColorPaletteResolved,
@@ -302,6 +372,10 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
302
372
  providerTheme,
303
373
  getElementStyles,
304
374
  toggleSeriesVisibility,
375
+ setSeriesVisibility,
376
+ setChartHiddenSeries,
377
+ seedChartHiddenSeries,
378
+ hasSeededChart,
305
379
  isSeriesVisible,
306
380
  getHiddenSeries,
307
381
  isColorPaletteResolved,
@@ -0,0 +1,47 @@
1
+ import { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
2
+ import { useGlobalChartsContext } from './use-global-charts-context';
3
+
4
+ // Hide seeded series before paint without triggering React's SSR warning.
5
+ const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
6
+
7
+ /**
8
+ * Seeds a chart's hidden series once per chart ID, the first time the provider
9
+ * sees that ID.
10
+ *
11
+ * The provider owns the record of what has been seeded, not this hook: a
12
+ * mount-scoped one would re-apply the defaults on every remount, and a chart
13
+ * remounts for reasons the reader never thinks of as a new chart — switching
14
+ * between line and bar, tabbing away and back, a resize flipping tabs into a
15
+ * dropdown. Each of those would throw away the series they had just revealed.
16
+ *
17
+ * @param chartId - The chart ID passed to the provider's visibility methods.
18
+ * @param seriesLabels - Labels to hide when this chart ID is first seen. Later values are ignored.
19
+ * @return The resolved hidden-series set.
20
+ */
21
+ export const useDefaultHiddenSeries = (
22
+ chartId: string,
23
+ seriesLabels?: readonly string[]
24
+ ): Set< string > => {
25
+ const { getHiddenSeries, seedChartHiddenSeries, hasSeededChart } = useGlobalChartsContext();
26
+ // Read through a ref so the effect never depends on a new array identity.
27
+ const labelsRef = useRef( seriesLabels );
28
+ labelsRef.current = seriesLabels;
29
+ const hasSeriesLabels = seriesLabels !== undefined;
30
+ const shouldUseDefaults = hasSeriesLabels && ! hasSeededChart( chartId );
31
+
32
+ useIsomorphicLayoutEffect( () => {
33
+ if ( labelsRef.current === undefined ) {
34
+ return;
35
+ }
36
+ seedChartHiddenSeries( chartId, labelsRef.current );
37
+ }, [ chartId, hasSeriesLabels, seedChartHiddenSeries ] );
38
+
39
+ // getHiddenSeries hands out a fresh copy per call, so hold the set still between
40
+ // visibility changes. Charts derive their rendered series from it, and a new
41
+ // identity every render invalidates every memo downstream — including the one
42
+ // the accessible tooltip watches, which closes a tooltip mid-navigation.
43
+ return useMemo(
44
+ () => ( shouldUseDefaults ? new Set( labelsRef.current ) : getHiddenSeries( chartId ) ),
45
+ [ shouldUseDefaults, getHiddenSeries, chartId ]
46
+ );
47
+ };
@@ -1728,6 +1728,148 @@ describe( 'ChartContext', () => {
1728
1728
  // Should have empty set after all series are visible
1729
1729
  expect( hidden.size ).toBe( 0 );
1730
1730
  } );
1731
+
1732
+ it( 'setSeriesVisibility sets state absolutely rather than toggling', () => {
1733
+ let contextValue: GlobalChartsContextValue;
1734
+ const TestComponent = () => {
1735
+ contextValue = useGlobalChartsContext();
1736
+ return <div>Test</div>;
1737
+ };
1738
+
1739
+ render(
1740
+ <GlobalChartsProvider>
1741
+ <TestComponent />
1742
+ </GlobalChartsProvider>
1743
+ );
1744
+
1745
+ act( () => {
1746
+ contextValue.setSeriesVisibility( 'test-chart', 'Series 1', false );
1747
+ } );
1748
+ expect( contextValue.isSeriesVisible( 'test-chart', 'Series 1' ) ).toBe( false );
1749
+
1750
+ // Calling it again with the same value must not flip it back.
1751
+ act( () => {
1752
+ contextValue.setSeriesVisibility( 'test-chart', 'Series 1', false );
1753
+ } );
1754
+ expect( contextValue.isSeriesVisible( 'test-chart', 'Series 1' ) ).toBe( false );
1755
+
1756
+ act( () => {
1757
+ contextValue.setSeriesVisibility( 'test-chart', 'Series 1', true );
1758
+ } );
1759
+ expect( contextValue.isSeriesVisible( 'test-chart', 'Series 1' ) ).toBe( true );
1760
+ } );
1761
+
1762
+ it( 'setChartHiddenSeries replaces the hidden set rather than merging into it', () => {
1763
+ let contextValue: GlobalChartsContextValue;
1764
+ const TestComponent = () => {
1765
+ contextValue = useGlobalChartsContext();
1766
+ return <div>Test</div>;
1767
+ };
1768
+
1769
+ render(
1770
+ <GlobalChartsProvider>
1771
+ <TestComponent />
1772
+ </GlobalChartsProvider>
1773
+ );
1774
+
1775
+ act( () => {
1776
+ contextValue.setChartHiddenSeries( 'test-chart', [ 'Series 1', 'Series 2' ] );
1777
+ } );
1778
+ expect( contextValue.getHiddenSeries( 'test-chart' ) ).toEqual(
1779
+ new Set( [ 'Series 1', 'Series 2' ] )
1780
+ );
1781
+
1782
+ act( () => {
1783
+ contextValue.setChartHiddenSeries( 'test-chart', [ 'Series 2' ] );
1784
+ } );
1785
+ expect( contextValue.isSeriesVisible( 'test-chart', 'Series 1' ) ).toBe( true );
1786
+ expect( contextValue.isSeriesVisible( 'test-chart', 'Series 2' ) ).toBe( false );
1787
+
1788
+ act( () => {
1789
+ contextValue.setChartHiddenSeries( 'test-chart', [] );
1790
+ } );
1791
+ expect( contextValue.getHiddenSeries( 'test-chart' ) ).toEqual( new Set() );
1792
+ } );
1793
+
1794
+ it( 'setChartHiddenSeries leaves other charts alone', () => {
1795
+ let contextValue: GlobalChartsContextValue;
1796
+ const TestComponent = () => {
1797
+ contextValue = useGlobalChartsContext();
1798
+ return <div>Test</div>;
1799
+ };
1800
+
1801
+ render(
1802
+ <GlobalChartsProvider>
1803
+ <TestComponent />
1804
+ </GlobalChartsProvider>
1805
+ );
1806
+
1807
+ act( () => {
1808
+ contextValue.setChartHiddenSeries( 'chart-a', [ 'Series 1' ] );
1809
+ contextValue.setChartHiddenSeries( 'chart-b', [ 'Series 2' ] );
1810
+ } );
1811
+
1812
+ expect( contextValue.isSeriesVisible( 'chart-a', 'Series 1' ) ).toBe( false );
1813
+ expect( contextValue.isSeriesVisible( 'chart-a', 'Series 2' ) ).toBe( true );
1814
+ expect( contextValue.isSeriesVisible( 'chart-b', 'Series 2' ) ).toBe( false );
1815
+ } );
1816
+
1817
+ it( 'a redundant setSeriesVisibility write does not change context identity or re-render', () => {
1818
+ let contextValue: GlobalChartsContextValue;
1819
+ let childUpdateTally = 0;
1820
+ const TestComponent = () => {
1821
+ contextValue = useGlobalChartsContext();
1822
+ childUpdateTally++;
1823
+ return <div>Test</div>;
1824
+ };
1825
+
1826
+ render(
1827
+ <GlobalChartsProvider>
1828
+ <TestComponent />
1829
+ </GlobalChartsProvider>
1830
+ );
1831
+
1832
+ act( () => {
1833
+ contextValue.setSeriesVisibility( 'test-chart', 'Series 1', false );
1834
+ } );
1835
+ const isSeriesVisibleAfterFirstWrite = contextValue.isSeriesVisible;
1836
+ const tallyAfterFirstWrite = childUpdateTally;
1837
+
1838
+ // Redundant write: the series is already hidden, so this must be a no-op.
1839
+ act( () => {
1840
+ contextValue.setSeriesVisibility( 'test-chart', 'Series 1', false );
1841
+ } );
1842
+
1843
+ expect( contextValue.isSeriesVisible ).toBe( isSeriesVisibleAfterFirstWrite );
1844
+ expect( childUpdateTally ).toBe( tallyAfterFirstWrite );
1845
+ } );
1846
+
1847
+ it( 'setting an already-empty hidden set does not create then delete a Map entry', () => {
1848
+ let contextValue: GlobalChartsContextValue;
1849
+ let childUpdateTally = 0;
1850
+ const TestComponent = () => {
1851
+ contextValue = useGlobalChartsContext();
1852
+ childUpdateTally++;
1853
+ return <div>Test</div>;
1854
+ };
1855
+
1856
+ render(
1857
+ <GlobalChartsProvider>
1858
+ <TestComponent />
1859
+ </GlobalChartsProvider>
1860
+ );
1861
+
1862
+ const tallyBeforeWrite = childUpdateTally;
1863
+ const isSeriesVisibleBefore = contextValue.isSeriesVisible;
1864
+
1865
+ // This chart has no hidden-series entry at all yet.
1866
+ act( () => {
1867
+ contextValue.setChartHiddenSeries( 'test-chart', [] );
1868
+ } );
1869
+
1870
+ expect( childUpdateTally ).toBe( tallyBeforeWrite );
1871
+ expect( contextValue.isSeriesVisible ).toBe( isSeriesVisibleBefore );
1872
+ } );
1731
1873
  } );
1732
1874
 
1733
1875
  describe( 'GlobalChartsProvider - CSS Variable Support', () => {
@@ -0,0 +1,56 @@
1
+ import { screen } from '@testing-library/react';
2
+ import { flushSync } from 'react-dom';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { GlobalChartsProvider } from '../global-charts-provider';
5
+ import { useDefaultHiddenSeries } from '../hooks/use-default-hidden-series';
6
+ import { useGlobalChartsContext } from '../hooks/use-global-charts-context';
7
+ import type { Root } from 'react-dom/client';
8
+
9
+ describe( 'useDefaultHiddenSeries paint timing', () => {
10
+ let host: HTMLDivElement;
11
+ let root: Root;
12
+ let previousActEnvironment: boolean | undefined;
13
+
14
+ beforeEach( () => {
15
+ previousActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT;
16
+ globalThis.IS_REACT_ACT_ENVIRONMENT = false;
17
+ host = document.createElement( 'div' );
18
+ document.body.appendChild( host );
19
+ root = createRoot( host );
20
+ } );
21
+
22
+ afterEach( () => {
23
+ flushSync( () => root.unmount() );
24
+ host.remove();
25
+ globalThis.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
26
+ } );
27
+
28
+ it( 'updates provider visibility before the first paint', () => {
29
+ const Chart = () => {
30
+ useDefaultHiddenSeries( 'chart', [ 'Visitors' ] );
31
+ return null;
32
+ };
33
+
34
+ const Visibility = () => {
35
+ const { isSeriesVisible } = useGlobalChartsContext();
36
+ return (
37
+ <span data-testid="series-visibility">
38
+ { isSeriesVisible( 'chart', 'Visitors' ) ? 'visible' : 'hidden' }
39
+ </span>
40
+ );
41
+ };
42
+
43
+ // flushSync lets us inspect the layout-effect result before a later passive
44
+ // effect could seed the provider.
45
+ flushSync( () => {
46
+ root.render(
47
+ <GlobalChartsProvider>
48
+ <Chart />
49
+ <Visibility />
50
+ </GlobalChartsProvider>
51
+ );
52
+ } );
53
+
54
+ expect( screen.getByTestId( 'series-visibility' ) ).toHaveTextContent( 'hidden' );
55
+ } );
56
+ } );
@@ -0,0 +1,39 @@
1
+ /** @jest-environment node */
2
+
3
+ import { renderToStaticMarkup } from 'react-dom/server';
4
+ import { useDefaultHiddenSeries } from '../hooks/use-default-hidden-series';
5
+
6
+ const mockGetHiddenSeries = jest.fn( () => new Set( [ 'Provider hidden' ] ) );
7
+ const mockSeedChartHiddenSeries = jest.fn();
8
+ const mockHasSeededChart = jest.fn( () => false );
9
+
10
+ jest.mock( '../hooks/use-global-charts-context', () => ( {
11
+ useGlobalChartsContext: () => ( {
12
+ getHiddenSeries: mockGetHiddenSeries,
13
+ seedChartHiddenSeries: mockSeedChartHiddenSeries,
14
+ hasSeededChart: mockHasSeededChart,
15
+ } ),
16
+ } ) );
17
+
18
+ describe( 'useDefaultHiddenSeries SSR', () => {
19
+ it( 'applies defaults to the server-rendered output before effects run', () => {
20
+ const Chart = () => {
21
+ const hiddenSeries = useDefaultHiddenSeries( 'chart', [ 'Visitors' ] );
22
+
23
+ return (
24
+ <>
25
+ { [ 'Visitors', 'Views' ].map( label =>
26
+ ! hiddenSeries.has( label ) ? <span key={ label }>{ label }</span> : null
27
+ ) }
28
+ </>
29
+ );
30
+ };
31
+
32
+ const view = renderToStaticMarkup( <Chart /> );
33
+
34
+ expect( view ).not.toContain( 'Visitors' );
35
+ expect( view ).toContain( 'Views' );
36
+ expect( mockGetHiddenSeries ).not.toHaveBeenCalled();
37
+ expect( mockSeedChartHiddenSeries ).not.toHaveBeenCalled();
38
+ } );
39
+ } );
@@ -0,0 +1,222 @@
1
+ import { render, act } from '@testing-library/react';
2
+ import { GlobalChartsProvider } from '../global-charts-provider';
3
+ import { useDefaultHiddenSeries } from '../hooks/use-default-hidden-series';
4
+ import { useGlobalChartsContext } from '../hooks/use-global-charts-context';
5
+ import type { GlobalChartsContextValue } from '../types';
6
+
7
+ describe( 'useDefaultHiddenSeries', () => {
8
+ let contextValue: GlobalChartsContextValue;
9
+ let resolvedHiddenSeries: Set< string >;
10
+
11
+ const Grab = () => {
12
+ contextValue = useGlobalChartsContext();
13
+ return null;
14
+ };
15
+
16
+ const Chart = ( { defaults, chartId = 'chart' }: { defaults?: string[]; chartId?: string } ) => {
17
+ resolvedHiddenSeries = useDefaultHiddenSeries( chartId, defaults );
18
+ return null;
19
+ };
20
+
21
+ it( 'hides the named series at mount', () => {
22
+ render(
23
+ <GlobalChartsProvider>
24
+ <Grab />
25
+ <Chart defaults={ [ 'Visitors' ] } />
26
+ </GlobalChartsProvider>
27
+ );
28
+
29
+ expect( contextValue.isSeriesVisible( 'chart', 'Visitors' ) ).toBe( false );
30
+ expect( contextValue.isSeriesVisible( 'chart', 'Views' ) ).toBe( true );
31
+ } );
32
+
33
+ it( 'writes nothing when no defaults are given', () => {
34
+ render(
35
+ <GlobalChartsProvider>
36
+ <Grab />
37
+ <Chart />
38
+ </GlobalChartsProvider>
39
+ );
40
+
41
+ expect( contextValue.getHiddenSeries( 'chart' ) ).toEqual( new Set() );
42
+ } );
43
+
44
+ it( 'reflects provider visibility changes after mount', () => {
45
+ render(
46
+ <GlobalChartsProvider>
47
+ <Grab />
48
+ <Chart />
49
+ </GlobalChartsProvider>
50
+ );
51
+
52
+ expect( resolvedHiddenSeries.has( 'Visitors' ) ).toBe( false );
53
+
54
+ act( () => {
55
+ contextValue.setSeriesVisibility( 'chart', 'Visitors', false );
56
+ } );
57
+
58
+ expect( resolvedHiddenSeries.has( 'Visitors' ) ).toBe( true );
59
+ } );
60
+
61
+ it( 'seeds the first defined value and ignores later values for the same chart ID', () => {
62
+ const Harness = ( { defaults }: { defaults?: string[] } ) => (
63
+ <GlobalChartsProvider>
64
+ <Grab />
65
+ <Chart defaults={ defaults } />
66
+ </GlobalChartsProvider>
67
+ );
68
+
69
+ const { rerender } = render( <Harness /> );
70
+
71
+ rerender( <Harness defaults={ [ 'Visitors' ] } /> );
72
+ expect( contextValue.getHiddenSeries( 'chart' ) ).toEqual( new Set( [ 'Visitors' ] ) );
73
+
74
+ rerender( <Harness defaults={ [ 'Views' ] } /> );
75
+ expect( contextValue.getHiddenSeries( 'chart' ) ).toEqual( new Set( [ 'Visitors' ] ) );
76
+ } );
77
+
78
+ it( 'treats an empty array as a defined default', () => {
79
+ const Harness = ( { defaults }: { defaults: string[] } ) => (
80
+ <GlobalChartsProvider>
81
+ <Grab />
82
+ <Chart defaults={ defaults } />
83
+ </GlobalChartsProvider>
84
+ );
85
+
86
+ const { rerender } = render( <Harness defaults={ [] } /> );
87
+
88
+ rerender( <Harness defaults={ [ 'Visitors' ] } /> );
89
+
90
+ expect( contextValue.getHiddenSeries( 'chart' ) ).toEqual( new Set() );
91
+ } );
92
+
93
+ it( 'does not re-hide a series the user revealed', () => {
94
+ const Harness = () => (
95
+ <GlobalChartsProvider>
96
+ <Grab />
97
+ <Chart defaults={ [ 'Visitors' ] } />
98
+ </GlobalChartsProvider>
99
+ );
100
+
101
+ const { rerender } = render( <Harness /> );
102
+
103
+ act( () => {
104
+ contextValue.setSeriesVisibility( 'chart', 'Visitors', true );
105
+ } );
106
+
107
+ rerender( <Harness /> );
108
+
109
+ expect( contextValue.isSeriesVisible( 'chart', 'Visitors' ) ).toBe( true );
110
+ } );
111
+
112
+ it( "keeps the reader's visibility choices across a remount", () => {
113
+ // The provider stays mounted; only the chart unmounts and comes back — a
114
+ // chart-type switch, a tab round-trip, a resize flipping tabs into a
115
+ // dropdown. None of those read as a new chart, so none may re-seed.
116
+ const Harness = ( { mounted }: { mounted: boolean } ) => (
117
+ <GlobalChartsProvider>
118
+ <Grab />
119
+ { mounted && <Chart defaults={ [ 'Visitors' ] } /> }
120
+ </GlobalChartsProvider>
121
+ );
122
+
123
+ const { rerender } = render( <Harness mounted /> );
124
+
125
+ act( () => {
126
+ contextValue.setSeriesVisibility( 'chart', 'Views', false );
127
+ } );
128
+ expect( contextValue.isSeriesVisible( 'chart', 'Views' ) ).toBe( false );
129
+
130
+ rerender( <Harness mounted={ false } /> );
131
+ rerender( <Harness mounted /> );
132
+
133
+ expect( contextValue.isSeriesVisible( 'chart', 'Visitors' ) ).toBe( false );
134
+ expect( contextValue.isSeriesVisible( 'chart', 'Views' ) ).toBe( false );
135
+ } );
136
+
137
+ it( 'leaves a fully revealed chart revealed after a remount', () => {
138
+ // Revealing the last hidden series empties the chart's hidden set, and the
139
+ // provider drops the entry with it. The seeded record has to survive that,
140
+ // or the defaults come back the next time the chart mounts.
141
+ const Harness = ( { mounted }: { mounted: boolean } ) => (
142
+ <GlobalChartsProvider>
143
+ <Grab />
144
+ { mounted && <Chart defaults={ [ 'Visitors' ] } /> }
145
+ </GlobalChartsProvider>
146
+ );
147
+
148
+ const { rerender } = render( <Harness mounted /> );
149
+
150
+ act( () => {
151
+ contextValue.setSeriesVisibility( 'chart', 'Visitors', true );
152
+ } );
153
+ expect( contextValue.getHiddenSeries( 'chart' ) ).toEqual( new Set() );
154
+
155
+ rerender( <Harness mounted={ false } /> );
156
+ rerender( <Harness mounted /> );
157
+
158
+ expect( contextValue.isSeriesVisible( 'chart', 'Visitors' ) ).toBe( true );
159
+ } );
160
+
161
+ it( 'retains provider visibility when remounted without defaults', () => {
162
+ const Harness = ( { defaults, mounted }: { defaults?: string[]; mounted: boolean } ) => (
163
+ <GlobalChartsProvider>
164
+ <Grab />
165
+ { mounted && <Chart defaults={ defaults } /> }
166
+ </GlobalChartsProvider>
167
+ );
168
+
169
+ const { rerender } = render( <Harness defaults={ [ 'Visitors' ] } mounted /> );
170
+ expect( contextValue.isSeriesVisible( 'chart', 'Visitors' ) ).toBe( false );
171
+
172
+ rerender( <Harness mounted={ false } /> );
173
+ rerender( <Harness mounted /> );
174
+
175
+ expect( contextValue.isSeriesVisible( 'chart', 'Visitors' ) ).toBe( false );
176
+ } );
177
+
178
+ it( 'keeps the same set across renders until visibility changes', () => {
179
+ // Charts memoize their rendered series off this set. A fresh identity per
180
+ // render invalidates those memos, which re-runs the accessible tooltip's
181
+ // effect and closes a tooltip the user is still navigating with.
182
+ const Harness = () => (
183
+ <GlobalChartsProvider>
184
+ <Grab />
185
+ <Chart defaults={ [ 'Visitors' ] } />
186
+ </GlobalChartsProvider>
187
+ );
188
+
189
+ const { rerender } = render( <Harness /> );
190
+ const seeded = resolvedHiddenSeries;
191
+
192
+ rerender( <Harness /> );
193
+ expect( resolvedHiddenSeries ).toBe( seeded );
194
+
195
+ act( () => {
196
+ contextValue.setSeriesVisibility( 'chart', 'Views', false );
197
+ } );
198
+ expect( resolvedHiddenSeries ).not.toBe( seeded );
199
+ } );
200
+
201
+ it( 're-seeds the defaults when chartId changes mid-mount', () => {
202
+ // Same mount, only the chartId prop changes (e.g. <LineChart chartId={ metric } ... />
203
+ // with `metric` going 'views' -> 'clicks'), so the component never unmounts.
204
+ const Harness = ( { chartId }: { chartId: string } ) => (
205
+ <GlobalChartsProvider>
206
+ <Grab />
207
+ <Chart chartId={ chartId } defaults={ [ 'Visitors' ] } />
208
+ </GlobalChartsProvider>
209
+ );
210
+
211
+ const { rerender } = render( <Harness chartId="views" /> );
212
+
213
+ expect( contextValue.isSeriesVisible( 'views', 'Visitors' ) ).toBe( false );
214
+
215
+ rerender( <Harness chartId="clicks" /> );
216
+
217
+ // The new chart id gets the same declared defaults seeded...
218
+ expect( contextValue.isSeriesVisible( 'clicks', 'Visitors' ) ).toBe( false );
219
+ // ...and the old id's hidden set is left as it was, not silently cleared.
220
+ expect( contextValue.isSeriesVisible( 'views', 'Visitors' ) ).toBe( false );
221
+ } );
222
+ } );
@@ -38,8 +38,14 @@ export interface GlobalChartsContextValue {
38
38
  getChartData: ( id: string ) => ChartRegistration | undefined;
39
39
  theme: CompleteChartTheme;
40
40
  getElementStyles: ( params: GetElementStylesParams ) => ElementStyles;
41
- // Series visibility management for interactive legends
41
+ // Series visibility management shared by charts, legends, and programmatic controls.
42
42
  toggleSeriesVisibility: ( chartId: string, seriesLabel: string ) => void;
43
+ setSeriesVisibility: ( chartId: string, seriesLabel: string, visible: boolean ) => void;
44
+ setChartHiddenSeries: ( chartId: string, seriesLabels: readonly string[] ) => void;
45
+ /** Applies a chart's defaults once. A no-op for a chart that has already been seeded. */
46
+ seedChartHiddenSeries: ( chartId: string, seriesLabels: readonly string[] ) => void;
47
+ /** Whether this chart's defaults have already been seeded, in this or an earlier mount. */
48
+ hasSeededChart: ( chartId: string ) => boolean;
43
49
  isSeriesVisible: ( chartId: string, seriesLabel: string ) => boolean;
44
50
  getHiddenSeries: ( chartId: string ) => Set< string >;
45
51
  isColorPaletteResolved: boolean;
@@ -57,8 +57,8 @@
57
57
  --a8c-charts-color-track: var(--wpds-color-background-track-neutral-weak, #f0f0f0);
58
58
  // Translucent dark surface; no WPDS token carries this.
59
59
  --a8c-charts-color-tooltip-surface: rgb(0 0 0 / 85%);
60
- --a8c-charts-color-zoom-selection: var(--wpds-color-background-interactive-brand-strong, #3858e9);
61
- --a8c-charts-color-zoom-selection-stroke: var(--wpds-color-stroke-interactive-brand, #3858e9);
60
+ --a8c-charts-color-zoom-selection: var(--wpds-color-background-interactive-brand-strong, var(--wp-admin-theme-color, #3858e9));
61
+ --a8c-charts-color-zoom-selection-stroke: var(--wpds-color-stroke-interactive-brand, var(--wp-admin-theme-color, #3858e9));
62
62
 
63
63
  // @wordpress/theme 1.0.0 removed the whole --wpds-elevation-* group while the
64
64
  // elevation model is redefined (gutenberg#80099). These carry the removed spec