@automattic/charts 4.0.0 → 4.1.1
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 +27 -0
- package/SECURITY.md +1 -0
- package/TOKENS.md +7 -5
- package/dist/index.cjs +614 -259
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +30 -0
- package/dist/index.d.cts +88 -74
- package/dist/index.d.ts +88 -74
- package/dist/index.js +615 -261
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
- package/src/charts/area-chart/area-chart.tsx +2 -1
- package/src/charts/area-chart/test/area-chart.test.tsx +31 -0
- package/src/charts/heatmap-chart/build-calendar-data.ts +189 -0
- 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 +10 -3
- package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
- package/src/charts/heatmap-chart/private/index.ts +0 -2
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +4 -1
- 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 +35 -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 +41 -7
- package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +178 -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 +40 -12
- package/src/components/tooltip/private/bounded-tooltip.tsx +71 -16
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +60 -1
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +102 -6
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +122 -3
- package/src/components/tooltip/xy-chart-tooltip.tsx +53 -14
- 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/styles/chart-scope.scss +11 -3
- package/src/styles/test/chart-scope.test.ts +35 -1
- 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
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +0 -179
|
@@ -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
|
);
|
|
@@ -6,7 +6,7 @@ import { ChartScopeContext } from '../../../providers/chart-scope';
|
|
|
6
6
|
import type { ReactNode } from 'react';
|
|
7
7
|
|
|
8
8
|
// A real chart is the harness rather than the subject: the crosshairs render only once visx has a data context and an open tooltip. The unresponsive export is what lets the test own the scope element — `withResponsive` otherwise provides its own wrapper as the scope.
|
|
9
|
-
const renderChart = ( scope?: HTMLElement ) => {
|
|
9
|
+
const renderChart = ( scope?: HTMLElement, tooltipPlacement?: 'auto' | 'below-axis' ) => {
|
|
10
10
|
const chart = (
|
|
11
11
|
<LineChartUnresponsive
|
|
12
12
|
width={ 500 }
|
|
@@ -22,6 +22,7 @@ const renderChart = ( scope?: HTMLElement ) => {
|
|
|
22
22
|
},
|
|
23
23
|
] }
|
|
24
24
|
withTooltips
|
|
25
|
+
tooltipPlacement={ tooltipPlacement }
|
|
25
26
|
withTooltipCrosshairs={ { showVertical: true } }
|
|
26
27
|
withGradientFill={ false }
|
|
27
28
|
/>
|
|
@@ -59,6 +60,64 @@ describe( 'AccessibleTooltip', () => {
|
|
|
59
60
|
document.body.removeChild( scope );
|
|
60
61
|
} );
|
|
61
62
|
|
|
63
|
+
it( 'keeps the existing focus scrolling for automatic tooltips', async () => {
|
|
64
|
+
const focus = jest.spyOn( HTMLElement.prototype, 'focus' );
|
|
65
|
+
try {
|
|
66
|
+
renderChart();
|
|
67
|
+
await openTooltip();
|
|
68
|
+
|
|
69
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
|
|
70
|
+
expect( focus ).toHaveBeenLastCalledWith();
|
|
71
|
+
} finally {
|
|
72
|
+
focus.mockRestore();
|
|
73
|
+
}
|
|
74
|
+
} );
|
|
75
|
+
|
|
76
|
+
it( 'focuses below-axis keyboard tooltips without scrolling their ancestors', async () => {
|
|
77
|
+
const focus = jest.spyOn( HTMLElement.prototype, 'focus' );
|
|
78
|
+
try {
|
|
79
|
+
renderChart( undefined, 'below-axis' );
|
|
80
|
+
await openTooltip();
|
|
81
|
+
|
|
82
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
|
|
83
|
+
expect( focus ).toHaveBeenCalledWith( { preventScroll: true } );
|
|
84
|
+
} finally {
|
|
85
|
+
focus.mockRestore();
|
|
86
|
+
}
|
|
87
|
+
} );
|
|
88
|
+
|
|
89
|
+
it.each( [
|
|
90
|
+
[ 'Tab', '{Tab}' ],
|
|
91
|
+
[ 'Escape', '{Escape}' ],
|
|
92
|
+
] )( 'requests scroll suppression when returning focus after %s', async ( _name, keys ) => {
|
|
93
|
+
const user = userEvent.setup();
|
|
94
|
+
renderChart( undefined, 'below-axis' );
|
|
95
|
+
await openTooltip();
|
|
96
|
+
const focus = jest.spyOn( screen.getByRole( 'grid' ), 'focus' );
|
|
97
|
+
try {
|
|
98
|
+
await user.keyboard( keys );
|
|
99
|
+
expect( focus ).toHaveBeenCalledWith( { preventScroll: true } );
|
|
100
|
+
} finally {
|
|
101
|
+
focus.mockRestore();
|
|
102
|
+
}
|
|
103
|
+
} );
|
|
104
|
+
|
|
105
|
+
it.each( [
|
|
106
|
+
[ 'Tab', '{Tab}' ],
|
|
107
|
+
[ 'Escape', '{Escape}' ],
|
|
108
|
+
] )( 'keeps default focus scrolling when returning focus after %s', async ( _name, keys ) => {
|
|
109
|
+
const user = userEvent.setup();
|
|
110
|
+
renderChart();
|
|
111
|
+
await openTooltip();
|
|
112
|
+
const focus = jest.spyOn( screen.getByRole( 'grid' ), 'focus' );
|
|
113
|
+
try {
|
|
114
|
+
await user.keyboard( keys );
|
|
115
|
+
expect( focus ).toHaveBeenCalledWith();
|
|
116
|
+
} finally {
|
|
117
|
+
focus.mockRestore();
|
|
118
|
+
}
|
|
119
|
+
} );
|
|
120
|
+
|
|
62
121
|
it( 'falls back to the catalog default when the role is unset', async () => {
|
|
63
122
|
renderChart();
|
|
64
123
|
|
|
@@ -12,6 +12,7 @@ describe( 'getBoundedPosition', () => {
|
|
|
12
12
|
top: 20,
|
|
13
13
|
...OFFSETS,
|
|
14
14
|
box: BOX,
|
|
15
|
+
wrapper: { left: 0, top: 0, right: 600, bottom: 300 },
|
|
15
16
|
bounds: { left: 0, top: 0, right: 600, bottom: 300 },
|
|
16
17
|
} )
|
|
17
18
|
).toEqual( { x: 50, y: 30 } );
|
|
@@ -24,6 +25,7 @@ describe( 'getBoundedPosition', () => {
|
|
|
24
25
|
top: 280,
|
|
25
26
|
...OFFSETS,
|
|
26
27
|
box: BOX,
|
|
28
|
+
wrapper: { left: 0, top: 0, right: 600, bottom: 300 },
|
|
27
29
|
bounds: { left: 0, top: 0, right: 600, bottom: 300 },
|
|
28
30
|
} )
|
|
29
31
|
).toEqual( { x: 282, y: 234 } );
|
|
@@ -38,23 +40,54 @@ describe( 'getBoundedPosition', () => {
|
|
|
38
40
|
top: 50,
|
|
39
41
|
...OFFSETS,
|
|
40
42
|
box: BOX,
|
|
43
|
+
wrapper: { left: 0, top: 0, right: 346, bottom: 200 },
|
|
41
44
|
bounds: { left: 0, top: 0, right: 346, bottom: 200 },
|
|
42
45
|
} )
|
|
43
46
|
).toEqual( { x: 138, y: 60 } );
|
|
44
47
|
} );
|
|
45
48
|
|
|
46
49
|
test( 'may leave the wrapper as long as it stays inside the clipping ancestor', () => {
|
|
47
|
-
//
|
|
48
|
-
//
|
|
50
|
+
// Neither side fits the 346px wrapper, and the clipping card ends 14px
|
|
51
|
+
// past its right edge, so the box keeps the near side and stops there.
|
|
49
52
|
expect(
|
|
50
53
|
getBoundedPosition( {
|
|
51
54
|
left: 173,
|
|
52
55
|
top: 50,
|
|
53
56
|
...OFFSETS,
|
|
54
57
|
box: BOX,
|
|
58
|
+
wrapper: { left: 0, top: 0, right: 346, bottom: 200 },
|
|
55
59
|
bounds: { left: -40, top: -60, right: 360, bottom: 200 },
|
|
56
60
|
} )
|
|
57
|
-
).toEqual( { x:
|
|
61
|
+
).toEqual( { x: 152, y: 60 } );
|
|
62
|
+
} );
|
|
63
|
+
|
|
64
|
+
test( 'flips up to stay inside the wrapper even when the page below has room', () => {
|
|
65
|
+
// The box fits above the anchor inside the 200px wrapper, so it goes
|
|
66
|
+
// there rather than below the chart, where the page would cover it.
|
|
67
|
+
expect(
|
|
68
|
+
getBoundedPosition( {
|
|
69
|
+
left: 40,
|
|
70
|
+
top: 180,
|
|
71
|
+
...OFFSETS,
|
|
72
|
+
box: BOX,
|
|
73
|
+
wrapper: { left: 0, top: 0, right: 600, bottom: 200 },
|
|
74
|
+
bounds: { left: 0, top: 0, right: 1024, bottom: 768 },
|
|
75
|
+
} )
|
|
76
|
+
).toEqual( { x: 50, y: 134 } );
|
|
77
|
+
} );
|
|
78
|
+
|
|
79
|
+
test( 'flips up when the wrapper fits below but the clipping ancestor does not', () => {
|
|
80
|
+
// A scrolled container cuts the wrapper off 20px under the anchor.
|
|
81
|
+
expect(
|
|
82
|
+
getBoundedPosition( {
|
|
83
|
+
left: 40,
|
|
84
|
+
top: 100,
|
|
85
|
+
...OFFSETS,
|
|
86
|
+
box: BOX,
|
|
87
|
+
wrapper: { left: 0, top: 0, right: 600, bottom: 300 },
|
|
88
|
+
bounds: { left: 0, top: -50, right: 600, bottom: 120 },
|
|
89
|
+
} )
|
|
90
|
+
).toEqual( { x: 50, y: 54 } );
|
|
58
91
|
} );
|
|
59
92
|
} );
|
|
60
93
|
|
|
@@ -100,11 +133,74 @@ describe( 'BoundedTooltip', () => {
|
|
|
100
133
|
</div>
|
|
101
134
|
);
|
|
102
135
|
|
|
103
|
-
// Neither side of the anchor fits
|
|
104
|
-
// the card's
|
|
105
|
-
expect( screen.getByTestId( 'box' ) ).toHaveStyle( { transform: 'translate(
|
|
136
|
+
// Neither side of the anchor fits the wrapper, so the box keeps the near
|
|
137
|
+
// side and stops at the card's right edge, 14px outside the wrapper.
|
|
138
|
+
expect( screen.getByTestId( 'box' ) ).toHaveStyle( { transform: 'translate(152px, 60px)' } );
|
|
106
139
|
} );
|
|
107
140
|
|
|
141
|
+
test( 'keeps the box inside the wrapper when nothing clips and the anchor sits low', () => {
|
|
142
|
+
jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
|
|
143
|
+
this: Element
|
|
144
|
+
) {
|
|
145
|
+
return this.classList.contains( 'visx-tooltip' )
|
|
146
|
+
? rect( 0, 0, 150, 100 )
|
|
147
|
+
: rect( 100, 100, 600, 200 );
|
|
148
|
+
} );
|
|
149
|
+
|
|
150
|
+
render(
|
|
151
|
+
<div style={ { position: 'relative' } }>
|
|
152
|
+
<BoundedTooltip left={ 300 } top={ 180 } data-testid="box">
|
|
153
|
+
content
|
|
154
|
+
</BoundedTooltip>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// The viewport has room below the wrapper, but that is where the page's
|
|
159
|
+
// own content paints over the box; above the anchor it fits in the chart.
|
|
160
|
+
expect( screen.getByTestId( 'box' ) ).toHaveStyle( { transform: 'translate(310px, 70px)' } );
|
|
161
|
+
} );
|
|
162
|
+
|
|
163
|
+
test.each( [
|
|
164
|
+
{ anchor: 20, x: 0, pointerLeft: 14 },
|
|
165
|
+
{ anchor: 150, x: 46, pointerLeft: 98 },
|
|
166
|
+
{ anchor: 280, x: 92, pointerLeft: 182 },
|
|
167
|
+
] )(
|
|
168
|
+
'keeps the below-axis pointer at x=$anchor after clamping',
|
|
169
|
+
( { anchor, x, pointerLeft } ) => {
|
|
170
|
+
jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
|
|
171
|
+
this: Element
|
|
172
|
+
) {
|
|
173
|
+
return this.classList.contains( 'visx-tooltip' )
|
|
174
|
+
? rect( 0, 0, 208, 36 )
|
|
175
|
+
: rect( 0, 0, 300, 100 );
|
|
176
|
+
} );
|
|
177
|
+
|
|
178
|
+
render(
|
|
179
|
+
<div style={ { overflow: 'hidden' } }>
|
|
180
|
+
<div style={ { position: 'relative' } }>
|
|
181
|
+
<BoundedTooltip placement="below-axis" left={ anchor } top={ 100 } data-testid="box">
|
|
182
|
+
content
|
|
183
|
+
</BoundedTooltip>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
expect( screen.getByTestId( 'box' ) ).toHaveStyle( {
|
|
189
|
+
transform: `translate(${ x }px, 106px)`,
|
|
190
|
+
} );
|
|
191
|
+
expect( screen.getByTestId( 'tooltip-axis-pointer' ) ).toHaveStyle( {
|
|
192
|
+
left: `${ pointerLeft }px`,
|
|
193
|
+
top: '-6px',
|
|
194
|
+
width: '12px',
|
|
195
|
+
height: '6px',
|
|
196
|
+
} );
|
|
197
|
+
expect( screen.getByTestId( 'tooltip-axis-pointer' ) ).toHaveAttribute(
|
|
198
|
+
'aria-hidden',
|
|
199
|
+
'true'
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
|
|
108
204
|
test( 'falls back to the viewport when nothing clips', () => {
|
|
109
205
|
jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
|
|
110
206
|
this: Element
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
|
-
import { BarSeries, LineSeries, TooltipContext, XYChart } from '@visx/xychart';
|
|
2
|
+
import { BarSeries, buildChartTheme, LineSeries, TooltipContext, XYChart } from '@visx/xychart';
|
|
3
3
|
import { useContext, useEffect } from 'react';
|
|
4
4
|
import { XyChartTooltip } from '../xy-chart-tooltip';
|
|
5
5
|
import type { XyChartTooltipProps } from '../../../visx/types';
|
|
6
|
-
import type { EventHandlerParams } from '@visx/xychart';
|
|
6
|
+
import type { EventHandlerParams, XYChartTheme } from '@visx/xychart';
|
|
7
7
|
|
|
8
8
|
type Datum = { x: number; y: number };
|
|
9
9
|
|
|
@@ -76,7 +76,8 @@ const renderChart = (
|
|
|
76
76
|
params: EventHandlerParams< Datum >[] = [
|
|
77
77
|
{ key: 'A', index: 1, datum: SERIES_A[ 1 ], svgPoint: { x: 123, y: 45 } },
|
|
78
78
|
],
|
|
79
|
-
margin = NO_MARGIN
|
|
79
|
+
margin = NO_MARGIN,
|
|
80
|
+
theme?: XYChartTheme
|
|
80
81
|
) =>
|
|
81
82
|
render(
|
|
82
83
|
<div data-testid="wrapper" style={ { position: 'relative' } }>
|
|
@@ -84,6 +85,7 @@ const renderChart = (
|
|
|
84
85
|
width={ 200 }
|
|
85
86
|
height={ 100 }
|
|
86
87
|
margin={ margin }
|
|
88
|
+
theme={ theme }
|
|
87
89
|
xScale={ { type: 'linear', domain: [ 0, 10 ] } }
|
|
88
90
|
yScale={ { type: 'linear', domain: [ 0, 100 ] } }
|
|
89
91
|
>
|
|
@@ -100,6 +102,23 @@ const renderChart = (
|
|
|
100
102
|
);
|
|
101
103
|
|
|
102
104
|
describe( 'XyChartTooltip', () => {
|
|
105
|
+
test.each( [
|
|
106
|
+
[ 'CanvasText', '0 1px 2px #22222255' ],
|
|
107
|
+
[ '#123456', '0 1px 2px #12345655' ],
|
|
108
|
+
] )( 'renders a valid shadow for htmlLabel color %s', async ( color, boxShadow ) => {
|
|
109
|
+
const theme = buildChartTheme( {
|
|
110
|
+
backgroundColor: '#ffffff',
|
|
111
|
+
colors: [ '#123456' ],
|
|
112
|
+
tickLength: 4,
|
|
113
|
+
gridColor: '#dddddd',
|
|
114
|
+
gridColorDark: '#222222',
|
|
115
|
+
htmlLabel: { color },
|
|
116
|
+
} );
|
|
117
|
+
renderChart( {}, undefined, undefined, theme );
|
|
118
|
+
|
|
119
|
+
await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { boxShadow } );
|
|
120
|
+
} );
|
|
121
|
+
|
|
103
122
|
test( 'renders the tooltip box inside the chart wrapper, not in a body-level portal', async () => {
|
|
104
123
|
const { container } = renderChart();
|
|
105
124
|
|
|
@@ -131,6 +150,54 @@ describe( 'XyChartTooltip', () => {
|
|
|
131
150
|
);
|
|
132
151
|
} );
|
|
133
152
|
|
|
153
|
+
test( 'anchors below the x-axis label band without moving datum glyphs or crosshairs', async () => {
|
|
154
|
+
const originalRect = Element.prototype.getBoundingClientRect;
|
|
155
|
+
const rectSpy = jest
|
|
156
|
+
.spyOn( Element.prototype, 'getBoundingClientRect' )
|
|
157
|
+
.mockImplementation( function ( this: Element ) {
|
|
158
|
+
const rect = originalRect.call( this );
|
|
159
|
+
return this.classList.contains( 'visx-tooltip' )
|
|
160
|
+
? { ...rect, width: 80, height: 30, right: rect.left + 80, bottom: rect.top + 30 }
|
|
161
|
+
: rect;
|
|
162
|
+
} );
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
renderChart(
|
|
166
|
+
{
|
|
167
|
+
tooltipPlacement: 'below-axis',
|
|
168
|
+
snapTooltipToDatumY: true,
|
|
169
|
+
showDatumGlyph: true,
|
|
170
|
+
showVerticalCrosshair: true,
|
|
171
|
+
showHorizontalCrosshair: true,
|
|
172
|
+
},
|
|
173
|
+
undefined,
|
|
174
|
+
{ top: 10, right: 20, bottom: 30, left: 40 }
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const box = await screen.findByTestId( 'tooltip-box' );
|
|
178
|
+
expect( box ).toHaveStyle( { transform: 'translate(70px, 106px)' } );
|
|
179
|
+
expect( screen.getByTestId( 'wrapper' ) ).toContainElement( box );
|
|
180
|
+
expect( screen.getByTestId( 'tooltip-axis-pointer' ) ).toHaveStyle( {
|
|
181
|
+
left: '34px',
|
|
182
|
+
top: '-6px',
|
|
183
|
+
} );
|
|
184
|
+
expect( screen.getByTestId( 'xy-chart-tooltip-glyph-group-A' ) ).toHaveAttribute(
|
|
185
|
+
'transform',
|
|
186
|
+
'translate(110, 40)'
|
|
187
|
+
);
|
|
188
|
+
expect( screen.getByTestId( 'xy-chart-tooltip-crosshair-vertical' ) ).toHaveAttribute(
|
|
189
|
+
'y2',
|
|
190
|
+
'70'
|
|
191
|
+
);
|
|
192
|
+
expect( screen.getByTestId( 'xy-chart-tooltip-crosshair-horizontal' ) ).toHaveAttribute(
|
|
193
|
+
'y1',
|
|
194
|
+
'40'
|
|
195
|
+
);
|
|
196
|
+
} finally {
|
|
197
|
+
rectSpy.mockRestore();
|
|
198
|
+
}
|
|
199
|
+
} );
|
|
200
|
+
|
|
134
201
|
test( 'draws one glyph per series in the SVG at the datum position', async () => {
|
|
135
202
|
renderChart( { showSeriesGlyphs: true }, BOTH_SERIES );
|
|
136
203
|
|
|
@@ -235,6 +302,38 @@ describe( 'XyChartTooltip', () => {
|
|
|
235
302
|
expect( horizontal ).toHaveAttribute( 'x2', '180' );
|
|
236
303
|
} );
|
|
237
304
|
|
|
305
|
+
test( 'applies crosshair paint without allowing geometry overrides', async () => {
|
|
306
|
+
const consumerStyle = {
|
|
307
|
+
stroke: 'purple',
|
|
308
|
+
strokeWidth: 8,
|
|
309
|
+
className: 'custom-crosshair',
|
|
310
|
+
x1: 999,
|
|
311
|
+
transform: 'translate(100 0)',
|
|
312
|
+
style: { strokeOpacity: 0.25, transform: 'translateX(100px)' },
|
|
313
|
+
};
|
|
314
|
+
renderChart( {
|
|
315
|
+
snapTooltipToDatumX: true,
|
|
316
|
+
snapTooltipToDatumY: true,
|
|
317
|
+
showVerticalCrosshair: true,
|
|
318
|
+
showHorizontalCrosshair: true,
|
|
319
|
+
verticalCrosshairStyle: consumerStyle,
|
|
320
|
+
horizontalCrosshairStyle: consumerStyle,
|
|
321
|
+
} );
|
|
322
|
+
|
|
323
|
+
for ( const direction of [ 'vertical', 'horizontal' ] ) {
|
|
324
|
+
const guide = await screen.findByTestId( `xy-chart-tooltip-crosshair-${ direction }` );
|
|
325
|
+
expect( guide ).toHaveAttribute( 'stroke', 'purple' );
|
|
326
|
+
expect( guide ).toHaveAttribute( 'stroke-width', '8' );
|
|
327
|
+
expect( guide ).toHaveClass( 'custom-crosshair' );
|
|
328
|
+
expect( guide ).toHaveStyle( { 'stroke-opacity': '0.25', transform: 'none' } );
|
|
329
|
+
expect( guide ).not.toHaveAttribute( 'transform' );
|
|
330
|
+
expect( guide ).toHaveAttribute( 'x1', direction === 'vertical' ? '100' : '0' );
|
|
331
|
+
expect( guide ).toHaveAttribute( 'x2', direction === 'vertical' ? '100' : '200' );
|
|
332
|
+
expect( guide ).toHaveAttribute( 'y1', direction === 'vertical' ? '0' : '50' );
|
|
333
|
+
expect( guide ).toHaveAttribute( 'y2', direction === 'vertical' ? '100' : '50' );
|
|
334
|
+
}
|
|
335
|
+
} );
|
|
336
|
+
|
|
238
337
|
test( 'stacks the box above the chart overlays, with zIndex as the override', async () => {
|
|
239
338
|
const { unmount } = renderChart();
|
|
240
339
|
await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { zIndex: '3' } );
|
|
@@ -244,6 +343,26 @@ describe( 'XyChartTooltip', () => {
|
|
|
244
343
|
await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { zIndex: '9' } );
|
|
245
344
|
} );
|
|
246
345
|
|
|
346
|
+
test.each( [ 'auto', 'below-axis' ] as const )(
|
|
347
|
+
'preserves %s background and stacking with a partial style override',
|
|
348
|
+
async tooltipPlacement => {
|
|
349
|
+
const { unmount } = renderChart( { tooltipPlacement } );
|
|
350
|
+
const defaultBox = await screen.findByTestId( 'tooltip-box' );
|
|
351
|
+
const background = defaultBox.style.backgroundColor;
|
|
352
|
+
expect( background ).not.toBe( '' );
|
|
353
|
+
expect( background ).not.toBe( 'transparent' );
|
|
354
|
+
unmount();
|
|
355
|
+
|
|
356
|
+
renderChart( { tooltipPlacement, style: { color: 'red' } } );
|
|
357
|
+
|
|
358
|
+
await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( {
|
|
359
|
+
backgroundColor: background,
|
|
360
|
+
zIndex: '3',
|
|
361
|
+
color: 'rgb(255, 0, 0)',
|
|
362
|
+
} );
|
|
363
|
+
}
|
|
364
|
+
);
|
|
365
|
+
|
|
247
366
|
test( 'accepts the portal-era options without passing them to the box', async () => {
|
|
248
367
|
renderChart( { scroll: true, debounce: 50, resizeObserverPolyfill: undefined } );
|
|
249
368
|
|