@automattic/charts 3.2.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/SECURITY.md +1 -0
- package/TOKENS.md +53 -58
- package/dist/index.cjs +1483 -664
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +93 -29
- package/dist/index.d.cts +233 -247
- package/dist/index.d.ts +233 -247
- package/dist/index.js +1484 -667
- package/dist/index.js.map +1 -1
- package/package.json +7 -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 +52 -16
- package/src/charts/heatmap-chart/heatmap-chart.tsx +89 -37
- package/src/charts/heatmap-chart/index.ts +2 -1
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +126 -89
- package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
- package/src/charts/heatmap-chart/private/index.ts +1 -1
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +3 -0
- package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +142 -12
- package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
- package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
- package/src/charts/heatmap-chart/types.ts +8 -2
- package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
- 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/leaderboard-chart/types.ts +2 -2
- package/src/charts/line-chart/line-chart.module.scss +10 -18
- package/src/charts/line-chart/line-chart.tsx +128 -26
- package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
- package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
- package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +174 -41
- 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/test/zoned-date-string.test.tsx +37 -0
- package/src/charts/line-chart/types.ts +17 -3
- 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 +59 -12
- 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 +21 -17
- package/src/components/tooltip/base-tooltip.module.scss +3 -3
- package/src/components/tooltip/private/bounded-tooltip.tsx +227 -0
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +30 -4
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +225 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +379 -0
- package/src/components/tooltip/xy-chart-tooltip.tsx +326 -0
- package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
- package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
- package/src/hooks/test/use-chart-margin.test.tsx +173 -3
- package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
- package/src/hooks/use-chart-data-transform.ts +10 -3
- package/src/hooks/use-chart-margin.tsx +54 -9
- package/src/hooks/use-xychart-theme.ts +25 -9
- package/src/index.ts +8 -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/test-utils/zoned-parsing-suite.ts +90 -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 +157 -0
- package/src/utils/date-parsing.ts +103 -52
- package/src/utils/get-edge-tick-widths.ts +33 -0
- package/src/utils/index.ts +4 -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/date-parsing.auckland.test.ts +6 -0
- package/src/utils/test/date-parsing.santiago.test.ts +6 -0
- package/src/utils/test/date-parsing.test.ts +16 -1
- package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
- package/src/utils/test/get-styles.test.ts +10 -7
- package/src/utils/test/merge-themes.test.ts +18 -46
- package/src/utils/warn-once.ts +23 -0
- package/src/visx/types.ts +56 -5
- 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
|
@@ -9,11 +9,13 @@ import {
|
|
|
9
9
|
import { DataContext } from '@visx/xychart';
|
|
10
10
|
import merge from 'deepmerge';
|
|
11
11
|
import { useContext, useRef, useEffect, useState, useMemo } from 'react';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { useGlobalChartsTheme } from '../../../providers';
|
|
13
|
+
import { ANNOTATION_POINTERS } from '../../../providers/chart-context/private/catalog-pointers';
|
|
14
|
+
import { isSafari } from '../../../utils';
|
|
14
15
|
import LineChartAnnotationLabelWithPopover, {
|
|
15
16
|
POPOVER_BUTTON_SIZE,
|
|
16
17
|
} from './line-chart-annotation-label-popover';
|
|
18
|
+
import type { AnnotationStyles } from '../../../types';
|
|
17
19
|
import type { LineChartAnnotationProps } from '../types';
|
|
18
20
|
import type { LabelProps } from '@visx/annotation';
|
|
19
21
|
import type { TextProps } from '@visx/text';
|
|
@@ -152,17 +154,22 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
|
|
|
152
154
|
renderLabelPopover,
|
|
153
155
|
} ) => {
|
|
154
156
|
const providerTheme = useGlobalChartsTheme();
|
|
155
|
-
const scopeElement = useChartScopeElement();
|
|
156
157
|
const { xScale, yScale } = useContext( DataContext ) || {};
|
|
157
158
|
const labelRef = useRef< SVGGElement >( null );
|
|
158
159
|
const [ height, setHeight ] = useState< number | null >( null );
|
|
159
160
|
|
|
160
|
-
// Deep merge
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
161
|
+
// Deep merge to preserve nested object properties. Pointers first, so a per-datum color wins.
|
|
162
|
+
// Memoized because the result is spread into the visx annotation parts: a fresh object every
|
|
163
|
+
// render would give each of them new props whether or not a style changed.
|
|
164
|
+
const styles = useMemo(
|
|
165
|
+
() =>
|
|
166
|
+
merge.all( [
|
|
167
|
+
ANNOTATION_POINTERS,
|
|
168
|
+
providerTheme.annotationStyles ?? {},
|
|
169
|
+
datumStyles ?? {},
|
|
170
|
+
] ) as AnnotationStyles,
|
|
171
|
+
[ providerTheme.annotationStyles, datumStyles ]
|
|
172
|
+
);
|
|
166
173
|
|
|
167
174
|
// Measure the label height once after initial render
|
|
168
175
|
useEffect( () => {
|
|
@@ -269,14 +276,8 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
|
|
|
269
276
|
return (
|
|
270
277
|
<g data-testid={ testId }>
|
|
271
278
|
<Annotation x={ x } y={ y } dx={ dx } dy={ dy }>
|
|
272
|
-
<Connector { ...styles?.connector }
|
|
273
|
-
{ subjectType === 'circle' &&
|
|
274
|
-
<CircleSubject
|
|
275
|
-
{ ...styles?.circleSubject }
|
|
276
|
-
fill={ resolveColor( styles?.circleSubject?.fill ) }
|
|
277
|
-
stroke={ resolveColor( styles?.circleSubject?.stroke ) }
|
|
278
|
-
/>
|
|
279
|
-
) }
|
|
279
|
+
<Connector { ...styles?.connector } />
|
|
280
|
+
{ subjectType === 'circle' && <CircleSubject { ...styles?.circleSubject } /> }
|
|
280
281
|
{ subjectType === 'line-vertical' && (
|
|
281
282
|
<LineSubject
|
|
282
283
|
min={ yMax }
|
|
@@ -312,8 +313,6 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
|
|
|
312
313
|
title={ title }
|
|
313
314
|
subtitle={ subtitle }
|
|
314
315
|
{ ...styles?.label }
|
|
315
|
-
anchorLineStroke={ resolveColor( styles?.label?.anchorLineStroke ) }
|
|
316
|
-
backgroundFill={ resolveColor( styles?.label?.backgroundFill ) }
|
|
317
316
|
{ ...labelPosition }
|
|
318
317
|
horizontalAnchor={ getHorizontalAnchor( subjectType, isFlippedHorizontally ) }
|
|
319
318
|
verticalAnchor={ getVerticalAnchor(
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { LineChartProps } from '../types';
|
|
2
|
+
|
|
3
|
+
type Crosshairs = NonNullable< LineChartProps[ 'withTooltipCrosshairs' ] >;
|
|
4
|
+
|
|
5
|
+
describe( 'line chart crosshair style types', () => {
|
|
6
|
+
test( 'accepts paint attributes', () => {
|
|
7
|
+
const crosshairs: Crosshairs = { verticalStyle: { stroke: 'purple', strokeWidth: 2 } };
|
|
8
|
+
|
|
9
|
+
expect( crosshairs.verticalStyle?.stroke ).toBe( 'purple' );
|
|
10
|
+
} );
|
|
11
|
+
|
|
12
|
+
test( 'reserves geometry attributes for the chart', () => {
|
|
13
|
+
const crosshairs: Crosshairs = {
|
|
14
|
+
// @ts-expect-error Crosshair coordinates are computed from the active datum.
|
|
15
|
+
verticalStyle: { x1: 12 },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const transformedCrosshairs: Crosshairs = {
|
|
19
|
+
// @ts-expect-error Crosshair transforms are not paint attributes.
|
|
20
|
+
verticalStyle: { transform: 'translate(100 0)' },
|
|
21
|
+
};
|
|
22
|
+
const transformedStyles: Crosshairs = {
|
|
23
|
+
// @ts-expect-error Inline crosshair styles accept only paint properties.
|
|
24
|
+
horizontalStyle: { style: { transform: 'translateX(100px)' } },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
expect( crosshairs ).toBeDefined();
|
|
28
|
+
expect( transformedCrosshairs ).toBeDefined();
|
|
29
|
+
expect( transformedStyles ).toBeDefined();
|
|
30
|
+
} );
|
|
31
|
+
} );
|
|
@@ -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
|
+
} );
|
|
@@ -104,6 +104,106 @@ describe( 'LineChart', () => {
|
|
|
104
104
|
);
|
|
105
105
|
};
|
|
106
106
|
|
|
107
|
+
test.each( [ 'auto', 'below-axis' ] as const )(
|
|
108
|
+
'passes custom tooltip container and crosshair styles with %s placement',
|
|
109
|
+
async tooltipPlacement => {
|
|
110
|
+
const user = userEvent.setup();
|
|
111
|
+
renderWithTheme( {
|
|
112
|
+
withTooltipCrosshairs: {
|
|
113
|
+
showVertical: true,
|
|
114
|
+
showHorizontal: true,
|
|
115
|
+
verticalStyle: { stroke: 'purple', strokeWidth: 40, strokeOpacity: 0.12 },
|
|
116
|
+
horizontalStyle: { stroke: 'orange', strokeDasharray: '4 2' },
|
|
117
|
+
},
|
|
118
|
+
tooltipPlacement,
|
|
119
|
+
tooltipStyle: { backgroundColor: 'black', color: 'white', boxShadow: 'none' },
|
|
120
|
+
} );
|
|
121
|
+
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
122
|
+
await user.keyboard( '{ArrowRight}' );
|
|
123
|
+
|
|
124
|
+
const vertical = screen.getByTestId( 'xy-chart-tooltip-crosshair-vertical' );
|
|
125
|
+
expect( vertical ).toHaveAttribute( 'stroke', 'purple' );
|
|
126
|
+
expect( vertical ).toHaveAttribute( 'stroke-width', '40' );
|
|
127
|
+
expect( vertical ).toHaveAttribute( 'stroke-opacity', '0.12' );
|
|
128
|
+
const horizontal = screen.getByTestId( 'xy-chart-tooltip-crosshair-horizontal' );
|
|
129
|
+
expect( horizontal ).toHaveAttribute( 'stroke', 'orange' );
|
|
130
|
+
expect( horizontal ).toHaveAttribute( 'stroke-dasharray', '4 2' );
|
|
131
|
+
expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( {
|
|
132
|
+
'background-color': 'rgb(0, 0, 0)',
|
|
133
|
+
color: 'rgb(255, 255, 255)',
|
|
134
|
+
'box-shadow': 'none',
|
|
135
|
+
} );
|
|
136
|
+
expect( screen.getByTestId( 'line-chart-tooltip-content' ) ).toHaveStyle( {
|
|
137
|
+
color: 'rgb(255, 255, 255)',
|
|
138
|
+
} );
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
test.each( [
|
|
142
|
+
[
|
|
143
|
+
{ color: 'white' },
|
|
144
|
+
'rgb(255, 255, 255)',
|
|
145
|
+
'var(--a8c-charts-color-tooltip-surface, rgb(0 0 0 / 85%))',
|
|
146
|
+
{ color: 'rgb(255, 255, 255)' },
|
|
147
|
+
],
|
|
148
|
+
[ { backgroundColor: 'white' }, 'var(--a8c-charts-color-label)', 'rgb(255, 255, 255)', {} ],
|
|
149
|
+
[ { background: 'white' }, 'var(--a8c-charts-color-label)', 'white', {} ],
|
|
150
|
+
[
|
|
151
|
+
{ color: 'white', background: 'black' },
|
|
152
|
+
'rgb(255, 255, 255)',
|
|
153
|
+
'black',
|
|
154
|
+
{ color: 'rgb(255, 255, 255)' },
|
|
155
|
+
],
|
|
156
|
+
] )(
|
|
157
|
+
'applies directional tooltip color defaults: %j',
|
|
158
|
+
async ( tooltipStyle, color, background, containerColorStyle ) => {
|
|
159
|
+
const user = userEvent.setup();
|
|
160
|
+
const defaults = document.createElement( 'style' );
|
|
161
|
+
defaults.textContent = `.line-chart__tooltip {
|
|
162
|
+
color: var(--a8c-charts-color-label);
|
|
163
|
+
background: var(--a8c-charts-color-surface);
|
|
164
|
+
}`;
|
|
165
|
+
renderWithTheme( { tooltipStyle } );
|
|
166
|
+
const chart = screen.getByTestId( 'line-chart' );
|
|
167
|
+
chart.appendChild( defaults );
|
|
168
|
+
chart.style.setProperty( '--a8c-charts-color-label', '#000' );
|
|
169
|
+
chart.style.setProperty( '--a8c-charts-color-label-axis', '#aaa' );
|
|
170
|
+
chart.style.setProperty( '--a8c-charts-color-surface', '#fff' );
|
|
171
|
+
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
172
|
+
await user.keyboard( '{ArrowRight}' );
|
|
173
|
+
|
|
174
|
+
const content = screen.getByTestId( 'line-chart-tooltip-content' );
|
|
175
|
+
expect( content ).toHaveStyle( {
|
|
176
|
+
color,
|
|
177
|
+
[ 'backgroundColor' in tooltipStyle ? 'background-color' : 'background' ]:
|
|
178
|
+
'background' in tooltipStyle || 'backgroundColor' in tooltipStyle
|
|
179
|
+
? background
|
|
180
|
+
: 'var(--a8c-charts-color-surface)',
|
|
181
|
+
} );
|
|
182
|
+
const container = screen.getByTestId( 'bounded-tooltip' );
|
|
183
|
+
expect( container ).toHaveStyle( {
|
|
184
|
+
...containerColorStyle,
|
|
185
|
+
[ 'background' in tooltipStyle ? 'background' : 'background-color' ]: background,
|
|
186
|
+
} );
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
test( 'preserves the existing tooltip colors for noncolor overrides', async () => {
|
|
191
|
+
const user = userEvent.setup();
|
|
192
|
+
renderWithTheme( { tooltipStyle: { boxShadow: 'none' } } );
|
|
193
|
+
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
194
|
+
await user.keyboard( '{ArrowRight}' );
|
|
195
|
+
|
|
196
|
+
const content = screen.getByTestId( 'line-chart-tooltip-content' );
|
|
197
|
+
expect( content.style.getPropertyValue( 'color' ) ).toBe( '' );
|
|
198
|
+
expect( content.style.getPropertyValue( 'background' ) ).toBe( '' );
|
|
199
|
+
const container = screen.getByTestId( 'bounded-tooltip' );
|
|
200
|
+
expect( container ).toHaveStyle( { 'box-shadow': 'none' } );
|
|
201
|
+
expect( container.style.getPropertyValue( 'background' ) ).toBe( '' );
|
|
202
|
+
expect( container.style.getPropertyValue( 'color' ) ).not.toBe(
|
|
203
|
+
'var(--a8c-charts-color-label-inverse)'
|
|
204
|
+
);
|
|
205
|
+
} );
|
|
206
|
+
|
|
107
207
|
describe( 'Data Validation', () => {
|
|
108
208
|
test( 'handles empty data array', () => {
|
|
109
209
|
renderWithTheme( { data: [] } );
|
|
@@ -445,10 +545,11 @@ describe( 'LineChart', () => {
|
|
|
445
545
|
data: [
|
|
446
546
|
{
|
|
447
547
|
label: 'Series A',
|
|
448
|
-
//
|
|
548
|
+
// 23h apart (not 24, and not both midnight): spacing inference would
|
|
549
|
+
// read this as daily buckets, and the two hours format distinctly.
|
|
449
550
|
data: [
|
|
450
551
|
{ date: new Date( '2024-01-01T00:00:00' ), value: 10 },
|
|
451
|
-
{ date: new Date( '2024-01-
|
|
552
|
+
{ date: new Date( '2024-01-01T23:00:00' ), value: 20 },
|
|
452
553
|
],
|
|
453
554
|
},
|
|
454
555
|
],
|
|
@@ -458,6 +559,27 @@ describe( 'LineChart', () => {
|
|
|
458
559
|
expect( ticks.length ).toBeGreaterThan( 1 );
|
|
459
560
|
} );
|
|
460
561
|
|
|
562
|
+
// Exactly 24 hours apart: bare hours would print "12 AM" twice and leave the
|
|
563
|
+
// second day unnamed, so the span reads as multi-day and dates its ticks.
|
|
564
|
+
test( 'names both days when two sub-daily points sit a full day apart', () => {
|
|
565
|
+
renderWithTheme( {
|
|
566
|
+
width: 800,
|
|
567
|
+
options: { axis: { x: { tickResolution: 'hour' } } },
|
|
568
|
+
data: [
|
|
569
|
+
{
|
|
570
|
+
label: 'Series A',
|
|
571
|
+
data: [
|
|
572
|
+
{ date: new Date( '2024-01-01T00:00:00' ), value: 10 },
|
|
573
|
+
{ date: new Date( '2024-01-02T00:00:00' ), value: 20 },
|
|
574
|
+
],
|
|
575
|
+
},
|
|
576
|
+
],
|
|
577
|
+
} );
|
|
578
|
+
|
|
579
|
+
expect( screen.getByText( 'Jan 1' ) ).toBeInTheDocument();
|
|
580
|
+
expect( screen.getByText( 'Jan 2' ) ).toBeInTheDocument();
|
|
581
|
+
} );
|
|
582
|
+
|
|
461
583
|
test( 'renders ticks in short date format.', () => {
|
|
462
584
|
renderWithTheme( {
|
|
463
585
|
width: 800,
|
|
@@ -599,6 +721,8 @@ describe( 'LineChart', () => {
|
|
|
599
721
|
expect( ticks.length ).toBeLessThan( 6 ); // Not much space
|
|
600
722
|
} );
|
|
601
723
|
|
|
724
|
+
// One repeated instant carries no spacing, so the axis falls back to
|
|
725
|
+
// calendar dates rather than reading a zero gap as hourly buckets.
|
|
602
726
|
test( 'renders only one tick when all ticks are the same', () => {
|
|
603
727
|
renderWithTheme( {
|
|
604
728
|
width: 800,
|
|
@@ -614,7 +738,7 @@ describe( 'LineChart', () => {
|
|
|
614
738
|
],
|
|
615
739
|
} );
|
|
616
740
|
|
|
617
|
-
const ticks = screen.getAllByText(
|
|
741
|
+
const ticks = screen.getAllByText( /^[A-Z][a-z]{2} \d{1,2}$/ );
|
|
618
742
|
expect( ticks ).toHaveLength( 1 );
|
|
619
743
|
} );
|
|
620
744
|
} );
|
|
@@ -1201,44 +1325,53 @@ describe( 'LineChart', () => {
|
|
|
1201
1325
|
} );
|
|
1202
1326
|
|
|
1203
1327
|
describe( 'Arrow Key Navigation', () => {
|
|
1204
|
-
test( '
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1328
|
+
test.each( [ 'auto', 'below-axis' ] as const )(
|
|
1329
|
+
'right arrow key navigates with %s tooltips',
|
|
1330
|
+
async tooltipPlacement => {
|
|
1331
|
+
const user = userEvent.setup();
|
|
1332
|
+
renderWithTheme( {
|
|
1333
|
+
tooltipPlacement,
|
|
1334
|
+
data: [
|
|
1335
|
+
{
|
|
1336
|
+
label: 'Series A',
|
|
1337
|
+
data: [
|
|
1338
|
+
{ date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
|
|
1339
|
+
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
1340
|
+
],
|
|
1341
|
+
options: {},
|
|
1342
|
+
},
|
|
1343
|
+
{
|
|
1344
|
+
label: 'Series B',
|
|
1345
|
+
data: [
|
|
1346
|
+
{ date: new Date( '2024-01-01' ), value: 15, label: 'Jan 1' },
|
|
1347
|
+
{ date: new Date( '2024-01-02' ), value: 25, label: 'Jan 2' },
|
|
1348
|
+
],
|
|
1349
|
+
options: {},
|
|
1350
|
+
},
|
|
1351
|
+
],
|
|
1352
|
+
} );
|
|
1353
|
+
|
|
1354
|
+
const chart = screen.getByRole( 'grid', { name: /line chart/i } );
|
|
1355
|
+
chart.focus();
|
|
1356
|
+
|
|
1357
|
+
// Single tab should focus on the first tooltip.
|
|
1358
|
+
await user.keyboard( '{ArrowRight}' );
|
|
1359
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
|
|
1360
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveAttribute( 'role', 'tooltip' );
|
|
1361
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveAttribute(
|
|
1362
|
+
'aria-atomic',
|
|
1363
|
+
'true'
|
|
1364
|
+
);
|
|
1365
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Series A' );
|
|
1366
|
+
expect( screen.queryByTestId( 'chart-tooltip-1' ) ).not.toBeInTheDocument();
|
|
1367
|
+
|
|
1368
|
+
// Second tab should focus on the second tooltip.
|
|
1369
|
+
await user.keyboard( '{ArrowRight}' );
|
|
1370
|
+
expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveFocus();
|
|
1371
|
+
expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveTextContent( 'Series B' );
|
|
1372
|
+
expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
|
|
1373
|
+
}
|
|
1374
|
+
);
|
|
1242
1375
|
|
|
1243
1376
|
test( 'left arrow key navigates to previous data point', async () => {
|
|
1244
1377
|
const user = userEvent.setup();
|
|
@@ -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
|
+
} );
|