@automattic/charts 3.1.1 → 4.0.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 +41 -0
- package/TOKENS.md +80 -46
- package/dist/index.cjs +1224 -662
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +72 -23
- package/dist/index.d.cts +230 -162
- package/dist/index.d.ts +230 -162
- package/dist/index.js +1224 -663
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
- package/src/charts/area-chart/area-chart.tsx +39 -25
- package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
- package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
- package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
- package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
- package/src/charts/area-chart/types.ts +4 -7
- package/src/charts/bar-chart/bar-chart.tsx +38 -11
- package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
- package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +67 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +79 -0
- package/src/charts/bar-list-chart/bar-list-chart.tsx +45 -2
- package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +38 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
- package/src/charts/geo-chart/geo-chart.module.scss +1 -0
- package/src/charts/geo-chart/geo-chart.tsx +5 -10
- package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
- package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
- package/src/charts/heatmap-chart/types.ts +2 -2
- package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
- package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
- package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
- package/src/charts/line-chart/line-chart.module.scss +10 -18
- package/src/charts/line-chart/line-chart.tsx +109 -25
- package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
- package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +74 -3
- package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
- package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
- package/src/charts/line-chart/types.ts +4 -1
- package/src/charts/pie-chart/pie-chart.module.scss +13 -1
- package/src/charts/pie-chart/pie-chart.tsx +30 -40
- package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
- package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
- package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
- package/src/charts/private/test/axis-formatting.test.tsx +85 -0
- package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
- package/src/charts/private/test/time-axis-options.test.ts +267 -0
- package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
- package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
- package/src/charts/private/test/time-axis.test.ts +9 -69
- package/src/charts/private/time-axis-options.ts +106 -0
- package/src/charts/private/time-axis.ts +318 -136
- package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
- package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
- package/src/charts/sparkline/sparkline.tsx +9 -3
- package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
- package/src/charts/sparkline/types.ts +2 -2
- package/src/components/legend/private/base-legend.module.scss +9 -5
- package/src/components/tooltip/accessible-tooltip.tsx +38 -10
- package/src/components/tooltip/base-tooltip.module.scss +3 -3
- package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +67 -0
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
- package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
- package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
- package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +17 -10
- package/src/hooks/test/use-xychart-theme.test.tsx +63 -5
- package/src/hooks/use-xychart-theme.ts +33 -23
- package/src/index.ts +4 -0
- package/src/providers/chart-context/global-charts-provider.tsx +64 -54
- package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
- package/src/providers/chart-context/index.ts +1 -0
- package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
- package/src/providers/chart-context/private/series-palette.ts +35 -0
- package/src/providers/chart-context/test/catalog-pointers.test.ts +61 -0
- package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
- package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -18
- package/src/providers/chart-context/themes.ts +7 -47
- package/src/providers/chart-context/types.ts +3 -0
- package/src/styles/chart-scope.scss +63 -58
- package/src/styles/test/chart-scope.test.ts +25 -40
- package/src/test-utils/series-fixtures.ts +31 -0
- package/src/types.ts +103 -73
- package/src/utils/bucket-info.ts +87 -0
- package/src/utils/color-utils.ts +0 -27
- package/src/utils/date-formatting.ts +174 -0
- package/src/utils/get-styles.ts +7 -1
- package/src/utils/index.ts +3 -0
- package/src/utils/test/bucket-info.test.ts +185 -0
- package/src/utils/test/color-utils.test.ts +1 -48
- package/src/utils/test/date-formatting.test.ts +89 -0
- package/src/utils/test/get-styles.test.ts +10 -7
- package/src/utils/test/merge-themes.test.ts +18 -46
- package/src/visx/types.ts +29 -2
- package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
- package/src/providers/chart-context/private/theme-override-vars.ts +0 -87
- package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -50
- package/src/providers/chart-context/test/sparse-theme.test.ts +0 -35
- package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
- package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -118
- package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -47
- package/src/styles/test/wpds-fallbacks.test.ts +0 -131
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment <rootDir>/tests/environment-los-angeles.mjs
|
|
3
|
+
*/
|
|
4
|
+
import { renderHook } from '@testing-library/react';
|
|
5
|
+
import { GlobalChartsProvider } from '../../../../providers';
|
|
6
|
+
import { useBarChartOptions } from '../use-bar-chart-options';
|
|
7
|
+
import type { SeriesData } from '../../../../types';
|
|
8
|
+
import type { ReactNode } from 'react';
|
|
9
|
+
|
|
10
|
+
// The runtime locale stays en-US: a bucket labeled in German, on Tokyo's
|
|
11
|
+
// calendar day, can only come from the provider.
|
|
12
|
+
|
|
13
|
+
// 15:30 UTC is Aug 2 in Los Angeles and Aug 3 in Tokyo.
|
|
14
|
+
const START = new Date( '2026-08-02T15:30:00Z' );
|
|
15
|
+
|
|
16
|
+
const daily: SeriesData[] = [
|
|
17
|
+
{
|
|
18
|
+
label: 'Series A',
|
|
19
|
+
data: Array.from( { length: 14 }, ( _, i ) => ( {
|
|
20
|
+
date: new Date( START.getTime() + i * 24 * 60 * 60 * 1000 ),
|
|
21
|
+
value: 10 + i,
|
|
22
|
+
} ) ),
|
|
23
|
+
options: {},
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const withProvider =
|
|
28
|
+
( props: { locale?: string; timeZone?: string } = {} ) =>
|
|
29
|
+
( { children }: { children: ReactNode } ) => (
|
|
30
|
+
<GlobalChartsProvider { ...props }>{ children }</GlobalChartsProvider>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const optionsFor = (
|
|
34
|
+
props?: { locale?: string; timeZone?: string },
|
|
35
|
+
options?: Parameters< typeof useBarChartOptions >[ 2 ]
|
|
36
|
+
) =>
|
|
37
|
+
renderHook( () => useBarChartOptions( daily, false, options ), {
|
|
38
|
+
wrapper: withProvider( props ),
|
|
39
|
+
} ).result.current;
|
|
40
|
+
|
|
41
|
+
describe( 'useBarChartOptions with a host formatting context', () => {
|
|
42
|
+
it( 'labels the tooltip bucket in the provider locale and time zone', () => {
|
|
43
|
+
const { tooltip } = optionsFor( { locale: 'de-DE', timeZone: 'Asia/Tokyo' } );
|
|
44
|
+
|
|
45
|
+
expect( tooltip.labelFormatter( START.getTime(), 0, [] ) ).toBe( '3. August 2026' );
|
|
46
|
+
} );
|
|
47
|
+
|
|
48
|
+
it( 'labels a weekly bucket in the provider locale and time zone', () => {
|
|
49
|
+
const { tooltip } = optionsFor(
|
|
50
|
+
{ locale: 'de-DE', timeZone: 'Asia/Tokyo' },
|
|
51
|
+
{ axis: { x: { tickResolution: 'week' } } }
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
expect( tooltip.labelFormatter( START.getTime(), 0, [] ) ).toBe( 'Week of 3. August 2026' );
|
|
55
|
+
} );
|
|
56
|
+
|
|
57
|
+
it( 'labels the axis ticks in the provider locale and time zone', () => {
|
|
58
|
+
const { axis } = optionsFor( { locale: 'de-DE', timeZone: 'Asia/Tokyo' } );
|
|
59
|
+
|
|
60
|
+
expect( axis.x.tickFormat( START, 0, [] ) ).toBe( '3. Aug.' );
|
|
61
|
+
} );
|
|
62
|
+
|
|
63
|
+
it( 'falls back to the runtime locale and zone when the provider supplies none', () => {
|
|
64
|
+
const { tooltip, axis } = optionsFor();
|
|
65
|
+
|
|
66
|
+
expect( tooltip.labelFormatter( START.getTime(), 0, [] ) ).toBe( 'August 2, 2026' );
|
|
67
|
+
expect( axis.x.tickFormat( START, 0, [] ) ).toBe( 'Aug 2' );
|
|
68
|
+
} );
|
|
69
|
+
} );
|
|
@@ -2,10 +2,19 @@ import { formatNumberCompact } from '@automattic/number-formatters';
|
|
|
2
2
|
import { __, sprintf } from '@wordpress/i18n';
|
|
3
3
|
import { useMemo } from 'react';
|
|
4
4
|
import { useDeepMemo } from '../../../hooks';
|
|
5
|
-
import {
|
|
5
|
+
import { useChartFormatting } from '../../../providers';
|
|
6
|
+
import { getBucketResolution } from '../../../utils/bucket-info';
|
|
7
|
+
import { createDateFormatter } from '../../../utils/date-formatting';
|
|
8
|
+
import { getBandTickValues, getFormatter } from '../../private/time-axis';
|
|
6
9
|
import { TruncatedXTickComponent, TruncatedYTickComponent } from './truncated-tick-component';
|
|
7
10
|
import type { EnhancedDataPoint } from '../../../hooks/use-zero-value-display';
|
|
8
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
DataPointDate,
|
|
13
|
+
BaseChartProps,
|
|
14
|
+
ChartFormatting,
|
|
15
|
+
SeriesData,
|
|
16
|
+
TickResolution,
|
|
17
|
+
} from '../../../types';
|
|
9
18
|
import type { TickFormatter } from '@visx/axis';
|
|
10
19
|
|
|
11
20
|
/** Outer padding of the category band scale (space at the chart edges). */
|
|
@@ -24,7 +33,7 @@ const TOOLTIP_FORMAT_BY_RESOLUTION: Record<
|
|
|
24
33
|
Exclude< TickResolution, 'week' >,
|
|
25
34
|
Intl.DateTimeFormatOptions
|
|
26
35
|
> = {
|
|
27
|
-
hour: { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric'
|
|
36
|
+
hour: { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric' },
|
|
28
37
|
day: { year: 'numeric', month: 'long', day: 'numeric' },
|
|
29
38
|
month: { year: 'numeric', month: 'long' },
|
|
30
39
|
year: { year: 'numeric' },
|
|
@@ -36,31 +45,33 @@ const TOOLTIP_FORMAT_BY_RESOLUTION: Record<
|
|
|
36
45
|
*
|
|
37
46
|
* @param data - Date-based series, already parsed and sorted by `useChartDataTransform`.
|
|
38
47
|
* @param tickResolution - Caller-declared bucket resolution, when known.
|
|
48
|
+
* @param formatting - Host locale and time zone.
|
|
39
49
|
* @return Tooltip label formatter.
|
|
40
50
|
*/
|
|
41
|
-
const getTooltipFormatter = (
|
|
51
|
+
const getTooltipFormatter = (
|
|
52
|
+
data: SeriesData[],
|
|
53
|
+
tickResolution: TickResolution | undefined,
|
|
54
|
+
formatting: ChartFormatting
|
|
55
|
+
) => {
|
|
42
56
|
// Only a declared 'week' reaches this branch: seven-day spacing is
|
|
43
57
|
// indistinguishable from sparse daily data, so inference reports 'day'.
|
|
44
58
|
if ( tickResolution === 'week' ) {
|
|
59
|
+
const formatDay = createDateFormatter( TOOLTIP_FORMAT_BY_RESOLUTION.day, formatting );
|
|
45
60
|
return ( timestamp: number ) =>
|
|
46
61
|
sprintf(
|
|
47
62
|
/* translators: %s is the first day of the week the bar covers. */
|
|
48
63
|
__( 'Week of %s', 'jetpack-charts' ),
|
|
49
|
-
|
|
50
|
-
year: 'numeric',
|
|
51
|
-
month: 'long',
|
|
52
|
-
day: 'numeric',
|
|
53
|
-
} )
|
|
64
|
+
formatDay( timestamp )
|
|
54
65
|
);
|
|
55
66
|
}
|
|
56
67
|
|
|
57
|
-
// Fall back to the day format rather than
|
|
58
|
-
//
|
|
68
|
+
// Fall back to the day format rather than empty options, which would print a
|
|
69
|
+
// bare numeric date for an unrecognized `tickResolution`.
|
|
59
70
|
const format =
|
|
60
71
|
TOOLTIP_FORMAT_BY_RESOLUTION[ getBucketResolution( data, tickResolution ) ] ??
|
|
61
72
|
TOOLTIP_FORMAT_BY_RESOLUTION.day;
|
|
62
73
|
|
|
63
|
-
return (
|
|
74
|
+
return createDateFormatter( format, formatting );
|
|
64
75
|
};
|
|
65
76
|
|
|
66
77
|
const identity = ( label: string ) => label;
|
|
@@ -152,6 +163,7 @@ export function useBarChartOptions(
|
|
|
152
163
|
// Callers reasonably pass an object literal, which is a fresh reference every
|
|
153
164
|
// render and would defeat every memo below.
|
|
154
165
|
const stableOptions = useDeepMemo( options );
|
|
166
|
+
const formatting = useChartFormatting();
|
|
155
167
|
|
|
156
168
|
// `labelOverflow` and `tickResolution` are consumed by this hook rather than
|
|
157
169
|
// forwarded — visx has an axis prop for neither — and `tickFormat` is merged
|
|
@@ -202,11 +214,11 @@ export function useBarChartOptions(
|
|
|
202
214
|
// formatter, which narrows with the overall span as well as the bucket
|
|
203
215
|
// size; the tooltip stays at the bucket's own granularity.
|
|
204
216
|
const hasLabels = Boolean( data?.[ 0 ]?.data?.[ 0 ]?.label );
|
|
205
|
-
const timeTickFormatter = hasLabels ? null : getFormatter( data, tickResolution );
|
|
217
|
+
const timeTickFormatter = hasLabels ? null : getFormatter( data, tickResolution, formatting );
|
|
206
218
|
const labelFormatter = timeTickFormatter ? byBucket( timeTickFormatter ) : identity;
|
|
207
219
|
const tooltipDatumFormatter = hasLabels
|
|
208
220
|
? labelFormatter
|
|
209
|
-
: byBucket( getTooltipFormatter( data, tickResolution ) );
|
|
221
|
+
: byBucket( getTooltipFormatter( data, tickResolution, formatting ) );
|
|
210
222
|
const valueFormatter = formatNumberCompact as TickFormatter< unknown >;
|
|
211
223
|
|
|
212
224
|
const bandDomain = timeTickFormatter ? getBandDomain( data, isSeriesRendered ) : null;
|
|
@@ -244,7 +256,7 @@ export function useBarChartOptions(
|
|
|
244
256
|
yScale: bandScale,
|
|
245
257
|
},
|
|
246
258
|
};
|
|
247
|
-
}, [ data, tickResolution, isSeriesRendered ] );
|
|
259
|
+
}, [ data, tickResolution, isSeriesRendered, formatting ] );
|
|
248
260
|
|
|
249
261
|
return useMemo( () => {
|
|
250
262
|
const orientationKey = horizontal ? 'horizontal' : 'vertical';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { GlobalChartsProvider } from '../../../providers';
|
|
3
|
+
import BarChart from '../bar-chart';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
// jsdom gives every element a zero-sized box, so visx can never resolve a
|
|
7
|
+
// pointer to a datum here. Standing `XYChart` in for a prop recorder keeps this
|
|
8
|
+
// test on the part jsdom can answer: that the handlers reach the chart at all.
|
|
9
|
+
// Whether a bar then reports the datum under the pointer is a browser question.
|
|
10
|
+
const mockXYChart = jest.fn();
|
|
11
|
+
jest.mock( '@visx/xychart', () => {
|
|
12
|
+
const actual = jest.requireActual( '@visx/xychart' );
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
__esModule: true,
|
|
16
|
+
...actual,
|
|
17
|
+
XYChart: ( props: { children?: ReactNode } ) => {
|
|
18
|
+
mockXYChart( props );
|
|
19
|
+
return <svg />;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
} );
|
|
23
|
+
|
|
24
|
+
const mockRefCallback = jest.fn();
|
|
25
|
+
jest.mock( '../../../hooks/use-element-size', () => ( {
|
|
26
|
+
useElementSize: () => [ mockRefCallback, 500, 300 ],
|
|
27
|
+
} ) );
|
|
28
|
+
|
|
29
|
+
const DATA = [
|
|
30
|
+
{
|
|
31
|
+
label: 'Series A',
|
|
32
|
+
data: [
|
|
33
|
+
{ date: new Date( '2024-01-01' ), value: 10 },
|
|
34
|
+
{ date: new Date( '2024-01-02' ), value: 20 },
|
|
35
|
+
],
|
|
36
|
+
options: {},
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Render the chart with the recorder standing in for `XYChart`.
|
|
42
|
+
*
|
|
43
|
+
* @param props - Props layered onto the defaults.
|
|
44
|
+
* @return The render result.
|
|
45
|
+
*/
|
|
46
|
+
function renderBarChart( props = {} ) {
|
|
47
|
+
return render(
|
|
48
|
+
<GlobalChartsProvider>
|
|
49
|
+
<BarChart width={ 500 } height={ 300 } data={ DATA } { ...props } />
|
|
50
|
+
</GlobalChartsProvider>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe( 'BarChart pointer events', () => {
|
|
55
|
+
beforeEach( () => mockXYChart.mockClear() );
|
|
56
|
+
|
|
57
|
+
test( 'hands the pointer handlers to the chart, so a bar can be clicked', () => {
|
|
58
|
+
const onPointerDown = jest.fn();
|
|
59
|
+
const onPointerUp = jest.fn();
|
|
60
|
+
|
|
61
|
+
renderBarChart( { onPointerDown, onPointerUp } );
|
|
62
|
+
|
|
63
|
+
const props = mockXYChart.mock.calls.at( -1 )[ 0 ];
|
|
64
|
+
expect( props.onPointerDown ).toBe( onPointerDown );
|
|
65
|
+
expect( props.onPointerUp ).toBe( onPointerUp );
|
|
66
|
+
} );
|
|
67
|
+
} );
|
|
@@ -996,6 +996,85 @@ describe( 'BarChart', () => {
|
|
|
996
996
|
} );
|
|
997
997
|
} );
|
|
998
998
|
|
|
999
|
+
describe( 'Activation', () => {
|
|
1000
|
+
const SERIES_A = {
|
|
1001
|
+
label: 'Series A',
|
|
1002
|
+
data: [
|
|
1003
|
+
{ date: new Date( '2024-01-01' ), value: 10 },
|
|
1004
|
+
{ date: new Date( '2024-01-02' ), value: 20 },
|
|
1005
|
+
],
|
|
1006
|
+
options: {},
|
|
1007
|
+
};
|
|
1008
|
+
const SERIES_B = {
|
|
1009
|
+
label: 'Series B',
|
|
1010
|
+
data: [
|
|
1011
|
+
{ date: new Date( '2024-01-01' ), value: 15 },
|
|
1012
|
+
{ date: new Date( '2024-01-02' ), value: 25 },
|
|
1013
|
+
],
|
|
1014
|
+
options: {},
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
test( 'Enter hands the selected bar to onDatumActivate', async () => {
|
|
1018
|
+
const user = userEvent.setup();
|
|
1019
|
+
const onDatumActivate = jest.fn();
|
|
1020
|
+
renderWithTheme( { withTooltips: true, data: [ SERIES_A ], onDatumActivate } );
|
|
1021
|
+
|
|
1022
|
+
screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
|
|
1023
|
+
await user.keyboard( '{ArrowRight}{ArrowRight}{Enter}' );
|
|
1024
|
+
|
|
1025
|
+
expect( onDatumActivate ).toHaveBeenCalledTimes( 1 );
|
|
1026
|
+
expect( onDatumActivate ).toHaveBeenCalledWith( {
|
|
1027
|
+
datum: SERIES_A.data[ 1 ],
|
|
1028
|
+
index: 1,
|
|
1029
|
+
key: 'Series A',
|
|
1030
|
+
} );
|
|
1031
|
+
} );
|
|
1032
|
+
|
|
1033
|
+
test( 'Space activates the selected bar too', async () => {
|
|
1034
|
+
const user = userEvent.setup();
|
|
1035
|
+
const onDatumActivate = jest.fn();
|
|
1036
|
+
renderWithTheme( { withTooltips: true, data: [ SERIES_A ], onDatumActivate } );
|
|
1037
|
+
|
|
1038
|
+
screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
|
|
1039
|
+
await user.keyboard( '{ArrowRight}[Space]' );
|
|
1040
|
+
|
|
1041
|
+
expect( onDatumActivate ).toHaveBeenCalledWith( {
|
|
1042
|
+
datum: SERIES_A.data[ 0 ],
|
|
1043
|
+
index: 0,
|
|
1044
|
+
key: 'Series A',
|
|
1045
|
+
} );
|
|
1046
|
+
} );
|
|
1047
|
+
|
|
1048
|
+
// The navigation index strides series-major within each data point, the
|
|
1049
|
+
// order the highlight outlines bars in, so the second stop is the second
|
|
1050
|
+
// series' first bar.
|
|
1051
|
+
test( 'walks the series at each data point before the next point', async () => {
|
|
1052
|
+
const user = userEvent.setup();
|
|
1053
|
+
const onDatumActivate = jest.fn();
|
|
1054
|
+
renderWithTheme( { withTooltips: true, data: [ SERIES_A, SERIES_B ], onDatumActivate } );
|
|
1055
|
+
|
|
1056
|
+
screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
|
|
1057
|
+
await user.keyboard( '{ArrowRight}{ArrowRight}{Enter}' );
|
|
1058
|
+
|
|
1059
|
+
expect( onDatumActivate ).toHaveBeenCalledWith( {
|
|
1060
|
+
datum: SERIES_B.data[ 0 ],
|
|
1061
|
+
index: 0,
|
|
1062
|
+
key: 'Series B',
|
|
1063
|
+
} );
|
|
1064
|
+
} );
|
|
1065
|
+
|
|
1066
|
+
test( 'Enter with no bar selected activates nothing', async () => {
|
|
1067
|
+
const user = userEvent.setup();
|
|
1068
|
+
const onDatumActivate = jest.fn();
|
|
1069
|
+
renderWithTheme( { withTooltips: true, data: [ SERIES_A ], onDatumActivate } );
|
|
1070
|
+
|
|
1071
|
+
screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
|
|
1072
|
+
await user.keyboard( '{Enter}' );
|
|
1073
|
+
|
|
1074
|
+
expect( onDatumActivate ).not.toHaveBeenCalled();
|
|
1075
|
+
} );
|
|
1076
|
+
} );
|
|
1077
|
+
|
|
999
1078
|
describe( 'Arrow Key Navigation', () => {
|
|
1000
1079
|
test( 'right arrow key navigates to next data point', async () => {
|
|
1001
1080
|
const user = userEvent.setup();
|
|
@@ -2,8 +2,10 @@ import { formatNumberCompact } from '@automattic/number-formatters';
|
|
|
2
2
|
import { Group } from '@visx/group';
|
|
3
3
|
import { createScale, scaleBand } from '@visx/scale';
|
|
4
4
|
import { Text, type TextProps } from '@visx/text';
|
|
5
|
+
import clsx from 'clsx';
|
|
5
6
|
import { useContext, useMemo } from 'react';
|
|
6
|
-
import { GlobalChartsContext, GlobalChartsProvider } from '../../providers';
|
|
7
|
+
import { GlobalChartsContext, GlobalChartsProvider, useGlobalChartsContext } from '../../providers';
|
|
8
|
+
import { isValidHexColor, lightenHexColor } from '../../utils';
|
|
7
9
|
import { BarChartUnresponsive } from '../bar-chart';
|
|
8
10
|
import { withResponsive } from '../private/with-responsive';
|
|
9
11
|
import type { SeriesData } from '../..';
|
|
@@ -211,6 +213,14 @@ const getDefaultYOffset = (
|
|
|
211
213
|
return -( barThickness + GAP_BETWEEN_BARS );
|
|
212
214
|
};
|
|
213
215
|
|
|
216
|
+
// How far a single-series bar fill travels from white toward the series color.
|
|
217
|
+
//
|
|
218
|
+
// With one series the label is drawn *on* the bar, so the fill is a text background and has to keep
|
|
219
|
+
// contrast with `--a8c-charts-color-label`. At full strength the catalog seed `#3858e9` leaves
|
|
220
|
+
// `#1e1e1e` at 2.97:1; this puts it at 9.0:1. Multi-series bars keep full strength — the label is
|
|
221
|
+
// lifted clear of them, so nothing is read against the fill.
|
|
222
|
+
const BAR_TINT_TOWARD_SERIES = 0.4;
|
|
223
|
+
|
|
214
224
|
const BarListChartInternal: FC< BarListChartProps > = ( {
|
|
215
225
|
data,
|
|
216
226
|
width,
|
|
@@ -222,8 +232,38 @@ const BarListChartInternal: FC< BarListChartProps > = ( {
|
|
|
222
232
|
bottom: 0,
|
|
223
233
|
top: 0,
|
|
224
234
|
},
|
|
235
|
+
className,
|
|
225
236
|
...rest
|
|
226
237
|
} ) => {
|
|
238
|
+
const { getElementStyles } = useGlobalChartsContext();
|
|
239
|
+
|
|
240
|
+
// A series carrying its own stroke is left alone — the consumer asked for that exact color.
|
|
241
|
+
const tintedData = useMemo( () => {
|
|
242
|
+
if ( data.length > 1 ) {
|
|
243
|
+
return data;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return data.map( ( series, index ) => {
|
|
247
|
+
if ( series.options?.stroke ) {
|
|
248
|
+
return series;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const { color } = getElementStyles( { data: series, index } );
|
|
252
|
+
|
|
253
|
+
if ( ! isValidHexColor( color ) ) {
|
|
254
|
+
return series;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
...series,
|
|
259
|
+
options: {
|
|
260
|
+
...series.options,
|
|
261
|
+
stroke: lightenHexColor( color, 1 - BAR_TINT_TOWARD_SERIES ),
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
} );
|
|
265
|
+
}, [ data, getElementStyles ] );
|
|
266
|
+
|
|
227
267
|
const chartOptions = useMemo( () => {
|
|
228
268
|
const isMultiSeries = data.length > 1;
|
|
229
269
|
|
|
@@ -260,7 +300,10 @@ const BarListChartInternal: FC< BarListChartProps > = ( {
|
|
|
260
300
|
<BarChartUnresponsive
|
|
261
301
|
orientation="horizontal"
|
|
262
302
|
gridVisibility={ 'none' }
|
|
263
|
-
|
|
303
|
+
// The root carries `bar-chart` too, since a bar list renders through one. This class is
|
|
304
|
+
// how a consumer reaches a bar list without reaching every bar chart.
|
|
305
|
+
className={ clsx( 'bar-list-chart', className ) }
|
|
306
|
+
data={ tintedData }
|
|
264
307
|
width={ width }
|
|
265
308
|
height={ height }
|
|
266
309
|
margin={ margin }
|
|
@@ -66,4 +66,42 @@ describe( 'BarListChart', () => {
|
|
|
66
66
|
renderChart( { data: [] } );
|
|
67
67
|
expect( screen.queryByText( 'Behemoth hat ' ) ).not.toBeInTheDocument();
|
|
68
68
|
} );
|
|
69
|
+
|
|
70
|
+
describe( 'single-series bar tint', () => {
|
|
71
|
+
// visx renders the bars, so they carry no attribute of our own to query by.
|
|
72
|
+
/* eslint-disable testing-library/no-node-access */
|
|
73
|
+
const barFills = () =>
|
|
74
|
+
Array.from(
|
|
75
|
+
screen.getByRole( 'grid' ).querySelectorAll< SVGRectElement >( '.visx-bar-group rect' )
|
|
76
|
+
).map( bar => bar.getAttribute( 'fill' ) );
|
|
77
|
+
/* eslint-enable testing-library/no-node-access */
|
|
78
|
+
|
|
79
|
+
// `lightenHexColor( '#3858e9', 1 - BAR_TINT_TOWARD_SERIES )` — the catalog seed traveling
|
|
80
|
+
// 40% from white toward the series color. Asserted as a literal so a change to the tint has
|
|
81
|
+
// to be made deliberately here rather than tracking the implementation.
|
|
82
|
+
const TINTED_SEED = '#afbcf6';
|
|
83
|
+
|
|
84
|
+
test( 'tints the bar so the label it carries stays readable', () => {
|
|
85
|
+
renderChart();
|
|
86
|
+
expect( barFills() ).not.toHaveLength( 0 );
|
|
87
|
+
barFills().forEach( fill => expect( fill ).toBe( TINTED_SEED ) );
|
|
88
|
+
} );
|
|
89
|
+
|
|
90
|
+
test( 'leaves a series carrying its own stroke at full strength', () => {
|
|
91
|
+
renderChart( {
|
|
92
|
+
data: salesByProduct.map( series => ( {
|
|
93
|
+
...series,
|
|
94
|
+
options: { ...series.options, stroke: '#ff0000' },
|
|
95
|
+
} ) ),
|
|
96
|
+
} );
|
|
97
|
+
expect( barFills() ).not.toHaveLength( 0 );
|
|
98
|
+
barFills().forEach( fill => expect( fill ).toBe( '#ff0000' ) );
|
|
99
|
+
} );
|
|
100
|
+
|
|
101
|
+
test( 'leaves multi-series bars at full strength, where the label is lifted clear', () => {
|
|
102
|
+
renderChart( { data: salesByChannel } );
|
|
103
|
+
expect( barFills() ).not.toHaveLength( 0 );
|
|
104
|
+
expect( barFills() ).not.toContain( TINTED_SEED );
|
|
105
|
+
} );
|
|
106
|
+
} );
|
|
69
107
|
} );
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
.conversion-funnel-chart {
|
|
2
|
+
position: relative;
|
|
2
3
|
|
|
3
4
|
&--loading {
|
|
4
5
|
opacity: 0.6;
|
|
@@ -14,21 +15,29 @@
|
|
|
14
15
|
overflow: hidden;
|
|
15
16
|
color: var(--a8c-charts-color-label);
|
|
16
17
|
text-overflow: ellipsis;
|
|
17
|
-
font-size: var(--wpds-typography-font-size-xl
|
|
18
|
+
font-size: var(--wpds-typography-font-size-xl);
|
|
18
19
|
font-style: normal;
|
|
19
|
-
font-weight: var(--wpds-typography-font-weight-emphasis
|
|
20
|
-
line-height: var(--wpds-typography-line-height-sm
|
|
20
|
+
font-weight: var(--wpds-typography-font-weight-emphasis);
|
|
21
|
+
line-height: var(--wpds-typography-line-height-sm);
|
|
21
22
|
margin: 0;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
.change-indicator {
|
|
25
26
|
overflow: hidden;
|
|
26
27
|
text-overflow: ellipsis;
|
|
27
|
-
font-size: var(--wpds-typography-font-size-md
|
|
28
|
+
font-size: var(--wpds-typography-font-size-md);
|
|
28
29
|
font-style: normal;
|
|
29
|
-
font-weight: var(--wpds-typography-font-weight-emphasis
|
|
30
|
-
line-height: var(--wpds-typography-line-height-sm
|
|
30
|
+
font-weight: var(--wpds-typography-font-weight-emphasis);
|
|
31
|
+
line-height: var(--wpds-typography-line-height-sm);
|
|
31
32
|
margin: 0;
|
|
33
|
+
|
|
34
|
+
&--positive {
|
|
35
|
+
color: var(--a8c-charts-color-trend-up);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
&--negative {
|
|
39
|
+
color: var(--a8c-charts-color-trend-down);
|
|
40
|
+
}
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
.funnel-container {
|
|
@@ -44,8 +53,8 @@
|
|
|
44
53
|
|
|
45
54
|
&--animated {
|
|
46
55
|
transition:
|
|
47
|
-
opacity var(--wpds-motion-duration-lg
|
|
48
|
-
var(--wpds-motion-easing-subtle
|
|
56
|
+
opacity var(--wpds-motion-duration-lg)
|
|
57
|
+
var(--wpds-motion-easing-subtle);
|
|
49
58
|
}
|
|
50
59
|
|
|
51
60
|
&--blurred {
|
|
@@ -55,9 +64,9 @@
|
|
|
55
64
|
|
|
56
65
|
.step-label {
|
|
57
66
|
color: var(--a8c-charts-color-label-secondary);
|
|
58
|
-
font-size: var(--wpds-typography-font-size-sm
|
|
59
|
-
font-weight: var(--wpds-typography-font-weight-default
|
|
60
|
-
line-height: var(--wpds-typography-line-height-xs
|
|
67
|
+
font-size: var(--wpds-typography-font-size-sm);
|
|
68
|
+
font-weight: var(--wpds-typography-font-weight-default);
|
|
69
|
+
line-height: var(--wpds-typography-line-height-xs);
|
|
61
70
|
white-space: nowrap;
|
|
62
71
|
overflow: hidden;
|
|
63
72
|
text-overflow: ellipsis;
|
|
@@ -65,14 +74,15 @@
|
|
|
65
74
|
|
|
66
75
|
.step-rate {
|
|
67
76
|
color: var(--a8c-charts-color-label);
|
|
68
|
-
font-size: var(--wpds-typography-font-size-md
|
|
69
|
-
font-weight: var(--wpds-typography-font-weight-emphasis
|
|
70
|
-
line-height: var(--wpds-typography-line-height-xs
|
|
77
|
+
font-size: var(--wpds-typography-font-size-md);
|
|
78
|
+
font-weight: var(--wpds-typography-font-weight-emphasis);
|
|
79
|
+
line-height: var(--wpds-typography-line-height-xs);
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
.bar-container {
|
|
74
83
|
flex: 1;
|
|
75
84
|
border-radius: var(--a8c-charts-border-radius-bar);
|
|
85
|
+
background-color: var(--a8c-charts-color-surface-secondary);
|
|
76
86
|
position: relative;
|
|
77
87
|
cursor: pointer;
|
|
78
88
|
}
|
|
@@ -108,30 +118,30 @@
|
|
|
108
118
|
background: var(--a8c-charts-color-surface);
|
|
109
119
|
|
|
110
120
|
// Override .visx-tooltip inline styles.
|
|
111
|
-
border-radius: var(--wpds-border-radius-md
|
|
112
|
-
padding: var(--wpds-dimension-padding-md
|
|
121
|
+
border-radius: var(--wpds-border-radius-md) !important;
|
|
122
|
+
padding: var(--wpds-dimension-padding-md) !important;
|
|
113
123
|
box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005) !important;
|
|
114
124
|
|
|
115
125
|
}
|
|
116
126
|
|
|
117
127
|
.tooltip-title {
|
|
118
128
|
color: var(--a8c-charts-color-label);
|
|
119
|
-
font-size: var(--wpds-typography-font-size-sm
|
|
129
|
+
font-size: var(--wpds-typography-font-size-sm);
|
|
120
130
|
font-style: normal;
|
|
121
|
-
font-weight: var(--wpds-typography-font-weight-default
|
|
122
|
-
line-height: var(--wpds-typography-line-height-xs
|
|
131
|
+
font-weight: var(--wpds-typography-font-weight-default);
|
|
132
|
+
line-height: var(--wpds-typography-line-height-xs);
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
.tooltip-content {
|
|
126
136
|
color: var(--a8c-charts-color-label);
|
|
127
|
-
font-size: var(--wpds-typography-font-size-md
|
|
137
|
+
font-size: var(--wpds-typography-font-size-md);
|
|
128
138
|
font-style: normal;
|
|
129
|
-
font-weight: var(--wpds-typography-font-weight-emphasis
|
|
130
|
-
line-height: var(--wpds-typography-line-height-sm
|
|
139
|
+
font-weight: var(--wpds-typography-font-weight-emphasis);
|
|
140
|
+
line-height: var(--wpds-typography-line-height-sm);
|
|
131
141
|
}
|
|
132
142
|
|
|
133
143
|
.empty-state {
|
|
134
144
|
text-align: center;
|
|
135
145
|
color: var(--a8c-charts-color-label-secondary);
|
|
136
|
-
font-size: var(--wpds-typography-font-size-lg
|
|
146
|
+
font-size: var(--wpds-typography-font-size-lg);
|
|
137
147
|
}
|