@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
@@ -1,6 +1,6 @@
1
1
  import { DataContext } from '@visx/xychart';
2
2
  import { useEffect, useState, useCallback } from 'react';
3
- import { useSingleChartContext } from '../../private/single-chart-context';
3
+ import { useChartInstanceContext } from '../../private/chart-instance-context';
4
4
  import styles from '../line-chart.module.scss';
5
5
  import type { AxisScale } from '@visx/axis';
6
6
  import type { FC, ReactNode } from 'react';
@@ -15,7 +15,7 @@ interface ScaleData {
15
15
  }
16
16
 
17
17
  const LineChartAnnotationsOverlay: FC< LineChartAnnotationsProps > = ( { children } ) => {
18
- const { chartRef, chartWidth, chartHeight } = useSingleChartContext();
18
+ const { chartRef, chartWidth, chartHeight } = useChartInstanceContext();
19
19
 
20
20
  const [ scales, setScales ] = useState< ScaleData | null >( null );
21
21
  const [ scalesStable, setScalesStable ] = useState< boolean >( false );
@@ -1,12 +1,14 @@
1
1
  /* eslint-disable react/jsx-no-bind */
2
2
 
3
- import { render, screen, waitFor, within } from '@testing-library/react';
3
+ import { render, screen, waitFor, within, act } from '@testing-library/react';
4
4
  import userEvent from '@testing-library/user-event';
5
5
  import { GlyphDiamond } from '@visx/glyph';
6
6
  import { createElement, createRef } from 'react';
7
7
  import { GlobalChartsProvider, defaultTheme } from '../../../providers';
8
+ import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
8
9
  import LineChart, { LineChartUnresponsive } from '../line-chart';
9
- import type { SingleChartRef } from '../../private/single-chart-context';
10
+ import type { GlobalChartsContextValue } from '../../../providers/chart-context/types';
11
+ import type { ChartInstanceRef } from '../../private/chart-instance-context';
10
12
 
11
13
  // Mock useElementSize to return non-zero dimensions in jsdom so charts render
12
14
  const mockRefCallback = jest.fn();
@@ -498,6 +500,57 @@ describe( 'LineChart', () => {
498
500
  expect( ticks.length ).toBeGreaterThan( 1 );
499
501
  } );
500
502
 
503
+ test( 'keeps the derived month formatter when tickFormat is passed as undefined.', () => {
504
+ renderWithTheme( {
505
+ width: 800,
506
+ options: { axis: { x: { tickFormat: undefined } } },
507
+ data: [
508
+ {
509
+ label: 'Series A',
510
+ data: [
511
+ { date: new Date( '2024-01-01' ), value: 10 },
512
+ { date: new Date( '2024-04-01' ), value: 20 },
513
+ { date: new Date( '2024-07-01' ), value: 30 },
514
+ { date: new Date( '2024-10-01' ), value: 40 },
515
+ { date: new Date( '2025-03-01' ), value: 50 },
516
+ ],
517
+ },
518
+ ],
519
+ } );
520
+
521
+ // January is absent: formatMonthOrYearTick renders it as the year instead.
522
+ const ticks = screen.getAllByText( /^(Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/ );
523
+ expect( ticks.length ).toBeGreaterThan( 1 );
524
+ expect(
525
+ screen.queryByText( /^(February|March|April|June|July|August|September|October)$/ )
526
+ ).not.toBeInTheDocument();
527
+ } );
528
+
529
+ test( 'honors an explicit tickFormat over the derived formatter.', () => {
530
+ renderWithTheme( {
531
+ width: 800,
532
+ options: {
533
+ axis: {
534
+ x: {
535
+ tickFormat: ( date: Date | number ) =>
536
+ `tick-${ new Date( Number( date ) ).getUTCMonth() }`,
537
+ },
538
+ },
539
+ },
540
+ data: [
541
+ {
542
+ label: 'Series A',
543
+ data: [
544
+ { date: new Date( '2024-01-01' ), value: 10 },
545
+ { date: new Date( '2024-07-01' ), value: 30 },
546
+ ],
547
+ },
548
+ ],
549
+ } );
550
+
551
+ expect( screen.getAllByText( /^tick-\d+$/ ).length ).toBeGreaterThan( 0 );
552
+ } );
553
+
501
554
  test( 'renders ticks in year format.', () => {
502
555
  renderWithTheme( {
503
556
  width: 800,
@@ -902,7 +955,7 @@ describe( 'LineChart', () => {
902
955
 
903
956
  describe( 'Chart Ref Interface', () => {
904
957
  test( 'exposes getScales method via ref', () => {
905
- const ref = createRef< SingleChartRef >();
958
+ const ref = createRef< ChartInstanceRef >();
906
959
  renderUnwrappedWithTheme( {}, 'default', ref );
907
960
 
908
961
  expect( ref.current?.getScales() ).toBeDefined();
@@ -911,7 +964,7 @@ describe( 'LineChart', () => {
911
964
  } );
912
965
 
913
966
  test( 'exposes getChartDimensions method via ref', () => {
914
- const ref = createRef< SingleChartRef >();
967
+ const ref = createRef< ChartInstanceRef >();
915
968
  renderUnwrappedWithTheme( { width: 800, height: 400 }, 'default', ref );
916
969
 
917
970
  const dimensions = ref.current?.getChartDimensions();
@@ -1298,7 +1351,7 @@ describe( 'LineChart', () => {
1298
1351
  expect( buttons ).toHaveLength( 0 );
1299
1352
  } );
1300
1353
 
1301
- it( 'shows all series when chartId is missing even if legendInteractive is true', () => {
1354
+ it( 'shows all series when nothing has been hidden', () => {
1302
1355
  render(
1303
1356
  <GlobalChartsProvider>
1304
1357
  <LineChartUnresponsive
@@ -1317,6 +1370,136 @@ describe( 'LineChart', () => {
1317
1370
  expect( item ).toHaveAttribute( 'aria-pressed', 'true' );
1318
1371
  } );
1319
1372
  } );
1373
+
1374
+ it( 'hides a series programmatically when the legend is not interactive', () => {
1375
+ let context: GlobalChartsContextValue;
1376
+ const Grab = () => {
1377
+ context = useGlobalChartsContext();
1378
+ return null;
1379
+ };
1380
+
1381
+ render(
1382
+ <GlobalChartsProvider>
1383
+ <Grab />
1384
+ <LineChartUnresponsive
1385
+ width={ 500 }
1386
+ height={ 300 }
1387
+ withGradientFill={ false }
1388
+ showLegend={ true }
1389
+ legend={ { interactive: false } }
1390
+ chartId="test-programmatic-line"
1391
+ data={ [
1392
+ {
1393
+ label: 'Series A',
1394
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
1395
+ options: {},
1396
+ },
1397
+ {
1398
+ label: 'Series B',
1399
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
1400
+ options: {},
1401
+ },
1402
+ ] }
1403
+ />
1404
+ </GlobalChartsProvider>
1405
+ );
1406
+
1407
+ expect( screen.queryByText( /all series are hidden/i ) ).not.toBeInTheDocument();
1408
+
1409
+ act( () => {
1410
+ context.toggleSeriesVisibility( 'test-programmatic-line', 'Series A' );
1411
+ context.toggleSeriesVisibility( 'test-programmatic-line', 'Series B' );
1412
+ } );
1413
+
1414
+ expect( screen.getByText( /all series are hidden/i ) ).toBeInTheDocument();
1415
+ } );
1416
+
1417
+ it( 'omits the click instruction when the legend cannot be clicked', () => {
1418
+ let context: GlobalChartsContextValue;
1419
+ const Grab = () => {
1420
+ context = useGlobalChartsContext();
1421
+ return null;
1422
+ };
1423
+
1424
+ render(
1425
+ <GlobalChartsProvider>
1426
+ <Grab />
1427
+ <LineChartUnresponsive
1428
+ width={ 500 }
1429
+ height={ 300 }
1430
+ withGradientFill={ false }
1431
+ showLegend={ true }
1432
+ legend={ { interactive: false } }
1433
+ chartId="test-empty-copy-line"
1434
+ data={ [
1435
+ {
1436
+ label: 'Series A',
1437
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
1438
+ options: {},
1439
+ },
1440
+ ] }
1441
+ />
1442
+ </GlobalChartsProvider>
1443
+ );
1444
+
1445
+ act( () => {
1446
+ context.toggleSeriesVisibility( 'test-empty-copy-line', 'Series A' );
1447
+ } );
1448
+
1449
+ expect( screen.getByText( 'All series are hidden.' ) ).toBeInTheDocument();
1450
+ expect( screen.queryByText( /click legend items/i ) ).not.toBeInTheDocument();
1451
+ } );
1452
+
1453
+ it( 'pins the value axis across a programmatic hide when rescaleYOnVisibilityChange is false', () => {
1454
+ let context: GlobalChartsContextValue;
1455
+ const Grab = () => {
1456
+ context = useGlobalChartsContext();
1457
+ return null;
1458
+ };
1459
+ const ref = createRef< ChartInstanceRef >();
1460
+
1461
+ render(
1462
+ <GlobalChartsProvider>
1463
+ <Grab />
1464
+ <LineChartUnresponsive
1465
+ width={ 500 }
1466
+ height={ 300 }
1467
+ withGradientFill={ false }
1468
+ showLegend={ true }
1469
+ legend={ { interactive: false } }
1470
+ chartId="test-programmatic-pin-line"
1471
+ rescaleYOnVisibilityChange={ false }
1472
+ ref={ ref }
1473
+ data={ [
1474
+ {
1475
+ label: 'Series A',
1476
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
1477
+ options: {},
1478
+ },
1479
+ {
1480
+ label: 'Series B',
1481
+ data: [ { date: new Date( '2024-01-01' ), value: 200, label: 'Jan 1' } ],
1482
+ options: {},
1483
+ },
1484
+ ] }
1485
+ />
1486
+ </GlobalChartsProvider>
1487
+ );
1488
+
1489
+ const before = (
1490
+ ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
1491
+ )?.domain();
1492
+ expect( before ).toBeDefined();
1493
+
1494
+ act( () => {
1495
+ context.toggleSeriesVisibility( 'test-programmatic-pin-line', 'Series B' );
1496
+ } );
1497
+
1498
+ const after = (
1499
+ ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
1500
+ )?.domain();
1501
+ expect( after ).toEqual( before );
1502
+ } );
1320
1503
  } );
1321
1504
 
1322
1505
  // The line is not animated, so it clips only while actually zoomed.
@@ -1523,4 +1706,129 @@ describe( 'LineChart', () => {
1523
1706
  ).toBeInTheDocument();
1524
1707
  } );
1525
1708
  } );
1709
+
1710
+ describe( 'defaultHiddenSeries', () => {
1711
+ it( 'renders a series hidden when named in defaultHiddenSeries', () => {
1712
+ render(
1713
+ <GlobalChartsProvider>
1714
+ <LineChartUnresponsive
1715
+ width={ 500 }
1716
+ height={ 300 }
1717
+ withGradientFill={ false }
1718
+ showLegend={ true }
1719
+ legend={ { interactive: true } }
1720
+ chartId="test-default-hidden-line"
1721
+ defaultHiddenSeries={ [ 'Series B' ] }
1722
+ data={ [
1723
+ {
1724
+ label: 'Series A',
1725
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
1726
+ options: {},
1727
+ },
1728
+ {
1729
+ label: 'Series B',
1730
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
1731
+ options: {},
1732
+ },
1733
+ ] }
1734
+ />
1735
+ </GlobalChartsProvider>
1736
+ );
1737
+
1738
+ const items = screen.getAllByRole( 'button' );
1739
+ expect( items[ 0 ] ).toHaveAttribute( 'aria-pressed', 'true' );
1740
+ expect( items[ 1 ] ).toHaveAttribute( 'aria-pressed', 'false' );
1741
+ } );
1742
+
1743
+ it( 'lets the user reveal a series seeded hidden', async () => {
1744
+ const user = userEvent.setup();
1745
+
1746
+ render(
1747
+ <GlobalChartsProvider>
1748
+ <LineChartUnresponsive
1749
+ width={ 500 }
1750
+ height={ 300 }
1751
+ withGradientFill={ false }
1752
+ showLegend={ true }
1753
+ legend={ { interactive: true } }
1754
+ chartId="test-default-hidden-reveal-line"
1755
+ defaultHiddenSeries={ [ 'Series B' ] }
1756
+ data={ [
1757
+ {
1758
+ label: 'Series A',
1759
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
1760
+ options: {},
1761
+ },
1762
+ {
1763
+ label: 'Series B',
1764
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
1765
+ options: {},
1766
+ },
1767
+ ] }
1768
+ />
1769
+ </GlobalChartsProvider>
1770
+ );
1771
+
1772
+ await user.click( screen.getAllByRole( 'button' )[ 1 ] );
1773
+
1774
+ expect( screen.getAllByRole( 'button' )[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
1775
+ } );
1776
+
1777
+ it( 'keeps a revealed series visible after a data change', async () => {
1778
+ // Matters specifically at chart level: useChartRegistration unregisters and
1779
+ // re-registers whenever legendItems change, which a data prop change triggers.
1780
+ // A hook-level harness has no registration at all, so it can't catch a
1781
+ // regression where re-registration re-triggers the seeding effect.
1782
+ const user = userEvent.setup();
1783
+ const chartId = 'test-default-hidden-data-change-line';
1784
+
1785
+ const makeData = ( seriesAValue: number ) => [
1786
+ {
1787
+ label: 'Series A',
1788
+ data: [ { date: new Date( '2024-01-01' ), value: seriesAValue, label: 'Jan 1' } ],
1789
+ options: {},
1790
+ },
1791
+ {
1792
+ label: 'Series B',
1793
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
1794
+ options: {},
1795
+ },
1796
+ ];
1797
+
1798
+ const { rerender } = render(
1799
+ <GlobalChartsProvider>
1800
+ <LineChartUnresponsive
1801
+ width={ 500 }
1802
+ height={ 300 }
1803
+ withGradientFill={ false }
1804
+ showLegend={ true }
1805
+ legend={ { interactive: true } }
1806
+ chartId={ chartId }
1807
+ defaultHiddenSeries={ [ 'Series B' ] }
1808
+ data={ makeData( 10 ) }
1809
+ />
1810
+ </GlobalChartsProvider>
1811
+ );
1812
+
1813
+ await user.click( screen.getAllByRole( 'button' )[ 1 ] );
1814
+ expect( screen.getAllByRole( 'button' )[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
1815
+
1816
+ rerender(
1817
+ <GlobalChartsProvider>
1818
+ <LineChartUnresponsive
1819
+ width={ 500 }
1820
+ height={ 300 }
1821
+ withGradientFill={ false }
1822
+ showLegend={ true }
1823
+ legend={ { interactive: true } }
1824
+ chartId={ chartId }
1825
+ defaultHiddenSeries={ [ 'Series B' ] }
1826
+ data={ makeData( 30 ) }
1827
+ />
1828
+ </GlobalChartsProvider>
1829
+ );
1830
+
1831
+ expect( screen.getAllByRole( 'button' )[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
1832
+ } );
1833
+ } );
1526
1834
  } );
@@ -3,6 +3,7 @@ import type {
3
3
  DataPointDate,
4
4
  SeriesData,
5
5
  SeriesChartLegendConfig,
6
+ SeriesVisibilityProps,
6
7
  AnnotationStyles,
7
8
  DataPoint,
8
9
  } from '../../types';
@@ -28,7 +29,7 @@ export type RenderLineGlyphProps< Datum extends object > = GlyphProps< Datum > &
28
29
  position?: 'start' | 'end';
29
30
  };
30
31
 
31
- export interface LineChartProps extends BaseChartProps< SeriesData[] > {
32
+ export interface LineChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
32
33
  /**
33
34
  * Legend configuration. Supports `collapseGroups` on top of the shared options.
34
35
  */
@@ -53,8 +54,8 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] > {
53
54
  */
54
55
  zoomable?: boolean;
55
56
  /**
56
- * Whether the Y axis rescales to fit only the visible series when series are
57
- * hidden or shown through the interactive legend.
57
+ * Whether the Y axis rescales to fit only the visible series when the set of
58
+ * visible series changes — via the interactive legend or otherwise.
58
59
  * Defaults to `true` (the pre-existing behaviour). Set to `false` to pin the Y
59
60
  * axis to the full data extent so hiding series does not move the chart's
60
61
  * baseline — useful for comparison charts. Matches `AreaChart`.
@@ -1,14 +1,13 @@
1
1
  import { Group } from '@visx/group';
2
2
  import { Pie } from '@visx/shape';
3
3
  import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
4
- import { __ } from '@wordpress/i18n';
5
4
  import clsx from 'clsx';
6
5
  import { useCallback, useContext, useMemo } from 'react';
7
6
  import { Legend, useChartLegendItems } from '../../components/legend';
8
7
  import { BaseTooltip } from '../../components/tooltip';
9
8
  import {
10
9
  useDataWithPercentages,
11
- useInteractiveLegendData,
10
+ useLegendVisibilityData,
12
11
  usePrefersReducedMotion,
13
12
  } from '../../hooks';
14
13
  import {
@@ -24,10 +23,10 @@ import { attachSubComponents, resolveFontSize } from '../../utils';
24
23
  import { getStringWidth } from '../../visx/text';
25
24
  import { Center } from '../private/center';
26
25
  import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition';
26
+ import { ChartInstanceContext } from '../private/chart-instance-context';
27
27
  import { ChartLayout } from '../private/chart-layout';
28
28
  import { RadialWipeAnimation } from '../private/radial-wipe-animation/';
29
- import { SingleChartContext } from '../private/single-chart-context';
30
- import { SvgEmptyState } from '../private/svg-empty-state';
29
+ import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
31
30
  import { withResponsive, ResponsiveConfig } from '../private/with-responsive';
32
31
  import styles from './pie-chart.module.scss';
33
32
  import type { LegendValueDisplay } from '../../components/legend';
@@ -218,11 +217,10 @@ const PieChartInternal = ( {
218
217
  // Calculate percentages from values (single source of truth)
219
218
  const dataWithPercentages = useDataWithPercentages( data );
220
219
 
221
- // Filter and recalculate data for interactive legends
222
- const { visibleData, allSegmentsHidden, legendData } = useInteractiveLegendData( {
220
+ // Filter and recalculate data from the shared legend visibility state.
221
+ const { visibleData, allSegmentsHidden, legendData } = useLegendVisibilityData( {
223
222
  data: dataWithPercentages,
224
223
  chartId,
225
- legendInteractive,
226
224
  isSeriesVisible,
227
225
  } );
228
226
 
@@ -308,7 +306,7 @@ const PieChartInternal = ( {
308
306
  );
309
307
 
310
308
  return (
311
- <SingleChartContext.Provider value={ { chartId } }>
309
+ <ChartInstanceContext.Provider value={ { chartId } }>
312
310
  <ChartLayout
313
311
  legendPosition={ legendPosition }
314
312
  legendElement={ legendElement }
@@ -385,10 +383,7 @@ const PieChartInternal = ( {
385
383
  >
386
384
  { allSegmentsHidden ? (
387
385
  <SvgEmptyState x={ 0 } y={ 0 } width={ width } height={ height }>
388
- { __(
389
- 'All segments are hidden. Click legend items to show data.',
390
- 'jetpack-charts'
391
- ) }
386
+ { getAllHiddenMessage( legendInteractive, 'segments' ) }
392
387
  </SvgEmptyState>
393
388
  ) : (
394
389
  <Pie< DataPointPercentageCalculated & { index: number } >
@@ -495,7 +490,7 @@ const PieChartInternal = ( {
495
490
  );
496
491
  } }
497
492
  </ChartLayout>
498
- </SingleChartContext.Provider>
493
+ </ChartInstanceContext.Provider>
499
494
  );
500
495
  };
501
496
 
@@ -1,6 +1,8 @@
1
+ /* eslint-disable react/jsx-no-bind */
2
+
1
3
  import { render, screen, waitFor } from '@testing-library/react';
2
4
  import userEvent from '@testing-library/user-event';
3
- import { GlobalChartsProvider } from '../../../providers';
5
+ import { GlobalChartsProvider, useGlobalChartsContext } from '../../../providers';
4
6
  import PieChart from '../pie-chart';
5
7
 
6
8
  describe( 'PieChart', () => {
@@ -548,4 +550,74 @@ describe( 'PieChart', () => {
548
550
  expect( screen.getByText( /33\.3/ ) ).toBeInTheDocument();
549
551
  } );
550
552
  } );
553
+
554
+ describe( 'Programmatic visibility (non-interactive legend)', () => {
555
+ const chartId = 'test-programmatic-hide-pie-chart';
556
+ const testData = [
557
+ { label: 'Segment A', value: 50 },
558
+ { label: 'Segment B', value: 50 },
559
+ ];
560
+
561
+ const Harness = () => {
562
+ const { toggleSeriesVisibility } = useGlobalChartsContext();
563
+ return (
564
+ <>
565
+ <button onClick={ () => toggleSeriesVisibility( chartId, 'Segment A' ) }>
566
+ Hide Segment A
567
+ </button>
568
+ <button onClick={ () => toggleSeriesVisibility( chartId, 'Segment B' ) }>
569
+ Hide Segment B
570
+ </button>
571
+ <PieChart
572
+ data={ testData }
573
+ size={ 500 }
574
+ showLegend
575
+ legend={ { interactive: false } }
576
+ chartId={ chartId }
577
+ />
578
+ </>
579
+ );
580
+ };
581
+
582
+ test( 'removes a segment hidden programmatically even though the legend is not clickable', async () => {
583
+ const user = userEvent.setup();
584
+
585
+ render(
586
+ <GlobalChartsProvider>
587
+ <Harness />
588
+ </GlobalChartsProvider>
589
+ );
590
+
591
+ expect( screen.getAllByTestId( 'pie-segment' ) ).toHaveLength( 2 );
592
+
593
+ await user.click( screen.getByRole( 'button', { name: 'Hide Segment A' } ) );
594
+
595
+ await waitFor( () => {
596
+ expect( screen.getAllByTestId( 'pie-segment' ) ).toHaveLength( 1 );
597
+ } );
598
+
599
+ // The legend itself stays non-interactive (no toggle button for the segment).
600
+ expect( screen.queryAllByRole( 'button', { name: /Segment A: /i } ) ).toHaveLength( 0 );
601
+ } );
602
+
603
+ test( 'empty-state copy omits the click instruction when the legend is not interactive', async () => {
604
+ const user = userEvent.setup();
605
+
606
+ render(
607
+ <GlobalChartsProvider>
608
+ <Harness />
609
+ </GlobalChartsProvider>
610
+ );
611
+
612
+ await user.click( screen.getByRole( 'button', { name: 'Hide Segment A' } ) );
613
+ await user.click( screen.getByRole( 'button', { name: 'Hide Segment B' } ) );
614
+
615
+ await waitFor( () => {
616
+ expect( screen.queryAllByTestId( 'pie-segment' ) ).toHaveLength( 0 );
617
+ } );
618
+
619
+ expect( screen.getByText( 'All segments are hidden.' ) ).toBeInTheDocument();
620
+ expect( screen.queryByText( /Click legend items to show data/i ) ).not.toBeInTheDocument();
621
+ } );
622
+ } );
551
623
  } );
@@ -6,8 +6,8 @@
6
6
  }
7
7
 
8
8
  .label {
9
- font-weight: var(--wpds-typography-font-weight-emphasis, 499);
10
- font-size: var(--wpds-typography-font-size-lg, 16px);
9
+ font-weight: var(--wpds-typography-font-weight-emphasis, 600);
10
+ font-size: var(--wpds-typography-font-size-lg, 15px);
11
11
  }
12
12
 
13
13
  .note {
@@ -2,14 +2,13 @@ import { Group } from '@visx/group';
2
2
  import { Pie } from '@visx/shape';
3
3
  import { Text } from '@visx/text';
4
4
  import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
5
- import { __ } from '@wordpress/i18n';
6
5
  import clsx from 'clsx';
7
6
  import { useCallback, useContext, useMemo } from 'react';
8
7
  import { Legend, useChartLegendItems } from '../../components/legend';
9
8
  import { BaseTooltip } from '../../components/tooltip';
10
9
  import {
11
10
  useDataWithPercentages,
12
- useInteractiveLegendData,
11
+ useLegendVisibilityData,
13
12
  usePrefersReducedMotion,
14
13
  } from '../../hooks';
15
14
  import {
@@ -23,10 +22,10 @@ import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
23
22
  import { attachSubComponents } from '../../utils';
24
23
  import { Center } from '../private/center';
25
24
  import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition';
25
+ import { ChartInstanceContext } from '../private/chart-instance-context';
26
26
  import { ChartLayout } from '../private/chart-layout';
27
27
  import { RadialWipeAnimation } from '../private/radial-wipe-animation';
28
- import { SingleChartContext } from '../private/single-chart-context';
29
- import { SvgEmptyState } from '../private/svg-empty-state';
28
+ import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
30
29
  import { withResponsive } from '../private/with-responsive';
31
30
  import styles from './pie-semi-circle-chart.module.scss';
32
31
  import type { LegendValueDisplay } from '../../components/legend';
@@ -247,11 +246,10 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
247
246
  // Calculate percentages from values (single source of truth)
248
247
  const dataWithPercentages = useDataWithPercentages( data );
249
248
 
250
- // Filter and recalculate data for interactive legends
251
- const { visibleData, allSegmentsHidden, legendData } = useInteractiveLegendData( {
249
+ // Filter and recalculate data from the shared legend visibility state.
250
+ const { visibleData, allSegmentsHidden, legendData } = useLegendVisibilityData( {
252
251
  data: dataWithPercentages,
253
252
  chartId,
254
- legendInteractive,
255
253
  isSeriesVisible,
256
254
  } );
257
255
 
@@ -353,7 +351,7 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
353
351
  );
354
352
 
355
353
  return (
356
- <SingleChartContext.Provider value={ { chartId } }>
354
+ <ChartInstanceContext.Provider value={ { chartId } }>
357
355
  <ChartLayout
358
356
  legendPosition={ legendPosition }
359
357
  legendElement={ legendElement }
@@ -427,10 +425,7 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
427
425
  >
428
426
  { allSegmentsHidden ? (
429
427
  <SvgEmptyState x={ 0 } y={ -radius / 2 } width={ width } height={ height }>
430
- { __(
431
- 'All segments are hidden. Click legend items to show data.',
432
- 'jetpack-charts'
433
- ) }
428
+ { getAllHiddenMessage( legendInteractive, 'segments' ) }
434
429
  </SvgEmptyState>
435
430
  ) : (
436
431
  <>
@@ -493,7 +488,7 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
493
488
  );
494
489
  } }
495
490
  </ChartLayout>
496
- </SingleChartContext.Provider>
491
+ </ChartInstanceContext.Provider>
497
492
  );
498
493
  };
499
494
 
@@ -15,11 +15,7 @@ export interface ChartInstanceContextValue {
15
15
  chartRef?: React.RefObject< ChartInstanceRef >;
16
16
  chartWidth?: number;
17
17
  chartHeight?: number;
18
+ isSeriesVisible?: ( seriesLabel: string ) => boolean;
18
19
  }
19
20
 
20
21
  export const ChartInstanceContext = createContext< ChartInstanceContextValue | null >( null );
21
-
22
- // Backward compatibility exports
23
- export const SingleChartContext = ChartInstanceContext;
24
- export type SingleChartContextValue = ChartInstanceContextValue;
25
- export type SingleChartRef = ChartInstanceRef;
@@ -0,0 +1,3 @@
1
+ export { ChartInstanceContext } from './chart-instance-context';
2
+ export { useChartInstanceContext } from './use-chart-instance-context';
3
+ export type { ChartInstanceContextValue, ChartInstanceRef } from './chart-instance-context';
@@ -1,5 +1,5 @@
1
1
  import { useContext } from 'react';
2
- import { ChartInstanceContext, type ChartInstanceContextValue } from './single-chart-context';
2
+ import { ChartInstanceContext, type ChartInstanceContextValue } from './chart-instance-context';
3
3
 
4
4
  export const useChartInstanceContext = (): ChartInstanceContextValue => {
5
5
  const context = useContext( ChartInstanceContext );
@@ -8,5 +8,3 @@ export const useChartInstanceContext = (): ChartInstanceContextValue => {
8
8
  }
9
9
  return context;
10
10
  };
11
-
12
- export const useSingleChartContext = useChartInstanceContext;