@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.
- package/CHANGELOG.md +29 -0
- package/TOKENS.md +109 -50
- package/dist/index.cjs +529 -233
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +118 -55
- package/dist/index.d.cts +90 -48
- package/dist/index.d.ts +90 -48
- package/dist/index.js +527 -235
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.module.scss +8 -3
- package/src/charts/area-chart/area-chart.tsx +25 -25
- package/src/charts/area-chart/private/overlays.tsx +3 -3
- package/src/charts/area-chart/test/area-chart.test.tsx +301 -11
- package/src/charts/area-chart/types.ts +4 -3
- package/src/charts/bar-chart/bar-chart.module.scss +14 -6
- package/src/charts/bar-chart/bar-chart.tsx +21 -15
- package/src/charts/bar-chart/test/bar-chart.test.tsx +255 -2
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +23 -18
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +107 -86
- package/src/charts/conversion-funnel-chart/test/chart-scope.test.tsx +36 -0
- package/src/charts/geo-chart/geo-chart.module.scss +8 -0
- package/src/charts/geo-chart/geo-chart.tsx +12 -4
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +22 -9
- package/src/charts/heatmap-chart/heatmap-chart.tsx +43 -13
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +65 -5
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +125 -0
- package/src/charts/heatmap-chart/types.ts +8 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +13 -7
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +17 -22
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +107 -1
- package/src/charts/leaderboard-chart/types.ts +0 -2
- package/src/charts/line-chart/line-chart.module.scss +12 -7
- package/src/charts/line-chart/line-chart.tsx +32 -28
- package/src/charts/line-chart/private/line-chart-annotation.tsx +4 -4
- package/src/charts/line-chart/private/line-chart-annotations-overlay.tsx +2 -2
- package/src/charts/line-chart/test/line-chart.test.tsx +262 -5
- package/src/charts/line-chart/types.ts +4 -3
- package/src/charts/pie-chart/pie-chart.tsx +12 -14
- package/src/charts/pie-chart/test/pie-chart.test.tsx +73 -1
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +2 -2
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +12 -14
- package/src/charts/private/{single-chart-context/single-chart-context.tsx → chart-instance-context/chart-instance-context.ts} +1 -5
- package/src/charts/private/chart-instance-context/index.ts +3 -0
- package/src/charts/private/{single-chart-context/use-single-chart-context.ts → chart-instance-context/use-chart-instance-context.ts} +1 -3
- package/src/charts/private/svg-empty-state/get-all-hidden-message.ts +41 -0
- package/src/charts/private/svg-empty-state/index.ts +1 -0
- package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
- package/src/charts/private/svg-empty-state/test/get-all-hidden-message.test.ts +15 -0
- package/src/charts/private/with-responsive/test/with-responsive.test.tsx +16 -0
- package/src/charts/private/with-responsive/with-responsive.tsx +18 -11
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +2 -2
- package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
- package/src/charts/private/x-zoom/x-zoom.tsx +2 -2
- package/src/components/legend/legend.tsx +4 -4
- package/src/components/legend/private/base-legend.module.scss +1 -1
- package/src/components/legend/private/base-legend.tsx +70 -19
- package/src/components/legend/test/legend.test.tsx +100 -4
- package/src/components/tooltip/accessible-tooltip.tsx +17 -4
- package/src/components/tooltip/base-tooltip.tsx +8 -1
- package/src/components/trend-indicator/test/trend-indicator.test.tsx +20 -0
- package/src/components/trend-indicator/trend-indicator.module.scss +4 -7
- package/src/components/trend-indicator/trend-indicator.tsx +3 -0
- package/src/hooks/index.ts +1 -1
- package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +28 -0
- package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +19 -13
- package/src/hooks/test/use-xychart-theme.test.tsx +25 -0
- package/src/hooks/{use-interactive-legend-data.ts → use-legend-visibility-data.ts} +29 -30
- package/src/hooks/use-xychart-theme.ts +6 -4
- package/src/index.ts +12 -3
- package/src/providers/chart-context/global-charts-provider.tsx +127 -30
- package/src/providers/chart-context/hooks/use-default-hidden-series.ts +47 -0
- package/src/providers/chart-context/private/theme-override-vars.ts +87 -0
- package/src/providers/chart-context/private/with-catalog-pointers.ts +50 -0
- package/src/providers/chart-context/test/chart-context.test.tsx +142 -40
- package/src/providers/chart-context/test/chart-scope-class.test.tsx +19 -0
- package/src/providers/chart-context/test/sparse-theme.test.ts +35 -0
- package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +29 -0
- package/src/providers/chart-context/test/theme-override-vars.test.ts +118 -0
- package/src/providers/chart-context/test/use-default-hidden-series-paint.test.tsx +56 -0
- package/src/providers/chart-context/test/use-default-hidden-series.ssr.test.tsx +39 -0
- package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +222 -0
- package/src/providers/chart-context/test/with-catalog-pointers.test.ts +47 -0
- package/src/providers/chart-context/themes.ts +18 -27
- package/src/providers/chart-context/types.ts +7 -4
- package/src/providers/chart-scope/chart-scope-context.ts +8 -0
- package/src/providers/chart-scope/index.ts +3 -0
- package/src/providers/chart-scope/test/chart-scope.test.tsx +24 -0
- package/src/providers/chart-scope/test/use-standalone-scope-class.test.tsx +20 -0
- package/src/providers/chart-scope/use-chart-scope-element.ts +9 -0
- package/src/providers/chart-scope/use-standalone-scope-class.ts +13 -0
- package/src/providers/index.ts +1 -0
- package/src/styles/chart-scope-class.ts +6 -0
- package/src/styles/chart-scope.scss +83 -0
- package/src/styles/test/chart-scope.test.ts +165 -0
- package/src/styles/test/wpds-fallbacks.test.ts +131 -0
- package/src/types.ts +12 -34
- package/src/charts/private/grid-control/grid-control.module.scss +0 -8
- package/src/charts/private/grid-control/grid-control.tsx +0 -26
- package/src/charts/private/grid-control/index.ts +0 -1
- package/src/charts/private/grid-control/test/grid-control.test.tsx +0 -56
- 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 {
|
|
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
|
-
<
|
|
476
|
+
<ChartInstanceContext.Provider value={ { chartId } }>
|
|
477
477
|
<Legend shape={ explicitShape } />
|
|
478
|
-
</
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
10
|
+
color: var(--a8c-charts-color-trend-up);
|
|
14
11
|
}
|
|
15
12
|
|
|
16
13
|
&--down {
|
|
17
|
-
color: var(--a8c-charts-color-trend-down
|
|
14
|
+
color: var(--a8c-charts-color-trend-down);
|
|
18
15
|
}
|
|
19
16
|
|
|
20
17
|
&--neutral {
|
|
21
|
-
color: var(--a8c-charts-color-trend-neutral
|
|
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
|
package/src/hooks/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
35
|
+
scope.style.setProperty( '--a8c-charts-color-background', '#111111' );
|
|
36
|
+
scope.style.setProperty( '--a8c-charts-color-axis', '#222222' );
|
|
31
37
|
|
|
32
|
-
const { result } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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
|
|
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
|
|
15
|
+
* Parameters for the useLegendVisibilityData hook.
|
|
16
16
|
*/
|
|
17
|
-
interface
|
|
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
|
|
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
|
|
27
|
+
* Return value from the useLegendVisibilityData hook.
|
|
30
28
|
*/
|
|
31
|
-
interface
|
|
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
|
|
43
|
+
* Custom hook to filter and recalculate chart data based on series visibility.
|
|
46
44
|
*
|
|
47
|
-
*
|
|
48
|
-
* 1. Filters data to show only visible series
|
|
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 } =
|
|
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
|
|
76
|
-
* @param params.data
|
|
77
|
-
* @param params.chartId
|
|
78
|
-
* @param params.
|
|
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
|
|
78
|
+
export const useLegendVisibilityData = < T extends DataPointWithPercentage >( {
|
|
83
79
|
data,
|
|
84
80
|
chartId,
|
|
85
|
-
legendInteractive,
|
|
86
81
|
isSeriesVisible,
|
|
87
|
-
}:
|
|
88
|
-
// Filter and recalculate data
|
|
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
|
-
//
|
|
92
|
-
|
|
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
|
|
107
|
+
}, [ data, chartId, isSeriesVisible ] );
|
|
111
108
|
|
|
112
|
-
// Check if all segments are hidden
|
|
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
|
|
115
|
-
}, [
|
|
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 ( !
|
|
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,
|
|
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
|
|
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 {
|
|
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';
|