@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.
Files changed (91) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/TOKENS.md +15 -10
  3. package/dist/index.cjs +427 -211
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +40 -26
  6. package/dist/index.d.cts +69 -29
  7. package/dist/index.d.ts +69 -29
  8. package/dist/index.js +427 -213
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.tsx +2 -2
  12. package/src/charts/area-chart/test/area-chart.test.tsx +1 -1
  13. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +1 -1
  14. package/src/charts/bar-chart/bar-chart.module.scss +7 -0
  15. package/src/charts/bar-chart/bar-chart.tsx +20 -14
  16. package/src/charts/bar-chart/private/comparison-bars.tsx +1 -0
  17. package/src/charts/bar-chart/test/bar-chart.test.tsx +25 -0
  18. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +0 -10
  19. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +5 -3
  20. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +20 -1
  21. package/src/charts/conversion-funnel-chart/types.ts +3 -0
  22. package/src/charts/heatmap-chart/heatmap-chart.module.scss +16 -7
  23. package/src/charts/heatmap-chart/heatmap-chart.tsx +80 -53
  24. package/src/charts/heatmap-chart/private/heatmap-context.ts +2 -2
  25. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +2 -2
  26. package/src/charts/heatmap-chart/private/heatmap-scale.ts +74 -0
  27. package/src/charts/heatmap-chart/private/index.ts +2 -1
  28. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +24 -6
  29. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +221 -25
  30. package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +78 -0
  31. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +18 -6
  32. package/src/charts/heatmap-chart/types.ts +6 -9
  33. package/src/charts/line-chart/crosshair-style-types.fixture.ts +23 -0
  34. package/src/charts/line-chart/line-chart.module.scss +0 -5
  35. package/src/charts/line-chart/line-chart.tsx +10 -42
  36. package/src/charts/line-chart/test/line-chart.test.tsx +19 -47
  37. package/src/charts/pie-chart/pie-chart.module.scss +9 -0
  38. package/src/charts/pie-chart/pie-chart.tsx +26 -80
  39. package/src/charts/pie-chart/test/pie-chart.test.tsx +21 -8
  40. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +4 -4
  41. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +1 -1
  42. package/src/charts/private/chart-composition/chart-html.tsx +1 -1
  43. package/src/charts/private/chart-composition/chart-svg.tsx +1 -1
  44. package/src/charts/private/label-text-color.ts +116 -0
  45. package/src/charts/private/radial-wipe-animation/radial-wipe-animation.tsx +2 -0
  46. package/src/charts/private/svg-empty-state/svg-empty-state.tsx +1 -1
  47. package/src/charts/private/test/label-text-color.test.ts +189 -0
  48. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +1 -0
  49. package/src/charts/private/with-responsive/with-responsive.tsx +2 -5
  50. package/src/components/tooltip/base-tooltip.tsx +31 -30
  51. package/src/components/tooltip/index.ts +3 -1
  52. package/src/components/tooltip/private/bounded-tooltip.tsx +32 -13
  53. package/src/components/tooltip/private/label-value-content.tsx +8 -0
  54. package/src/components/tooltip/private/tooltip-theme.tsx +37 -0
  55. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +336 -0
  56. package/src/components/tooltip/test/bounded-tooltip.test.tsx +82 -1
  57. package/src/components/tooltip/test/tool-tip.test.tsx +10 -3
  58. package/src/components/tooltip/test/tooltip-box.test.tsx +53 -0
  59. package/src/components/tooltip/test/tooltip-theme.test.tsx +61 -0
  60. package/src/components/tooltip/test/xy-chart-tooltip-overlay.test.tsx +445 -0
  61. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +286 -356
  62. package/src/components/tooltip/tooltip-box.module.scss +21 -0
  63. package/src/components/tooltip/tooltip-box.tsx +43 -0
  64. package/src/components/tooltip/types.ts +7 -1
  65. package/src/components/tooltip/xy-chart-tooltip.tsx +411 -301
  66. package/src/components/trend-indicator/trend-indicator.tsx +1 -0
  67. package/src/hooks/index.ts +1 -0
  68. package/src/hooks/test/use-chart-margin.test.tsx +8 -4
  69. package/src/hooks/test/use-xychart-theme.test.tsx +3 -3
  70. package/src/hooks/use-chart-margin.tsx +21 -18
  71. package/src/hooks/use-isomorphic-layout-effect.ts +5 -0
  72. package/src/hooks/use-xychart-theme.ts +4 -12
  73. package/src/index.ts +8 -3
  74. package/src/providers/chart-context/global-charts-provider.tsx +5 -14
  75. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +3 -5
  76. package/src/providers/chart-context/private/catalog-pointers.ts +1 -0
  77. package/src/providers/chart-context/private/perceptual-color.ts +45 -4
  78. package/src/providers/chart-context/private/resolve-opaque-hex.ts +16 -0
  79. package/src/providers/chart-context/test/perceptual-color.test.ts +31 -0
  80. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +1 -1
  81. package/src/styles/chart-scope-class.ts +5 -0
  82. package/src/styles/chart-scope.scss +10 -6
  83. package/src/styles/test/chart-scope.test.ts +3 -3
  84. package/src/test-utils/zoned-parsing-suite.ts +0 -10
  85. package/src/utils/color-utils.ts +2 -1
  86. package/src/utils/test/date-formatting.test.ts +0 -10
  87. package/src/visx/types.ts +1 -1
  88. package/src/charts/line-chart/test/crosshair-style-types.test.ts +0 -31
  89. package/src/components/tooltip/accessible-tooltip.tsx +0 -433
  90. package/src/components/tooltip/base-tooltip.module.scss +0 -20
  91. 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: '6px',
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
- test( 'applies correct positioning styles', () => {
30
+ it( 'places a legacy box centered above its point', () => {
31
31
  render( <BaseTooltip { ...defaultProps } /> );
32
- const tooltip = screen.getByRole( 'tooltip' );
33
- expect( tooltip ).toHaveStyle( {
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
+ } );