@automattic/charts 3.2.0 → 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 +29 -0
- package/TOKENS.md +53 -58
- package/dist/index.cjs +1012 -542
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +72 -29
- package/dist/index.d.cts +151 -179
- package/dist/index.d.ts +151 -179
- package/dist/index.js +1013 -544
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- 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 +2 -1
- 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-list-chart/bar-list-chart.tsx +5 -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 +93 -24
- 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 +27 -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/test/sparkline.test.tsx +29 -56
- package/src/charts/sparkline/types.ts +1 -1
- package/src/components/legend/private/base-legend.module.scss +9 -5
- package/src/components/tooltip/accessible-tooltip.tsx +13 -15
- 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 +2 -3
- 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.test.tsx +16 -3
- package/src/hooks/use-xychart-theme.ts +25 -9
- package/src/index.ts +4 -0
- package/src/providers/chart-context/global-charts-provider.tsx +37 -38
- 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 +3 -13
- package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
- 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 -25
- package/src/providers/chart-context/themes.ts +7 -52
- package/src/providers/chart-context/types.ts +3 -0
- package/src/styles/chart-scope.scss +39 -62
- package/src/styles/test/chart-scope.test.ts +19 -47
- package/src/test-utils/series-fixtures.ts +31 -0
- package/src/types.ts +79 -166
- 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/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 -131
- package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
- 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 -183
- package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
- package/src/styles/test/wpds-fallbacks.test.ts +0 -131
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment <rootDir>/tests/environment-los-angeles.mjs
|
|
3
|
+
*/
|
|
4
|
+
import { render, screen } from '@testing-library/react';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
6
|
+
import { GlobalChartsProvider } from '../../../providers';
|
|
7
|
+
import LineChart, { renderDefaultTooltip } from '../line-chart';
|
|
8
|
+
import type { DataPointDate } from '../../../types';
|
|
9
|
+
import type { RenderTooltipParams } from '../../../visx/types';
|
|
10
|
+
|
|
11
|
+
const mockRefCallback = jest.fn();
|
|
12
|
+
jest.mock( '../../../hooks/use-element-size', () => ( {
|
|
13
|
+
useElementSize: () => [ mockRefCallback, 500, 300 ],
|
|
14
|
+
} ) );
|
|
15
|
+
|
|
16
|
+
// The instant below is Aug 2 in Los Angeles and Aug 3 in Tokyo, so a tooltip
|
|
17
|
+
// that reads the runtime zone prints a different day from the axis.
|
|
18
|
+
|
|
19
|
+
const date = new Date( '2026-08-02T15:30:00Z' );
|
|
20
|
+
|
|
21
|
+
const params = {
|
|
22
|
+
tooltipData: {
|
|
23
|
+
nearestDatum: { key: 'Series A', datum: { date, value: 10 }, index: 0, distance: 0 },
|
|
24
|
+
datumByKey: { 'Series A': { key: 'Series A', datum: { date, value: 10 }, index: 0 } },
|
|
25
|
+
},
|
|
26
|
+
} as unknown as RenderTooltipParams< DataPointDate >;
|
|
27
|
+
|
|
28
|
+
describe( 'renderDefaultTooltip date heading', () => {
|
|
29
|
+
it( 'uses the locale and time zone from the provider', () => {
|
|
30
|
+
render(
|
|
31
|
+
<GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
|
|
32
|
+
{ renderDefaultTooltip( params ) }
|
|
33
|
+
</GlobalChartsProvider>
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect( screen.getByText( '3.8.2026' ) ).toBeInTheDocument();
|
|
37
|
+
} );
|
|
38
|
+
|
|
39
|
+
it( 'falls back to the runtime locale and zone when the provider supplies none', () => {
|
|
40
|
+
render( <GlobalChartsProvider>{ renderDefaultTooltip( params ) }</GlobalChartsProvider> );
|
|
41
|
+
|
|
42
|
+
expect( screen.getByText( '8/2/2026' ) ).toBeInTheDocument();
|
|
43
|
+
} );
|
|
44
|
+
|
|
45
|
+
// The tooltip renders through a portal, so this is the only check that the
|
|
46
|
+
// provider's context actually reaches it inside a real chart.
|
|
47
|
+
it( 'reaches the tooltip a mounted chart opens', async () => {
|
|
48
|
+
const user = userEvent.setup();
|
|
49
|
+
render(
|
|
50
|
+
<GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
|
|
51
|
+
<LineChart
|
|
52
|
+
width={ 500 }
|
|
53
|
+
height={ 300 }
|
|
54
|
+
withGradientFill={ false }
|
|
55
|
+
data={ [
|
|
56
|
+
{
|
|
57
|
+
label: 'Series A',
|
|
58
|
+
data: [
|
|
59
|
+
{ date, value: 10 },
|
|
60
|
+
{ date: new Date( date.getTime() + 24 * 60 * 60 * 1000 ), value: 20 },
|
|
61
|
+
],
|
|
62
|
+
options: {},
|
|
63
|
+
},
|
|
64
|
+
] }
|
|
65
|
+
/>
|
|
66
|
+
</GlobalChartsProvider>
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
70
|
+
await user.keyboard( '{ArrowRight}' );
|
|
71
|
+
|
|
72
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( '3.8.2026' );
|
|
73
|
+
} );
|
|
74
|
+
} );
|
|
@@ -445,10 +445,11 @@ describe( 'LineChart', () => {
|
|
|
445
445
|
data: [
|
|
446
446
|
{
|
|
447
447
|
label: 'Series A',
|
|
448
|
-
//
|
|
448
|
+
// 23h apart (not 24, and not both midnight): spacing inference would
|
|
449
|
+
// read this as daily buckets, and the two hours format distinctly.
|
|
449
450
|
data: [
|
|
450
451
|
{ date: new Date( '2024-01-01T00:00:00' ), value: 10 },
|
|
451
|
-
{ date: new Date( '2024-01-
|
|
452
|
+
{ date: new Date( '2024-01-01T23:00:00' ), value: 20 },
|
|
452
453
|
],
|
|
453
454
|
},
|
|
454
455
|
],
|
|
@@ -458,6 +459,27 @@ describe( 'LineChart', () => {
|
|
|
458
459
|
expect( ticks.length ).toBeGreaterThan( 1 );
|
|
459
460
|
} );
|
|
460
461
|
|
|
462
|
+
// Exactly 24 hours apart: bare hours would print "12 AM" twice and leave the
|
|
463
|
+
// second day unnamed, so the span reads as multi-day and dates its ticks.
|
|
464
|
+
test( 'names both days when two sub-daily points sit a full day apart', () => {
|
|
465
|
+
renderWithTheme( {
|
|
466
|
+
width: 800,
|
|
467
|
+
options: { axis: { x: { tickResolution: 'hour' } } },
|
|
468
|
+
data: [
|
|
469
|
+
{
|
|
470
|
+
label: 'Series A',
|
|
471
|
+
data: [
|
|
472
|
+
{ date: new Date( '2024-01-01T00:00:00' ), value: 10 },
|
|
473
|
+
{ date: new Date( '2024-01-02T00:00:00' ), value: 20 },
|
|
474
|
+
],
|
|
475
|
+
},
|
|
476
|
+
],
|
|
477
|
+
} );
|
|
478
|
+
|
|
479
|
+
expect( screen.getByText( 'Jan 1' ) ).toBeInTheDocument();
|
|
480
|
+
expect( screen.getByText( 'Jan 2' ) ).toBeInTheDocument();
|
|
481
|
+
} );
|
|
482
|
+
|
|
461
483
|
test( 'renders ticks in short date format.', () => {
|
|
462
484
|
renderWithTheme( {
|
|
463
485
|
width: 800,
|
|
@@ -599,6 +621,8 @@ describe( 'LineChart', () => {
|
|
|
599
621
|
expect( ticks.length ).toBeLessThan( 6 ); // Not much space
|
|
600
622
|
} );
|
|
601
623
|
|
|
624
|
+
// One repeated instant carries no spacing, so the axis falls back to
|
|
625
|
+
// calendar dates rather than reading a zero gap as hourly buckets.
|
|
602
626
|
test( 'renders only one tick when all ticks are the same', () => {
|
|
603
627
|
renderWithTheme( {
|
|
604
628
|
width: 800,
|
|
@@ -614,7 +638,7 @@ describe( 'LineChart', () => {
|
|
|
614
638
|
],
|
|
615
639
|
} );
|
|
616
640
|
|
|
617
|
-
const ticks = screen.getAllByText(
|
|
641
|
+
const ticks = screen.getAllByText( /^[A-Z][a-z]{2} \d{1,2}$/ );
|
|
618
642
|
expect( ticks ).toHaveLength( 1 );
|
|
619
643
|
} );
|
|
620
644
|
} );
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment <rootDir>/tests/environment-los-angeles.mjs
|
|
3
|
+
*/
|
|
4
|
+
import { render, screen } from '@testing-library/react';
|
|
5
|
+
import { GlobalChartsProvider } from '../../../providers';
|
|
6
|
+
import LineChart from '../line-chart';
|
|
7
|
+
|
|
8
|
+
const mockRefCallback = jest.fn();
|
|
9
|
+
jest.mock( '../../../hooks/use-element-size', () => ( {
|
|
10
|
+
useElementSize: () => [ mockRefCallback, 500, 300 ],
|
|
11
|
+
} ) );
|
|
12
|
+
|
|
13
|
+
// Each instant below is one day in Los Angeles and the next in Tokyo.
|
|
14
|
+
const data = [
|
|
15
|
+
{
|
|
16
|
+
label: 'Series A',
|
|
17
|
+
data: Array.from( { length: 8 }, ( _, index ) => ( {
|
|
18
|
+
date: new Date( new Date( '2026-08-02T15:30:00Z' ).getTime() + index * 86400000 ),
|
|
19
|
+
value: index,
|
|
20
|
+
} ) ),
|
|
21
|
+
options: {},
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// A long series and a short one, so hiding the long one leaves visx a much
|
|
26
|
+
// narrower domain than the whole dataset would give.
|
|
27
|
+
const monthly = ( label: string, count: number ) => ( {
|
|
28
|
+
label,
|
|
29
|
+
data: Array.from( { length: count }, ( _, index ) => ( {
|
|
30
|
+
date: new Date( Date.UTC( 2026, index, 1, 12 ) ),
|
|
31
|
+
value: index,
|
|
32
|
+
} ) ),
|
|
33
|
+
options: {},
|
|
34
|
+
} );
|
|
35
|
+
|
|
36
|
+
describe( 'line chart tick placement', () => {
|
|
37
|
+
it( 'places one tick per host zone calendar day, with no gap', () => {
|
|
38
|
+
render(
|
|
39
|
+
<GlobalChartsProvider locale="en-US" timeZone="Asia/Tokyo">
|
|
40
|
+
<LineChart width={ 500 } height={ 300 } withGradientFill={ false } data={ data } />
|
|
41
|
+
</GlobalChartsProvider>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const ticks = screen.getAllByText( /^[A-Z][a-z]{2} \d{1,2}$/ );
|
|
45
|
+
expect( ticks.map( tick => tick.textContent ) ).toEqual( [
|
|
46
|
+
'Aug 3',
|
|
47
|
+
'Aug 4',
|
|
48
|
+
'Aug 5',
|
|
49
|
+
'Aug 6',
|
|
50
|
+
'Aug 7',
|
|
51
|
+
'Aug 8',
|
|
52
|
+
'Aug 9',
|
|
53
|
+
'Aug 10',
|
|
54
|
+
] );
|
|
55
|
+
} );
|
|
56
|
+
|
|
57
|
+
it( 'keeps every tick inside the axis when a series is hidden', () => {
|
|
58
|
+
const { container } = render(
|
|
59
|
+
<GlobalChartsProvider locale="en-US" timeZone="Asia/Tokyo">
|
|
60
|
+
<LineChart
|
|
61
|
+
width={ 500 }
|
|
62
|
+
height={ 300 }
|
|
63
|
+
withGradientFill={ false }
|
|
64
|
+
data={ [ monthly( 'Long', 12 ), monthly( 'Short', 3 ) ] }
|
|
65
|
+
defaultHiddenSeries={ [ 'Long' ] }
|
|
66
|
+
/>
|
|
67
|
+
</GlobalChartsProvider>
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// The x axis renders before the y axis, and neither carries an orientation class.
|
|
71
|
+
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- A tick's position is an SVG attribute no query reaches. */
|
|
72
|
+
const xAxis = container.querySelectorAll( '.visx-axis' )[ 0 ];
|
|
73
|
+
const offsets = Array.from( xAxis.querySelectorAll( '.visx-axis-tick text' ) ).map( tick =>
|
|
74
|
+
Number( tick.getAttribute( 'x' ) )
|
|
75
|
+
);
|
|
76
|
+
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
|
|
77
|
+
|
|
78
|
+
expect( offsets.length ).toBeGreaterThan( 1 );
|
|
79
|
+
for ( const offset of offsets ) {
|
|
80
|
+
expect( offset ).toBeGreaterThanOrEqual( 0 );
|
|
81
|
+
expect( offset ).toBeLessThanOrEqual( 500 );
|
|
82
|
+
}
|
|
83
|
+
} );
|
|
84
|
+
} );
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment <rootDir>/tests/environment-los-angeles.mjs
|
|
3
|
+
*/
|
|
4
|
+
import { render, screen } from '@testing-library/react';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
6
|
+
import { GlobalChartsProvider } from '../../../providers';
|
|
7
|
+
import LineChart, { renderDefaultTooltip } from '../line-chart';
|
|
8
|
+
import type { BucketInfo, DataPointDate } from '../../../types';
|
|
9
|
+
import type { RenderTooltipParams } from '../../../visx/types';
|
|
10
|
+
|
|
11
|
+
const mockRefCallback = jest.fn();
|
|
12
|
+
jest.mock( '../../../hooks/use-element-size', () => ( {
|
|
13
|
+
useElementSize: () => [ mockRefCallback, 500, 300 ],
|
|
14
|
+
} ) );
|
|
15
|
+
|
|
16
|
+
const paramsFor = ( date: Date ) =>
|
|
17
|
+
( {
|
|
18
|
+
tooltipData: {
|
|
19
|
+
nearestDatum: { key: 'Series A', datum: { date, value: 10 }, index: 0, distance: 0 },
|
|
20
|
+
datumByKey: { 'Series A': { key: 'Series A', datum: { date, value: 10 }, index: 0 } },
|
|
21
|
+
},
|
|
22
|
+
} ) as unknown as RenderTooltipParams< DataPointDate >;
|
|
23
|
+
|
|
24
|
+
const HOURLY: BucketInfo = { bucket: 'hour', displayResolution: 'hour' };
|
|
25
|
+
const DAILY: BucketInfo = { bucket: 'day', displayResolution: 'day' };
|
|
26
|
+
const MONTHLY: BucketInfo = { bucket: 'month', displayResolution: 'month' };
|
|
27
|
+
const YEARLY: BucketInfo = { bucket: 'year', displayResolution: 'year' };
|
|
28
|
+
|
|
29
|
+
// 09:30 and 13:30 on the same Tokyo calendar day.
|
|
30
|
+
const morning = new Date( '2026-08-02T00:30:00Z' );
|
|
31
|
+
const afternoon = new Date( '2026-08-02T04:30:00Z' );
|
|
32
|
+
|
|
33
|
+
const headingFor = ( date: Date, bucketInfo?: BucketInfo, locale = 'en-US' ) => {
|
|
34
|
+
const { container, unmount } = render(
|
|
35
|
+
<GlobalChartsProvider locale={ locale } timeZone="Asia/Tokyo">
|
|
36
|
+
{ renderDefaultTooltip( { ...paramsFor( date ), bucketInfo } ) }
|
|
37
|
+
</GlobalChartsProvider>
|
|
38
|
+
);
|
|
39
|
+
const text = container.textContent ?? '';
|
|
40
|
+
unmount();
|
|
41
|
+
return text;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe( 'default tooltip at hourly resolution', () => {
|
|
45
|
+
it( 'distinguishes two hours of the same day', () => {
|
|
46
|
+
expect( headingFor( morning, HOURLY ) ).not.toBe( headingFor( afternoon, HOURLY ) );
|
|
47
|
+
} );
|
|
48
|
+
|
|
49
|
+
it( 'names the hour in the host time zone', () => {
|
|
50
|
+
// Full de-DE/Asia/Tokyo heading: pins the date style and the hour together.
|
|
51
|
+
expect( headingFor( morning, HOURLY, 'de-DE' ) ).toContain( '2.8.2026, 09 Uhr' );
|
|
52
|
+
} );
|
|
53
|
+
|
|
54
|
+
it( 'keeps the day in the heading at month and year resolution', () => {
|
|
55
|
+
expect( headingFor( morning, MONTHLY, 'de-DE' ) ).toContain( '2.8.2026' );
|
|
56
|
+
expect( headingFor( morning, YEARLY, 'de-DE' ) ).toContain( '2.8.2026' );
|
|
57
|
+
} );
|
|
58
|
+
|
|
59
|
+
it( 'names no hour at daily resolution', () => {
|
|
60
|
+
expect( headingFor( morning, DAILY ) ).not.toContain( 'AM' );
|
|
61
|
+
} );
|
|
62
|
+
|
|
63
|
+
it( 'renders as a date when no bucketInfo is supplied', () => {
|
|
64
|
+
render(
|
|
65
|
+
<GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
|
|
66
|
+
{ renderDefaultTooltip( paramsFor( new Date( '2026-08-02T15:30:00Z' ) ) ) }
|
|
67
|
+
</GlobalChartsProvider>
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect( screen.getByText( '3.8.2026' ) ).toBeInTheDocument();
|
|
71
|
+
} );
|
|
72
|
+
|
|
73
|
+
it( 'reaches the tooltip an hourly chart opens', async () => {
|
|
74
|
+
const user = userEvent.setup();
|
|
75
|
+
render(
|
|
76
|
+
<GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
|
|
77
|
+
<LineChart
|
|
78
|
+
width={ 500 }
|
|
79
|
+
height={ 300 }
|
|
80
|
+
withGradientFill={ false }
|
|
81
|
+
options={ { axis: { x: { tickResolution: 'hour' } } } }
|
|
82
|
+
data={ [
|
|
83
|
+
{
|
|
84
|
+
label: 'Series A',
|
|
85
|
+
data: [
|
|
86
|
+
{ date: morning, value: 10 },
|
|
87
|
+
{ date: afternoon, value: 20 },
|
|
88
|
+
],
|
|
89
|
+
options: {},
|
|
90
|
+
},
|
|
91
|
+
] }
|
|
92
|
+
/>
|
|
93
|
+
</GlobalChartsProvider>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
97
|
+
await user.keyboard( '{ArrowRight}' );
|
|
98
|
+
|
|
99
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( '2.8.2026, 09 Uhr' );
|
|
100
|
+
} );
|
|
101
|
+
|
|
102
|
+
it( 'drops the hour once the hourly series is hidden, as the axis does', async () => {
|
|
103
|
+
const user = userEvent.setup();
|
|
104
|
+
render(
|
|
105
|
+
<GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
|
|
106
|
+
<LineChart
|
|
107
|
+
width={ 500 }
|
|
108
|
+
height={ 300 }
|
|
109
|
+
withGradientFill={ false }
|
|
110
|
+
defaultHiddenSeries={ [ 'Hourly' ] }
|
|
111
|
+
data={ [
|
|
112
|
+
{
|
|
113
|
+
label: 'Hourly',
|
|
114
|
+
data: [
|
|
115
|
+
{ date: morning, value: 10 },
|
|
116
|
+
{ date: afternoon, value: 20 },
|
|
117
|
+
],
|
|
118
|
+
options: {},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
label: 'Daily',
|
|
122
|
+
data: [
|
|
123
|
+
{ date: morning, value: 5 },
|
|
124
|
+
{ date: new Date( '2026-08-03T00:30:00Z' ), value: 6 },
|
|
125
|
+
],
|
|
126
|
+
options: {},
|
|
127
|
+
},
|
|
128
|
+
] }
|
|
129
|
+
/>
|
|
130
|
+
</GlobalChartsProvider>
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
134
|
+
await user.keyboard( '{ArrowRight}' );
|
|
135
|
+
|
|
136
|
+
const tooltip = screen.getByTestId( 'chart-tooltip-0' );
|
|
137
|
+
expect( tooltip ).toHaveTextContent( '2.8.2026' );
|
|
138
|
+
expect( tooltip ).not.toHaveTextContent( 'Uhr' );
|
|
139
|
+
} );
|
|
140
|
+
} );
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
BaseChartProps,
|
|
3
|
+
BucketInfo,
|
|
3
4
|
DataPointDate,
|
|
4
5
|
SeriesData,
|
|
5
6
|
SeriesChartLegendConfig,
|
|
@@ -37,7 +38,9 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] >, SeriesVi
|
|
|
37
38
|
withGradientFill: boolean;
|
|
38
39
|
smoothing?: boolean;
|
|
39
40
|
curveType?: CurveType;
|
|
40
|
-
renderTooltip?: (
|
|
41
|
+
renderTooltip?: (
|
|
42
|
+
params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo }
|
|
43
|
+
) => ReactNode;
|
|
41
44
|
withStartGlyphs?: boolean;
|
|
42
45
|
withEndGlyphs?: boolean;
|
|
43
46
|
renderGlyph?: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
.pie-chart {
|
|
2
|
-
|
|
2
|
+
// Anchors the tooltip, which renders next to the SVG in here.
|
|
3
|
+
&__plot {
|
|
4
|
+
position: relative;
|
|
5
|
+
}
|
|
3
6
|
|
|
4
7
|
// Fill parent when no explicit width/height provided
|
|
5
8
|
&--responsive {
|
|
@@ -7,4 +10,13 @@
|
|
|
7
10
|
width: 100%;
|
|
8
11
|
}
|
|
9
12
|
|
|
13
|
+
// Both literals are load-bearing: an undeclared role makes the
|
|
14
|
+
// `fill` invalid, and `fill` starts at black.
|
|
15
|
+
&__label-plate {
|
|
16
|
+
fill: var(--a8c-charts-color-label-background, transparent);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
&__label-text {
|
|
20
|
+
fill: var(--a8c-charts-color-label-inverse, #f0f0f0);
|
|
21
|
+
}
|
|
10
22
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Group } from '@visx/group';
|
|
2
2
|
import { Pie } from '@visx/shape';
|
|
3
|
-
import { useTooltip
|
|
3
|
+
import { useTooltip } from '@visx/tooltip';
|
|
4
4
|
import clsx from 'clsx';
|
|
5
|
-
import { useCallback, useContext, useMemo } from 'react';
|
|
5
|
+
import { useCallback, useContext, useMemo, useRef } from 'react';
|
|
6
6
|
import { Legend, useChartLegendItems } from '../../components/legend';
|
|
7
7
|
import { BaseTooltip } from '../../components/tooltip';
|
|
8
|
+
import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
|
|
8
9
|
import {
|
|
9
10
|
useDataWithPercentages,
|
|
10
11
|
useLegendVisibilityData,
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
useGlobalChartsTheme,
|
|
19
20
|
GlobalChartsContext,
|
|
20
21
|
} from '../../providers';
|
|
21
|
-
import {
|
|
22
|
+
import { useStandaloneScopeClass } from '../../providers/chart-scope';
|
|
22
23
|
import { attachSubComponents, resolveFontSize } from '../../utils';
|
|
23
24
|
import { getStringWidth } from '../../visx/text';
|
|
24
25
|
import { Center } from '../private/center';
|
|
@@ -196,14 +197,10 @@ const PieChartInternal = ( {
|
|
|
196
197
|
const chartId = useChartId( providedChartId );
|
|
197
198
|
const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, hideTooltip, showTooltip } =
|
|
198
199
|
useTooltip< DataPointPercentageCalculated >();
|
|
200
|
+
const standaloneScopeClass = useStandaloneScopeClass();
|
|
199
201
|
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
const { containerRef, TooltipInPortal, containerBounds } = useTooltipInPortal( {
|
|
203
|
-
detectBounds: true,
|
|
204
|
-
scroll: true,
|
|
205
|
-
debounce: 0,
|
|
206
|
-
} );
|
|
202
|
+
// The tooltip renders inside this element, so pointer coordinates are taken relative to it.
|
|
203
|
+
const containerRef = useRef< HTMLDivElement >( null );
|
|
207
204
|
|
|
208
205
|
const onMouseLeave = useCallback( () => {
|
|
209
206
|
if ( ! withTooltips ) {
|
|
@@ -325,13 +322,6 @@ const PieChartInternal = ( {
|
|
|
325
322
|
} }
|
|
326
323
|
trailingContent={
|
|
327
324
|
<>
|
|
328
|
-
{ withTooltips && tooltipOpen && tooltipData && (
|
|
329
|
-
<TooltipInPortal top={ tooltipTop || 0 } left={ tooltipLeft || 0 }>
|
|
330
|
-
<div className={ CHART_SCOPE_CLASS } role="tooltip">
|
|
331
|
-
{ renderTooltip( { tooltipData } ) }
|
|
332
|
-
</div>
|
|
333
|
-
</TooltipInPortal>
|
|
334
|
-
) }
|
|
335
325
|
{ htmlChildren }
|
|
336
326
|
{ otherChildren }
|
|
337
327
|
</>
|
|
@@ -359,7 +349,7 @@ const PieChartInternal = ( {
|
|
|
359
349
|
: 0;
|
|
360
350
|
|
|
361
351
|
return (
|
|
362
|
-
<Center ref={ containerRef }>
|
|
352
|
+
<Center ref={ containerRef } className={ styles[ 'pie-chart__plot' ] }>
|
|
363
353
|
<svg
|
|
364
354
|
viewBox={ `0 0 ${ width } ${ height }` }
|
|
365
355
|
preserveAspectRatio="xMidYMid meet"
|
|
@@ -403,20 +393,15 @@ const PieChartInternal = ( {
|
|
|
403
393
|
return;
|
|
404
394
|
}
|
|
405
395
|
|
|
406
|
-
|
|
407
|
-
if (
|
|
396
|
+
const bounds = containerRef.current?.getBoundingClientRect();
|
|
397
|
+
if ( ! bounds ) {
|
|
408
398
|
return;
|
|
409
399
|
}
|
|
410
400
|
|
|
411
|
-
// Use clientX/Y and subtract containerBounds to cancel out any stale offset.
|
|
412
|
-
// TooltipInPortal calculates: tooltipLeft + containerBounds.left + scrollX
|
|
413
|
-
// By passing (clientX - containerBounds.left), we get:
|
|
414
|
-
// (clientX - containerBounds.left) + containerBounds.left + scrollX = clientX + scrollX
|
|
415
|
-
// This gives correct page coordinates regardless of stale bounds.
|
|
416
401
|
showTooltip( {
|
|
417
402
|
tooltipData: arc.data,
|
|
418
|
-
tooltipLeft: event.clientX -
|
|
419
|
-
tooltipTop: event.clientY -
|
|
403
|
+
tooltipLeft: event.clientX - bounds.left + tooltipOffsetX,
|
|
404
|
+
tooltipTop: event.clientY - bounds.top + tooltipOffsetY,
|
|
420
405
|
} );
|
|
421
406
|
};
|
|
422
407
|
|
|
@@ -450,23 +435,21 @@ const PieChartInternal = ( {
|
|
|
450
435
|
<path { ...pathProps } />
|
|
451
436
|
{ showLabels && hasSpaceForLabel && (
|
|
452
437
|
<g>
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
/>
|
|
464
|
-
) }
|
|
438
|
+
<rect
|
|
439
|
+
className={ styles[ 'pie-chart__label-plate' ] }
|
|
440
|
+
x={ centroidX - backgroundWidth / 2 }
|
|
441
|
+
y={ centroidY - backgroundHeight / 2 }
|
|
442
|
+
width={ backgroundWidth }
|
|
443
|
+
height={ backgroundHeight }
|
|
444
|
+
rx={ 4 }
|
|
445
|
+
ry={ 4 }
|
|
446
|
+
pointerEvents="none"
|
|
447
|
+
/>
|
|
465
448
|
<text
|
|
449
|
+
className={ styles[ 'pie-chart__label-text' ] }
|
|
466
450
|
x={ centroidX }
|
|
467
451
|
y={ centroidY }
|
|
468
452
|
dy=".33em"
|
|
469
|
-
fill={ providerTheme.labelTextColor || '#333' }
|
|
470
453
|
fontSize={ fontSize }
|
|
471
454
|
textAnchor="middle"
|
|
472
455
|
pointerEvents="none"
|
|
@@ -486,6 +469,13 @@ const PieChartInternal = ( {
|
|
|
486
469
|
{ ! allSegmentsHidden && svgChildren }
|
|
487
470
|
</Group>
|
|
488
471
|
</svg>
|
|
472
|
+
{ withTooltips && tooltipOpen && tooltipData && (
|
|
473
|
+
<BoundedTooltip top={ tooltipTop || 0 } left={ tooltipLeft || 0 }>
|
|
474
|
+
<div className={ standaloneScopeClass } role="tooltip">
|
|
475
|
+
{ renderTooltip( { tooltipData } ) }
|
|
476
|
+
</div>
|
|
477
|
+
</BoundedTooltip>
|
|
478
|
+
) }
|
|
489
479
|
</Center>
|
|
490
480
|
);
|
|
491
481
|
} }
|
|
@@ -621,3 +621,59 @@ describe( 'PieChart', () => {
|
|
|
621
621
|
} );
|
|
622
622
|
} );
|
|
623
623
|
} );
|
|
624
|
+
|
|
625
|
+
// Chart container at (100, 50); the tooltip box measures 120x40. Everything
|
|
626
|
+
// else the charts measure (wrapper, clipping lookups) reports the container.
|
|
627
|
+
const mockRects = () =>
|
|
628
|
+
jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
|
|
629
|
+
this: Element
|
|
630
|
+
) {
|
|
631
|
+
const box = this.classList.contains( 'visx-tooltip' );
|
|
632
|
+
return {
|
|
633
|
+
left: box ? 0 : 100,
|
|
634
|
+
top: box ? 0 : 50,
|
|
635
|
+
width: box ? 120 : 400,
|
|
636
|
+
height: box ? 40 : 300,
|
|
637
|
+
right: box ? 120 : 500,
|
|
638
|
+
bottom: box ? 40 : 350,
|
|
639
|
+
x: box ? 0 : 100,
|
|
640
|
+
y: box ? 0 : 50,
|
|
641
|
+
toJSON: () => ( {} ),
|
|
642
|
+
} as DOMRect;
|
|
643
|
+
} );
|
|
644
|
+
|
|
645
|
+
describe( 'PieChart tooltip position', () => {
|
|
646
|
+
afterEach( () => {
|
|
647
|
+
jest.restoreAllMocks();
|
|
648
|
+
} );
|
|
649
|
+
|
|
650
|
+
test( 'places the box relative to the chart container, at the pointer plus the offsets', async () => {
|
|
651
|
+
mockRects();
|
|
652
|
+
const user = userEvent.setup();
|
|
653
|
+
render(
|
|
654
|
+
<GlobalChartsProvider>
|
|
655
|
+
<PieChart
|
|
656
|
+
data={ [
|
|
657
|
+
{ label: 'A', value: 60, valueDisplay: '60' },
|
|
658
|
+
{ label: 'B', value: 40, valueDisplay: '40' },
|
|
659
|
+
] }
|
|
660
|
+
width={ 400 }
|
|
661
|
+
height={ 300 }
|
|
662
|
+
withTooltips
|
|
663
|
+
/>
|
|
664
|
+
</GlobalChartsProvider>
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
await user.pointer( {
|
|
668
|
+
target: screen.getAllByTestId( 'pie-segment' )[ 0 ],
|
|
669
|
+
coords: { clientX: 150, clientY: 120 },
|
|
670
|
+
} );
|
|
671
|
+
|
|
672
|
+
// Pointer at (150, 120) is (50, 70) inside the container; the default
|
|
673
|
+
// -15px vertical offset makes that (50, 55), and the box adds 10px each way.
|
|
674
|
+
await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
|
|
675
|
+
expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( {
|
|
676
|
+
transform: 'translate(60px, 65px)',
|
|
677
|
+
} );
|
|
678
|
+
} );
|
|
679
|
+
} );
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
.pie-semi-circle-chart {
|
|
2
|
+
// Anchors the tooltip, which renders next to the SVG in here.
|
|
3
|
+
&__plot {
|
|
4
|
+
position: relative;
|
|
5
|
+
}
|
|
6
|
+
|
|
2
7
|
// Fill parent when no explicit width/height provided
|
|
3
8
|
&--responsive {
|
|
4
9
|
height: 100%;
|
|
@@ -6,11 +11,11 @@
|
|
|
6
11
|
}
|
|
7
12
|
|
|
8
13
|
.label {
|
|
9
|
-
font-weight: var(--wpds-typography-font-weight-emphasis
|
|
10
|
-
font-size: var(--wpds-typography-font-size-lg
|
|
14
|
+
font-weight: var(--wpds-typography-font-weight-emphasis);
|
|
15
|
+
font-size: var(--wpds-typography-font-size-lg);
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
.note {
|
|
14
|
-
font-size: var(--wpds-typography-font-size-md
|
|
19
|
+
font-size: var(--wpds-typography-font-size-md);
|
|
15
20
|
}
|
|
16
21
|
}
|