@automattic/charts 3.1.0 → 3.2.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 +17 -0
- package/TOKENS.md +51 -12
- package/dist/index.cjs +307 -213
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +7 -1
- package/dist/index.d.cts +223 -127
- package/dist/index.d.ts +223 -127
- package/dist/index.js +306 -212
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/charts/area-chart/area-chart.tsx +2 -2
- package/src/charts/area-chart/test/area-chart.test.tsx +45 -0
- package/src/charts/bar-chart/bar-chart.tsx +36 -10
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +19 -12
- package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +67 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +101 -0
- package/src/charts/bar-list-chart/bar-list-chart.tsx +40 -2
- package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +38 -0
- package/src/charts/line-chart/line-chart.tsx +18 -3
- package/src/charts/line-chart/test/line-chart.test.tsx +98 -0
- package/src/charts/sparkline/sparkline.tsx +9 -3
- package/src/charts/sparkline/types.ts +1 -1
- package/src/components/tooltip/accessible-tooltip.tsx +32 -2
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +68 -0
- package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +4 -4
- package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +17 -10
- package/src/hooks/test/use-xychart-theme.test.tsx +50 -5
- package/src/hooks/use-xychart-theme.ts +14 -20
- package/src/providers/chart-context/global-charts-provider.tsx +44 -33
- package/src/providers/chart-context/private/series-palette.ts +45 -0
- package/src/providers/chart-context/private/theme-override-vars.ts +46 -2
- package/src/providers/chart-context/private/with-catalog-pointers.ts +45 -2
- package/src/providers/chart-context/test/sparse-theme.test.ts +16 -2
- package/src/providers/chart-context/test/theme-override-vars.test.ts +65 -0
- package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +8 -1
- package/src/providers/chart-context/test/with-catalog-pointers.test.ts +57 -0
- package/src/providers/chart-context/themes.ts +8 -3
- package/src/styles/chart-scope.scss +52 -24
- package/src/styles/test/chart-scope.test.ts +22 -9
- package/src/types.ts +148 -31
- package/src/utils/get-styles.ts +7 -1
|
@@ -500,6 +500,57 @@ describe( 'LineChart', () => {
|
|
|
500
500
|
expect( ticks.length ).toBeGreaterThan( 1 );
|
|
501
501
|
} );
|
|
502
502
|
|
|
503
|
+
test( 'keeps the derived month formatter when tickFormat is passed as undefined.', () => {
|
|
504
|
+
renderWithTheme( {
|
|
505
|
+
width: 800,
|
|
506
|
+
options: { axis: { x: { tickFormat: undefined } } },
|
|
507
|
+
data: [
|
|
508
|
+
{
|
|
509
|
+
label: 'Series A',
|
|
510
|
+
data: [
|
|
511
|
+
{ date: new Date( '2024-01-01' ), value: 10 },
|
|
512
|
+
{ date: new Date( '2024-04-01' ), value: 20 },
|
|
513
|
+
{ date: new Date( '2024-07-01' ), value: 30 },
|
|
514
|
+
{ date: new Date( '2024-10-01' ), value: 40 },
|
|
515
|
+
{ date: new Date( '2025-03-01' ), value: 50 },
|
|
516
|
+
],
|
|
517
|
+
},
|
|
518
|
+
],
|
|
519
|
+
} );
|
|
520
|
+
|
|
521
|
+
// January is absent: formatMonthOrYearTick renders it as the year instead.
|
|
522
|
+
const ticks = screen.getAllByText( /^(Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/ );
|
|
523
|
+
expect( ticks.length ).toBeGreaterThan( 1 );
|
|
524
|
+
expect(
|
|
525
|
+
screen.queryByText( /^(February|March|April|June|July|August|September|October)$/ )
|
|
526
|
+
).not.toBeInTheDocument();
|
|
527
|
+
} );
|
|
528
|
+
|
|
529
|
+
test( 'honors an explicit tickFormat over the derived formatter.', () => {
|
|
530
|
+
renderWithTheme( {
|
|
531
|
+
width: 800,
|
|
532
|
+
options: {
|
|
533
|
+
axis: {
|
|
534
|
+
x: {
|
|
535
|
+
tickFormat: ( date: Date | number ) =>
|
|
536
|
+
`tick-${ new Date( Number( date ) ).getUTCMonth() }`,
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
data: [
|
|
541
|
+
{
|
|
542
|
+
label: 'Series A',
|
|
543
|
+
data: [
|
|
544
|
+
{ date: new Date( '2024-01-01' ), value: 10 },
|
|
545
|
+
{ date: new Date( '2024-07-01' ), value: 30 },
|
|
546
|
+
],
|
|
547
|
+
},
|
|
548
|
+
],
|
|
549
|
+
} );
|
|
550
|
+
|
|
551
|
+
expect( screen.getAllByText( /^tick-\d+$/ ).length ).toBeGreaterThan( 0 );
|
|
552
|
+
} );
|
|
553
|
+
|
|
503
554
|
test( 'renders ticks in year format.', () => {
|
|
504
555
|
renderWithTheme( {
|
|
505
556
|
width: 800,
|
|
@@ -1102,6 +1153,53 @@ describe( 'LineChart', () => {
|
|
|
1102
1153
|
} );
|
|
1103
1154
|
} );
|
|
1104
1155
|
|
|
1156
|
+
describe( 'Activation', () => {
|
|
1157
|
+
const SERIES_A = {
|
|
1158
|
+
label: 'Series A',
|
|
1159
|
+
data: [
|
|
1160
|
+
{ date: new Date( '2024-01-01' ), value: 10 },
|
|
1161
|
+
{ date: new Date( '2024-01-02' ), value: 20 },
|
|
1162
|
+
],
|
|
1163
|
+
options: {},
|
|
1164
|
+
};
|
|
1165
|
+
const SERIES_B = {
|
|
1166
|
+
label: 'Series B',
|
|
1167
|
+
data: [
|
|
1168
|
+
{ date: new Date( '2024-01-01' ), value: 15 },
|
|
1169
|
+
{ date: new Date( '2024-01-02' ), value: 25 },
|
|
1170
|
+
],
|
|
1171
|
+
options: {},
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
// Navigation steps through x positions; the first series names the point.
|
|
1175
|
+
test( 'Enter hands the selected point to onDatumActivate', async () => {
|
|
1176
|
+
const user = userEvent.setup();
|
|
1177
|
+
const onDatumActivate = jest.fn();
|
|
1178
|
+
renderWithTheme( { data: [ SERIES_A, SERIES_B ], onDatumActivate } );
|
|
1179
|
+
|
|
1180
|
+
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
1181
|
+
await user.keyboard( '{ArrowRight}{ArrowRight}{Enter}' );
|
|
1182
|
+
|
|
1183
|
+
expect( onDatumActivate ).toHaveBeenCalledTimes( 1 );
|
|
1184
|
+
expect( onDatumActivate ).toHaveBeenCalledWith( {
|
|
1185
|
+
datum: SERIES_A.data[ 1 ],
|
|
1186
|
+
index: 1,
|
|
1187
|
+
key: 'Series A',
|
|
1188
|
+
} );
|
|
1189
|
+
} );
|
|
1190
|
+
|
|
1191
|
+
test( 'Enter with no point selected activates nothing', async () => {
|
|
1192
|
+
const user = userEvent.setup();
|
|
1193
|
+
const onDatumActivate = jest.fn();
|
|
1194
|
+
renderWithTheme( { data: [ SERIES_A ], onDatumActivate } );
|
|
1195
|
+
|
|
1196
|
+
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
1197
|
+
await user.keyboard( '{Enter}' );
|
|
1198
|
+
|
|
1199
|
+
expect( onDatumActivate ).not.toHaveBeenCalled();
|
|
1200
|
+
} );
|
|
1201
|
+
} );
|
|
1202
|
+
|
|
1105
1203
|
describe( 'Arrow Key Navigation', () => {
|
|
1106
1204
|
test( 'right arrow key navigates to next data point', async () => {
|
|
1107
1205
|
const user = userEvent.setup();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import clsx from 'clsx';
|
|
2
|
-
import { useMemo, forwardRef } from 'react';
|
|
3
|
-
import { useGlobalChartsTheme } from '../../providers';
|
|
2
|
+
import { useContext, useMemo, forwardRef } from 'react';
|
|
3
|
+
import { GlobalChartsContext, useGlobalChartsTheme } from '../../providers';
|
|
4
4
|
import { LineChartUnresponsive } from '../line-chart';
|
|
5
5
|
import { withResponsive } from '../private/with-responsive';
|
|
6
6
|
import styles from './sparkline.module.scss';
|
|
@@ -60,6 +60,9 @@ const SparklineComponent = forwardRef< HTMLDivElement, SparklineProps >(
|
|
|
60
60
|
ref
|
|
61
61
|
) => {
|
|
62
62
|
const theme = useGlobalChartsTheme();
|
|
63
|
+
// Read through the context rather than `useGlobalChartsContext()`, which throws: Sparkline
|
|
64
|
+
// renders standalone too, and only the LineChart it delegates to supplies a provider.
|
|
65
|
+
const chartsContext = useContext( GlobalChartsContext );
|
|
63
66
|
|
|
64
67
|
// Get theme defaults for sparkline
|
|
65
68
|
const themeStrokeWidth = theme.sparkline?.strokeWidth ?? 1.5;
|
|
@@ -127,7 +130,10 @@ const SparklineComponent = forwardRef< HTMLDivElement, SparklineProps >(
|
|
|
127
130
|
if ( data.length === 1 ) {
|
|
128
131
|
const cx = width / 2;
|
|
129
132
|
const cy = height / 2;
|
|
130
|
-
|
|
133
|
+
// The multi-point path lets LineChart resolve the palette; a lone circle has to ask for
|
|
134
|
+
// the same slot itself, or omitting `color` paints black instead of the series color.
|
|
135
|
+
const resolvedColor =
|
|
136
|
+
color || chartsContext?.getElementStyles( { index: 0 } ).color || '#000000';
|
|
131
137
|
|
|
132
138
|
return (
|
|
133
139
|
<div
|
|
@@ -47,7 +47,7 @@ export interface SparklineProps {
|
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
49
|
* Color for the line stroke (hex or CSS color)
|
|
50
|
-
* @default
|
|
50
|
+
* @default The first series palette color (`--a8c-charts-color-series-1`)
|
|
51
51
|
*/
|
|
52
52
|
color?: string;
|
|
53
53
|
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { Tooltip, TooltipContext } from '@visx/xychart';
|
|
2
2
|
import clsx from 'clsx';
|
|
3
3
|
import { useContext, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
4
|
+
import { useGlobalChartsTheme } from '../../providers';
|
|
5
|
+
import { useChartScopeElement } from '../../providers/chart-scope';
|
|
4
6
|
import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
|
|
7
|
+
import { resolveCssVariable } from '../../utils';
|
|
5
8
|
import type { SeriesData, DataPointDate } from '../../types';
|
|
6
9
|
import type { RenderTooltipParams, XyChartTooltipProps } from '../../visx/types';
|
|
7
10
|
import type { ReactNode } from 'react';
|
|
@@ -45,9 +48,21 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
|
|
|
45
48
|
keyboardFocusedClassName,
|
|
46
49
|
series = [],
|
|
47
50
|
mode = 'group',
|
|
51
|
+
verticalCrosshairStyle,
|
|
52
|
+
horizontalCrosshairStyle,
|
|
48
53
|
...props
|
|
49
54
|
} ) => {
|
|
50
55
|
const tooltipContext = useContext( TooltipContext );
|
|
56
|
+
const scopeElement = useChartScopeElement();
|
|
57
|
+
const gridStroke = useGlobalChartsTheme().gridStyles?.stroke;
|
|
58
|
+
|
|
59
|
+
// The crosshair is painted in a portal outside the scope, so it needs a resolved color; see TOKENS.md#the-svg-bridge.
|
|
60
|
+
const crosshairStroke = useMemo( () => {
|
|
61
|
+
const stroke = gridStroke ? resolveCssVariable( gridStroke, scopeElement ) : null;
|
|
62
|
+
|
|
63
|
+
// Passing `stroke: undefined` would erase the crosshair: it overrides visx's own value, and SVG's initial `stroke` is `none`.
|
|
64
|
+
return stroke ? { stroke } : undefined;
|
|
65
|
+
}, [ gridStroke, scopeElement ] );
|
|
51
66
|
|
|
52
67
|
const tooltipData = useMemo( () => {
|
|
53
68
|
if ( mode !== 'individual' ) return [];
|
|
@@ -158,7 +173,14 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
|
|
|
158
173
|
};
|
|
159
174
|
}, [ renderTooltip, selectedIndex, tooltipRef, keyboardFocusedClassName ] );
|
|
160
175
|
|
|
161
|
-
return
|
|
176
|
+
return (
|
|
177
|
+
<Tooltip
|
|
178
|
+
{ ...props }
|
|
179
|
+
verticalCrosshairStyle={ { ...crosshairStroke, ...verticalCrosshairStyle } }
|
|
180
|
+
horizontalCrosshairStyle={ { ...crosshairStroke, ...horizontalCrosshairStyle } }
|
|
181
|
+
renderTooltip={ focusableRenderTooltip }
|
|
182
|
+
/>
|
|
183
|
+
);
|
|
162
184
|
};
|
|
163
185
|
|
|
164
186
|
// Keyboard navigation hook for charts
|
|
@@ -172,6 +194,11 @@ interface UseKeyboardNavigationProps {
|
|
|
172
194
|
* Total number of navigation points (length of tooltip data array)
|
|
173
195
|
*/
|
|
174
196
|
totalPoints: number;
|
|
197
|
+
/**
|
|
198
|
+
* Called with the selected index on Enter or Space, so a chart can treat the
|
|
199
|
+
* keyboard selection the way it treats a click.
|
|
200
|
+
*/
|
|
201
|
+
onActivate?: ( index: number ) => void;
|
|
175
202
|
}
|
|
176
203
|
|
|
177
204
|
export const useKeyboardNavigation = ( {
|
|
@@ -181,6 +208,7 @@ export const useKeyboardNavigation = ( {
|
|
|
181
208
|
setIsNavigating,
|
|
182
209
|
chartRef,
|
|
183
210
|
totalPoints,
|
|
211
|
+
onActivate,
|
|
184
212
|
}: UseKeyboardNavigationProps ) => {
|
|
185
213
|
// Focus the tooltip as soon as it is rendered
|
|
186
214
|
const tooltipRef = useCallback(
|
|
@@ -237,9 +265,11 @@ export const useKeyboardNavigation = ( {
|
|
|
237
265
|
setSelectedIndex( undefined );
|
|
238
266
|
setIsNavigating( false );
|
|
239
267
|
chartRef.current?.focus();
|
|
268
|
+
} else if ( ( event.key === 'Enter' || event.key === ' ' ) && selectedIndex !== undefined ) {
|
|
269
|
+
onActivate?.( selectedIndex );
|
|
240
270
|
}
|
|
241
271
|
},
|
|
242
|
-
[ totalPoints, selectedIndex, setSelectedIndex, setIsNavigating, chartRef ]
|
|
272
|
+
[ totalPoints, selectedIndex, setSelectedIndex, setIsNavigating, chartRef, onActivate ]
|
|
243
273
|
);
|
|
244
274
|
|
|
245
275
|
return {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { LineChartUnresponsive } from '../../../charts/line-chart/line-chart';
|
|
4
|
+
import { GlobalChartsProvider } from '../../../providers';
|
|
5
|
+
import { ChartScopeContext } from '../../../providers/chart-scope';
|
|
6
|
+
import type { ReactNode } from 'react';
|
|
7
|
+
|
|
8
|
+
// A real chart is the harness rather than the subject: the crosshairs render only once visx has a data context and an open tooltip. The unresponsive export is what lets the test own the scope element — `withResponsive` otherwise provides its own wrapper as the scope.
|
|
9
|
+
const renderChart = ( scope?: HTMLElement ) => {
|
|
10
|
+
const chart = (
|
|
11
|
+
<LineChartUnresponsive
|
|
12
|
+
width={ 500 }
|
|
13
|
+
height={ 300 }
|
|
14
|
+
data={ [
|
|
15
|
+
{
|
|
16
|
+
label: 'Series A',
|
|
17
|
+
data: [
|
|
18
|
+
{ date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
|
|
19
|
+
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
20
|
+
],
|
|
21
|
+
options: {},
|
|
22
|
+
},
|
|
23
|
+
] }
|
|
24
|
+
withTooltips
|
|
25
|
+
withTooltipCrosshairs={ { showVertical: true } }
|
|
26
|
+
withGradientFill={ false }
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const scoped: ReactNode = scope ? (
|
|
31
|
+
<ChartScopeContext.Provider value={ scope }>{ chart }</ChartScopeContext.Provider>
|
|
32
|
+
) : (
|
|
33
|
+
chart
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return render( <GlobalChartsProvider>{ scoped }</GlobalChartsProvider> );
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const openTooltip = async () => {
|
|
40
|
+
const user = userEvent.setup();
|
|
41
|
+
|
|
42
|
+
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
43
|
+
await user.keyboard( '{ArrowRight}' );
|
|
44
|
+
|
|
45
|
+
// eslint-disable-next-line testing-library/no-node-access -- visx owns the crosshair and hardcodes its class name, so there is no attribute to reach it by.
|
|
46
|
+
return document.querySelector( '.visx-crosshair-vertical line' );
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
describe( 'AccessibleTooltip', () => {
|
|
50
|
+
// The crosshair is painted in a portal outside the scope element; see TOKENS.md#the-svg-bridge.
|
|
51
|
+
it( 'reads the grid role from the scope element', async () => {
|
|
52
|
+
const scope = document.createElement( 'div' );
|
|
53
|
+
scope.style.setProperty( '--a8c-charts-color-grid', 'rgb(1, 2, 3)' );
|
|
54
|
+
document.body.appendChild( scope );
|
|
55
|
+
|
|
56
|
+
renderChart( scope );
|
|
57
|
+
|
|
58
|
+
await expect( openTooltip() ).resolves.toHaveAttribute( 'stroke', 'rgb(1, 2, 3)' );
|
|
59
|
+
|
|
60
|
+
document.body.removeChild( scope );
|
|
61
|
+
} );
|
|
62
|
+
|
|
63
|
+
it( 'falls back to the catalog default when the role is unset', async () => {
|
|
64
|
+
renderChart();
|
|
65
|
+
|
|
66
|
+
await expect( openTooltip() ).resolves.toHaveAttribute( 'stroke', '#dbdbdb' );
|
|
67
|
+
} );
|
|
68
|
+
} );
|
|
@@ -4,15 +4,15 @@ import { ChartScopeContext } from '../../providers/chart-scope';
|
|
|
4
4
|
import { useXYChartTheme } from '../use-xychart-theme';
|
|
5
5
|
import type { ReactNode } from 'react';
|
|
6
6
|
|
|
7
|
-
// A `theme` prop override reaches CSS-painted elements through the inline `--a8c-charts-*` var GlobalChartsProvider writes on its wrapper, and reaches
|
|
7
|
+
// A `theme` prop override reaches CSS-painted elements through the inline `--a8c-charts-*` var GlobalChartsProvider writes on its wrapper, and reaches the values our own components read through `providerTheme`. Before the fix, `providerTheme` carried the override's literal directly, so an element-level `--a8c-charts-color-background` 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
8
|
describe( 'useXYChartTheme resolves a theme prop override through the CSS cascade', () => {
|
|
9
9
|
it( "an element override set inside the provider tree beats the provider's theme prop literal", () => {
|
|
10
10
|
const nestedOverride = document.createElement( 'div' );
|
|
11
|
-
nestedOverride.style.setProperty( '--a8c-charts-color-
|
|
11
|
+
nestedOverride.style.setProperty( '--a8c-charts-color-background', '#00ff00' );
|
|
12
12
|
document.body.appendChild( nestedOverride );
|
|
13
13
|
|
|
14
14
|
const wrapper = ( { children }: { children: ReactNode } ) => (
|
|
15
|
-
<GlobalChartsProvider theme={ {
|
|
15
|
+
<GlobalChartsProvider theme={ { backgroundColor: 'rgb(255,0,0)' } }>
|
|
16
16
|
<ChartScopeContext.Provider value={ nestedOverride }>
|
|
17
17
|
{ children }
|
|
18
18
|
</ChartScopeContext.Provider>
|
|
@@ -21,7 +21,7 @@ describe( 'useXYChartTheme resolves a theme prop override through the CSS cascad
|
|
|
21
21
|
|
|
22
22
|
const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
|
|
23
23
|
|
|
24
|
-
expect( result.current.
|
|
24
|
+
expect( result.current.backgroundColor ).toBe( '#00ff00' );
|
|
25
25
|
|
|
26
26
|
document.body.removeChild( nestedOverride );
|
|
27
27
|
} );
|
|
@@ -4,7 +4,7 @@ import { useXYChartTheme } from '../use-xychart-theme';
|
|
|
4
4
|
import type { SeriesData } from '../../types';
|
|
5
5
|
import type { ReactNode } from 'react';
|
|
6
6
|
|
|
7
|
-
// The theme resolves
|
|
7
|
+
// The theme resolves the background role and the five palette slots. 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 color changes, so a dashboard mounting N charts pays the count below N times over.
|
|
8
8
|
const STABLE_DATA: SeriesData[] = [];
|
|
9
9
|
|
|
10
10
|
describe( 'useXYChartTheme style reads', () => {
|
|
@@ -13,7 +13,6 @@ describe( 'useXYChartTheme style reads', () => {
|
|
|
13
13
|
|
|
14
14
|
beforeEach( () => {
|
|
15
15
|
scope = document.createElement( 'div' );
|
|
16
|
-
scope.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
|
|
17
16
|
document.body.appendChild( scope );
|
|
18
17
|
getComputedStyleSpy = jest.spyOn( window, 'getComputedStyle' );
|
|
19
18
|
} );
|
|
@@ -33,14 +32,13 @@ describe( 'useXYChartTheme style reads', () => {
|
|
|
33
32
|
|
|
34
33
|
it( 'reads every role from one computed-style snapshot, not one per role', () => {
|
|
35
34
|
scope.style.setProperty( '--a8c-charts-color-background', '#111111' );
|
|
36
|
-
scope.style.setProperty( '--a8c-charts-color-
|
|
35
|
+
scope.style.setProperty( '--a8c-charts-color-series-2', '#222222' );
|
|
37
36
|
|
|
38
37
|
const { result } = renderWithScope();
|
|
39
38
|
|
|
40
39
|
expect( getComputedStyleSpy ).toHaveBeenCalledTimes( 1 );
|
|
41
40
|
expect( result.current.backgroundColor ).toBe( '#111111' );
|
|
42
|
-
expect( result.current.
|
|
43
|
-
expect( result.current.axisStyles.x.bottom.axisLine.stroke ).toBe( '#222222' );
|
|
41
|
+
expect( result.current.colors ).toContain( '#222222' );
|
|
44
42
|
} );
|
|
45
43
|
|
|
46
44
|
it( 'does not re-read styles when the hook re-renders with the same inputs', () => {
|
|
@@ -74,16 +72,25 @@ describe( 'useXYChartTheme style reads', () => {
|
|
|
74
72
|
expect( result.current.colors[ 0 ] ).toBe( 'rebeccapurple' );
|
|
75
73
|
} );
|
|
76
74
|
|
|
77
|
-
// The memo key
|
|
75
|
+
// The memo key serializes the strokes rather than joining them, so a color containing a comma or a space survives the round-trip intact. `rgba(0, 0, 0, 0.5)` is the one that proves it: it holds both separators and comes back whole.
|
|
76
|
+
//
|
|
77
|
+
// The `var()` entry resolves to its fallback rather than passing through, which is the point of resolving the palette at all — visx paints these as SVG presentation attributes, where a `var()` resolves to nothing.
|
|
78
78
|
it( 'keeps a stroke that contains separators intact', () => {
|
|
79
79
|
const { result } = renderWithScope( [
|
|
80
80
|
{ label: 'A', options: { stroke: 'rgba(0, 0, 0, 0.5)' }, data: [] },
|
|
81
81
|
{ label: 'B', options: { stroke: 'var(--brand, #fff)' }, data: [] },
|
|
82
82
|
] as unknown as SeriesData[] );
|
|
83
83
|
|
|
84
|
-
expect( result.current.colors.slice( 0, 2 ) ).toEqual( [
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
expect( result.current.colors.slice( 0, 2 ) ).toEqual( [ 'rgba(0, 0, 0, 0.5)', '#fff' ] );
|
|
85
|
+
} );
|
|
86
|
+
|
|
87
|
+
// Four of the five palette slots carry no catalog default, so in jsdom they resolve to nothing. Passing those to visx unresolved would make them its default stroke for any series without an explicit one, painting nothing at all.
|
|
88
|
+
it( 'drops palette entries that resolve to nothing', () => {
|
|
89
|
+
const { result } = renderWithScope( [] );
|
|
90
|
+
|
|
91
|
+
expect( result.current.colors ).not.toEqual(
|
|
92
|
+
expect.arrayContaining( [ expect.stringContaining( 'var(' ) ] )
|
|
93
|
+
);
|
|
94
|
+
expect( result.current.colors.length ).toBeGreaterThan( 0 );
|
|
88
95
|
} );
|
|
89
96
|
} );
|
|
@@ -4,11 +4,11 @@ import { useXYChartTheme } from '../use-xychart-theme';
|
|
|
4
4
|
import type { ReactNode } from 'react';
|
|
5
5
|
|
|
6
6
|
describe( 'useXYChartTheme', () => {
|
|
7
|
-
it( 'resolves
|
|
8
|
-
document.documentElement.style.setProperty( '--a8c-charts-color-
|
|
7
|
+
it( 'resolves background color against the scope element, not the document root', () => {
|
|
8
|
+
document.documentElement.style.setProperty( '--a8c-charts-color-background', '#ff0000' );
|
|
9
9
|
|
|
10
10
|
const scope = document.createElement( 'div' );
|
|
11
|
-
scope.style.setProperty( '--a8c-charts-color-
|
|
11
|
+
scope.style.setProperty( '--a8c-charts-color-background', '#00ff00' );
|
|
12
12
|
document.body.appendChild( scope );
|
|
13
13
|
|
|
14
14
|
const wrapper = ( { children }: { children: ReactNode } ) => (
|
|
@@ -17,9 +17,54 @@ describe( 'useXYChartTheme', () => {
|
|
|
17
17
|
|
|
18
18
|
const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
|
|
19
19
|
|
|
20
|
-
expect( result.current.
|
|
20
|
+
expect( result.current.backgroundColor ).toBe( '#00ff00' );
|
|
21
|
+
|
|
22
|
+
document.documentElement.style.removeProperty( '--a8c-charts-color-background' );
|
|
23
|
+
document.body.removeChild( scope );
|
|
24
|
+
} );
|
|
25
|
+
|
|
26
|
+
// Resolving a paint-only color here would freeze it: visx writes whatever it is handed onto the element, so a literal stops following the cascade and an override set below the provider wrapper never reaches it. Handing visx the pointer instead lets it resolve at the element it paints. jsdom does not compute `var()`, so what the pointer resolves *to* is covered in Storybook; this pins that the chain survives the theme build intact.
|
|
27
|
+
it( 'hands visx the catalog pointer for paint-only colors rather than a resolved value', () => {
|
|
28
|
+
const scope = document.createElement( 'div' );
|
|
29
|
+
document.body.appendChild( scope );
|
|
30
|
+
|
|
31
|
+
const wrapper = ( { children }: { children: ReactNode } ) => (
|
|
32
|
+
<ChartScopeContext.Provider value={ scope }>{ children }</ChartScopeContext.Provider>
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
|
|
36
|
+
|
|
37
|
+
expect( result.current.gridStyles.stroke ).toBe( 'var(--a8c-charts-color-grid, #dbdbdb)' );
|
|
38
|
+
expect( result.current.axisStyles.x.bottom.axisLine.stroke ).toBe(
|
|
39
|
+
'var(--a8c-charts-color-axis, #dbdbdb)'
|
|
40
|
+
);
|
|
41
|
+
expect( result.current.axisStyles.x.bottom.tickLine.stroke ).toBe(
|
|
42
|
+
'var(--a8c-charts-color-tick, #dbdbdb)'
|
|
43
|
+
);
|
|
44
|
+
expect( result.current.axisStyles.x.bottom.tickLabel.fill ).toBe(
|
|
45
|
+
'var(--a8c-charts-color-label-axis, #1e1e1e)'
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
document.body.removeChild( scope );
|
|
49
|
+
} );
|
|
50
|
+
|
|
51
|
+
// The one label color that has to arrive resolved: visx paints it on a portal container appended to `document.body`, where the catalog is not declared, and concatenates it into `box-shadow: 0 1px 2px ${color}55`, which a `var()` chain would invalidate.
|
|
52
|
+
it( 'resolves the tooltip label color while the tick labels keep the pointer', () => {
|
|
53
|
+
const scope = document.createElement( 'div' );
|
|
54
|
+
scope.style.setProperty( '--a8c-charts-color-label-axis', '#0000ff' );
|
|
55
|
+
document.body.appendChild( scope );
|
|
56
|
+
|
|
57
|
+
const wrapper = ( { children }: { children: ReactNode } ) => (
|
|
58
|
+
<ChartScopeContext.Provider value={ scope }>{ children }</ChartScopeContext.Provider>
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
|
|
62
|
+
|
|
63
|
+
expect( result.current.htmlLabel.color ).toBe( '#0000ff' );
|
|
64
|
+
expect( result.current.svgLabelSmall.fill ).toBe(
|
|
65
|
+
'var(--a8c-charts-color-label-axis, #1e1e1e)'
|
|
66
|
+
);
|
|
21
67
|
|
|
22
|
-
document.documentElement.style.removeProperty( '--a8c-charts-color-grid' );
|
|
23
68
|
document.body.removeChild( scope );
|
|
24
69
|
} );
|
|
25
70
|
} );
|
|
@@ -9,7 +9,7 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
|
|
|
9
9
|
const theme = useGlobalChartsTheme();
|
|
10
10
|
const scopeElement = useChartScopeElement();
|
|
11
11
|
|
|
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.
|
|
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. Serialized 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 color cannot round-trip.
|
|
13
13
|
const seriesColorKey = JSON.stringify(
|
|
14
14
|
( data ?? [] )
|
|
15
15
|
.map( series => series.options?.stroke )
|
|
@@ -17,35 +17,29 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
|
|
|
17
17
|
);
|
|
18
18
|
|
|
19
19
|
return useMemo( () => {
|
|
20
|
-
//
|
|
20
|
+
// Only what is read as a string is resolved here — the palette and `backgroundColor` — plus `htmlLabel.color` below, which is painted outside the scope. Resolving against the chart's own scope element, never :root, is what reads an override set inside the provider tree.
|
|
21
21
|
//
|
|
22
|
-
// One resolver per theme build, so
|
|
22
|
+
// One resolver per theme build, so all three share a single getComputedStyle call.
|
|
23
23
|
const resolve = createCssVariableResolver( scopeElement );
|
|
24
24
|
const resolveColor = ( value?: string ): string | undefined =>
|
|
25
25
|
value ? resolve( value ) ?? value : value;
|
|
26
26
|
|
|
27
27
|
const seriesColors: string[] = JSON.parse( seriesColorKey );
|
|
28
28
|
|
|
29
|
+
// The palette gets the same treatment as every other color here, and needs it more: `theme.colors` is the one field that is always a `var()` chain, and the four slots without a catalog default resolve to nothing at all. visx builds its `colorScale` from this array and uses it as the default stroke for a series rendered without an explicit one, so an unresolved entry paints nothing rather than degrading. Entries that resolve to nothing are dropped so the scale compacts the way the provider's own palette does.
|
|
30
|
+
const paletteColors = [ ...seriesColors, ...( theme.colors ?? [] ) ]
|
|
31
|
+
.map( color => resolveColor( color ) )
|
|
32
|
+
.filter( ( color ): color is string => Boolean( color ) && ! color.includes( 'var(' ) );
|
|
33
|
+
|
|
34
|
+
// The tooltip is painted in a portal outside the scope, and visx concatenates this color into a `box-shadow` where a chain cannot take a suffix; see TOKENS.md#the-svg-bridge. Passing it explicitly leaves `svgLabelSmall.fill`, which visx derives it from, a chain for the SVG tick labels.
|
|
35
|
+
const htmlLabelColor = resolveColor( theme.svgLabelSmall?.fill );
|
|
36
|
+
|
|
37
|
+
// The grid, axis, tick and label colors are spread through untouched, and that is the whole mechanism: visx writes each one onto the element it paints — as an inline style for the grid, a presentation attribute elsewhere — and a `var()` chain resolves there natively. So the role is read at the painted element rather than snapshot at the provider wrapper, which is what makes an override on a chart's own class work, keeps a theme change live without a re-render, and leaves nothing to resolve during SSR. Resolving them here would freeze the color instead.
|
|
29
38
|
return buildChartTheme( {
|
|
30
39
|
...theme,
|
|
31
|
-
colors:
|
|
40
|
+
colors: paletteColors,
|
|
32
41
|
backgroundColor: resolveColor( theme.backgroundColor ),
|
|
33
|
-
|
|
34
|
-
...theme.gridStyles,
|
|
35
|
-
stroke: resolveColor( theme.gridStyles.stroke ),
|
|
36
|
-
},
|
|
37
|
-
xAxisLineStyles: theme.xAxisLineStyles && {
|
|
38
|
-
...theme.xAxisLineStyles,
|
|
39
|
-
stroke: resolveColor( theme.xAxisLineStyles.stroke ),
|
|
40
|
-
},
|
|
41
|
-
xTickLineStyles: theme.xTickLineStyles && {
|
|
42
|
-
...theme.xTickLineStyles,
|
|
43
|
-
stroke: resolveColor( theme.xTickLineStyles.stroke ),
|
|
44
|
-
},
|
|
45
|
-
svgLabelSmall: theme.svgLabelSmall && {
|
|
46
|
-
...theme.svgLabelSmall,
|
|
47
|
-
fill: resolveColor( theme.svgLabelSmall.fill ),
|
|
48
|
-
},
|
|
42
|
+
htmlLabel: htmlLabelColor ? { color: htmlLabelColor } : undefined,
|
|
49
43
|
} );
|
|
50
44
|
}, [ theme, seriesColorKey, scopeElement ] );
|
|
51
45
|
};
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
// Imported from the module rather than the `chart-scope` barrel: the barrel also pulls `use-standalone-scope-class`, which imports `GlobalChartsContext` back from this file. That cycle resolves today only because the binding is read lazily inside the hook body.
|
|
25
25
|
import { ChartScopeContext } from '../chart-scope/chart-scope-context';
|
|
26
26
|
import { getChartColor, type ColorCache } from './private/get-chart-color';
|
|
27
|
+
import { SERIES_PALETTE_POINTERS } from './private/series-palette';
|
|
27
28
|
import { themeOverrideVars } from './private/theme-override-vars';
|
|
28
29
|
import { withCatalogPointers } from './private/with-catalog-pointers';
|
|
29
30
|
import { defaultTheme } from './themes';
|
|
@@ -91,45 +92,48 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
|
|
|
91
92
|
// Resolves CSS variables from the wrapper element's scope to handle scoped variables
|
|
92
93
|
// Note: Only re-runs when providerTheme changes, not when wrapper element changes.
|
|
93
94
|
// This is intentional, as wrapperRef is expected to be stable for the lifetime of the provider.
|
|
95
|
+
// A remount is not a gap in that: effects always run on mount, so a new instance resolves
|
|
96
|
+
// against its own node. Only a node swap *within* one instance would go unseen, and the catalog
|
|
97
|
+
// is declared on the `.a8c-charts-scope` class rather than on a particular element, so the
|
|
98
|
+
// replacement carries the same computed values anyway.
|
|
94
99
|
useLayoutEffect( () => {
|
|
95
100
|
setIsColorPaletteResolved( false );
|
|
96
|
-
const { colors } = providerTheme;
|
|
97
101
|
const resolvedColors: string[] = [];
|
|
98
102
|
const hues: number[] = [];
|
|
99
103
|
const existingHslColors: Array< [ number, number, number ] > = [];
|
|
100
104
|
let minHue = 360;
|
|
101
105
|
let maxHue = 0;
|
|
102
106
|
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
107
|
+
// Resolved from the theme rather than from `SERIES_PALETTE_POINTERS`, and it has to be. In a
|
|
108
|
+
// browser the two are equivalent — both name the same slots, and the wrapper's theme-layer
|
|
109
|
+
// vars answer either. But `withCatalogPointers` puts the consumer's own color in each
|
|
110
|
+
// pointer's terminal position, and that literal is the only carrier for the palette where
|
|
111
|
+
// `getComputedStyle` resolves nothing: SSR and jsdom. Walking the manifest instead makes
|
|
112
|
+
// every consumer palette collapse to the catalog seed there.
|
|
113
|
+
for ( const color of providerTheme.colors ?? SERIES_PALETTE_POINTERS ) {
|
|
114
|
+
// Normalize color to hex format, handling CSS variables, RGB, HSL, etc.
|
|
115
|
+
// This uses normalizeColorToHex which resolves CSS variables and converts
|
|
116
|
+
// rgb(), rgba(), hsl() formats to hex
|
|
117
|
+
const normalizedColor = normalizeColorToHex( color, wrapperRef.current, resolveCssVariable );
|
|
118
|
+
|
|
119
|
+
// Only process valid hex colors. An unset palette slot returns its own
|
|
120
|
+
// `var()` unchanged, so this is also what compacts the palette: slots the
|
|
121
|
+
// consumer never set drop out here and `getChartColor` generates past
|
|
122
|
+
// whatever survived.
|
|
123
|
+
if ( normalizedColor.startsWith( '#' ) ) {
|
|
124
|
+
resolvedColors.push( normalizedColor );
|
|
125
|
+
const hslColor = d3Hsl( normalizedColor );
|
|
126
|
+
// d3Hsl returns NaN values for invalid colors
|
|
127
|
+
if ( ! isNaN( hslColor.h ) ) {
|
|
128
|
+
const hslTuple: [ number, number, number ] = [
|
|
129
|
+
hslColor.h,
|
|
130
|
+
hslColor.s * 100,
|
|
131
|
+
hslColor.l * 100,
|
|
132
|
+
];
|
|
133
|
+
hues.push( hslTuple[ 0 ] );
|
|
134
|
+
existingHslColors.push( hslTuple );
|
|
135
|
+
minHue = Math.min( minHue, hslTuple[ 0 ] );
|
|
136
|
+
maxHue = Math.max( maxHue, hslTuple[ 0 ] );
|
|
133
137
|
}
|
|
134
138
|
}
|
|
135
139
|
}
|
|
@@ -153,11 +157,18 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
|
|
|
153
157
|
() => new Map()
|
|
154
158
|
);
|
|
155
159
|
|
|
156
|
-
// Reset group color mappings when
|
|
160
|
+
// Reset group color mappings when the resolved palette changes.
|
|
161
|
+
//
|
|
162
|
+
// Keyed on the resolved colors rather than on `providerTheme.colors`, which holds five
|
|
163
|
+
// catalog pointers and so does not move with a `theme.colors` change — a consumer's colors
|
|
164
|
+
// reach the palette through the theme-layer vars on the wrapper. Keying on content also stops
|
|
165
|
+
// a consumer passing an inline `theme` object from resetting the map on every render.
|
|
166
|
+
const paletteKey = colorCache.colors.join( ',' );
|
|
167
|
+
|
|
157
168
|
useEffect( () => {
|
|
158
169
|
// Create a completely new Map instance to trigger dependencies, e.g. useChartLegendItems
|
|
159
170
|
setGroupToColorMap( new Map() );
|
|
160
|
-
}, [
|
|
171
|
+
}, [ paletteKey ] );
|
|
161
172
|
|
|
162
173
|
const registerChart = useCallback( ( id: string, data: ChartRegistration ) => {
|
|
163
174
|
setCharts( prev => new Map( prev ).set( id, data ) );
|