@automattic/charts 4.0.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 +18 -0
- package/SECURITY.md +1 -0
- package/TOKENS.md +1 -1
- package/dist/index.cjs +548 -199
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +21 -0
- package/dist/index.d.cts +86 -72
- package/dist/index.d.ts +86 -72
- package/dist/index.js +549 -201
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +31 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +44 -9
- package/src/charts/heatmap-chart/index.ts +2 -1
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +126 -92
- package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
- 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 +91 -0
- 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 +6 -0
- package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
- package/src/charts/leaderboard-chart/types.ts +2 -2
- package/src/charts/line-chart/line-chart.tsx +39 -6
- package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +147 -38
- package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
- package/src/charts/line-chart/types.ts +13 -2
- package/src/charts/pie-chart/test/pie-chart.test.tsx +3 -12
- package/src/components/tooltip/accessible-tooltip.tsx +8 -2
- package/src/components/tooltip/private/bounded-tooltip.tsx +71 -16
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +28 -1
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +102 -6
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +100 -0
- package/src/components/tooltip/xy-chart-tooltip.tsx +51 -13
- 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/use-chart-data-transform.ts +10 -3
- package/src/hooks/use-chart-margin.tsx +54 -9
- package/src/index.ts +4 -0
- package/src/test-utils/zoned-parsing-suite.ts +90 -0
- package/src/types.ts +2 -2
- package/src/utils/date-formatting.ts +1 -18
- package/src/utils/date-parsing.ts +103 -52
- package/src/utils/get-edge-tick-widths.ts +33 -0
- package/src/utils/index.ts +1 -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/warn-once.ts +23 -0
- package/src/visx/types.ts +27 -3
|
@@ -58,7 +58,7 @@ import type { RenderTooltipParams } from '../../visx/types';
|
|
|
58
58
|
import type { ResponsiveConfig } from '../private/with-responsive';
|
|
59
59
|
import type { TickFormatter } from '@visx/axis';
|
|
60
60
|
import type { GlyphProps } from '@visx/xychart';
|
|
61
|
-
import type { FC, Ref } from 'react';
|
|
61
|
+
import type { CSSProperties, FC, Ref } from 'react';
|
|
62
62
|
|
|
63
63
|
const defaultRenderGlyph = < Datum extends object >( props: RenderLineGlyphProps< Datum > ) => {
|
|
64
64
|
return <DefaultGlyph { ...props } key={ props.key } />;
|
|
@@ -108,11 +108,13 @@ const TooltipDate: FC< { date?: Date; displayResolution: Exclude< TickResolution
|
|
|
108
108
|
* one row per visible series (label + formatted value), sorted descending by
|
|
109
109
|
* value. Reused by AreaChart, which has the same multi-series shape.
|
|
110
110
|
*
|
|
111
|
-
* @param params
|
|
111
|
+
* @param params - visx tooltip data and the chart's optional `bucketInfo`.
|
|
112
|
+
* @param contentStyle - Explicit tooltip content color overrides.
|
|
112
113
|
* @return Tooltip JSX, or `null` when no datum is hovered.
|
|
113
114
|
*/
|
|
114
115
|
export const renderDefaultTooltip = (
|
|
115
|
-
params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo }
|
|
116
|
+
params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo },
|
|
117
|
+
contentStyle?: Pick< CSSProperties, 'color' | 'background' | 'backgroundColor' >
|
|
116
118
|
) => {
|
|
117
119
|
const { tooltipData, bucketInfo } = params;
|
|
118
120
|
const nearestDatum = tooltipData?.nearestDatum?.datum;
|
|
@@ -126,7 +128,11 @@ export const renderDefaultTooltip = (
|
|
|
126
128
|
.sort( ( a, b ) => b.value - a.value );
|
|
127
129
|
|
|
128
130
|
return (
|
|
129
|
-
<div
|
|
131
|
+
<div
|
|
132
|
+
className={ styles[ 'line-chart__tooltip' ] }
|
|
133
|
+
data-testid="line-chart-tooltip-content"
|
|
134
|
+
style={ contentStyle }
|
|
135
|
+
>
|
|
130
136
|
<div className={ styles[ 'line-chart__tooltip-date' ] }>
|
|
131
137
|
<TooltipDate
|
|
132
138
|
date={ nearestDatum.date }
|
|
@@ -221,6 +227,8 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
221
227
|
smoothing = true,
|
|
222
228
|
curveType,
|
|
223
229
|
renderTooltip = renderDefaultTooltip,
|
|
230
|
+
tooltipPlacement,
|
|
231
|
+
tooltipStyle,
|
|
224
232
|
withStartGlyphs = false,
|
|
225
233
|
withEndGlyphs = false,
|
|
226
234
|
animation,
|
|
@@ -354,6 +362,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
354
362
|
chartRef,
|
|
355
363
|
totalPoints: dataSorted[ 0 ]?.data.length || 0,
|
|
356
364
|
onActivate: activateSelectedPoint,
|
|
365
|
+
preventTooltipScroll: tooltipPlacement === 'below-axis',
|
|
357
366
|
} );
|
|
358
367
|
|
|
359
368
|
const chartOptions = useMemo( () => {
|
|
@@ -473,12 +482,32 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
473
482
|
yAccessor: ( d: DataPointDate ) => d?.value,
|
|
474
483
|
};
|
|
475
484
|
|
|
485
|
+
const resolvedTooltipStyle = useMemo( () => {
|
|
486
|
+
if ( renderTooltip !== renderDefaultTooltip || ! tooltipStyle ) return tooltipStyle;
|
|
487
|
+
if ( ! tooltipStyle.color || tooltipStyle.background || tooltipStyle.backgroundColor ) {
|
|
488
|
+
return tooltipStyle;
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
backgroundColor: 'var(--a8c-charts-color-tooltip-surface, rgb(0 0 0 / 85%))',
|
|
492
|
+
...tooltipStyle,
|
|
493
|
+
};
|
|
494
|
+
}, [ renderTooltip, tooltipStyle ] );
|
|
495
|
+
|
|
476
496
|
// Augments every renderTooltip call with the chart's bucket classification,
|
|
477
497
|
// default or custom, so a heading keyed on it can't disagree with the axis.
|
|
478
498
|
const tooltipRenderer = useMemo(
|
|
479
499
|
() => ( params: RenderTooltipParams< DataPointDate > ) =>
|
|
480
|
-
renderTooltip
|
|
481
|
-
|
|
500
|
+
renderTooltip === renderDefaultTooltip
|
|
501
|
+
? renderDefaultTooltip(
|
|
502
|
+
{ ...params, bucketInfo },
|
|
503
|
+
{
|
|
504
|
+
color: tooltipStyle?.color,
|
|
505
|
+
background: tooltipStyle?.background,
|
|
506
|
+
backgroundColor: tooltipStyle?.backgroundColor,
|
|
507
|
+
}
|
|
508
|
+
)
|
|
509
|
+
: renderTooltip( { ...params, bucketInfo } ),
|
|
510
|
+
[ renderTooltip, bucketInfo, tooltipStyle ]
|
|
482
511
|
);
|
|
483
512
|
|
|
484
513
|
if ( error ) {
|
|
@@ -676,6 +705,8 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
676
705
|
<AccessibleTooltip
|
|
677
706
|
detectBounds
|
|
678
707
|
snapTooltipToDatumX
|
|
708
|
+
tooltipPlacement={ tooltipPlacement }
|
|
709
|
+
style={ resolvedTooltipStyle }
|
|
679
710
|
snapTooltipToDatumY
|
|
680
711
|
showSeriesGlyphs
|
|
681
712
|
renderTooltip={ tooltipRenderer }
|
|
@@ -683,6 +714,8 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
683
714
|
glyphStyle={ glyphStyle }
|
|
684
715
|
showVerticalCrosshair={ withTooltipCrosshairs?.showVertical }
|
|
685
716
|
showHorizontalCrosshair={ withTooltipCrosshairs?.showHorizontal }
|
|
717
|
+
verticalCrosshairStyle={ withTooltipCrosshairs?.verticalStyle }
|
|
718
|
+
horizontalCrosshairStyle={ withTooltipCrosshairs?.horizontalStyle }
|
|
686
719
|
selectedIndex={ selectedIndex }
|
|
687
720
|
tooltipRef={ tooltipRef }
|
|
688
721
|
keyboardFocusedClassName={
|
|
@@ -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
|
+
} );
|
|
@@ -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: [] } );
|
|
@@ -1225,44 +1325,53 @@ describe( 'LineChart', () => {
|
|
|
1225
1325
|
} );
|
|
1226
1326
|
|
|
1227
1327
|
describe( 'Arrow Key Navigation', () => {
|
|
1228
|
-
test( '
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
+
);
|
|
1266
1375
|
|
|
1267
1376
|
test( 'left arrow key navigates to previous data point', async () => {
|
|
1268
1377
|
const user = userEvent.setup();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment <rootDir>/tests/environment-auckland.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
|
+
// Naive day strings, the shape the Stats payload gives for day buckets.
|
|
14
|
+
const data = [
|
|
15
|
+
{
|
|
16
|
+
label: 'Series A',
|
|
17
|
+
data: [
|
|
18
|
+
{ dateString: '2026-08-02', value: 1 },
|
|
19
|
+
{ dateString: '2026-08-03', value: 2 },
|
|
20
|
+
{ dateString: '2026-08-04', value: 3 },
|
|
21
|
+
],
|
|
22
|
+
options: {},
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
describe( 'dateString under a host time zone', () => {
|
|
27
|
+
it( 'dates a naive string in the host zone, not the viewer browser zone', () => {
|
|
28
|
+
render(
|
|
29
|
+
<GlobalChartsProvider locale="en-US" timeZone="Asia/Tokyo">
|
|
30
|
+
<LineChart width={ 500 } height={ 300 } withGradientFill={ false } data={ data } />
|
|
31
|
+
</GlobalChartsProvider>
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const ticks = screen.getAllByText( /^[A-Z][a-z]{2} \d{1,2}$/ );
|
|
35
|
+
expect( ticks.map( tick => tick.textContent ) ).toEqual( [ 'Aug 2', 'Aug 3', 'Aug 4' ] );
|
|
36
|
+
} );
|
|
37
|
+
} );
|
|
@@ -8,9 +8,9 @@ import type {
|
|
|
8
8
|
AnnotationStyles,
|
|
9
9
|
DataPoint,
|
|
10
10
|
} from '../../types';
|
|
11
|
-
import type { RenderTooltipParams } from '../../visx/types';
|
|
11
|
+
import type { CrosshairStyle, RenderTooltipParams, XyChartTooltipProps } from '../../visx/types';
|
|
12
12
|
import type { GlyphProps } from '@visx/xychart';
|
|
13
|
-
import type { ReactNode, SVGProps, FC } from 'react';
|
|
13
|
+
import type { ReactNode, SVGProps, FC, CSSProperties } from 'react';
|
|
14
14
|
|
|
15
15
|
export type LineChartAnnotationProps = {
|
|
16
16
|
datum: DataPointDate;
|
|
@@ -41,6 +41,15 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] >, SeriesVi
|
|
|
41
41
|
renderTooltip?: (
|
|
42
42
|
params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo }
|
|
43
43
|
) => ReactNode;
|
|
44
|
+
/**
|
|
45
|
+
* Place the panel below the x-axis tick labels with a pointer at the datum x; horizontal bounds still apply.
|
|
46
|
+
* @default 'auto'
|
|
47
|
+
*/
|
|
48
|
+
tooltipPlacement?: XyChartTooltipProps< DataPointDate >[ 'tooltipPlacement' ];
|
|
49
|
+
/**
|
|
50
|
+
* Inline container styles; see Below-Axis Tooltips in stories/index.docs.mdx for content color overrides.
|
|
51
|
+
*/
|
|
52
|
+
tooltipStyle?: CSSProperties;
|
|
44
53
|
withStartGlyphs?: boolean;
|
|
45
54
|
withEndGlyphs?: boolean;
|
|
46
55
|
renderGlyph?: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode;
|
|
@@ -49,6 +58,8 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] >, SeriesVi
|
|
|
49
58
|
withTooltipCrosshairs?: {
|
|
50
59
|
showVertical?: boolean;
|
|
51
60
|
showHorizontal?: boolean;
|
|
61
|
+
verticalStyle?: CrosshairStyle;
|
|
62
|
+
horizontalStyle?: CrosshairStyle;
|
|
52
63
|
};
|
|
53
64
|
/**
|
|
54
65
|
* Enable drag-to-zoom on the X axis. The user drags horizontally to
|
|
@@ -123,18 +123,9 @@ describe( 'PieChart', () => {
|
|
|
123
123
|
test( 'hides labels when showLabels is false', () => {
|
|
124
124
|
renderWithTheme( { showLabels: false } );
|
|
125
125
|
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
// Check if this text element is not the measurement element
|
|
130
|
-
return (
|
|
131
|
-
( content === 'A' || content === 'B' ) &&
|
|
132
|
-
element?.id !== '__react_svg_text_measurement_id'
|
|
133
|
-
);
|
|
134
|
-
} );
|
|
135
|
-
|
|
136
|
-
// Labels should not be present in the rendered output (excluding measurement text)
|
|
137
|
-
expect( labelElements ).toHaveLength( 0 );
|
|
126
|
+
// A plain query, so this also fails if the measurement node stops being
|
|
127
|
+
// ignored — see tests/setup-text-measurement.js.
|
|
128
|
+
expect( screen.queryAllByText( /^[AB]$/ ) ).toHaveLength( 0 );
|
|
138
129
|
} );
|
|
139
130
|
|
|
140
131
|
test( 'shows labels when showLabels is explicitly true', () => {
|
|
@@ -200,6 +200,7 @@ interface UseKeyboardNavigationProps {
|
|
|
200
200
|
* keyboard selection the way it treats a click.
|
|
201
201
|
*/
|
|
202
202
|
onActivate?: ( index: number ) => void;
|
|
203
|
+
preventTooltipScroll?: boolean;
|
|
203
204
|
}
|
|
204
205
|
|
|
205
206
|
export const useKeyboardNavigation = ( {
|
|
@@ -210,15 +211,20 @@ export const useKeyboardNavigation = ( {
|
|
|
210
211
|
chartRef,
|
|
211
212
|
totalPoints,
|
|
212
213
|
onActivate,
|
|
214
|
+
preventTooltipScroll = false,
|
|
213
215
|
}: UseKeyboardNavigationProps ) => {
|
|
214
216
|
// Focus the tooltip as soon as it is rendered
|
|
215
217
|
const tooltipRef = useCallback(
|
|
216
218
|
( element: HTMLDivElement | null ) => {
|
|
217
219
|
if ( element && selectedIndex !== undefined ) {
|
|
218
|
-
|
|
220
|
+
if ( preventTooltipScroll ) {
|
|
221
|
+
element.focus( { preventScroll: true } );
|
|
222
|
+
} else {
|
|
223
|
+
element.focus();
|
|
224
|
+
}
|
|
219
225
|
}
|
|
220
226
|
},
|
|
221
|
-
[ selectedIndex ]
|
|
227
|
+
[ preventTooltipScroll, selectedIndex ]
|
|
222
228
|
);
|
|
223
229
|
|
|
224
230
|
// On each focus of chart, reset the selectedIndex to 0, if keyboard navigation is not already active
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Tooltip, defaultStyles } from '@visx/tooltip';
|
|
2
2
|
import { useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
+
import type { TooltipPlacement } from '../../../visx/types';
|
|
3
4
|
import type { TooltipProps } from '@visx/tooltip';
|
|
4
5
|
|
|
5
6
|
type Bounds = { left: number; top: number; right: number; bottom: number };
|
|
@@ -9,9 +10,14 @@ export type BoundedTooltipProps = Omit< TooltipProps, 'left' | 'top' | 'applyPos
|
|
|
9
10
|
/** Anchor, in the coordinates of the positioned wrapper the box renders into. */
|
|
10
11
|
left?: number;
|
|
11
12
|
top?: number;
|
|
13
|
+
placement?: TooltipPlacement;
|
|
12
14
|
};
|
|
13
15
|
|
|
14
16
|
const DEFAULT_OFFSET = 10;
|
|
17
|
+
const POINTER_HEIGHT = 6;
|
|
18
|
+
|
|
19
|
+
const clamp = ( position: number, min: number, max: number, size: number ) =>
|
|
20
|
+
Math.min( Math.max( position, min ), Math.max( min, max - size ) );
|
|
15
21
|
|
|
16
22
|
const isClipping = ( element: Element ) => {
|
|
17
23
|
const { overflow, overflowX, overflowY } = getComputedStyle( element );
|
|
@@ -33,19 +39,25 @@ const findClippingAncestor = ( wrapper: Element ): Element | null => {
|
|
|
33
39
|
};
|
|
34
40
|
|
|
35
41
|
/**
|
|
36
|
-
* Where the box goes, in wrapper coordinates.
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
42
|
+
* Where the box goes, in wrapper coordinates. Automatic placement flips to
|
|
43
|
+
* the side that clips less, then clamps inside `bounds`; below-axis placement
|
|
44
|
+
* is centered on the anchor and clamped horizontally.
|
|
45
|
+
*
|
|
46
|
+
* The flip measures against the visible part of the wrapper, its intersection
|
|
47
|
+
* with `bounds`. Only the clamp is bound by the page: a box that fits inside the
|
|
48
|
+
* chart on one side stays there, so it does not reach past the chart into
|
|
49
|
+
* siblings that paint over it.
|
|
40
50
|
*
|
|
41
|
-
* @param params - Anchor, offsets, the box size and the
|
|
51
|
+
* @param params - Anchor, offsets, the box size and the rects to keep inside.
|
|
42
52
|
* @param params.left - Anchor x, in wrapper coordinates.
|
|
43
53
|
* @param params.top - Anchor y, in wrapper coordinates.
|
|
44
54
|
* @param params.offsetLeft - Gap between the anchor and the box, horizontally.
|
|
45
55
|
* @param params.offsetTop - Gap between the anchor and the box, vertically.
|
|
46
56
|
* @param params.box - Rendered size of the box.
|
|
57
|
+
* @param params.wrapper - The wrapper's own edges, in wrapper coordinates.
|
|
58
|
+
* @param params.placement - Fixed below-axis placement or automatic flipping.
|
|
47
59
|
* @param params.bounds - Edges the box must keep inside, in wrapper coordinates.
|
|
48
|
-
* @return The box's top-left corner
|
|
60
|
+
* @return The box's top-left corner; fixed placement preserves the axis's subpixel y.
|
|
49
61
|
*/
|
|
50
62
|
export const getBoundedPosition = ( {
|
|
51
63
|
left,
|
|
@@ -53,31 +65,53 @@ export const getBoundedPosition = ( {
|
|
|
53
65
|
offsetLeft,
|
|
54
66
|
offsetTop,
|
|
55
67
|
box,
|
|
68
|
+
wrapper,
|
|
56
69
|
bounds,
|
|
70
|
+
placement = 'auto',
|
|
57
71
|
}: {
|
|
58
72
|
left: number;
|
|
59
73
|
top: number;
|
|
60
74
|
offsetLeft: number;
|
|
61
75
|
offsetTop: number;
|
|
62
76
|
box: Size;
|
|
77
|
+
wrapper: Bounds;
|
|
63
78
|
bounds: Bounds;
|
|
79
|
+
placement?: BoundedTooltipProps[ 'placement' ];
|
|
64
80
|
} ) => {
|
|
81
|
+
if ( placement === 'below-axis' ) {
|
|
82
|
+
return {
|
|
83
|
+
x: Math.round( clamp( left - box.width / 2, bounds.left, bounds.right, box.width ) ),
|
|
84
|
+
y: top + POINTER_HEIGHT,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The part of the wrapper that is not cut off: the flip keeps the box inside it.
|
|
89
|
+
const fit = {
|
|
90
|
+
left: Math.max( wrapper.left, bounds.left ),
|
|
91
|
+
top: Math.max( wrapper.top, bounds.top ),
|
|
92
|
+
right: Math.min( wrapper.right, bounds.right ),
|
|
93
|
+
bottom: Math.min( wrapper.bottom, bounds.bottom ),
|
|
94
|
+
};
|
|
95
|
+
|
|
65
96
|
const rightX = left + offsetLeft;
|
|
66
97
|
const leftX = left - offsetLeft - box.width;
|
|
67
|
-
const rightOverflow = rightX + box.width -
|
|
68
|
-
const leftOverflow =
|
|
98
|
+
const rightOverflow = rightX + box.width - fit.right;
|
|
99
|
+
const leftOverflow = fit.left - leftX;
|
|
69
100
|
let x = rightOverflow > 0 && rightOverflow > leftOverflow ? leftX : rightX;
|
|
70
101
|
|
|
71
102
|
const downY = top + offsetTop;
|
|
72
103
|
const upY = top - offsetTop - box.height;
|
|
73
|
-
const downOverflow = downY + box.height -
|
|
74
|
-
const upOverflow =
|
|
104
|
+
const downOverflow = downY + box.height - fit.bottom;
|
|
105
|
+
const upOverflow = fit.top - upY;
|
|
75
106
|
let y = downOverflow > 0 && downOverflow > upOverflow ? upY : downY;
|
|
76
107
|
|
|
77
|
-
x =
|
|
78
|
-
y =
|
|
108
|
+
x = clamp( x, bounds.left, bounds.right, box.width );
|
|
109
|
+
y = clamp( y, bounds.top, bounds.bottom, box.height );
|
|
79
110
|
|
|
80
|
-
return {
|
|
111
|
+
return {
|
|
112
|
+
x: Math.round( x ),
|
|
113
|
+
y: Math.round( y ),
|
|
114
|
+
};
|
|
81
115
|
};
|
|
82
116
|
|
|
83
117
|
/**
|
|
@@ -88,7 +122,8 @@ export const getBoundedPosition = ( {
|
|
|
88
122
|
* alone would let the box spill into an `overflow: hidden` card and be cut off.
|
|
89
123
|
*
|
|
90
124
|
* Re-measures on every render, so a box whose content changes width between
|
|
91
|
-
* two hovers is placed for its current size.
|
|
125
|
+
* two hovers is placed for its current size. See `getBoundedPosition` for the
|
|
126
|
+
* below-axis exception.
|
|
92
127
|
*
|
|
93
128
|
* @param props - visx `Tooltip` props.
|
|
94
129
|
* @param props.left - Anchor x, in wrapper coordinates.
|
|
@@ -98,6 +133,7 @@ export const getBoundedPosition = ( {
|
|
|
98
133
|
* @param props.style - Box styles; visx's defaults unless `unstyled`.
|
|
99
134
|
* @param props.unstyled - Skip `style` and leave the box bare.
|
|
100
135
|
* @param props.children - Box content.
|
|
136
|
+
* @param props.placement - Fixed below-axis placement or automatic flipping.
|
|
101
137
|
* @return The tooltip box.
|
|
102
138
|
*/
|
|
103
139
|
export const BoundedTooltip = ( {
|
|
@@ -108,6 +144,7 @@ export const BoundedTooltip = ( {
|
|
|
108
144
|
style = defaultStyles,
|
|
109
145
|
unstyled = false,
|
|
110
146
|
children,
|
|
147
|
+
placement = 'auto',
|
|
111
148
|
...rest
|
|
112
149
|
}: BoundedTooltipProps ) => {
|
|
113
150
|
const nodeRef = useRef< HTMLDivElement >( null );
|
|
@@ -136,7 +173,9 @@ export const BoundedTooltip = ( {
|
|
|
136
173
|
top,
|
|
137
174
|
offsetLeft,
|
|
138
175
|
offsetTop,
|
|
176
|
+
placement,
|
|
139
177
|
box: { width: own.width, height: own.height },
|
|
178
|
+
wrapper: { left: 0, top: 0, right: wrapperRect.width, bottom: wrapperRect.height },
|
|
140
179
|
bounds: {
|
|
141
180
|
left: clipRect.left - wrapperRect.left,
|
|
142
181
|
top: clipRect.top - wrapperRect.top,
|
|
@@ -148,10 +187,10 @@ export const BoundedTooltip = ( {
|
|
|
148
187
|
current && current.x === next.x && current.y === next.y ? current : next
|
|
149
188
|
);
|
|
150
189
|
// `children` re-runs the measurement when the content, and so the box size, changes.
|
|
151
|
-
}, [ left, top, offsetLeft, offsetTop, children ] );
|
|
190
|
+
}, [ left, top, offsetLeft, offsetTop, children, placement ] );
|
|
152
191
|
|
|
153
192
|
const x = position?.x ?? left + offsetLeft;
|
|
154
|
-
const y = position?.y ?? top + offsetTop;
|
|
193
|
+
const y = position?.y ?? top + ( placement === 'below-axis' ? POINTER_HEIGHT : offsetTop );
|
|
155
194
|
|
|
156
195
|
return (
|
|
157
196
|
<Tooltip
|
|
@@ -166,6 +205,22 @@ export const BoundedTooltip = ( {
|
|
|
166
205
|
} }
|
|
167
206
|
{ ...rest }
|
|
168
207
|
>
|
|
208
|
+
{ placement === 'below-axis' && (
|
|
209
|
+
<span
|
|
210
|
+
aria-hidden="true"
|
|
211
|
+
data-testid="tooltip-axis-pointer"
|
|
212
|
+
style={ {
|
|
213
|
+
position: 'absolute',
|
|
214
|
+
left: left - x - POINTER_HEIGHT,
|
|
215
|
+
top: -POINTER_HEIGHT,
|
|
216
|
+
width: POINTER_HEIGHT * 2,
|
|
217
|
+
height: POINTER_HEIGHT,
|
|
218
|
+
background: 'inherit',
|
|
219
|
+
clipPath: 'polygon(50% 0, 100% 100%, 0 100%)',
|
|
220
|
+
pointerEvents: 'none',
|
|
221
|
+
} }
|
|
222
|
+
/>
|
|
223
|
+
) }
|
|
169
224
|
{ children }
|
|
170
225
|
</Tooltip>
|
|
171
226
|
);
|