@automattic/charts 4.6.0 → 4.8.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 +32 -0
- package/TOKENS.md +15 -10
- package/dist/index.cjs +427 -211
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +40 -26
- package/dist/index.d.cts +69 -29
- package/dist/index.d.ts +69 -29
- package/dist/index.js +427 -213
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.tsx +2 -2
- package/src/charts/area-chart/test/area-chart.test.tsx +1 -1
- package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +1 -1
- package/src/charts/bar-chart/bar-chart.module.scss +7 -0
- package/src/charts/bar-chart/bar-chart.tsx +20 -14
- package/src/charts/bar-chart/private/comparison-bars.tsx +1 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +25 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +0 -10
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +5 -3
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +20 -1
- package/src/charts/conversion-funnel-chart/types.ts +3 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +16 -7
- package/src/charts/heatmap-chart/heatmap-chart.tsx +80 -53
- package/src/charts/heatmap-chart/private/heatmap-context.ts +2 -2
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +2 -2
- package/src/charts/heatmap-chart/private/heatmap-scale.ts +74 -0
- package/src/charts/heatmap-chart/private/index.ts +2 -1
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +24 -6
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +221 -25
- package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +78 -0
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +18 -6
- package/src/charts/heatmap-chart/types.ts +6 -9
- package/src/charts/line-chart/crosshair-style-types.fixture.ts +23 -0
- package/src/charts/line-chart/line-chart.module.scss +0 -5
- package/src/charts/line-chart/line-chart.tsx +10 -42
- package/src/charts/line-chart/test/line-chart.test.tsx +19 -47
- package/src/charts/pie-chart/pie-chart.module.scss +9 -0
- package/src/charts/pie-chart/pie-chart.tsx +26 -80
- package/src/charts/pie-chart/test/pie-chart.test.tsx +21 -8
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +4 -4
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +1 -1
- package/src/charts/private/chart-composition/chart-html.tsx +1 -1
- package/src/charts/private/chart-composition/chart-svg.tsx +1 -1
- package/src/charts/private/label-text-color.ts +116 -0
- package/src/charts/private/radial-wipe-animation/radial-wipe-animation.tsx +2 -0
- package/src/charts/private/svg-empty-state/svg-empty-state.tsx +1 -1
- package/src/charts/private/test/label-text-color.test.ts +189 -0
- package/src/charts/private/with-responsive/test/with-responsive.test.tsx +1 -0
- package/src/charts/private/with-responsive/with-responsive.tsx +2 -5
- package/src/components/tooltip/base-tooltip.tsx +31 -30
- package/src/components/tooltip/index.ts +3 -1
- package/src/components/tooltip/private/bounded-tooltip.tsx +32 -13
- package/src/components/tooltip/private/label-value-content.tsx +8 -0
- package/src/components/tooltip/private/tooltip-theme.tsx +37 -0
- package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +336 -0
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +82 -1
- package/src/components/tooltip/test/tool-tip.test.tsx +10 -3
- package/src/components/tooltip/test/tooltip-box.test.tsx +53 -0
- package/src/components/tooltip/test/tooltip-theme.test.tsx +61 -0
- package/src/components/tooltip/test/xy-chart-tooltip-overlay.test.tsx +445 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +286 -356
- package/src/components/tooltip/tooltip-box.module.scss +21 -0
- package/src/components/tooltip/tooltip-box.tsx +43 -0
- package/src/components/tooltip/types.ts +7 -1
- package/src/components/tooltip/xy-chart-tooltip.tsx +411 -301
- package/src/components/trend-indicator/trend-indicator.tsx +1 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/test/use-chart-margin.test.tsx +8 -4
- package/src/hooks/test/use-xychart-theme.test.tsx +3 -3
- package/src/hooks/use-chart-margin.tsx +21 -18
- package/src/hooks/use-isomorphic-layout-effect.ts +5 -0
- package/src/hooks/use-xychart-theme.ts +4 -12
- package/src/index.ts +8 -3
- package/src/providers/chart-context/global-charts-provider.tsx +5 -14
- package/src/providers/chart-context/hooks/use-default-hidden-series.ts +3 -5
- package/src/providers/chart-context/private/catalog-pointers.ts +1 -0
- package/src/providers/chart-context/private/perceptual-color.ts +45 -4
- package/src/providers/chart-context/private/resolve-opaque-hex.ts +16 -0
- package/src/providers/chart-context/test/perceptual-color.test.ts +31 -0
- package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +1 -1
- package/src/styles/chart-scope-class.ts +5 -0
- package/src/styles/chart-scope.scss +10 -6
- package/src/styles/test/chart-scope.test.ts +3 -3
- package/src/test-utils/zoned-parsing-suite.ts +0 -10
- package/src/utils/color-utils.ts +2 -1
- package/src/utils/test/date-formatting.test.ts +0 -10
- package/src/visx/types.ts +1 -1
- package/src/charts/line-chart/test/crosshair-style-types.test.ts +0 -31
- package/src/components/tooltip/accessible-tooltip.tsx +0 -433
- package/src/components/tooltip/base-tooltip.module.scss +0 -20
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +0 -331
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { DataContext, TooltipContext } from '@visx/xychart';
|
|
2
|
+
import { useCallback, useContext, useState } from 'react';
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
import { TooltipBox } from '../tooltip-box';
|
|
5
|
+
import { BoundedTooltip, TOOLTIP_Z_INDEX } from './bounded-tooltip';
|
|
6
|
+
import type {
|
|
7
|
+
CrosshairStyle,
|
|
8
|
+
RenderTooltipGlyphProps,
|
|
9
|
+
XyChartTooltipProps,
|
|
10
|
+
} from '../../../visx/types';
|
|
11
|
+
import type { TooltipContextType } from '@visx/xychart';
|
|
12
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
13
|
+
|
|
14
|
+
const CROSSHAIR_STROKE_WIDTH = 1.5;
|
|
15
|
+
const CROSSHAIR_PAINT_PROPERTIES = new Set( [
|
|
16
|
+
'stroke',
|
|
17
|
+
'strokeWidth',
|
|
18
|
+
'strokeOpacity',
|
|
19
|
+
'strokeDasharray',
|
|
20
|
+
'strokeLinecap',
|
|
21
|
+
'opacity',
|
|
22
|
+
] );
|
|
23
|
+
|
|
24
|
+
const crosshairPaintProps = ( props: CrosshairStyle = {} ) => {
|
|
25
|
+
const pickPaint = ( value: object ) =>
|
|
26
|
+
Object.fromEntries(
|
|
27
|
+
Object.entries( value ).filter( ( [ key ] ) => CROSSHAIR_PAINT_PROPERTIES.has( key ) )
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
...pickPaint( props ),
|
|
32
|
+
...( props.className === undefined ? {} : { className: props.className } ),
|
|
33
|
+
...( props.style === undefined ? {} : { style: pickPaint( props.style ) } ),
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
const DEFAULT_GLYPH_RADIUS = 4;
|
|
37
|
+
const FALLBACK_COLOR = '#222';
|
|
38
|
+
// Portal-era options, accepted for compatibility and dropped before the box
|
|
39
|
+
// renders: it sits in the chart wrapper and moves with it, and nothing
|
|
40
|
+
// measures it any more.
|
|
41
|
+
const PORTAL_OPTIONS = new Set( [ 'scroll', 'debounce', 'resizeObserverPolyfill' ] );
|
|
42
|
+
|
|
43
|
+
type ScaleFn = ( value: unknown ) => number | undefined;
|
|
44
|
+
|
|
45
|
+
const isValidNumber = ( value: unknown ): value is number =>
|
|
46
|
+
typeof value === 'number' && Number.isFinite( value );
|
|
47
|
+
|
|
48
|
+
// Band scales place a datum at the start of its band; snapping targets the band's centre.
|
|
49
|
+
// A d3 scale is a function that carries `bandwidth` as a property.
|
|
50
|
+
const scaleBandwidth = ( scale: unknown ): number => {
|
|
51
|
+
const bandwidth = ( scale as { bandwidth?: unknown } | null | undefined )?.bandwidth;
|
|
52
|
+
return typeof bandwidth === 'function' ? Number( bandwidth() ) : 0;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const DefaultGlyph = < Datum extends object >( {
|
|
56
|
+
x,
|
|
57
|
+
y,
|
|
58
|
+
size,
|
|
59
|
+
color,
|
|
60
|
+
glyphStyle,
|
|
61
|
+
seriesKey,
|
|
62
|
+
}: Omit< RenderTooltipGlyphProps< Datum >, 'key' > & { seriesKey: string } ) => {
|
|
63
|
+
const { theme } = useContext( DataContext ) || {};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<circle
|
|
67
|
+
data-testid={ `xy-chart-tooltip-glyph-${ seriesKey }` }
|
|
68
|
+
cx={ x }
|
|
69
|
+
cy={ y }
|
|
70
|
+
r={ size }
|
|
71
|
+
fill={ color }
|
|
72
|
+
stroke={ theme?.backgroundColor }
|
|
73
|
+
strokeWidth={ CROSSHAIR_STROKE_WIDTH }
|
|
74
|
+
paintOrder="fill"
|
|
75
|
+
{ ...glyphStyle }
|
|
76
|
+
/>
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const defaultRenderGlyph = < Datum extends object >( {
|
|
81
|
+
key,
|
|
82
|
+
...props
|
|
83
|
+
}: RenderTooltipGlyphProps< Datum > ) => (
|
|
84
|
+
<DefaultGlyph key={ key } seriesKey={ key } { ...props } />
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
type XyChartTooltipContentProps< Datum extends object > = XyChartTooltipProps< Datum > & {
|
|
88
|
+
tooltipContext: TooltipContextType< Datum >;
|
|
89
|
+
/** The SVG's HTML parent once found. The box waits for it; the SVG overlay does not. */
|
|
90
|
+
container: HTMLElement | null;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const XyChartTooltipContent = < Datum extends object >( {
|
|
94
|
+
tooltipContext,
|
|
95
|
+
container,
|
|
96
|
+
renderTooltip,
|
|
97
|
+
renderGlyph = defaultRenderGlyph,
|
|
98
|
+
glyphStyle,
|
|
99
|
+
snapTooltipToDatumX = false,
|
|
100
|
+
snapTooltipToDatumY = false,
|
|
101
|
+
showVerticalCrosshair = false,
|
|
102
|
+
showHorizontalCrosshair = false,
|
|
103
|
+
showDatumGlyph = false,
|
|
104
|
+
showSeriesGlyphs = false,
|
|
105
|
+
verticalCrosshairStyle,
|
|
106
|
+
horizontalCrosshairStyle,
|
|
107
|
+
detectBounds = true,
|
|
108
|
+
tooltipPlacement = 'auto',
|
|
109
|
+
tooltipAnchorTop,
|
|
110
|
+
zIndex = TOOLTIP_Z_INDEX,
|
|
111
|
+
style,
|
|
112
|
+
...rest
|
|
113
|
+
}: XyChartTooltipContentProps< Datum > ) => {
|
|
114
|
+
const tooltipProps = Object.fromEntries(
|
|
115
|
+
Object.entries( rest ).filter( ( [ key ] ) => ! PORTAL_OPTIONS.has( key ) )
|
|
116
|
+
) as Omit< typeof rest, 'scroll' | 'debounce' | 'resizeObserverPolyfill' >;
|
|
117
|
+
const {
|
|
118
|
+
colorScale,
|
|
119
|
+
theme,
|
|
120
|
+
innerHeight = 0,
|
|
121
|
+
innerWidth = 0,
|
|
122
|
+
margin,
|
|
123
|
+
xScale,
|
|
124
|
+
yScale,
|
|
125
|
+
dataRegistry,
|
|
126
|
+
} = useContext( DataContext ) || {};
|
|
127
|
+
|
|
128
|
+
const tooltipContent = renderTooltip ? renderTooltip( { ...tooltipContext, colorScale } ) : null;
|
|
129
|
+
if ( tooltipContent == null ) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const scaleX = xScale as ScaleFn | undefined;
|
|
134
|
+
const scaleY = yScale as ScaleFn | undefined;
|
|
135
|
+
|
|
136
|
+
// Datum position in SVG coordinates, from the accessors the series registered.
|
|
137
|
+
const getDatumLeftTop = ( key: string, datum: Datum ) => {
|
|
138
|
+
const entry = dataRegistry?.get( key );
|
|
139
|
+
const left =
|
|
140
|
+
scaleX && entry?.xAccessor
|
|
141
|
+
? Number( scaleX( entry.xAccessor( datum ) ) ) + scaleBandwidth( xScale ) / 2
|
|
142
|
+
: undefined;
|
|
143
|
+
const top =
|
|
144
|
+
scaleY && entry?.yAccessor
|
|
145
|
+
? Number( scaleY( entry.yAccessor( datum ) ) ) + scaleBandwidth( yScale ) / 2
|
|
146
|
+
: undefined;
|
|
147
|
+
return { left, top };
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const nearestDatum = tooltipContext.tooltipData?.nearestDatum;
|
|
151
|
+
let { tooltipLeft, tooltipTop } = tooltipContext;
|
|
152
|
+
|
|
153
|
+
if (
|
|
154
|
+
nearestDatum &&
|
|
155
|
+
( snapTooltipToDatumX || snapTooltipToDatumY || tooltipPlacement === 'below-axis' )
|
|
156
|
+
) {
|
|
157
|
+
const { left, top } = getDatumLeftTop( nearestDatum.key, nearestDatum.datum );
|
|
158
|
+
if ( ( snapTooltipToDatumX || tooltipPlacement === 'below-axis' ) && isValidNumber( left ) ) {
|
|
159
|
+
tooltipLeft = left;
|
|
160
|
+
}
|
|
161
|
+
if ( snapTooltipToDatumY && isValidNumber( top ) ) {
|
|
162
|
+
tooltipTop = top;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const glyphs: ReactNode[] = [];
|
|
167
|
+
if ( showDatumGlyph || showSeriesGlyphs ) {
|
|
168
|
+
const size = Number( glyphStyle?.radius ?? DEFAULT_GLYPH_RADIUS );
|
|
169
|
+
const labelColor = theme?.htmlLabel?.color ?? FALLBACK_COLOR;
|
|
170
|
+
// visx colours a lone nearest-datum glyph like the gridlines and series glyphs like labels.
|
|
171
|
+
const fallbackColor = showSeriesGlyphs
|
|
172
|
+
? labelColor
|
|
173
|
+
: ( theme?.gridStyles?.stroke ?? labelColor );
|
|
174
|
+
let entries: Array< { key: string; datum: Datum; index: number } > = [];
|
|
175
|
+
if ( showSeriesGlyphs ) {
|
|
176
|
+
entries = Object.values( tooltipContext.tooltipData?.datumByKey ?? {} );
|
|
177
|
+
} else if ( nearestDatum ) {
|
|
178
|
+
entries = [ nearestDatum ];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for ( const { key, datum, index } of entries ) {
|
|
182
|
+
const { left, top } = getDatumLeftTop( key, datum );
|
|
183
|
+
if ( ! isValidNumber( left ) || ! isValidNumber( top ) ) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// The group carries the position and the renderer draws at the origin,
|
|
187
|
+
// as with visx's portal: a renderer that ignores `x` and `y` still lands
|
|
188
|
+
// on its datum.
|
|
189
|
+
glyphs.push(
|
|
190
|
+
<g
|
|
191
|
+
key={ key }
|
|
192
|
+
className="visx-tooltip-glyph"
|
|
193
|
+
data-testid={ `xy-chart-tooltip-glyph-group-${ key }` }
|
|
194
|
+
transform={ `translate(${ left }, ${ top })` }
|
|
195
|
+
>
|
|
196
|
+
{ renderGlyph( {
|
|
197
|
+
key,
|
|
198
|
+
color: colorScale?.( key ) ?? fallbackColor,
|
|
199
|
+
datum,
|
|
200
|
+
index,
|
|
201
|
+
size,
|
|
202
|
+
x: 0,
|
|
203
|
+
y: 0,
|
|
204
|
+
glyphStyle,
|
|
205
|
+
isNearestDatum: nearestDatum?.key === key,
|
|
206
|
+
} ) }
|
|
207
|
+
</g>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const crosshairStroke = theme?.gridStyles?.stroke ?? theme?.htmlLabel?.color ?? FALLBACK_COLOR;
|
|
213
|
+
const marginTop = margin?.top ?? 0;
|
|
214
|
+
const marginLeft = margin?.left ?? 0;
|
|
215
|
+
|
|
216
|
+
const boxStyle: CSSProperties = { zIndex, ...style };
|
|
217
|
+
const { offsetLeft = 10, offsetTop = 10, unstyled, className, ...boxProps } = tooltipProps;
|
|
218
|
+
const anchorTop =
|
|
219
|
+
tooltipPlacement === 'below-axis'
|
|
220
|
+
? marginTop + innerHeight + ( margin?.bottom ?? 0 )
|
|
221
|
+
: ( tooltipAnchorTop ?? tooltipTop );
|
|
222
|
+
const box =
|
|
223
|
+
detectBounds || tooltipPlacement !== 'auto' ? (
|
|
224
|
+
<BoundedTooltip
|
|
225
|
+
left={ tooltipLeft }
|
|
226
|
+
top={ anchorTop }
|
|
227
|
+
offsetLeft={ offsetLeft }
|
|
228
|
+
offsetTop={ offsetTop }
|
|
229
|
+
unstyled={ unstyled }
|
|
230
|
+
className={ className }
|
|
231
|
+
style={ boxStyle }
|
|
232
|
+
{ ...boxProps }
|
|
233
|
+
{ ...( tooltipPlacement !== 'auto' && { placement: tooltipPlacement } ) }
|
|
234
|
+
>
|
|
235
|
+
{ tooltipContent }
|
|
236
|
+
</BoundedTooltip>
|
|
237
|
+
) : (
|
|
238
|
+
<TooltipBox
|
|
239
|
+
role="presentation"
|
|
240
|
+
unstyled={ unstyled }
|
|
241
|
+
className={ className }
|
|
242
|
+
style={ {
|
|
243
|
+
position: 'absolute',
|
|
244
|
+
left: ( tooltipLeft ?? 0 ) + offsetLeft,
|
|
245
|
+
top: ( anchorTop ?? 0 ) + offsetTop,
|
|
246
|
+
...( ! unstyled && boxStyle ),
|
|
247
|
+
} }
|
|
248
|
+
{ ...boxProps }
|
|
249
|
+
>
|
|
250
|
+
{ tooltipContent }
|
|
251
|
+
</TooltipBox>
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<>
|
|
256
|
+
<g className="visx-tooltip-overlay" pointerEvents="none">
|
|
257
|
+
{ showVerticalCrosshair && isValidNumber( tooltipLeft ) && (
|
|
258
|
+
<line
|
|
259
|
+
className="visx-crosshair visx-crosshair-vertical"
|
|
260
|
+
data-testid="xy-chart-tooltip-crosshair-vertical"
|
|
261
|
+
stroke={ crosshairStroke }
|
|
262
|
+
strokeWidth={ CROSSHAIR_STROKE_WIDTH }
|
|
263
|
+
{ ...crosshairPaintProps( verticalCrosshairStyle ) }
|
|
264
|
+
x1={ tooltipLeft }
|
|
265
|
+
x2={ tooltipLeft }
|
|
266
|
+
y1={ marginTop }
|
|
267
|
+
y2={ marginTop + innerHeight }
|
|
268
|
+
/>
|
|
269
|
+
) }
|
|
270
|
+
{ showHorizontalCrosshair && isValidNumber( tooltipTop ) && (
|
|
271
|
+
<line
|
|
272
|
+
className="visx-crosshair visx-crosshair-horizontal"
|
|
273
|
+
data-testid="xy-chart-tooltip-crosshair-horizontal"
|
|
274
|
+
stroke={ crosshairStroke }
|
|
275
|
+
strokeWidth={ CROSSHAIR_STROKE_WIDTH }
|
|
276
|
+
{ ...crosshairPaintProps( horizontalCrosshairStyle ) }
|
|
277
|
+
x1={ marginLeft }
|
|
278
|
+
x2={ marginLeft + innerWidth }
|
|
279
|
+
y1={ tooltipTop }
|
|
280
|
+
y2={ tooltipTop }
|
|
281
|
+
/>
|
|
282
|
+
) }
|
|
283
|
+
{ glyphs }
|
|
284
|
+
</g>
|
|
285
|
+
{ container && createPortal( box, container ) }
|
|
286
|
+
</>
|
|
287
|
+
);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* In-tree replacement for `@visx/xychart`'s `Tooltip`.
|
|
292
|
+
*
|
|
293
|
+
* visx renders its tooltip box, glyphs and crosshairs through portals appended
|
|
294
|
+
* to `document.body`. That puts them in the page's root stacking context, where
|
|
295
|
+
* they paint above every sticky or fixed element that lives inside a nested
|
|
296
|
+
* stacking context, and no z-index on that element can change the order. This
|
|
297
|
+
* component draws the glyphs and crosshairs straight into the chart SVG and
|
|
298
|
+
* renders the tooltip box into the SVG's parent element, so all of them stack
|
|
299
|
+
* as ordinary descendants of the chart.
|
|
300
|
+
*
|
|
301
|
+
* Render it as a child of `XYChart`. The element wrapping that `XYChart` must
|
|
302
|
+
* be `position: relative` with the SVG at its origin, and `isolation: isolate`:
|
|
303
|
+
* the box is placed with the SVG-local coordinates visx reports, and the
|
|
304
|
+
* isolation keeps the box's `zIndex` from competing with page chrome. The box
|
|
305
|
+
* flips and clamps to stay inside the nearest ancestor that clips its overflow
|
|
306
|
+
* (or the viewport), so it may extend past the chart wrapper but is never cut
|
|
307
|
+
* off unless that ancestor is smaller than the box itself. Below-axis placement
|
|
308
|
+
* pins it beneath the x-axis and applies horizontal bounds only.
|
|
309
|
+
*
|
|
310
|
+
* @param props - visx's `Tooltip` options. `scroll`, `debounce` and `resizeObserverPolyfill` are accepted and ignored.
|
|
311
|
+
* @return An anchor in the SVG, plus the overlay and the tooltip box while the tooltip is open.
|
|
312
|
+
*/
|
|
313
|
+
export const XyChartTooltipOverlay = < Datum extends object >(
|
|
314
|
+
props: XyChartTooltipProps< Datum >
|
|
315
|
+
) => {
|
|
316
|
+
const tooltipContext = useContext( TooltipContext ) as TooltipContextType< Datum > | null;
|
|
317
|
+
const [ container, setContainer ] = useState< HTMLElement | null >( null );
|
|
318
|
+
|
|
319
|
+
// Children of XYChart render inside its SVG, and the box needs an HTML parent.
|
|
320
|
+
const anchorRef = useCallback( ( node: SVGGElement | null ) => {
|
|
321
|
+
setContainer( node?.ownerSVGElement?.parentElement ?? null );
|
|
322
|
+
}, [] );
|
|
323
|
+
|
|
324
|
+
return (
|
|
325
|
+
<>
|
|
326
|
+
<g ref={ anchorRef } data-testid="xy-chart-tooltip-anchor" />
|
|
327
|
+
{ tooltipContext?.tooltipOpen && (
|
|
328
|
+
<XyChartTooltipContent
|
|
329
|
+
{ ...props }
|
|
330
|
+
tooltipContext={ tooltipContext }
|
|
331
|
+
container={ container }
|
|
332
|
+
/>
|
|
333
|
+
) }
|
|
334
|
+
</>
|
|
335
|
+
);
|
|
336
|
+
};
|
|
@@ -126,6 +126,87 @@ describe( 'BoundedTooltip', () => {
|
|
|
126
126
|
jest.restoreAllMocks();
|
|
127
127
|
} );
|
|
128
128
|
|
|
129
|
+
test( 'draws the package surface, not the visx default box', () => {
|
|
130
|
+
render(
|
|
131
|
+
<BoundedTooltip left={ 10 } top={ 10 } data-testid="box">
|
|
132
|
+
content
|
|
133
|
+
</BoundedTooltip>
|
|
134
|
+
);
|
|
135
|
+
const box = screen.getByTestId( 'box' );
|
|
136
|
+
expect( box ).toHaveClass( 'surface' );
|
|
137
|
+
expect( box ).not.toHaveStyle( { backgroundColor: 'white' } );
|
|
138
|
+
// toHaveStyle can't assert an absent inline style; box.style reads the empty string directly.
|
|
139
|
+
// eslint-disable-next-line jest-dom/prefer-to-have-style
|
|
140
|
+
expect( box.style.boxShadow ).toBe( '' );
|
|
141
|
+
// eslint-disable-next-line jest-dom/prefer-to-have-style
|
|
142
|
+
expect( box.style.padding ).toBe( '' );
|
|
143
|
+
} );
|
|
144
|
+
|
|
145
|
+
test( 'keeps a caller class beside the surface', () => {
|
|
146
|
+
render(
|
|
147
|
+
<BoundedTooltip left={ 10 } top={ 10 } className="extra" data-testid="box">
|
|
148
|
+
content
|
|
149
|
+
</BoundedTooltip>
|
|
150
|
+
);
|
|
151
|
+
expect( screen.getByTestId( 'box' ) ).toHaveClass( 'surface', 'extra' );
|
|
152
|
+
} );
|
|
153
|
+
|
|
154
|
+
test( 'drops the surface when unstyled', () => {
|
|
155
|
+
render(
|
|
156
|
+
<BoundedTooltip left={ 10 } top={ 10 } unstyled data-testid="box">
|
|
157
|
+
content
|
|
158
|
+
</BoundedTooltip>
|
|
159
|
+
);
|
|
160
|
+
expect( screen.getByTestId( 'box' ) ).not.toHaveClass( 'surface' );
|
|
161
|
+
expect( screen.getByTestId( 'box' ) ).not.toHaveClass( 'a8c-charts-tooltip-scope' );
|
|
162
|
+
} );
|
|
163
|
+
|
|
164
|
+
test( 're-declares the chart catalog on a styled box under its own class, out of reach of page-wide chart overrides', () => {
|
|
165
|
+
render(
|
|
166
|
+
<BoundedTooltip left={ 10 } top={ 10 } data-testid="box">
|
|
167
|
+
content
|
|
168
|
+
</BoundedTooltip>
|
|
169
|
+
);
|
|
170
|
+
const box = screen.getByTestId( 'box' );
|
|
171
|
+
expect( box ).toHaveClass( 'a8c-charts-tooltip-scope', 'surface' );
|
|
172
|
+
expect( box ).not.toHaveClass( 'a8c-charts-scope' );
|
|
173
|
+
} );
|
|
174
|
+
|
|
175
|
+
test( 'measures against the positioned wrapper past a display: contents element', () => {
|
|
176
|
+
jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
|
|
177
|
+
this: Element
|
|
178
|
+
) {
|
|
179
|
+
if ( this.classList.contains( 'visx-tooltip' ) ) {
|
|
180
|
+
return rect( 0, 0, 208, 36 );
|
|
181
|
+
}
|
|
182
|
+
return this.getAttribute( 'data-testid' ) === 'contents'
|
|
183
|
+
? rect( 0, 0, 0, 0 )
|
|
184
|
+
: rect( 0, 0, 600, 200 );
|
|
185
|
+
} );
|
|
186
|
+
|
|
187
|
+
render(
|
|
188
|
+
<div style={ { position: 'relative', overflow: 'hidden' } }>
|
|
189
|
+
<div data-testid="contents" style={ { display: 'contents' } }>
|
|
190
|
+
<BoundedTooltip left={ 300 } top={ 50 } unstyled data-testid="box">
|
|
191
|
+
content
|
|
192
|
+
</BoundedTooltip>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Measured against the zero-size contents element, the box would flip to (82, 4).
|
|
198
|
+
expect( screen.getByTestId( 'box' ) ).toHaveStyle( { transform: 'translate(310px, 60px)' } );
|
|
199
|
+
} );
|
|
200
|
+
|
|
201
|
+
test( 'lets an inline style override the surface', () => {
|
|
202
|
+
render(
|
|
203
|
+
<BoundedTooltip left={ 10 } top={ 10 } style={ { background: 'red' } } data-testid="box">
|
|
204
|
+
content
|
|
205
|
+
</BoundedTooltip>
|
|
206
|
+
);
|
|
207
|
+
expect( screen.getByTestId( 'box' ) ).toHaveStyle( { background: 'red' } );
|
|
208
|
+
} );
|
|
209
|
+
|
|
129
210
|
test( 'keeps the box inside an overflow-hidden ancestor that is wider than the wrapper', () => {
|
|
130
211
|
// Card at x=400 (400 wide, overflow hidden); plot wrapper at x=440, 346 wide.
|
|
131
212
|
jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
|
|
@@ -209,7 +290,7 @@ describe( 'BoundedTooltip', () => {
|
|
|
209
290
|
left: `${ pointerLeft }px`,
|
|
210
291
|
top: '-6px',
|
|
211
292
|
width: '12px',
|
|
212
|
-
height: '
|
|
293
|
+
height: '7px',
|
|
213
294
|
} );
|
|
214
295
|
expect( screen.getByTestId( 'tooltip-axis-pointer' ) ).toHaveAttribute(
|
|
215
296
|
'aria-hidden',
|
|
@@ -27,13 +27,20 @@ describe( 'BaseTooltip', () => {
|
|
|
27
27
|
expect( screen.getByText( 'Custom Child Content' ) ).toBeInTheDocument();
|
|
28
28
|
} );
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
it( 'places a legacy box centered above its point', () => {
|
|
31
31
|
render( <BaseTooltip { ...defaultProps } /> );
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
expect( screen.getByRole( 'tooltip' ) ).toHaveStyle( {
|
|
33
|
+
position: 'absolute',
|
|
34
34
|
top: '100px',
|
|
35
35
|
left: '200px',
|
|
36
|
+
transform: 'translate(-50%, -100%)',
|
|
36
37
|
} );
|
|
38
|
+
expect( screen.getByRole( 'tooltip' ) ).toHaveClass( 'visx-tooltip', 'surface' );
|
|
39
|
+
} );
|
|
40
|
+
|
|
41
|
+
it( 'renders in flow without top and left', () => {
|
|
42
|
+
render( <BaseTooltip>Content</BaseTooltip> );
|
|
43
|
+
expect( screen.getByRole( 'tooltip' ) ).not.toHaveStyle( { position: 'absolute' } );
|
|
37
44
|
} );
|
|
38
45
|
|
|
39
46
|
test( 'handles missing valueDisplay', () => {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { createRef } from 'react';
|
|
3
|
+
import { TooltipBox } from '../tooltip-box';
|
|
4
|
+
|
|
5
|
+
describe( 'TooltipBox', () => {
|
|
6
|
+
it( 'renders the styled box with the class consumers target', () => {
|
|
7
|
+
render( <TooltipBox>Content</TooltipBox> );
|
|
8
|
+
const box = screen.getByRole( 'tooltip' );
|
|
9
|
+
expect( box ).toHaveTextContent( 'Content' );
|
|
10
|
+
expect( box ).toHaveClass( 'visx-tooltip', 'surface', 'a8c-charts-tooltip-scope' );
|
|
11
|
+
expect( box ).not.toHaveClass( 'a8c-charts-scope' );
|
|
12
|
+
} );
|
|
13
|
+
|
|
14
|
+
it( 'leaves positioning to the caller', () => {
|
|
15
|
+
render(
|
|
16
|
+
<TooltipBox style={ { position: 'absolute', left: 12, top: 34 } }>Content</TooltipBox>
|
|
17
|
+
);
|
|
18
|
+
expect( screen.getByRole( 'tooltip' ) ).toHaveStyle( {
|
|
19
|
+
position: 'absolute',
|
|
20
|
+
left: '12px',
|
|
21
|
+
top: '34px',
|
|
22
|
+
} );
|
|
23
|
+
} );
|
|
24
|
+
|
|
25
|
+
it( 'keeps a caller class beside the surface', () => {
|
|
26
|
+
render( <TooltipBox className="custom">Content</TooltipBox> );
|
|
27
|
+
expect( screen.getByRole( 'tooltip' ) ).toHaveClass( 'surface', 'custom' );
|
|
28
|
+
} );
|
|
29
|
+
|
|
30
|
+
it( 'drops the surface and scope class when unstyled, keeping style', () => {
|
|
31
|
+
render(
|
|
32
|
+
<TooltipBox unstyled style={ { left: 5 } }>
|
|
33
|
+
Content
|
|
34
|
+
</TooltipBox>
|
|
35
|
+
);
|
|
36
|
+
const box = screen.getByRole( 'tooltip' );
|
|
37
|
+
expect( box ).not.toHaveClass( 'surface' );
|
|
38
|
+
expect( box ).not.toHaveClass( 'a8c-charts-tooltip-scope' );
|
|
39
|
+
expect( box ).toHaveClass( 'visx-tooltip' );
|
|
40
|
+
expect( box ).toHaveStyle( { left: '5px' } );
|
|
41
|
+
} );
|
|
42
|
+
|
|
43
|
+
it( 'lets the caller replace the role', () => {
|
|
44
|
+
render( <TooltipBox role="presentation">Content</TooltipBox> );
|
|
45
|
+
expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
|
|
46
|
+
} );
|
|
47
|
+
|
|
48
|
+
it( 'forwards its ref to the box', () => {
|
|
49
|
+
const ref = createRef< HTMLDivElement >();
|
|
50
|
+
render( <TooltipBox ref={ ref }>Content</TooltipBox> );
|
|
51
|
+
expect( ref.current ).toBe( screen.getByRole( 'tooltip' ) );
|
|
52
|
+
} );
|
|
53
|
+
} );
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { resolveCssVariable } from '../../../utils/resolve-css-var';
|
|
3
|
+
import { TooltipTheme } from '../private/tooltip-theme';
|
|
4
|
+
|
|
5
|
+
jest.mock( '@wordpress/theme', () => ( { ...jest.requireActual( '@wordpress/theme' ) } ) );
|
|
6
|
+
jest.mock( '../../../utils/resolve-css-var', () => ( { resolveCssVariable: jest.fn() } ) );
|
|
7
|
+
|
|
8
|
+
const theme = jest.requireMock( '@wordpress/theme' );
|
|
9
|
+
const { ThemeProvider } = jest.requireActual( '@wordpress/theme' );
|
|
10
|
+
|
|
11
|
+
const spyOnThemeProvider = () =>
|
|
12
|
+
jest.spyOn( theme, 'ThemeProvider' ).mockImplementation( ( { children } ) => children );
|
|
13
|
+
|
|
14
|
+
describe( 'TooltipTheme', () => {
|
|
15
|
+
afterEach( () => {
|
|
16
|
+
jest.restoreAllMocks();
|
|
17
|
+
theme.ThemeProvider = ThemeProvider;
|
|
18
|
+
} );
|
|
19
|
+
|
|
20
|
+
test( 'renders the box unthemed when core exports no public ThemeProvider', () => {
|
|
21
|
+
theme.ThemeProvider = undefined;
|
|
22
|
+
jest.mocked( resolveCssVariable ).mockReturnValue( '#1e1e1e' );
|
|
23
|
+
|
|
24
|
+
render( <TooltipTheme>box</TooltipTheme> );
|
|
25
|
+
|
|
26
|
+
expect( screen.getByText( 'box' ) ).toBeInTheDocument();
|
|
27
|
+
} );
|
|
28
|
+
|
|
29
|
+
test.each( [ 'rgba(0, 0, 0, 0.85)', 'not-a-color', null ] )(
|
|
30
|
+
'seeds the default surface when the surface role resolves to %p',
|
|
31
|
+
value => {
|
|
32
|
+
const provider = spyOnThemeProvider();
|
|
33
|
+
jest.mocked( resolveCssVariable ).mockReturnValue( value );
|
|
34
|
+
|
|
35
|
+
render( <TooltipTheme>box</TooltipTheme> );
|
|
36
|
+
|
|
37
|
+
expect( provider.mock.calls[ 0 ][ 0 ] ).toMatchObject( {
|
|
38
|
+
color: { background: '#1e1e1e' },
|
|
39
|
+
} );
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
test( 'seeds an opaque surface role as hex', () => {
|
|
44
|
+
const provider = spyOnThemeProvider();
|
|
45
|
+
jest.mocked( resolveCssVariable ).mockReturnValue( 'rgb(255, 255, 255)' );
|
|
46
|
+
|
|
47
|
+
render( <TooltipTheme>box</TooltipTheme> );
|
|
48
|
+
|
|
49
|
+
expect( provider.mock.calls[ 0 ][ 0 ] ).toMatchObject( {
|
|
50
|
+
color: { background: '#ffffff' },
|
|
51
|
+
} );
|
|
52
|
+
} );
|
|
53
|
+
|
|
54
|
+
test( 'renders with the real ThemeProvider when the surface role is translucent', () => {
|
|
55
|
+
jest.mocked( resolveCssVariable ).mockReturnValue( 'rgba(0, 0, 0, 0.85)' );
|
|
56
|
+
|
|
57
|
+
render( <TooltipTheme>box</TooltipTheme> );
|
|
58
|
+
|
|
59
|
+
expect( screen.getByText( 'box' ) ).toBeInTheDocument();
|
|
60
|
+
} );
|
|
61
|
+
} );
|