@automattic/charts 2.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/TOKENS.md +109 -50
  3. package/dist/index.cjs +529 -233
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +118 -55
  6. package/dist/index.d.cts +90 -48
  7. package/dist/index.d.ts +90 -48
  8. package/dist/index.js +527 -235
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.module.scss +8 -3
  12. package/src/charts/area-chart/area-chart.tsx +25 -25
  13. package/src/charts/area-chart/private/overlays.tsx +3 -3
  14. package/src/charts/area-chart/test/area-chart.test.tsx +301 -11
  15. package/src/charts/area-chart/types.ts +4 -3
  16. package/src/charts/bar-chart/bar-chart.module.scss +14 -6
  17. package/src/charts/bar-chart/bar-chart.tsx +21 -15
  18. package/src/charts/bar-chart/test/bar-chart.test.tsx +255 -2
  19. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +23 -18
  20. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +107 -86
  21. package/src/charts/conversion-funnel-chart/test/chart-scope.test.tsx +36 -0
  22. package/src/charts/geo-chart/geo-chart.module.scss +8 -0
  23. package/src/charts/geo-chart/geo-chart.tsx +12 -4
  24. package/src/charts/heatmap-chart/heatmap-chart.module.scss +22 -9
  25. package/src/charts/heatmap-chart/heatmap-chart.tsx +43 -13
  26. package/src/charts/heatmap-chart/private/build-calendar-data.ts +65 -5
  27. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +125 -0
  28. package/src/charts/heatmap-chart/types.ts +8 -0
  29. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +13 -7
  30. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +17 -22
  31. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +107 -1
  32. package/src/charts/leaderboard-chart/types.ts +0 -2
  33. package/src/charts/line-chart/line-chart.module.scss +12 -7
  34. package/src/charts/line-chart/line-chart.tsx +32 -28
  35. package/src/charts/line-chart/private/line-chart-annotation.tsx +4 -4
  36. package/src/charts/line-chart/private/line-chart-annotations-overlay.tsx +2 -2
  37. package/src/charts/line-chart/test/line-chart.test.tsx +262 -5
  38. package/src/charts/line-chart/types.ts +4 -3
  39. package/src/charts/pie-chart/pie-chart.tsx +12 -14
  40. package/src/charts/pie-chart/test/pie-chart.test.tsx +73 -1
  41. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +2 -2
  42. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +12 -14
  43. package/src/charts/private/{single-chart-context/single-chart-context.tsx → chart-instance-context/chart-instance-context.ts} +1 -5
  44. package/src/charts/private/chart-instance-context/index.ts +3 -0
  45. package/src/charts/private/{single-chart-context/use-single-chart-context.ts → chart-instance-context/use-chart-instance-context.ts} +1 -3
  46. package/src/charts/private/svg-empty-state/get-all-hidden-message.ts +41 -0
  47. package/src/charts/private/svg-empty-state/index.ts +1 -0
  48. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  49. package/src/charts/private/svg-empty-state/test/get-all-hidden-message.test.ts +15 -0
  50. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +16 -0
  51. package/src/charts/private/with-responsive/with-responsive.tsx +18 -11
  52. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +2 -2
  53. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  54. package/src/charts/private/x-zoom/x-zoom.tsx +2 -2
  55. package/src/components/legend/legend.tsx +4 -4
  56. package/src/components/legend/private/base-legend.module.scss +1 -1
  57. package/src/components/legend/private/base-legend.tsx +70 -19
  58. package/src/components/legend/test/legend.test.tsx +100 -4
  59. package/src/components/tooltip/accessible-tooltip.tsx +17 -4
  60. package/src/components/tooltip/base-tooltip.tsx +8 -1
  61. package/src/components/trend-indicator/test/trend-indicator.test.tsx +20 -0
  62. package/src/components/trend-indicator/trend-indicator.module.scss +4 -7
  63. package/src/components/trend-indicator/trend-indicator.tsx +3 -0
  64. package/src/hooks/index.ts +1 -1
  65. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +28 -0
  66. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +19 -13
  67. package/src/hooks/test/use-xychart-theme.test.tsx +25 -0
  68. package/src/hooks/{use-interactive-legend-data.ts → use-legend-visibility-data.ts} +29 -30
  69. package/src/hooks/use-xychart-theme.ts +6 -4
  70. package/src/index.ts +12 -3
  71. package/src/providers/chart-context/global-charts-provider.tsx +127 -30
  72. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +47 -0
  73. package/src/providers/chart-context/private/theme-override-vars.ts +87 -0
  74. package/src/providers/chart-context/private/with-catalog-pointers.ts +50 -0
  75. package/src/providers/chart-context/test/chart-context.test.tsx +142 -40
  76. package/src/providers/chart-context/test/chart-scope-class.test.tsx +19 -0
  77. package/src/providers/chart-context/test/sparse-theme.test.ts +35 -0
  78. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +29 -0
  79. package/src/providers/chart-context/test/theme-override-vars.test.ts +118 -0
  80. package/src/providers/chart-context/test/use-default-hidden-series-paint.test.tsx +56 -0
  81. package/src/providers/chart-context/test/use-default-hidden-series.ssr.test.tsx +39 -0
  82. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +222 -0
  83. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +47 -0
  84. package/src/providers/chart-context/themes.ts +18 -27
  85. package/src/providers/chart-context/types.ts +7 -4
  86. package/src/providers/chart-scope/chart-scope-context.ts +8 -0
  87. package/src/providers/chart-scope/index.ts +3 -0
  88. package/src/providers/chart-scope/test/chart-scope.test.tsx +24 -0
  89. package/src/providers/chart-scope/test/use-standalone-scope-class.test.tsx +20 -0
  90. package/src/providers/chart-scope/use-chart-scope-element.ts +9 -0
  91. package/src/providers/chart-scope/use-standalone-scope-class.ts +13 -0
  92. package/src/providers/index.ts +1 -0
  93. package/src/styles/chart-scope-class.ts +6 -0
  94. package/src/styles/chart-scope.scss +83 -0
  95. package/src/styles/test/chart-scope.test.ts +165 -0
  96. package/src/styles/test/wpds-fallbacks.test.ts +131 -0
  97. package/src/types.ts +12 -34
  98. package/src/charts/private/grid-control/grid-control.module.scss +0 -8
  99. package/src/charts/private/grid-control/grid-control.tsx +0 -26
  100. package/src/charts/private/grid-control/index.ts +0 -1
  101. package/src/charts/private/grid-control/test/grid-control.test.tsx +0 -56
  102. package/src/charts/private/single-chart-context/index.ts +0 -8
@@ -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 {
@@ -19,13 +18,14 @@ import {
19
18
  useGlobalChartsContext,
20
19
  GlobalChartsContext,
21
20
  } from '../../providers';
21
+ import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
22
22
  import { attachSubComponents } from '../../utils';
23
23
  import { Center } from '../private/center';
24
24
  import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition';
25
+ import { ChartInstanceContext } from '../private/chart-instance-context';
25
26
  import { ChartLayout } from '../private/chart-layout';
26
27
  import { RadialWipeAnimation } from '../private/radial-wipe-animation';
27
- import { SingleChartContext } from '../private/single-chart-context';
28
- import { SvgEmptyState } from '../private/svg-empty-state';
28
+ import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
29
29
  import { withResponsive } from '../private/with-responsive';
30
30
  import styles from './pie-semi-circle-chart.module.scss';
31
31
  import type { LegendValueDisplay } from '../../components/legend';
@@ -246,11 +246,10 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
246
246
  // Calculate percentages from values (single source of truth)
247
247
  const dataWithPercentages = useDataWithPercentages( data );
248
248
 
249
- // Filter and recalculate data for interactive legends
250
- const { visibleData, allSegmentsHidden, legendData } = useInteractiveLegendData( {
249
+ // Filter and recalculate data from the shared legend visibility state.
250
+ const { visibleData, allSegmentsHidden, legendData } = useLegendVisibilityData( {
251
251
  data: dataWithPercentages,
252
252
  chartId,
253
- legendInteractive,
254
253
  isSeriesVisible,
255
254
  } );
256
255
 
@@ -352,7 +351,7 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
352
351
  );
353
352
 
354
353
  return (
355
- <SingleChartContext.Provider value={ { chartId } }>
354
+ <ChartInstanceContext.Provider value={ { chartId } }>
356
355
  <ChartLayout
357
356
  legendPosition={ legendPosition }
358
357
  legendElement={ legendElement }
@@ -375,7 +374,9 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
375
374
  <>
376
375
  { withTooltips && tooltipOpen && tooltipData && (
377
376
  <TooltipInPortal top={ tooltipTop || 0 } left={ tooltipLeft || 0 }>
378
- <div role="tooltip">{ renderTooltip( { tooltipData } ) }</div>
377
+ <div className={ CHART_SCOPE_CLASS } role="tooltip">
378
+ { renderTooltip( { tooltipData } ) }
379
+ </div>
379
380
  </TooltipInPortal>
380
381
  ) }
381
382
  { htmlChildren }
@@ -424,10 +425,7 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
424
425
  >
425
426
  { allSegmentsHidden ? (
426
427
  <SvgEmptyState x={ 0 } y={ -radius / 2 } width={ width } height={ height }>
427
- { __(
428
- 'All segments are hidden. Click legend items to show data.',
429
- 'jetpack-charts'
430
- ) }
428
+ { getAllHiddenMessage( legendInteractive, 'segments' ) }
431
429
  </SvgEmptyState>
432
430
  ) : (
433
431
  <>
@@ -490,7 +488,7 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
490
488
  );
491
489
  } }
492
490
  </ChartLayout>
493
- </SingleChartContext.Provider>
491
+ </ChartInstanceContext.Provider>
494
492
  );
495
493
  };
496
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;
@@ -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';
@@ -1,5 +1,5 @@
1
1
  .svg-empty-state {
2
2
  text-align: center;
3
3
  font-size: var(--wpds-typography-font-size-md, 13px);
4
- color: var(--a8c-charts-color-label-secondary, var(--wpds-color-foreground-content-neutral-weak, #707070));
4
+ color: var(--a8c-charts-color-label-secondary);
5
5
  }
@@ -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
+ } );
@@ -1,4 +1,6 @@
1
1
  import { render, screen } from '@testing-library/react';
2
+ import { useContext } from 'react';
3
+ import { ChartScopeContext } from '../../../../providers/chart-scope';
2
4
  import { withResponsive } from '../index';
3
5
  import type { BaseChartProps } from '../../../../types';
4
6
 
@@ -311,4 +313,18 @@ describe( 'withResponsive', () => {
311
313
  expect( screen.getByTestId( 'responsive-container' ) ).toBeInTheDocument();
312
314
  } );
313
315
  } );
316
+
317
+ it( 'publishes the responsive wrapper element as the chart scope', () => {
318
+ // The catalog is declared on the GlobalChartsProvider wrapper (or a standalone component's own element), not on every chart root, so this wrapper doesn't carry a scope class itself — but it must still be the element the JS bridge resolves scoped CSS variables against, via ChartScopeContext, so it picks up any override set inside the provider tree the same way a CSS-painted sibling would.
319
+ const ScopeProbe = () => {
320
+ const scopeNode = useContext( ChartScopeContext );
321
+ return <span data-testid="scope-probe">{ scopeNode?.dataset.testid }</span>;
322
+ };
323
+ const Chart: ( props: BaseChartProps ) => JSX.Element = () => <ScopeProbe />;
324
+ const Wrapped = withResponsive( Chart );
325
+
326
+ render( <Wrapped data={ [] } /> );
327
+
328
+ expect( screen.getByTestId( 'scope-probe' ) ).toHaveTextContent( 'responsive-wrapper' );
329
+ } );
314
330
  } );
@@ -1,6 +1,7 @@
1
1
  import { useParentSize } from '@visx/responsive';
2
2
  import clsx from 'clsx';
3
3
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
4
+ import { ChartScopeContext } from '../../../providers/chart-scope';
4
5
  import styles from './with-responsive.module.scss';
5
6
  import type { BaseChartProps } from '../../../types';
6
7
  import type { ComponentType } from 'react';
@@ -66,6 +67,9 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
66
67
 
67
68
  const hasAspectRatio = aspectRatio !== undefined && aspectRatio > 0;
68
69
 
70
+ // The context needs a re-render when the node attaches; wrapperRef alone cannot provide that, so mirror the node into state from the same callback ref.
71
+ const [ scopeNode, setScopeNode ] = useState< HTMLDivElement | null >( null );
72
+
69
73
  // Keep our own handle on the wrapper so we can read its live height below, and
70
74
  // still hand the node to useParentSize's ref (a callback ref in practice; guard
71
75
  // the object-ref shape too).
@@ -73,6 +77,7 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
73
77
  const setWrapperRef = useCallback(
74
78
  ( node: HTMLDivElement | null ) => {
75
79
  wrapperRef.current = node;
80
+ setScopeNode( node );
76
81
  if ( typeof parentRef === 'function' ) {
77
82
  parentRef( node );
78
83
  } else if ( parentRef ) {
@@ -160,17 +165,19 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
160
165
  ...( height !== undefined ? { height } : null ),
161
166
  } }
162
167
  >
163
- { hasAspectRatio ? (
164
- <div
165
- data-testid="responsive-content"
166
- className={ styles.content }
167
- style={ { width: boxWidth, height: boxHeight } }
168
- >
169
- { wrappedComponent }
170
- </div>
171
- ) : (
172
- wrappedComponent
173
- ) }
168
+ <ChartScopeContext.Provider value={ scopeNode }>
169
+ { hasAspectRatio ? (
170
+ <div
171
+ data-testid="responsive-content"
172
+ className={ styles.content }
173
+ style={ { width: boxWidth, height: boxHeight } }
174
+ >
175
+ { wrappedComponent }
176
+ </div>
177
+ ) : (
178
+ wrappedComponent
179
+ ) }
180
+ </ChartScopeContext.Provider>
174
181
  </div>
175
182
  );
176
183
  };
@@ -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,9 +3,9 @@
3
3
  // The selection's translucency lives in the SVG opacity properties so
4
4
  // the colors themselves can come straight from WPDS tokens.
5
5
  &__selection {
6
- fill: var(--a8c-charts-color-zoom-selection, var(--wpds-color-background-interactive-brand-strong, #3858e9));
6
+ fill: var(--a8c-charts-color-zoom-selection);
7
7
  fill-opacity: 0.16;
8
- stroke: var(--a8c-charts-color-zoom-selection-stroke, var(--wpds-color-stroke-interactive-brand, #3858e9));
8
+ stroke: var(--a8c-charts-color-zoom-selection-stroke);
9
9
  stroke-opacity: 0.65;
10
10
  stroke-width: var(--wpds-border-width-xs, 1px);
11
11
  pointer-events: none;
@@ -29,7 +29,7 @@
29
29
  // through it. Scoped to the rest state so the WPDS
30
30
  // hover/active/focus backgrounds still apply.
31
31
  &:not(:hover, :active, :focus) {
32
- background-color: var(--a8c-charts-color-background, var(--wpds-color-background-surface-neutral-strong, #fff));
32
+ background-color: var(--a8c-charts-color-surface);
33
33
  }
34
34
  }
35
35
  }
@@ -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,11 +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';
18
+ import { useStandaloneScopeClass } from '../../../providers/chart-scope';
16
19
  import { valueOrIdentity, valueOrIdentityString, labelTransformFactory } from '../utils';
17
20
  import styles from './base-legend.module.scss';
18
- import type { BaseLegendProps } from '../types';
21
+ import type { BaseLegendItem, BaseLegendProps } from '../types';
19
22
 
20
23
  const ALIGNMENT_TO_FLEX = {
21
24
  start: 'flex-start',
@@ -57,6 +60,52 @@ const LegendText = ( {
57
60
  );
58
61
  };
59
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
+
60
109
  /*
61
110
  * Base legend component that displays color-coded items with labels based on visx LegendOrdinal.
62
111
  * We avoid using LegendOrdinal directly to enable support for advanced features such as interactivity.
@@ -102,6 +151,8 @@ export const BaseLegend: ForwardRefExoticComponent<
102
151
 
103
152
  const theme = useGlobalChartsTheme();
104
153
  const context = useContext( GlobalChartsContext );
154
+ const chartInstanceContext = useContext( ChartInstanceContext );
155
+ const standaloneScopeClass = useStandaloneScopeClass();
105
156
 
106
157
  const legendScale = scaleOrdinal( {
107
158
  domain: items.map( item => item.label ),
@@ -118,29 +169,28 @@ export const BaseLegend: ForwardRefExoticComponent<
118
169
  const handleLegendClick = useCallback(
119
170
  ( seriesLabels: string[] ) => {
120
171
  if ( interactive && chartId && context ) {
121
- // Converge the whole group on the representative's next state: only toggle series that
122
- // currently match it, so a desynced group (e.g. one member hidden programmatically)
123
- // ends up uniformly hidden/shown after one click.
124
172
  const representativeVisible = context.isSeriesVisible( chartId, seriesLabels[ 0 ] );
125
- seriesLabels.forEach( label => {
126
- if ( context.isSeriesVisible( chartId, label ) === representativeVisible ) {
127
- context.toggleSeriesVisibility( chartId, label );
128
- }
129
- } );
173
+ seriesLabels.forEach( label =>
174
+ context.setSeriesVisibility( chartId, label, ! representativeVisible )
175
+ );
130
176
  }
131
177
  },
132
178
  [ interactive, chartId, context ]
133
179
  );
134
180
 
135
- // 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.
136
183
  const isSeriesVisible = useCallback(
137
184
  ( seriesLabel: string ) => {
138
- if ( ! interactive || ! chartId || ! context ) {
185
+ if ( chartInstanceContext?.isSeriesVisible ) {
186
+ return chartInstanceContext.isSeriesVisible( seriesLabel );
187
+ }
188
+ if ( ! chartId || ! context ) {
139
189
  return true;
140
190
  }
141
191
  return context.isSeriesVisible( chartId, seriesLabel );
142
192
  },
143
- [ interactive, chartId, context ]
193
+ [ chartId, chartInstanceContext, context ]
144
194
  );
145
195
 
146
196
  // Create event handlers to avoid inline arrow functions
@@ -187,9 +237,9 @@ export const BaseLegend: ForwardRefExoticComponent<
187
237
  align={ orientation === 'vertical' ? flexAlignment : undefined }
188
238
  justify={ orientation === 'horizontal' ? flexAlignment : undefined }
189
239
  wrap={ orientation === 'horizontal' ? 'wrap' : undefined }
190
- role="list"
240
+ role={ interactive ? undefined : 'list' }
191
241
  data-testid={ `legend-${ orientation }` }
192
- className={ clsx( styles.legend, className ) }
242
+ className={ clsx( standaloneScopeClass, styles.legend, className ) }
193
243
  style={ theme.legend?.containerStyles }
194
244
  >
195
245
  { labels.map( ( label, i ) => {
@@ -221,14 +271,15 @@ export const BaseLegend: ForwardRefExoticComponent<
221
271
  }
222
272
  onClick={ handleClick }
223
273
  onKeyDown={ handleKeyDown }
224
- role={ interactive ? 'button' : undefined }
274
+ role={ interactive ? 'button' : 'listitem' }
225
275
  tabIndex={ interactive ? 0 : undefined }
226
276
  aria-pressed={ interactive ? visible : undefined }
227
- aria-label={
277
+ aria-label={ getLegendItemAriaLabel(
278
+ label.text,
279
+ matchedItem?.value,
280
+ visible,
228
281
  interactive
229
- ? `${ label.text }: ${ visible ? 'visible' : 'hidden' }. Toggle visibility.`
230
- : undefined
231
- }
282
+ ) }
232
283
  >
233
284
  { items[ i ]?.renderGlyph ? (
234
285
  <svg