@automattic/charts 3.2.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/SECURITY.md +1 -0
  3. package/TOKENS.md +53 -58
  4. package/dist/index.cjs +1483 -664
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.css +93 -29
  7. package/dist/index.d.cts +233 -247
  8. package/dist/index.d.ts +233 -247
  9. package/dist/index.js +1484 -667
  10. package/dist/index.js.map +1 -1
  11. package/package.json +7 -6
  12. package/src/charts/area-chart/area-chart.tsx +39 -25
  13. package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
  14. package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
  15. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
  16. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
  17. package/src/charts/area-chart/types.ts +4 -7
  18. package/src/charts/bar-chart/bar-chart.tsx +2 -1
  19. package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
  20. package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
  21. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -0
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  23. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  24. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  25. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  26. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  27. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  28. package/src/charts/heatmap-chart/heatmap-chart.module.scss +52 -16
  29. package/src/charts/heatmap-chart/heatmap-chart.tsx +89 -37
  30. package/src/charts/heatmap-chart/index.ts +2 -1
  31. package/src/charts/heatmap-chart/private/build-calendar-data.ts +126 -89
  32. package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
  33. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  34. package/src/charts/heatmap-chart/private/index.ts +1 -1
  35. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
  36. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +3 -0
  37. package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
  38. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +142 -12
  39. package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
  40. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
  41. package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
  42. package/src/charts/heatmap-chart/types.ts +8 -2
  43. package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
  44. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  45. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  46. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  47. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  48. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  49. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  50. package/src/charts/leaderboard-chart/types.ts +2 -2
  51. package/src/charts/line-chart/line-chart.module.scss +10 -18
  52. package/src/charts/line-chart/line-chart.tsx +128 -26
  53. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  54. package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
  55. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  56. package/src/charts/line-chart/test/line-chart.test.tsx +174 -41
  57. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  58. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  59. package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
  60. package/src/charts/line-chart/types.ts +17 -3
  61. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  62. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  63. package/src/charts/pie-chart/test/pie-chart.test.tsx +59 -12
  64. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  65. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  66. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  67. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  68. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  69. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  70. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  71. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  72. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  73. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  74. package/src/charts/private/test/time-axis.test.ts +9 -69
  75. package/src/charts/private/time-axis-options.ts +106 -0
  76. package/src/charts/private/time-axis.ts +318 -136
  77. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  78. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  79. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  80. package/src/charts/sparkline/types.ts +1 -1
  81. package/src/components/legend/private/base-legend.module.scss +9 -5
  82. package/src/components/tooltip/accessible-tooltip.tsx +21 -17
  83. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  84. package/src/components/tooltip/private/bounded-tooltip.tsx +227 -0
  85. package/src/components/tooltip/test/accessible-tooltip.test.tsx +30 -4
  86. package/src/components/tooltip/test/bounded-tooltip.test.tsx +225 -0
  87. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +379 -0
  88. package/src/components/tooltip/xy-chart-tooltip.tsx +326 -0
  89. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  90. package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
  91. package/src/hooks/test/use-chart-margin.test.tsx +173 -3
  92. package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
  93. package/src/hooks/use-chart-data-transform.ts +10 -3
  94. package/src/hooks/use-chart-margin.tsx +54 -9
  95. package/src/hooks/use-xychart-theme.ts +25 -9
  96. package/src/index.ts +8 -0
  97. package/src/providers/chart-context/global-charts-provider.tsx +37 -38
  98. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  99. package/src/providers/chart-context/index.ts +1 -0
  100. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  101. package/src/providers/chart-context/private/series-palette.ts +3 -13
  102. package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
  103. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  104. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -25
  105. package/src/providers/chart-context/themes.ts +7 -52
  106. package/src/providers/chart-context/types.ts +3 -0
  107. package/src/styles/chart-scope.scss +39 -62
  108. package/src/styles/test/chart-scope.test.ts +19 -47
  109. package/src/test-utils/series-fixtures.ts +31 -0
  110. package/src/test-utils/zoned-parsing-suite.ts +90 -0
  111. package/src/types.ts +79 -166
  112. package/src/utils/bucket-info.ts +87 -0
  113. package/src/utils/color-utils.ts +0 -27
  114. package/src/utils/date-formatting.ts +157 -0
  115. package/src/utils/date-parsing.ts +103 -52
  116. package/src/utils/get-edge-tick-widths.ts +33 -0
  117. package/src/utils/index.ts +4 -0
  118. package/src/utils/test/bucket-info.test.ts +185 -0
  119. package/src/utils/test/color-utils.test.ts +1 -48
  120. package/src/utils/test/date-formatting.test.ts +89 -0
  121. package/src/utils/test/date-parsing.auckland.test.ts +6 -0
  122. package/src/utils/test/date-parsing.santiago.test.ts +6 -0
  123. package/src/utils/test/date-parsing.test.ts +16 -1
  124. package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
  125. package/src/utils/test/get-styles.test.ts +10 -7
  126. package/src/utils/test/merge-themes.test.ts +18 -46
  127. package/src/utils/warn-once.ts +23 -0
  128. package/src/visx/types.ts +56 -5
  129. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  130. package/src/providers/chart-context/private/theme-override-vars.ts +0 -131
  131. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
  132. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  133. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -183
  134. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
  135. package/src/styles/test/wpds-fallbacks.test.ts +0 -131
@@ -0,0 +1,225 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { BoundedTooltip, getBoundedPosition } from '../private/bounded-tooltip';
3
+
4
+ const BOX = { width: 208, height: 36 };
5
+ const OFFSETS = { offsetLeft: 10, offsetTop: 10 };
6
+
7
+ describe( 'getBoundedPosition', () => {
8
+ test( 'sits below and to the right of the anchor when that fits', () => {
9
+ expect(
10
+ getBoundedPosition( {
11
+ left: 40,
12
+ top: 20,
13
+ ...OFFSETS,
14
+ box: BOX,
15
+ wrapper: { left: 0, top: 0, right: 600, bottom: 300 },
16
+ bounds: { left: 0, top: 0, right: 600, bottom: 300 },
17
+ } )
18
+ ).toEqual( { x: 50, y: 30 } );
19
+ } );
20
+
21
+ test( 'flips to the other side when the near side clips more', () => {
22
+ expect(
23
+ getBoundedPosition( {
24
+ left: 500,
25
+ top: 280,
26
+ ...OFFSETS,
27
+ box: BOX,
28
+ wrapper: { left: 0, top: 0, right: 600, bottom: 300 },
29
+ bounds: { left: 0, top: 0, right: 600, bottom: 300 },
30
+ } )
31
+ ).toEqual( { x: 282, y: 234 } );
32
+ } );
33
+
34
+ test( 'pins the box to the edge when neither side fits, instead of spilling', () => {
35
+ // A 208px box mid-way across a 346px plot: neither side fits, so the box
36
+ // keeps the near side and parks at the right edge instead of spilling.
37
+ expect(
38
+ getBoundedPosition( {
39
+ left: 173,
40
+ top: 50,
41
+ ...OFFSETS,
42
+ box: BOX,
43
+ wrapper: { left: 0, top: 0, right: 346, bottom: 200 },
44
+ bounds: { left: 0, top: 0, right: 346, bottom: 200 },
45
+ } )
46
+ ).toEqual( { x: 138, y: 60 } );
47
+ } );
48
+
49
+ test( 'may leave the wrapper as long as it stays inside the clipping ancestor', () => {
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.
52
+ expect(
53
+ getBoundedPosition( {
54
+ left: 173,
55
+ top: 50,
56
+ ...OFFSETS,
57
+ box: BOX,
58
+ wrapper: { left: 0, top: 0, right: 346, bottom: 200 },
59
+ bounds: { left: -40, top: -60, right: 360, bottom: 200 },
60
+ } )
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 } );
91
+ } );
92
+ } );
93
+
94
+ const rect = ( left: number, top: number, width: number, height: number ) =>
95
+ ( {
96
+ left,
97
+ top,
98
+ width,
99
+ height,
100
+ right: left + width,
101
+ bottom: top + height,
102
+ x: left,
103
+ y: top,
104
+ toJSON: () => ( {} ),
105
+ } ) as DOMRect;
106
+
107
+ describe( 'BoundedTooltip', () => {
108
+ afterEach( () => {
109
+ jest.restoreAllMocks();
110
+ } );
111
+
112
+ test( 'keeps the box inside an overflow-hidden ancestor that is wider than the wrapper', () => {
113
+ // Card at x=400 (400 wide, overflow hidden); plot wrapper at x=440, 346 wide.
114
+ jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
115
+ this: Element
116
+ ) {
117
+ if ( this.classList.contains( 'visx-tooltip' ) ) {
118
+ return rect( 0, 0, 208, 36 );
119
+ }
120
+ if ( this.getAttribute( 'data-testid' ) === 'card' ) {
121
+ return rect( 400, 100, 400, 400 );
122
+ }
123
+ return rect( 440, 160, 346, 200 );
124
+ } );
125
+
126
+ render(
127
+ <div data-testid="card" style={ { overflow: 'hidden' } }>
128
+ <div style={ { position: 'relative' } }>
129
+ <BoundedTooltip left={ 173 } top={ 50 } data-testid="box">
130
+ content
131
+ </BoundedTooltip>
132
+ </div>
133
+ </div>
134
+ );
135
+
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)' } );
139
+ } );
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
+
204
+ test( 'falls back to the viewport when nothing clips', () => {
205
+ jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
206
+ this: Element
207
+ ) {
208
+ return this.classList.contains( 'visx-tooltip' )
209
+ ? rect( 0, 0, 208, 36 )
210
+ : rect( 900, 0, 346, 200 );
211
+ } );
212
+
213
+ render(
214
+ <div style={ { position: 'relative' } }>
215
+ <BoundedTooltip left={ 173 } top={ 50 } data-testid="box">
216
+ content
217
+ </BoundedTooltip>
218
+ </div>
219
+ );
220
+
221
+ // JSDOM's viewport is 1024px wide and the wrapper starts at 900: the box
222
+ // flips left of the anchor and stops at the viewport's right edge.
223
+ expect( screen.getByTestId( 'box' ) ).toHaveStyle( { transform: 'translate(-84px, 60px)' } );
224
+ } );
225
+ } );
@@ -0,0 +1,379 @@
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
+ import { BarSeries, LineSeries, TooltipContext, XYChart } from '@visx/xychart';
3
+ import { useContext, useEffect } from 'react';
4
+ import { XyChartTooltip } from '../xy-chart-tooltip';
5
+ import type { XyChartTooltipProps } from '../../../visx/types';
6
+ import type { EventHandlerParams } from '@visx/xychart';
7
+
8
+ type Datum = { x: number; y: number };
9
+
10
+ const SERIES_A: Datum[] = [
11
+ { x: 0, y: 0 },
12
+ { x: 5, y: 50 },
13
+ { x: 10, y: 100 },
14
+ ];
15
+ const SERIES_B: Datum[] = [
16
+ { x: 0, y: 100 },
17
+ { x: 5, y: 25 },
18
+ { x: 10, y: 0 },
19
+ ];
20
+
21
+ const xAccessor = ( d: Datum ) => d.x;
22
+ const yAccessor = ( d: Datum ) => d.y;
23
+
24
+ type BandDatum = { x: string; y: number };
25
+ const BAND_DATA: BandDatum[] = [
26
+ { x: 'a', y: 50 },
27
+ { x: 'b', y: 80 },
28
+ ];
29
+ const bandXAccessor = ( d: BandDatum ) => d.x;
30
+ const bandYAccessor = ( d: BandDatum ) => d.y;
31
+ const renderBarTooltip = () => <span>bar</span>;
32
+ const BAND_PARAMS = [
33
+ { key: 'bars', index: 0, datum: BAND_DATA[ 0 ], svgPoint: { x: 20, y: 90 } },
34
+ ] as unknown as EventHandlerParams< Datum >[];
35
+
36
+ // The pointer sits on A's datum, 25px above B's, so A is the nearest datum.
37
+ const BOTH_SERIES: EventHandlerParams< Datum >[] = [
38
+ {
39
+ key: 'A',
40
+ index: 1,
41
+ datum: SERIES_A[ 1 ],
42
+ svgPoint: { x: 100, y: 50 },
43
+ distanceX: 0,
44
+ distanceY: 0,
45
+ },
46
+ {
47
+ key: 'B',
48
+ index: 1,
49
+ datum: SERIES_B[ 1 ],
50
+ svgPoint: { x: 100, y: 50 },
51
+ distanceX: 0,
52
+ distanceY: 25,
53
+ },
54
+ ];
55
+
56
+ // Opens the tooltip the way a pointer event would, once the series have registered.
57
+ const OpenTooltip = ( { params }: { params: EventHandlerParams< Datum >[] } ) => {
58
+ const tooltipContext = useContext( TooltipContext );
59
+ useEffect( () => {
60
+ params.forEach( p => tooltipContext?.showTooltip( p ) );
61
+ // The context object changes identity on every tooltip update; re-running would loop.
62
+ // eslint-disable-next-line react-hooks/exhaustive-deps
63
+ }, [] );
64
+ return null;
65
+ };
66
+
67
+ const renderTooltip: XyChartTooltipProps< Datum >[ 'renderTooltip' ] = ( { tooltipData } ) => (
68
+ <span>{ `nearest:${ tooltipData?.nearestDatum?.key ?? '' }` }</span>
69
+ );
70
+
71
+ const NO_MARGIN = { top: 0, right: 0, bottom: 0, left: 0 };
72
+
73
+ // 200x100 chart; with no margin x maps 0..10 to 0..200px and y maps 0..100 to 100..0px.
74
+ const renderChart = (
75
+ tooltipProps: Partial< XyChartTooltipProps< Datum > > = {},
76
+ params: EventHandlerParams< Datum >[] = [
77
+ { key: 'A', index: 1, datum: SERIES_A[ 1 ], svgPoint: { x: 123, y: 45 } },
78
+ ],
79
+ margin = NO_MARGIN
80
+ ) =>
81
+ render(
82
+ <div data-testid="wrapper" style={ { position: 'relative' } }>
83
+ <XYChart
84
+ width={ 200 }
85
+ height={ 100 }
86
+ margin={ margin }
87
+ xScale={ { type: 'linear', domain: [ 0, 10 ] } }
88
+ yScale={ { type: 'linear', domain: [ 0, 100 ] } }
89
+ >
90
+ <LineSeries dataKey="A" data={ SERIES_A } xAccessor={ xAccessor } yAccessor={ yAccessor } />
91
+ <LineSeries dataKey="B" data={ SERIES_B } xAccessor={ xAccessor } yAccessor={ yAccessor } />
92
+ <XyChartTooltip
93
+ renderTooltip={ renderTooltip }
94
+ data-testid="tooltip-box"
95
+ { ...tooltipProps }
96
+ />
97
+ <OpenTooltip params={ params } />
98
+ </XYChart>
99
+ </div>
100
+ );
101
+
102
+ describe( 'XyChartTooltip', () => {
103
+ test( 'renders the tooltip box inside the chart wrapper, not in a body-level portal', async () => {
104
+ const { container } = renderChart();
105
+
106
+ const box = await screen.findByTestId( 'tooltip-box' );
107
+
108
+ expect( box ).toHaveTextContent( 'nearest:A' );
109
+ expect( screen.getByTestId( 'wrapper' ) ).toContainElement( box );
110
+ // A body-level portal would sit outside the render root.
111
+ expect( container ).toContainElement( box );
112
+ } );
113
+
114
+ test( 'places the box at the pointer position when snapping is off', async () => {
115
+ renderChart();
116
+
117
+ // TooltipWithBounds moves the box through a transform from (0, 0), after its 10px offsets.
118
+ await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( {
119
+ transform: 'translate(133px, 55px)',
120
+ } );
121
+ } );
122
+
123
+ test( 'snaps the box to the nearest datum', async () => {
124
+ renderChart( { snapTooltipToDatumX: true, snapTooltipToDatumY: true } );
125
+
126
+ // Datum (5, 50) sits at (100px, 50px); the default 10px offsets apply after snapping.
127
+ await waitFor( () =>
128
+ expect( screen.getByTestId( 'tooltip-box' ) ).toHaveStyle( {
129
+ transform: 'translate(110px, 60px)',
130
+ } )
131
+ );
132
+ } );
133
+
134
+ test( 'anchors below the x-axis label band without moving datum glyphs or crosshairs', async () => {
135
+ const originalRect = Element.prototype.getBoundingClientRect;
136
+ const rectSpy = jest
137
+ .spyOn( Element.prototype, 'getBoundingClientRect' )
138
+ .mockImplementation( function ( this: Element ) {
139
+ const rect = originalRect.call( this );
140
+ return this.classList.contains( 'visx-tooltip' )
141
+ ? { ...rect, width: 80, height: 30, right: rect.left + 80, bottom: rect.top + 30 }
142
+ : rect;
143
+ } );
144
+
145
+ try {
146
+ renderChart(
147
+ {
148
+ tooltipPlacement: 'below-axis',
149
+ snapTooltipToDatumY: true,
150
+ showDatumGlyph: true,
151
+ showVerticalCrosshair: true,
152
+ showHorizontalCrosshair: true,
153
+ },
154
+ undefined,
155
+ { top: 10, right: 20, bottom: 30, left: 40 }
156
+ );
157
+
158
+ const box = await screen.findByTestId( 'tooltip-box' );
159
+ expect( box ).toHaveStyle( { transform: 'translate(70px, 106px)' } );
160
+ expect( screen.getByTestId( 'wrapper' ) ).toContainElement( box );
161
+ expect( screen.getByTestId( 'tooltip-axis-pointer' ) ).toHaveStyle( {
162
+ left: '34px',
163
+ top: '-6px',
164
+ } );
165
+ expect( screen.getByTestId( 'xy-chart-tooltip-glyph-group-A' ) ).toHaveAttribute(
166
+ 'transform',
167
+ 'translate(110, 40)'
168
+ );
169
+ expect( screen.getByTestId( 'xy-chart-tooltip-crosshair-vertical' ) ).toHaveAttribute(
170
+ 'y2',
171
+ '70'
172
+ );
173
+ expect( screen.getByTestId( 'xy-chart-tooltip-crosshair-horizontal' ) ).toHaveAttribute(
174
+ 'y1',
175
+ '40'
176
+ );
177
+ } finally {
178
+ rectSpy.mockRestore();
179
+ }
180
+ } );
181
+
182
+ test( 'draws one glyph per series in the SVG at the datum position', async () => {
183
+ renderChart( { showSeriesGlyphs: true }, BOTH_SERIES );
184
+
185
+ const glyphA = await screen.findByTestId( 'xy-chart-tooltip-glyph-A' );
186
+ const glyphB = await screen.findByTestId( 'xy-chart-tooltip-glyph-B' );
187
+
188
+ // The group is placed at the datum; the glyph itself draws at the origin.
189
+ expect( screen.getByTestId( 'xy-chart-tooltip-glyph-group-A' ) ).toHaveAttribute(
190
+ 'transform',
191
+ 'translate(100, 50)'
192
+ );
193
+ expect( screen.getByTestId( 'xy-chart-tooltip-glyph-group-B' ) ).toHaveAttribute(
194
+ 'transform',
195
+ 'translate(100, 75)'
196
+ );
197
+ expect( glyphA ).toHaveAttribute( 'cx', '0' );
198
+ expect( glyphA ).toHaveAttribute( 'cy', '0' );
199
+ expect( glyphB ).toHaveAttribute( 'cx', '0' );
200
+ expect( glyphB ).toHaveAttribute( 'cy', '0' );
201
+ // Drawn into the chart SVG, not portaled out as HTML.
202
+ expect( glyphA ).toBeInstanceOf( SVGElement );
203
+ } );
204
+
205
+ test( 'hands the glyph renderer the origin, like visx, and marks the nearest datum', async () => {
206
+ const renderGlyph = jest.fn( () => null );
207
+ renderChart( { showSeriesGlyphs: true, renderGlyph }, BOTH_SERIES );
208
+
209
+ await waitFor( () => expect( renderGlyph ).toHaveBeenCalledTimes( 2 ) );
210
+
211
+ const calls = renderGlyph.mock.calls as unknown as Array<
212
+ [ { key: string; x: number; y: number; isNearestDatum: boolean; datum: Datum } ]
213
+ >;
214
+ const byKey = Object.fromEntries( calls.map( ( [ p ] ) => [ p.key, p ] ) );
215
+ // A renderer written against visx's Tooltip ignores x and y, so both stay 0.
216
+ expect( byKey.A ).toMatchObject( { x: 0, y: 0, datum: SERIES_A[ 1 ] } );
217
+ expect( byKey.B ).toMatchObject( { x: 0, y: 0, datum: SERIES_B[ 1 ] } );
218
+ expect( byKey.A.isNearestDatum ).toBe( true );
219
+ expect( byKey.B.isNearestDatum ).toBe( false );
220
+ } );
221
+
222
+ test( 'centres a band-scale datum in its band', async () => {
223
+ render(
224
+ <div style={ { position: 'relative' } }>
225
+ <XYChart
226
+ width={ 200 }
227
+ height={ 100 }
228
+ margin={ NO_MARGIN }
229
+ xScale={ { type: 'band', domain: [ 'a', 'b' ], paddingInner: 0, paddingOuter: 0 } }
230
+ yScale={ { type: 'linear', domain: [ 0, 100 ] } }
231
+ >
232
+ <BarSeries
233
+ dataKey="bars"
234
+ data={ BAND_DATA }
235
+ xAccessor={ bandXAccessor }
236
+ yAccessor={ bandYAccessor }
237
+ />
238
+ <XyChartTooltip
239
+ renderTooltip={ renderBarTooltip }
240
+ data-testid="tooltip-box"
241
+ snapTooltipToDatumX
242
+ snapTooltipToDatumY
243
+ showDatumGlyph
244
+ />
245
+ <OpenTooltip params={ BAND_PARAMS } />
246
+ </XYChart>
247
+ </div>
248
+ );
249
+
250
+ // Band 'a' spans 0..100px; the datum sits at its centre, 50px, not its start.
251
+ await waitFor( () =>
252
+ expect( screen.getByTestId( 'xy-chart-tooltip-glyph-group-bars' ) ).toHaveAttribute(
253
+ 'transform',
254
+ 'translate(50, 50)'
255
+ )
256
+ );
257
+ expect( screen.getByTestId( 'tooltip-box' ) ).toHaveStyle( {
258
+ transform: 'translate(60px, 60px)',
259
+ } );
260
+ } );
261
+
262
+ test( 'draws the crosshairs across the plot area, inside the margins, at the snapped position', async () => {
263
+ renderChart(
264
+ {
265
+ snapTooltipToDatumX: true,
266
+ snapTooltipToDatumY: true,
267
+ showVerticalCrosshair: true,
268
+ showHorizontalCrosshair: true,
269
+ },
270
+ undefined,
271
+ { top: 10, right: 20, bottom: 30, left: 40 }
272
+ );
273
+
274
+ const vertical = await screen.findByTestId( 'xy-chart-tooltip-crosshair-vertical' );
275
+ const horizontal = await screen.findByTestId( 'xy-chart-tooltip-crosshair-horizontal' );
276
+
277
+ // Plot area is 140x60 at (40, 10); datum (5, 50) sits at its centre, (110, 40).
278
+ expect( vertical ).toHaveAttribute( 'x1', '110' );
279
+ expect( vertical ).toHaveAttribute( 'y1', '10' );
280
+ expect( vertical ).toHaveAttribute( 'y2', '70' );
281
+ expect( horizontal ).toHaveAttribute( 'y1', '40' );
282
+ expect( horizontal ).toHaveAttribute( 'x1', '40' );
283
+ expect( horizontal ).toHaveAttribute( 'x2', '180' );
284
+ } );
285
+
286
+ test( 'applies crosshair paint without allowing geometry overrides', async () => {
287
+ const consumerStyle = {
288
+ stroke: 'purple',
289
+ strokeWidth: 8,
290
+ className: 'custom-crosshair',
291
+ x1: 999,
292
+ transform: 'translate(100 0)',
293
+ style: { strokeOpacity: 0.25, transform: 'translateX(100px)' },
294
+ };
295
+ renderChart( {
296
+ snapTooltipToDatumX: true,
297
+ snapTooltipToDatumY: true,
298
+ showVerticalCrosshair: true,
299
+ showHorizontalCrosshair: true,
300
+ verticalCrosshairStyle: consumerStyle,
301
+ horizontalCrosshairStyle: consumerStyle,
302
+ } );
303
+
304
+ for ( const direction of [ 'vertical', 'horizontal' ] ) {
305
+ const guide = await screen.findByTestId( `xy-chart-tooltip-crosshair-${ direction }` );
306
+ expect( guide ).toHaveAttribute( 'stroke', 'purple' );
307
+ expect( guide ).toHaveAttribute( 'stroke-width', '8' );
308
+ expect( guide ).toHaveClass( 'custom-crosshair' );
309
+ expect( guide ).toHaveStyle( { 'stroke-opacity': '0.25', transform: 'none' } );
310
+ expect( guide ).not.toHaveAttribute( 'transform' );
311
+ expect( guide ).toHaveAttribute( 'x1', direction === 'vertical' ? '100' : '0' );
312
+ expect( guide ).toHaveAttribute( 'x2', direction === 'vertical' ? '100' : '200' );
313
+ expect( guide ).toHaveAttribute( 'y1', direction === 'vertical' ? '0' : '50' );
314
+ expect( guide ).toHaveAttribute( 'y2', direction === 'vertical' ? '100' : '50' );
315
+ }
316
+ } );
317
+
318
+ test( 'stacks the box above the chart overlays, with zIndex as the override', async () => {
319
+ const { unmount } = renderChart();
320
+ await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { zIndex: '3' } );
321
+ unmount();
322
+
323
+ renderChart( { zIndex: 9 } );
324
+ await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { zIndex: '9' } );
325
+ } );
326
+
327
+ test.each( [ 'auto', 'below-axis' ] as const )(
328
+ 'preserves %s background and stacking with a partial style override',
329
+ async tooltipPlacement => {
330
+ const { unmount } = renderChart( { tooltipPlacement } );
331
+ const defaultBox = await screen.findByTestId( 'tooltip-box' );
332
+ const background = defaultBox.style.backgroundColor;
333
+ expect( background ).not.toBe( '' );
334
+ expect( background ).not.toBe( 'transparent' );
335
+ unmount();
336
+
337
+ renderChart( { tooltipPlacement, style: { color: 'red' } } );
338
+
339
+ await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( {
340
+ backgroundColor: background,
341
+ zIndex: '3',
342
+ color: 'rgb(255, 0, 0)',
343
+ } );
344
+ }
345
+ );
346
+
347
+ test( 'accepts the portal-era options without passing them to the box', async () => {
348
+ renderChart( { scroll: true, debounce: 50, resizeObserverPolyfill: undefined } );
349
+
350
+ const box = await screen.findByTestId( 'tooltip-box' );
351
+
352
+ expect( box ).not.toHaveAttribute( 'scroll' );
353
+ expect( box ).not.toHaveAttribute( 'debounce' );
354
+ } );
355
+
356
+ test( 'renders no box when there is no tooltip renderer', async () => {
357
+ renderChart( { renderTooltip: undefined } );
358
+
359
+ await expect( screen.findByTestId( 'xy-chart-tooltip-anchor' ) ).resolves.toBeInTheDocument();
360
+ expect( screen.queryByTestId( 'tooltip-box' ) ).not.toBeInTheDocument();
361
+ } );
362
+
363
+ test( 'renders nothing when the tooltip content is empty', async () => {
364
+ renderChart( { renderTooltip: () => null, showSeriesGlyphs: true }, BOTH_SERIES );
365
+
366
+ // The anchor is the only thing rendered while there is nothing to show.
367
+ await expect( screen.findByTestId( 'xy-chart-tooltip-anchor' ) ).resolves.toBeInTheDocument();
368
+ expect( screen.queryByTestId( 'tooltip-box' ) ).not.toBeInTheDocument();
369
+ expect( screen.queryByTestId( 'xy-chart-tooltip-glyph-A' ) ).not.toBeInTheDocument();
370
+ } );
371
+
372
+ test( 'renders nothing while the tooltip is closed', () => {
373
+ renderChart( { showSeriesGlyphs: true, showVerticalCrosshair: true }, [] );
374
+
375
+ expect( screen.queryByTestId( 'tooltip-box' ) ).not.toBeInTheDocument();
376
+ expect( screen.queryByTestId( 'xy-chart-tooltip-glyph-A' ) ).not.toBeInTheDocument();
377
+ expect( screen.queryByTestId( 'xy-chart-tooltip-crosshair-vertical' ) ).not.toBeInTheDocument();
378
+ } );
379
+ } );