@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,8 +1,8 @@
1
1
  /* eslint-disable react/jsx-no-bind */
2
2
  import { render, screen } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
- import { useMemo } from 'react';
5
- import { SingleChartContext } from '../../../charts/private/single-chart-context';
4
+ import { useEffect, useMemo } from 'react';
5
+ import { ChartInstanceContext } from '../../../charts/private/chart-instance-context';
6
6
  import {
7
7
  GlobalChartsProvider,
8
8
  useChartId,
@@ -473,9 +473,9 @@ describe( 'BaseLegend', () => {
473
473
  return render(
474
474
  <GlobalChartsProvider>
475
475
  <ChartRegistrar chartType={ chartType } chartId={ chartId } />
476
- <SingleChartContext.Provider value={ { chartId } }>
476
+ <ChartInstanceContext.Provider value={ { chartId } }>
477
477
  <Legend shape={ explicitShape } />
478
- </SingleChartContext.Provider>
478
+ </ChartInstanceContext.Provider>
479
479
  </GlobalChartsProvider>
480
480
  );
481
481
  };
@@ -535,6 +535,7 @@ describe( 'BaseLegend', () => {
535
535
  expect( legendItems ).toHaveLength( 2 );
536
536
  expect( legendItems[ 0 ] ).toHaveAttribute( 'tabIndex', '0' );
537
537
  expect( legendItems[ 0 ] ).toHaveAttribute( 'aria-pressed', 'true' );
538
+ expect( screen.queryByRole( 'list' ) ).not.toBeInTheDocument();
538
539
  } );
539
540
 
540
541
  it( 'handles click events to toggle visibility', async () => {
@@ -621,6 +622,24 @@ describe( 'BaseLegend', () => {
621
622
  expect( buttons ).toHaveLength( 0 );
622
623
  } );
623
624
 
625
+ it( 'uses chart-instance visibility before provider state', () => {
626
+ render(
627
+ <GlobalChartsProvider>
628
+ <ChartInstanceContext.Provider
629
+ value={ {
630
+ chartId: 'test-chart',
631
+ isSeriesVisible: label => label !== 'Item 1',
632
+ } }
633
+ >
634
+ <BaseLegend items={ defaultItems } chartId="test-chart" />
635
+ </ChartInstanceContext.Provider>
636
+ </GlobalChartsProvider>
637
+ );
638
+
639
+ expect( screen.getByRole( 'listitem', { name: /Item 1.*hidden/ } ) ).toBeInTheDocument();
640
+ expect( screen.getByText( 'Item 2' ) ).toBeInTheDocument();
641
+ } );
642
+
624
643
  it( 'works without chartId but does not toggle', async () => {
625
644
  const user = userEvent.setup();
626
645
 
@@ -754,4 +773,81 @@ describe( 'BaseLegend', () => {
754
773
  expect( legendButton ).toHaveAttribute( 'aria-pressed', 'false' );
755
774
  } );
756
775
  } );
776
+
777
+ describe( 'Non-interactive legend reflects hidden state', () => {
778
+ const HiddenSeriesHarness = ( { interactive }: { interactive: boolean } ) => {
779
+ const context = useGlobalChartsContext();
780
+ useEffect( () => {
781
+ context.toggleSeriesVisibility( 'legend-a11y-chart', 'Series A' );
782
+ // eslint-disable-next-line react-hooks/exhaustive-deps
783
+ }, [] );
784
+ return (
785
+ <BaseLegend
786
+ chartId="legend-a11y-chart"
787
+ interactive={ interactive }
788
+ items={ [
789
+ { label: 'Series A', value: '10', color: '#ff0000' },
790
+ { label: 'Series B', value: '20', color: '#00ff00' },
791
+ ] }
792
+ />
793
+ );
794
+ };
795
+
796
+ it( 'marks a programmatically hidden series inactive on a non-interactive legend', () => {
797
+ render(
798
+ <GlobalChartsProvider>
799
+ <HiddenSeriesHarness interactive={ false } />
800
+ </GlobalChartsProvider>
801
+ );
802
+
803
+ const items = screen.getAllByTestId( 'legend-item' );
804
+ expect( items[ 0 ] ).toHaveClass( 'legend-item--inactive' );
805
+ expect( items[ 1 ] ).not.toHaveClass( 'legend-item--inactive' );
806
+ } );
807
+
808
+ it( 'does not make a non-interactive legend item operable', () => {
809
+ render(
810
+ <GlobalChartsProvider>
811
+ <HiddenSeriesHarness interactive={ false } />
812
+ </GlobalChartsProvider>
813
+ );
814
+
815
+ expect( screen.queryAllByRole( 'button' ) ).toHaveLength( 0 );
816
+ const items = screen.getAllByTestId( 'legend-item' );
817
+ expect( items[ 0 ] ).not.toHaveAttribute( 'aria-pressed' );
818
+ expect( items[ 0 ] ).not.toHaveAttribute( 'tabindex' );
819
+ expect( items[ 0 ] ).toHaveAttribute( 'role', 'listitem' );
820
+ } );
821
+
822
+ it( 'names the hidden state accessibly on a non-interactive legend', () => {
823
+ render(
824
+ <GlobalChartsProvider>
825
+ <HiddenSeriesHarness interactive={ false } />
826
+ </GlobalChartsProvider>
827
+ );
828
+
829
+ const items = screen.getAllByTestId( 'legend-item' );
830
+ expect( items[ 0 ] ).toHaveAttribute( 'aria-label', 'Series A, 10: hidden' );
831
+ expect( items[ 1 ] ).not.toHaveAttribute( 'aria-label' );
832
+ } );
833
+
834
+ it( 'includes the item value in interactive accessible names', () => {
835
+ render(
836
+ <GlobalChartsProvider>
837
+ <HiddenSeriesHarness interactive={ true } />
838
+ </GlobalChartsProvider>
839
+ );
840
+
841
+ expect(
842
+ screen.getByRole( 'button', {
843
+ name: 'Series A, 10: hidden. Toggle visibility.',
844
+ } )
845
+ ).toBeInTheDocument();
846
+ expect(
847
+ screen.getByRole( 'button', {
848
+ name: 'Series B, 20: visible. Toggle visibility.',
849
+ } )
850
+ ).toBeInTheDocument();
851
+ } );
852
+ } );
757
853
  } );
@@ -1,5 +1,7 @@
1
1
  import { Tooltip, TooltipContext } from '@visx/xychart';
2
- import { useContext, useEffect, useCallback, useMemo } from 'react';
2
+ import clsx from 'clsx';
3
+ import { useContext, useEffect, useCallback, useMemo, useRef } from 'react';
4
+ import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
3
5
  import type { SeriesData, DataPointDate } from '../../types';
4
6
  import type { RenderTooltipParams, XyChartTooltipProps } from '../../visx/types';
5
7
  import type { ReactNode } from 'react';
@@ -77,13 +79,24 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
77
79
  return flattened;
78
80
  }, [ series, mode ] );
79
81
 
82
+ // Tracks whether this effect opened a tooltip, so it only closes its own.
83
+ const hasKeyboardSelection = useRef( false );
84
+
80
85
  // Handle tooltip highlighting for keyboard navigation
81
86
  useEffect( () => {
82
87
  if ( selectedIndex === undefined ) {
83
- tooltipContext?.hideTooltip();
88
+ // visx debounces the hide and cancels only the most recently scheduled one,
89
+ // so hiding on every run leaves earlier hides pending. One of those lands
90
+ // mid-navigation and closes the tooltip the user is reading.
91
+ if ( hasKeyboardSelection.current ) {
92
+ hasKeyboardSelection.current = false;
93
+ tooltipContext?.hideTooltip();
94
+ }
84
95
  return;
85
96
  }
86
97
 
98
+ hasKeyboardSelection.current = true;
99
+
87
100
  if ( mode === 'group' ) {
88
101
  // Show all series at the selected data point index in single tooltip.
89
102
  series.forEach( ( s, index ) => {
@@ -128,7 +141,7 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
128
141
  tabIndex={ -1 }
129
142
  role="tooltip"
130
143
  aria-atomic="true"
131
- className={ keyboardFocusedClassName }
144
+ className={ clsx( CHART_SCOPE_CLASS, keyboardFocusedClassName ) }
132
145
  data-testid={ `chart-tooltip-${ selectedIndex }` }
133
146
  key={ `chart-tooltip-${ selectedIndex }` }
134
147
  >
@@ -138,7 +151,7 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
138
151
  }
139
152
 
140
153
  return (
141
- <div role="tooltip" aria-live="polite">
154
+ <div className={ CHART_SCOPE_CLASS } role="tooltip" aria-live="polite">
142
155
  { tooltipContent }
143
156
  </div>
144
157
  );
@@ -1,4 +1,6 @@
1
1
  import { formatNumber } from '@automattic/number-formatters';
2
+ import clsx from 'clsx';
3
+ import { useStandaloneScopeClass } from '../../providers/chart-scope';
2
4
  import styles from './base-tooltip.module.scss';
3
5
  import type { CSSProperties, ComponentType, ReactNode } from 'react';
4
6
 
@@ -57,13 +59,18 @@ export const BaseTooltip = ( {
57
59
  renderContainer = true,
58
60
  }: BaseTooltipProps ) => {
59
61
  const content = children || ( data && <Component data={ data } className={ className } /> );
62
+ const standaloneScopeClass = useStandaloneScopeClass();
60
63
 
61
64
  if ( ! renderContainer ) {
62
65
  return content;
63
66
  }
64
67
 
65
68
  return (
66
- <div className={ styles.tooltip } style={ { top, left, ...style } } role="tooltip">
69
+ <div
70
+ className={ clsx( standaloneScopeClass, styles.tooltip ) }
71
+ style={ { top, left, ...style } }
72
+ role="tooltip"
73
+ >
67
74
  { content }
68
75
  </div>
69
76
  );
@@ -1,4 +1,6 @@
1
1
  import { render, screen } from '@testing-library/react';
2
+ import { GlobalChartsProvider } from '../../../providers/chart-context/global-charts-provider';
3
+ import { CHART_SCOPE_CLASS } from '../../../styles/chart-scope-class';
2
4
  import { TrendIndicator } from '../trend-indicator';
3
5
 
4
6
  describe( 'TrendIndicator', () => {
@@ -79,6 +81,24 @@ describe( 'TrendIndicator', () => {
79
81
  expect( container.firstChild ).toHaveStyle( { fontSize: '32px' } );
80
82
  } );
81
83
 
84
+ describe( 'chart scope class', () => {
85
+ it( 'carries the scope class when rendered with no provider above it', () => {
86
+ render( <TrendIndicator direction="up" value="+10%" /> );
87
+
88
+ expect( screen.getByLabelText( 'Increase: +10%' ) ).toHaveClass( CHART_SCOPE_CLASS );
89
+ } );
90
+
91
+ it( 'does not carry the scope class when rendered inside a GlobalChartsProvider', () => {
92
+ render(
93
+ <GlobalChartsProvider>
94
+ <TrendIndicator direction="up" value="+10%" />
95
+ </GlobalChartsProvider>
96
+ );
97
+
98
+ expect( screen.getByLabelText( 'Increase: +10%' ) ).not.toHaveClass( CHART_SCOPE_CLASS );
99
+ } );
100
+ } );
101
+
82
102
  describe( 'accessibility', () => {
83
103
  it( 'provides aria-label for up trend', () => {
84
104
  const { container } = render( <TrendIndicator direction="up" value="+14%" /> );
@@ -3,22 +3,19 @@
3
3
  align-items: center;
4
4
  gap: 0.125em;
5
5
  font-size: var(--wpds-typography-font-size-md, 13px);
6
- font-weight: var(--wpds-typography-font-weight-emphasis, 499);
6
+ font-weight: var(--wpds-typography-font-weight-emphasis, 600);
7
7
  line-height: 1;
8
8
 
9
- // Trend colours use the --a8c-charts-color-trend-* catalog roles, falling back
10
- // to the deprecated --charts-trend-*-color override API and then to the
11
- // accessible WPDS semantic tokens.
12
9
  &--up {
13
- color: var(--a8c-charts-color-trend-up, var(--charts-trend-up-color, var(--wpds-color-foreground-content-success-weak, #008030)));
10
+ color: var(--a8c-charts-color-trend-up);
14
11
  }
15
12
 
16
13
  &--down {
17
- color: var(--a8c-charts-color-trend-down, var(--charts-trend-down-color, var(--wpds-color-foreground-content-error-weak, #cc1818)));
14
+ color: var(--a8c-charts-color-trend-down);
18
15
  }
19
16
 
20
17
  &--neutral {
21
- color: var(--a8c-charts-color-trend-neutral, var(--charts-trend-neutral-color, var(--wpds-color-foreground-content-neutral-weak, #707070)));
18
+ color: var(--a8c-charts-color-trend-neutral);
22
19
  }
23
20
 
24
21
  &__icon {
@@ -1,4 +1,5 @@
1
1
  import clsx from 'clsx';
2
+ import { useStandaloneScopeClass } from '../../providers/chart-scope';
2
3
  import styles from './trend-indicator.module.scss';
3
4
  import type { TrendIndicatorProps, TrendDirection } from './types';
4
5
 
@@ -47,10 +48,12 @@ export function TrendIndicator( {
47
48
  showIcon = true,
48
49
  }: TrendIndicatorProps ) {
49
50
  const ariaLabel = `${ DIRECTION_LABELS[ direction ] }: ${ value }`;
51
+ const standaloneScopeClass = useStandaloneScopeClass();
50
52
 
51
53
  return (
52
54
  <span
53
55
  className={ clsx(
56
+ standaloneScopeClass,
54
57
  styles[ 'trend-indicator' ],
55
58
  styles[ `trend-indicator--${ direction }` ],
56
59
  className
@@ -7,5 +7,5 @@ export { useElementSize } from './use-element-size';
7
7
  export { useTextTruncation } from './use-text-truncation';
8
8
  export { useZeroValueDisplay } from './use-zero-value-display';
9
9
  export { useDataWithPercentages } from './use-data-with-percentages';
10
- export { useInteractiveLegendData } from './use-interactive-legend-data';
10
+ export { useLegendVisibilityData } from './use-legend-visibility-data';
11
11
  export { usePrefersReducedMotion } from './use-prefers-reduced-motion';
@@ -0,0 +1,28 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { GlobalChartsProvider } from '../../providers/chart-context/global-charts-provider';
3
+ import { ChartScopeContext } from '../../providers/chart-scope';
4
+ import { useXYChartTheme } from '../use-xychart-theme';
5
+ import type { ReactNode } from 'react';
6
+
7
+ // A `theme` prop override reaches CSS-painted elements through the inline `--a8c-charts-*` var GlobalChartsProvider writes on its wrapper, and reaches visx (SVG presentation attributes) through `providerTheme`. Before the fix, `providerTheme` carried the override's literal directly, so an element-level `--a8c-charts-color-grid` override set inside the provider tree had no effect on the JS-resolved value — the two paths could disagree. After the fix, `providerTheme` carries the catalog pointer for an overridden role, so both paths resolve through the same CSS cascade and the nearer element wins.
8
+ describe( 'useXYChartTheme resolves a theme prop override through the CSS cascade', () => {
9
+ it( "an element override set inside the provider tree beats the provider's theme prop literal", () => {
10
+ const nestedOverride = document.createElement( 'div' );
11
+ nestedOverride.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
12
+ document.body.appendChild( nestedOverride );
13
+
14
+ const wrapper = ( { children }: { children: ReactNode } ) => (
15
+ <GlobalChartsProvider theme={ { gridStyles: { stroke: 'rgb(255,0,0)' } } }>
16
+ <ChartScopeContext.Provider value={ nestedOverride }>
17
+ { children }
18
+ </ChartScopeContext.Provider>
19
+ </GlobalChartsProvider>
20
+ );
21
+
22
+ const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
23
+
24
+ expect( result.current.gridStyles.stroke ).toBe( '#00ff00' );
25
+
26
+ document.body.removeChild( nestedOverride );
27
+ } );
28
+ } );
@@ -1,35 +1,41 @@
1
1
  import { renderHook } from '@testing-library/react';
2
+ import { ChartScopeContext } from '../../providers/chart-scope';
2
3
  import { useXYChartTheme } from '../use-xychart-theme';
3
4
  import type { SeriesData } from '../../types';
5
+ import type { ReactNode } from 'react';
4
6
 
5
- // The theme resolves five catalog roles — background, grid, axis, tick and axis label. Each `getComputedStyle` call can force the browser to flush pending style, and this memo re-runs whenever `data` changes identity, so a dashboard mounting N charts pays the count below N times over.
7
+ // The theme resolves five catalog roles — background, grid, axis, tick and axis label. Each `getComputedStyle` call can force the browser to flush pending style, and this memo re-runs only when the scope element attaches or a series colour changes, so a dashboard mounting N charts pays the count below N times over.
6
8
  const STABLE_DATA: SeriesData[] = [];
7
9
 
8
10
  describe( 'useXYChartTheme style reads', () => {
11
+ let scope: HTMLElement;
9
12
  let getComputedStyleSpy: jest.SpyInstance;
10
13
 
11
14
  beforeEach( () => {
12
- document.documentElement.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
15
+ scope = document.createElement( 'div' );
16
+ scope.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
17
+ document.body.appendChild( scope );
13
18
  getComputedStyleSpy = jest.spyOn( window, 'getComputedStyle' );
14
19
  } );
15
20
 
16
21
  afterEach( () => {
17
22
  getComputedStyleSpy.mockRestore();
18
- document.documentElement.style.removeProperty( '--a8c-charts-color-grid' );
19
- document.documentElement.style.removeProperty( '--a8c-charts-color-background' );
20
- document.documentElement.style.removeProperty( '--a8c-charts-color-axis' );
23
+ scope.remove();
21
24
  } );
22
25
 
23
- const renderTheme = ( data: SeriesData[] = STABLE_DATA ) =>
26
+ const renderWithScope = ( data: SeriesData[] = STABLE_DATA ) =>
24
27
  renderHook( ( { series }: { series: SeriesData[] } ) => useXYChartTheme( series ), {
25
28
  initialProps: { series: data },
29
+ wrapper: ( { children }: { children: ReactNode } ) => (
30
+ <ChartScopeContext.Provider value={ scope }>{ children }</ChartScopeContext.Provider>
31
+ ),
26
32
  } );
27
33
 
28
34
  it( 'reads every role from one computed-style snapshot, not one per role', () => {
29
- document.documentElement.style.setProperty( '--a8c-charts-color-background', '#111111' );
30
- document.documentElement.style.setProperty( '--a8c-charts-color-axis', '#222222' );
35
+ scope.style.setProperty( '--a8c-charts-color-background', '#111111' );
36
+ scope.style.setProperty( '--a8c-charts-color-axis', '#222222' );
31
37
 
32
- const { result } = renderTheme();
38
+ const { result } = renderWithScope();
33
39
 
34
40
  expect( getComputedStyleSpy ).toHaveBeenCalledTimes( 1 );
35
41
  expect( result.current.backgroundColor ).toBe( '#111111' );
@@ -38,7 +44,7 @@ describe( 'useXYChartTheme style reads', () => {
38
44
  } );
39
45
 
40
46
  it( 'does not re-read styles when the hook re-renders with the same inputs', () => {
41
- const { rerender } = renderTheme();
47
+ const { rerender } = renderWithScope();
42
48
 
43
49
  getComputedStyleSpy.mockClear();
44
50
  rerender( { series: STABLE_DATA } );
@@ -48,7 +54,7 @@ describe( 'useXYChartTheme style reads', () => {
48
54
 
49
55
  // A caller passing an inline array literal — `<LineChart data={ [ … ] } />` — hands the hook a new array identity on every render. The memo keys on the series strokes instead, so the theme is not rebuilt and no style is read. Keyed on `data` this cost one query per render, and before the roles shared a snapshot it cost five.
50
56
  it( 'does not rebuild when the caller passes a fresh data array of the same strokes', () => {
51
- const { rerender } = renderTheme( [] );
57
+ const { rerender } = renderWithScope( [] );
52
58
 
53
59
  getComputedStyleSpy.mockClear();
54
60
  rerender( { series: [] } );
@@ -59,7 +65,7 @@ describe( 'useXYChartTheme style reads', () => {
59
65
  it( 'rebuilds when a series stroke actually changes', () => {
60
66
  const withStroke = ( stroke: string ) =>
61
67
  [ { label: 'A', options: { stroke }, data: [] } ] as unknown as SeriesData[];
62
- const { result, rerender } = renderTheme( withStroke( 'rgba(0, 0, 0, 0.5)' ) );
68
+ const { result, rerender } = renderWithScope( withStroke( 'rgba(0, 0, 0, 0.5)' ) );
63
69
 
64
70
  getComputedStyleSpy.mockClear();
65
71
  rerender( { series: withStroke( 'rebeccapurple' ) } );
@@ -70,7 +76,7 @@ describe( 'useXYChartTheme style reads', () => {
70
76
 
71
77
  // The memo key serialises the strokes rather than joining them, so a colour containing a comma or a space survives the round-trip intact.
72
78
  it( 'keeps a stroke that contains separators intact', () => {
73
- const { result } = renderTheme( [
79
+ const { result } = renderWithScope( [
74
80
  { label: 'A', options: { stroke: 'rgba(0, 0, 0, 0.5)' }, data: [] },
75
81
  { label: 'B', options: { stroke: 'var(--brand, #fff)' }, data: [] },
76
82
  ] as unknown as SeriesData[] );
@@ -0,0 +1,25 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { ChartScopeContext } from '../../providers/chart-scope';
3
+ import { useXYChartTheme } from '../use-xychart-theme';
4
+ import type { ReactNode } from 'react';
5
+
6
+ describe( 'useXYChartTheme', () => {
7
+ it( 'resolves grid colour against the scope element, not the document root', () => {
8
+ document.documentElement.style.setProperty( '--a8c-charts-color-grid', '#ff0000' );
9
+
10
+ const scope = document.createElement( 'div' );
11
+ scope.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
12
+ document.body.appendChild( scope );
13
+
14
+ const wrapper = ( { children }: { children: ReactNode } ) => (
15
+ <ChartScopeContext.Provider value={ scope }>{ children }</ChartScopeContext.Provider>
16
+ );
17
+
18
+ const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
19
+
20
+ expect( result.current.gridStyles.stroke ).toBe( '#00ff00' );
21
+
22
+ document.documentElement.style.removeProperty( '--a8c-charts-color-grid' );
23
+ document.body.removeChild( scope );
24
+ } );
25
+ } );
@@ -1,7 +1,7 @@
1
1
  import { useMemo } from 'react';
2
2
 
3
3
  /**
4
- * Data point interface for charts with interactive legends.
4
+ * Data point interface for charts with visibility-aware legends.
5
5
  * Requires label for series identification, value for calculations,
6
6
  * and percentage (should be pre-calculated by the chart component).
7
7
  */
@@ -12,23 +12,21 @@ interface DataPointWithPercentage {
12
12
  }
13
13
 
14
14
  /**
15
- * Parameters for the useInteractiveLegendData hook.
15
+ * Parameters for the useLegendVisibilityData hook.
16
16
  */
17
- interface UseInteractiveLegendDataParams< T extends DataPointWithPercentage > {
17
+ interface UseLegendVisibilityDataParams< T extends DataPointWithPercentage > {
18
18
  /** The chart data with pre-calculated percentages */
19
19
  data: T[];
20
- /** Unique chart identifier, required for interactive legends */
20
+ /** Unique chart identifier, required for filtering */
21
21
  chartId: string | undefined;
22
- /** Whether interactive legend filtering is enabled */
23
- legendInteractive: boolean;
24
22
  /** Function to check if a series is visible in the legend */
25
23
  isSeriesVisible: ( chartId: string, label: string ) => boolean;
26
24
  }
27
25
 
28
26
  /**
29
- * Return value from the useInteractiveLegendData hook.
27
+ * Return value from the useLegendVisibilityData hook.
30
28
  */
31
- interface UseInteractiveLegendDataResult< T extends DataPointWithPercentage > {
29
+ interface UseLegendVisibilityDataResult< T extends DataPointWithPercentage > {
32
30
  /** Filtered data array containing only visible segments with recalculated percentages */
33
31
  visibleData: T[];
34
32
  /** Boolean indicating if all segments are hidden */
@@ -42,10 +40,10 @@ interface UseInteractiveLegendDataResult< T extends DataPointWithPercentage > {
42
40
  }
43
41
 
44
42
  /**
45
- * Custom hook to filter and recalculate chart data for interactive legends.
43
+ * Custom hook to filter and recalculate chart data based on series visibility.
46
44
  *
47
- * When interactive legends are enabled, this hook:
48
- * 1. Filters data to show only visible series based on legend selection
45
+ * Regardless of whether the legend is interactive (clickable), this hook:
46
+ * 1. Filters data to show only visible series (visibility can also be set programmatically)
49
47
  * 2. Recalculates percentages so visible segments total 100%
50
48
  * 3. Tracks whether all segments are hidden to show empty state
51
49
  *
@@ -54,10 +52,9 @@ interface UseInteractiveLegendDataResult< T extends DataPointWithPercentage > {
54
52
  *
55
53
  * @example
56
54
  * ```tsx
57
- * const { visibleData, allSegmentsHidden, legendData } = useInteractiveLegendData({
55
+ * const { visibleData, allSegmentsHidden, legendData } = useLegendVisibilityData({
58
56
  * data: chartData,
59
57
  * chartId: 'my-pie-chart',
60
- * legendInteractive: true,
61
58
  * isSeriesVisible: (id, label) => context.isSeriesVisible(id, label),
62
59
  * });
63
60
  *
@@ -72,24 +69,24 @@ interface UseInteractiveLegendDataResult< T extends DataPointWithPercentage > {
72
69
  * return <PieChart data={visibleData} />;
73
70
  * ```
74
71
  *
75
- * @param params - Configuration object for the hook
76
- * @param params.data - The chart data to filter
77
- * @param params.chartId - Unique identifier for the chart (required for interactive mode)
78
- * @param params.legendInteractive - Whether to enable interactive filtering
79
- * @param params.isSeriesVisible - Function to check series visibility
72
+ * @param params - Configuration object for the hook
73
+ * @param params.data - The chart data to filter
74
+ * @param params.chartId - Unique identifier for the chart (required for filtering)
75
+ * @param params.isSeriesVisible - Function to check series visibility
80
76
  * @return Object containing visibleData, allSegmentsHidden flag, and legendData with recalculated percentages
81
77
  */
82
- export const useInteractiveLegendData = < T extends DataPointWithPercentage >( {
78
+ export const useLegendVisibilityData = < T extends DataPointWithPercentage >( {
83
79
  data,
84
80
  chartId,
85
- legendInteractive,
86
81
  isSeriesVisible,
87
- }: UseInteractiveLegendDataParams< T > ): UseInteractiveLegendDataResult< T > => {
88
- // Filter and recalculate data for interactive legends
82
+ }: UseLegendVisibilityDataParams< T > ): UseLegendVisibilityDataResult< T > => {
83
+ // Filter and recalculate data based on shared legend visibility state.
89
84
  // Note: data should already have percentages calculated by the chart component
90
85
  const visibleData = useMemo( () => {
91
- // If interactive mode is disabled or no chartId, return data as-is
92
- if ( ! chartId || ! legendInteractive ) {
86
+ // Visibility filtering applies regardless of whether the legend is
87
+ // interactive: interactivity only controls whether clicking the legend
88
+ // can change visibility, not whether the chart honors it.
89
+ if ( ! chartId ) {
93
90
  return data;
94
91
  }
95
92
 
@@ -107,18 +104,20 @@ export const useInteractiveLegendData = < T extends DataPointWithPercentage >( {
107
104
  ...segment,
108
105
  percentage: totalValue > 0 ? ( segment.value / totalValue ) * 100 : 0,
109
106
  } ) );
110
- }, [ data, chartId, isSeriesVisible, legendInteractive ] );
107
+ }, [ data, chartId, isSeriesVisible ] );
111
108
 
112
- // Check if all segments are hidden (only relevant in interactive mode)
109
+ // Check if all segments are hidden. Guard on the source data being non-empty:
110
+ // unlike line/area's `.every()`, an empty `visibleData` alone can't distinguish
111
+ // "everything was hidden" from "there was nothing to show" for an empty dataset.
113
112
  const allSegmentsHidden = useMemo( () => {
114
- return legendInteractive && visibleData.length === 0;
115
- }, [ legendInteractive, visibleData ] );
113
+ return visibleData.length === 0 && data.length > 0;
114
+ }, [ visibleData, data ] );
116
115
 
117
116
  // Prepare legend data with percentages
118
117
  // Hidden items keep their original percentage (calculated from all values)
119
118
  // Visible items show recalculated percentages (totaling 100%)
120
119
  const legendData = useMemo( () => {
121
- if ( ! legendInteractive || ! chartId ) {
120
+ if ( ! chartId ) {
122
121
  return data;
123
122
  }
124
123
 
@@ -135,7 +134,7 @@ export const useInteractiveLegendData = < T extends DataPointWithPercentage >( {
135
134
  // For visible items, get the recalculated percentage from visibleData
136
135
  return visibleDataMap.get( segment.label ) || segment;
137
136
  } );
138
- }, [ data, visibleData, legendInteractive, chartId, isSeriesVisible ] );
137
+ }, [ data, visibleData, chartId, isSeriesVisible ] );
139
138
 
140
139
  return { visibleData, allSegmentsHidden, legendData };
141
140
  };
@@ -1,11 +1,13 @@
1
1
  import { buildChartTheme } from '@visx/xychart';
2
2
  import { useMemo } from 'react';
3
3
  import { useGlobalChartsTheme } from '../providers';
4
+ import { useChartScopeElement } from '../providers/chart-scope';
4
5
  import { createCssVariableResolver } from '../utils';
5
6
  import type { SeriesData } from '../types';
6
7
 
7
8
  export const useXYChartTheme = ( data: SeriesData[] ) => {
8
9
  const theme = useGlobalChartsTheme();
10
+ const scopeElement = useChartScopeElement();
9
11
 
10
12
  // The only thing the theme takes from `data` is the series strokes, so key the memo on those rather than on the array's identity. A caller passing an inline literal — `<LineChart data={ [ … ] } />`, which the stories and several consumers do — otherwise rebuilds the whole theme on every render. Serialised rather than joined: a stroke can be `rgba(0, 0, 0, 0.5)` or `var(--brand, #fff)`, and any separator that reads naturally inside a colour cannot round-trip.
11
13
  const seriesColorKey = JSON.stringify(
@@ -15,10 +17,10 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
15
17
  );
16
18
 
17
19
  return useMemo( () => {
18
- // visx applies grid, axis, and tick-label colors as SVG presentation attributes, where CSS var() cannot resolve. Resolve them to concrete values before handing the theme to buildChartTheme.
20
+ // visx applies grid, axis, and tick-label colours as SVG presentation attributes, where CSS var() cannot resolve. Resolve the catalog token against the chart's own scope element — never :root — so this reads any override set inside the provider tree, the same one a CSS-painted element would inherit.
19
21
  //
20
- // One resolver per theme build, so the five roles below share a single getComputedStyle call rather than taking one each.
21
- const resolve = createCssVariableResolver();
22
+ // One resolver per theme build, so the five roles below share a single getComputedStyle call rather than taking one each: this memo re-runs only when the scope element attaches or a series colour changes, which for a dashboard of charts is the difference between two style queries per chart and ten.
23
+ const resolve = createCssVariableResolver( scopeElement );
22
24
  const resolveColor = ( value?: string ): string | undefined =>
23
25
  value ? resolve( value ) ?? value : value;
24
26
 
@@ -45,5 +47,5 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
45
47
  fill: resolveColor( theme.svgLabelSmall.fill ),
46
48
  },
47
49
  } );
48
- }, [ theme, seriesColorKey ] );
50
+ }, [ theme, seriesColorKey, scopeElement ] );
49
51
  };
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@ export { BarChart, BarChartUnresponsive } from './charts/bar-chart';
4
4
  export { BarListChart, BarListChartUnresponsive } from './charts/bar-list-chart';
5
5
  export { ConversionFunnelChart } from './charts/conversion-funnel-chart';
6
6
  export { GeoChart, GeoChartUnresponsive } from './charts/geo-chart';
7
+ // A value export, not a type: building a tooltip column header needs the enum member itself.
8
+ export { GoogleDataTableColumnRoleType } from './types';
7
9
  export {
8
10
  HeatmapChart,
9
11
  HeatmapChartUnresponsive,
@@ -32,6 +34,8 @@ export {
32
34
  useGlobalChartsTheme,
33
35
  GlobalChartsContext,
34
36
  defaultTheme,
37
+ useChartScopeElement,
38
+ useChartRegistration,
35
39
  } from './providers';
36
40
 
37
41
  // Types - explicit exports (excludes internal types like DataPointPercentageCalculated)
@@ -59,10 +63,9 @@ export type {
59
63
  LegendPosition,
60
64
  ChartLegendConfig,
61
65
  SeriesChartLegendConfig,
66
+ SeriesVisibilityProps,
62
67
  BaseChartProps,
63
- GridProps,
64
68
  GoogleDataTableColumn,
65
- GoogleDataTableColumnRoleType,
66
69
  GoogleDataTableRow,
67
70
  LegendShape,
68
71
  LegendShapeLabel,
@@ -122,6 +125,12 @@ export type { ArcData } from './charts/pie-semi-circle-chart';
122
125
  export type { SparklineProps, GradientConfig, SparklineDataPoint } from './charts/sparkline';
123
126
 
124
127
  // Utilities
125
- export { parseAsLocalDate, formatMetricValue, formatPercentage, mergeThemes } from './utils';
128
+ export {
129
+ parseAsLocalDate,
130
+ formatMetricValue,
131
+ formatPercentage,
132
+ mergeThemes,
133
+ resolveCssVariable,
134
+ } from './utils';
126
135
  export * from './utils/color-utils';
127
136
  export type { MetricValueType } from './utils';