@automattic/charts 0.56.3 → 0.56.5

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 (186) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/charts/bar-chart/index.cjs +5 -5
  3. package/dist/charts/bar-chart/index.css +12 -0
  4. package/dist/charts/bar-chart/index.css.map +1 -1
  5. package/dist/charts/bar-chart/index.js +4 -4
  6. package/dist/charts/bar-list-chart/index.cjs +6 -6
  7. package/dist/charts/bar-list-chart/index.css +12 -0
  8. package/dist/charts/bar-list-chart/index.css.map +1 -1
  9. package/dist/charts/bar-list-chart/index.js +5 -5
  10. package/dist/charts/conversion-funnel-chart/index.cjs +5 -3
  11. package/dist/charts/conversion-funnel-chart/index.cjs.map +1 -1
  12. package/dist/charts/conversion-funnel-chart/index.css +14 -1
  13. package/dist/charts/conversion-funnel-chart/index.css.map +1 -1
  14. package/dist/charts/conversion-funnel-chart/index.d.cts +2 -0
  15. package/dist/charts/conversion-funnel-chart/index.d.ts +2 -0
  16. package/dist/charts/conversion-funnel-chart/index.js +4 -2
  17. package/dist/charts/geo-chart/index.cjs +4 -4
  18. package/dist/charts/geo-chart/index.css +12 -0
  19. package/dist/charts/geo-chart/index.css.map +1 -1
  20. package/dist/charts/geo-chart/index.js +3 -3
  21. package/dist/charts/leaderboard-chart/index.cjs +5 -5
  22. package/dist/charts/leaderboard-chart/index.css +12 -0
  23. package/dist/charts/leaderboard-chart/index.css.map +1 -1
  24. package/dist/charts/leaderboard-chart/index.js +4 -4
  25. package/dist/charts/line-chart/index.cjs +5 -5
  26. package/dist/charts/line-chart/index.css +12 -0
  27. package/dist/charts/line-chart/index.css.map +1 -1
  28. package/dist/charts/line-chart/index.js +4 -4
  29. package/dist/charts/pie-chart/index.cjs +7 -7
  30. package/dist/charts/pie-chart/index.css +12 -0
  31. package/dist/charts/pie-chart/index.css.map +1 -1
  32. package/dist/charts/pie-chart/index.js +6 -6
  33. package/dist/charts/pie-semi-circle-chart/index.cjs +7 -7
  34. package/dist/charts/pie-semi-circle-chart/index.css +12 -0
  35. package/dist/charts/pie-semi-circle-chart/index.css.map +1 -1
  36. package/dist/charts/pie-semi-circle-chart/index.js +6 -6
  37. package/dist/charts/sparkline/index.cjs +6 -6
  38. package/dist/charts/sparkline/index.css +12 -0
  39. package/dist/charts/sparkline/index.css.map +1 -1
  40. package/dist/charts/sparkline/index.js +5 -5
  41. package/dist/{chunk-OTZT3MC2.cjs → chunk-2A34OA5O.cjs} +19 -20
  42. package/dist/chunk-2A34OA5O.cjs.map +1 -0
  43. package/dist/{chunk-H34CJSR6.js → chunk-32ESS4MV.js} +406 -363
  44. package/dist/chunk-32ESS4MV.js.map +1 -0
  45. package/dist/{chunk-CEZGL6YP.js → chunk-6CCZL2JJ.js} +15 -7
  46. package/dist/chunk-6CCZL2JJ.js.map +1 -0
  47. package/dist/{chunk-NW3RUYK2.cjs → chunk-7QDEU3KN.cjs} +15 -22
  48. package/dist/chunk-7QDEU3KN.cjs.map +1 -0
  49. package/dist/chunk-7TQSPLIN.js +351 -0
  50. package/dist/chunk-7TQSPLIN.js.map +1 -0
  51. package/dist/{chunk-T4J6TI55.js → chunk-AFWQR3SM.js} +102 -79
  52. package/dist/chunk-AFWQR3SM.js.map +1 -0
  53. package/dist/{chunk-5XI443YP.js → chunk-BPYKWMI7.js} +72 -64
  54. package/dist/chunk-BPYKWMI7.js.map +1 -0
  55. package/dist/{chunk-TNRKEBTA.js → chunk-DBY6C4O2.js} +148 -164
  56. package/dist/{chunk-TNRKEBTA.js.map → chunk-DBY6C4O2.js.map} +1 -1
  57. package/dist/chunk-DLSUC7RN.js +1065 -0
  58. package/dist/chunk-DLSUC7RN.js.map +1 -0
  59. package/dist/{chunk-TVV7ZI7C.cjs → chunk-EJJO2QNB.cjs} +399 -356
  60. package/dist/chunk-EJJO2QNB.cjs.map +1 -0
  61. package/dist/{chunk-ODF5O5PV.cjs → chunk-FIFSYVN6.cjs} +154 -170
  62. package/dist/chunk-FIFSYVN6.cjs.map +1 -0
  63. package/dist/chunk-FY325WQ4.cjs +1065 -0
  64. package/dist/chunk-FY325WQ4.cjs.map +1 -0
  65. package/dist/{chunk-SRXJLAKG.cjs → chunk-I2276W3I.cjs} +28 -37
  66. package/dist/chunk-I2276W3I.cjs.map +1 -0
  67. package/dist/{chunk-7UJPVCMB.cjs → chunk-IHESL7H5.cjs} +265 -262
  68. package/dist/chunk-IHESL7H5.cjs.map +1 -0
  69. package/dist/chunk-JL4ZKKZU.cjs +375 -0
  70. package/dist/chunk-JL4ZKKZU.cjs.map +1 -0
  71. package/dist/chunk-KHRPRH4V.js +165 -0
  72. package/dist/chunk-KHRPRH4V.js.map +1 -0
  73. package/dist/{chunk-A3AEEGKR.js → chunk-KXRWNFQJ.js} +20 -21
  74. package/dist/chunk-KXRWNFQJ.js.map +1 -0
  75. package/dist/{chunk-2VPPTJS2.js → chunk-KXSLMOW5.js} +256 -253
  76. package/dist/chunk-KXSLMOW5.js.map +1 -0
  77. package/dist/chunk-LT4YOIMM.js +375 -0
  78. package/dist/chunk-LT4YOIMM.js.map +1 -0
  79. package/dist/chunk-LTPJPIDP.cjs +165 -0
  80. package/dist/chunk-LTPJPIDP.cjs.map +1 -0
  81. package/dist/chunk-NGHXTIUE.cjs +120 -0
  82. package/dist/chunk-NGHXTIUE.cjs.map +1 -0
  83. package/dist/chunk-PCOI2GT5.js +120 -0
  84. package/dist/chunk-PCOI2GT5.js.map +1 -0
  85. package/dist/{chunk-YYQ4IK5V.cjs → chunk-Q6G3BGCL.cjs} +103 -80
  86. package/dist/chunk-Q6G3BGCL.cjs.map +1 -0
  87. package/dist/{chunk-HIWNB5PK.cjs → chunk-RCY6XLGU.cjs} +13 -5
  88. package/dist/chunk-RCY6XLGU.cjs.map +1 -0
  89. package/dist/chunk-TKPK4RFS.cjs +351 -0
  90. package/dist/chunk-TKPK4RFS.cjs.map +1 -0
  91. package/dist/{chunk-C33AQZEC.js → chunk-TYIH5LMV.js} +16 -23
  92. package/dist/chunk-TYIH5LMV.js.map +1 -0
  93. package/dist/chunk-X6GX4QUJ.js +421 -0
  94. package/dist/chunk-X6GX4QUJ.js.map +1 -0
  95. package/dist/chunk-XCXAWMJQ.cjs +421 -0
  96. package/dist/chunk-XCXAWMJQ.cjs.map +1 -0
  97. package/dist/chunk-XWYZIFZW.js +66 -0
  98. package/dist/chunk-XWYZIFZW.js.map +1 -0
  99. package/dist/{chunk-7HROSZRS.cjs → chunk-Y3NNQMAX.cjs} +70 -62
  100. package/dist/chunk-Y3NNQMAX.cjs.map +1 -0
  101. package/dist/components/legend/index.cjs +2 -2
  102. package/dist/components/legend/index.css +12 -0
  103. package/dist/components/legend/index.css.map +1 -1
  104. package/dist/components/legend/index.js +1 -1
  105. package/dist/components/tooltip/index.cjs +2 -2
  106. package/dist/components/tooltip/index.js +1 -1
  107. package/dist/components/trend-indicator/index.cjs +2 -2
  108. package/dist/components/trend-indicator/index.js +1 -1
  109. package/dist/hooks/index.cjs +4 -2
  110. package/dist/hooks/index.cjs.map +1 -1
  111. package/dist/hooks/index.css +12 -0
  112. package/dist/hooks/index.css.map +1 -1
  113. package/dist/hooks/index.d.cts +28 -2
  114. package/dist/hooks/index.d.ts +28 -2
  115. package/dist/hooks/index.js +3 -1
  116. package/dist/index.cjs +18 -18
  117. package/dist/index.cjs.map +1 -1
  118. package/dist/index.css +14 -1
  119. package/dist/index.css.map +1 -1
  120. package/dist/index.d.cts +1 -1
  121. package/dist/index.d.ts +1 -1
  122. package/dist/index.js +17 -17
  123. package/dist/providers/index.cjs +2 -2
  124. package/dist/providers/index.css +12 -0
  125. package/dist/providers/index.css.map +1 -1
  126. package/dist/providers/index.d.cts +1 -1
  127. package/dist/providers/index.d.ts +1 -1
  128. package/dist/providers/index.js +1 -1
  129. package/dist/{themes-DQs9rbN5.d.cts → themes-BDVaIfBz.d.cts} +9 -0
  130. package/dist/{themes-CRV5fVzJ.d.ts → themes-mcS8QNkQ.d.ts} +9 -0
  131. package/package.json +6 -2
  132. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +2 -1
  133. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +16 -6
  134. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +34 -0
  135. package/src/charts/conversion-funnel-chart/types.ts +2 -0
  136. package/src/charts/pie-chart/pie-chart.tsx +2 -3
  137. package/src/hooks/index.ts +1 -0
  138. package/src/hooks/test/use-chart-margin.test.tsx +44 -0
  139. package/src/hooks/test/use-tooltip-portal-relocator.test.ts +216 -0
  140. package/src/hooks/use-chart-margin.tsx +92 -6
  141. package/src/hooks/use-tooltip-portal-relocator.module.scss +10 -0
  142. package/src/hooks/use-tooltip-portal-relocator.ts +177 -0
  143. package/src/providers/chart-context/global-charts-provider.tsx +18 -1
  144. package/tsup.config.ts +11 -0
  145. package/dist/chunk-2VPPTJS2.js.map +0 -1
  146. package/dist/chunk-5XI443YP.js.map +0 -1
  147. package/dist/chunk-7HROSZRS.cjs.map +0 -1
  148. package/dist/chunk-7UJPVCMB.cjs.map +0 -1
  149. package/dist/chunk-A3AEEGKR.js.map +0 -1
  150. package/dist/chunk-C33AQZEC.js.map +0 -1
  151. package/dist/chunk-CEZGL6YP.js.map +0 -1
  152. package/dist/chunk-COOC2TVQ.js +0 -167
  153. package/dist/chunk-COOC2TVQ.js.map +0 -1
  154. package/dist/chunk-EJHLLXBV.js +0 -362
  155. package/dist/chunk-EJHLLXBV.js.map +0 -1
  156. package/dist/chunk-FWMJ2FR2.js +0 -121
  157. package/dist/chunk-FWMJ2FR2.js.map +0 -1
  158. package/dist/chunk-GRYNIPWH.cjs +0 -385
  159. package/dist/chunk-GRYNIPWH.cjs.map +0 -1
  160. package/dist/chunk-H34CJSR6.js.map +0 -1
  161. package/dist/chunk-HIWNB5PK.cjs.map +0 -1
  162. package/dist/chunk-IZWC33YN.cjs +0 -357
  163. package/dist/chunk-IZWC33YN.cjs.map +0 -1
  164. package/dist/chunk-KOF32DBL.cjs +0 -1058
  165. package/dist/chunk-KOF32DBL.cjs.map +0 -1
  166. package/dist/chunk-LHWRZMF7.cjs +0 -362
  167. package/dist/chunk-LHWRZMF7.cjs.map +0 -1
  168. package/dist/chunk-MFRS2PEY.cjs +0 -121
  169. package/dist/chunk-MFRS2PEY.cjs.map +0 -1
  170. package/dist/chunk-MMDLXS6O.js +0 -75
  171. package/dist/chunk-MMDLXS6O.js.map +0 -1
  172. package/dist/chunk-NW3RUYK2.cjs.map +0 -1
  173. package/dist/chunk-ODF5O5PV.cjs.map +0 -1
  174. package/dist/chunk-OTZT3MC2.cjs.map +0 -1
  175. package/dist/chunk-SBRMWDWM.js +0 -357
  176. package/dist/chunk-SBRMWDWM.js.map +0 -1
  177. package/dist/chunk-SRXJLAKG.cjs.map +0 -1
  178. package/dist/chunk-T4J6TI55.js.map +0 -1
  179. package/dist/chunk-TVV7ZI7C.cjs.map +0 -1
  180. package/dist/chunk-XVMXWV3C.cjs +0 -167
  181. package/dist/chunk-XVMXWV3C.cjs.map +0 -1
  182. package/dist/chunk-YYQ4IK5V.cjs.map +0 -1
  183. package/dist/chunk-ZDNCF642.js +0 -1058
  184. package/dist/chunk-ZDNCF642.js.map +0 -1
  185. package/dist/chunk-ZWBUEHKF.js +0 -385
  186. package/dist/chunk-ZWBUEHKF.js.map +0 -1
@@ -0,0 +1,216 @@
1
+ /* eslint-disable testing-library/no-node-access */
2
+ import { renderHook } from '@testing-library/react';
3
+ import { useTooltipPortalRelocator } from '../use-tooltip-portal-relocator';
4
+
5
+ // In the production build, CSS module class names are hashed (e.g. "a8ccharts-abc123").
6
+ // In jest, the SCSS module import is stubbed to a filename string, so
7
+ // styles.relocatedPortal resolves to undefined and classList.add() is a no-op.
8
+ // We mock the module to return a proper class map so we can assert on class names.
9
+ jest.mock( '../use-tooltip-portal-relocator.module.scss', () => ( {
10
+ __esModule: true,
11
+ default: { relocatedPortal: 'relocatedPortal' },
12
+ } ) );
13
+
14
+ /**
15
+ * Create a mock visx tooltip portal node for testing.
16
+ * @return {HTMLDivElement} A div mimicking a visx tooltip portal.
17
+ */
18
+ function createVisxPortalNode(): HTMLDivElement {
19
+ const portal = document.createElement( 'div' );
20
+ const child = document.createElement( 'div' );
21
+ child.className = 'visx-tooltip';
22
+ portal.appendChild( child );
23
+ return portal;
24
+ }
25
+
26
+ /**
27
+ * Sets up a container, ref, and renders the hook.
28
+ * Optionally appends a visx portal node to document.body before rendering.
29
+ * @param options - Setup options.
30
+ * @param options.withPortal - If true, creates and appends a visx portal before rendering.
31
+ * @return Setup result with container, ref, unmount, and optionally the portal node.
32
+ */
33
+ function setupHook( { withPortal = false } = {} ) {
34
+ const container = document.createElement( 'div' );
35
+ document.body.appendChild( container );
36
+
37
+ let portal: HTMLDivElement | undefined;
38
+ if ( withPortal ) {
39
+ portal = createVisxPortalNode();
40
+ document.body.appendChild( portal );
41
+ }
42
+
43
+ const ref = { current: container };
44
+ const { unmount } = renderHook( () => useTooltipPortalRelocator( ref ) );
45
+
46
+ return { container, ref, unmount, portal };
47
+ }
48
+
49
+ describe( 'useTooltipPortalRelocator', () => {
50
+ let nativeRemoveChild: typeof document.body.removeChild;
51
+
52
+ beforeAll( () => {
53
+ nativeRemoveChild = document.body.removeChild;
54
+ } );
55
+
56
+ afterEach( () => {
57
+ // Restore native removeChild and clear all body children to prevent
58
+ // leaked portals from interfering with subsequent tests.
59
+ document.body.removeChild = nativeRemoveChild;
60
+ while ( document.body.firstChild ) {
61
+ nativeRemoveChild.call( document.body, document.body.firstChild );
62
+ }
63
+ } );
64
+
65
+ test( 'does nothing when containerRef is undefined', () => {
66
+ const { unmount } = renderHook( () => useTooltipPortalRelocator( undefined ) );
67
+ const portal = createVisxPortalNode();
68
+ document.body.appendChild( portal );
69
+ expect( portal.parentNode ).toBe( document.body );
70
+ unmount();
71
+ } );
72
+
73
+ test( 'does nothing when containerRef.current is null', () => {
74
+ const nullRef = { current: null };
75
+ const { unmount } = renderHook( () => useTooltipPortalRelocator( nullRef ) );
76
+ const portal = createVisxPortalNode();
77
+ document.body.appendChild( portal );
78
+ expect( portal.parentNode ).toBe( document.body );
79
+ unmount();
80
+ } );
81
+
82
+ test( 'relocates existing visx portal nodes into the container', () => {
83
+ const { container, unmount, portal } = setupHook( { withPortal: true } );
84
+ expect( portal!.parentNode ).toBe( container );
85
+ unmount();
86
+ } );
87
+
88
+ test( 'applies relocated-portal class to relocated portals', () => {
89
+ const { unmount, portal } = setupHook( { withPortal: true } );
90
+ expect( portal! ).toHaveClass( 'relocatedPortal' );
91
+ unmount();
92
+ } );
93
+
94
+ test( 'does not relocate newly added non-visx nodes', async () => {
95
+ const { unmount } = setupHook();
96
+
97
+ const regularDiv = document.createElement( 'div' );
98
+ regularDiv.id = 'some-id';
99
+ document.body.appendChild( regularDiv );
100
+
101
+ // MutationObserver is async — wait for microtask
102
+ await new Promise( resolve => setTimeout( resolve, 0 ) );
103
+
104
+ expect( regularDiv.parentNode ).toBe( document.body );
105
+ unmount();
106
+ } );
107
+
108
+ test( 'observes and relocates newly added portal nodes', async () => {
109
+ const { container, unmount } = setupHook();
110
+
111
+ const portal = createVisxPortalNode();
112
+ document.body.appendChild( portal );
113
+
114
+ // MutationObserver is async — wait for microtask
115
+ await new Promise( resolve => setTimeout( resolve, 0 ) );
116
+
117
+ expect( portal.parentNode ).toBe( container );
118
+ unmount();
119
+ } );
120
+
121
+ test( 'patched removeChild handles relocated nodes without throwing', () => {
122
+ const { unmount, portal } = setupHook( { withPortal: true } );
123
+
124
+ // Portal is now in container, but visx will call document.body.removeChild(portal)
125
+ expect( () => document.body.removeChild( portal! ) ).not.toThrow();
126
+ expect( portal!.parentNode ).toBeNull();
127
+ unmount();
128
+ } );
129
+
130
+ test( 'patched removeChild delegates to original for non-relocated nodes', () => {
131
+ const { unmount } = setupHook();
132
+
133
+ const regularDiv = document.createElement( 'div' );
134
+ document.body.appendChild( regularDiv );
135
+ expect( () => document.body.removeChild( regularDiv ) ).not.toThrow();
136
+ expect( regularDiv.parentNode ).toBeNull();
137
+ unmount();
138
+ } );
139
+
140
+ test( 'cleanup restores removeChild when it has not been wrapped by others', () => {
141
+ const originalRemoveChild = document.body.removeChild;
142
+ const { unmount } = setupHook();
143
+
144
+ // removeChild should be patched
145
+ expect( document.body.removeChild ).not.toBe( originalRemoveChild );
146
+
147
+ unmount();
148
+
149
+ // Should be restored
150
+ expect( document.body.removeChild ).toBe( originalRemoveChild );
151
+ } );
152
+
153
+ test( 'cleanup leaves removeChild when another wrapper was installed after ours', () => {
154
+ const { unmount } = setupHook();
155
+
156
+ // Simulate another library wrapping removeChild after our patch
157
+ const ourPatch = document.body.removeChild;
158
+ const thirdPartyWrapper = function < T extends Node >( child: T ): T {
159
+ return ourPatch.call( document.body, child );
160
+ };
161
+ document.body.removeChild = thirdPartyWrapper;
162
+
163
+ unmount();
164
+
165
+ // Should NOT restore — third party wrapper is still in place
166
+ expect( document.body.removeChild ).toBe( thirdPartyWrapper );
167
+ } );
168
+
169
+ test( 'cleanup moves relocated nodes back to document.body', () => {
170
+ const { container, unmount, portal } = setupHook( { withPortal: true } );
171
+
172
+ expect( portal!.parentNode ).toBe( container );
173
+
174
+ unmount();
175
+
176
+ // Node should be moved back to body on cleanup
177
+ expect( portal!.parentNode ).toBe( document.body );
178
+ } );
179
+
180
+ test( 'cleanup removes relocated-portal class from nodes', () => {
181
+ const { unmount, portal } = setupHook( { withPortal: true } );
182
+
183
+ expect( portal! ).toHaveClass( 'relocatedPortal' );
184
+
185
+ unmount();
186
+
187
+ expect( portal! ).not.toHaveClass( 'relocatedPortal' );
188
+ } );
189
+
190
+ test( 'ref-counting allows multiple instances to share the patch', () => {
191
+ const container1 = document.createElement( 'div' );
192
+ const container2 = document.createElement( 'div' );
193
+ document.body.appendChild( container1 );
194
+ document.body.appendChild( container2 );
195
+
196
+ const ref1 = { current: container1 };
197
+ const ref2 = { current: container2 };
198
+
199
+ const originalRemoveChild = document.body.removeChild;
200
+
201
+ const { unmount: unmountFirst } = renderHook( () => useTooltipPortalRelocator( ref1 ) );
202
+ const patchedFn = document.body.removeChild;
203
+ const { unmount: unmountSecond } = renderHook( () => useTooltipPortalRelocator( ref2 ) );
204
+
205
+ // Both should share the same patched removeChild
206
+ expect( document.body.removeChild ).toBe( patchedFn );
207
+
208
+ // Unmounting the first should keep the patch (ref count > 0)
209
+ unmountFirst();
210
+ expect( document.body.removeChild ).toBe( patchedFn );
211
+
212
+ // Unmounting the second should restore the original
213
+ unmountSecond();
214
+ expect( document.body.removeChild ).toBe( originalRemoveChild );
215
+ } );
216
+ } );
@@ -4,6 +4,79 @@ import { getLongestTickWidth } from '../utils';
4
4
  import type { BaseChartProps, DataPointDate, SeriesData } from '../types';
5
5
  import type { XYChartTheme } from '@visx/xychart';
6
6
 
7
+ /**
8
+ * Base top margin used when no dynamic adjustments are necessary.
9
+ */
10
+ const DEFAULT_MARGIN_TOP = 10;
11
+
12
+ /**
13
+ * Base right margin used when no dynamic adjustments are necessary.
14
+ */
15
+ const DEFAULT_MARGIN_RIGHT = 20;
16
+
17
+ /**
18
+ * Base bottom margin used for charts with a bottom X-axis.
19
+ * This is large enough for typical font sizes and will be increased
20
+ * dynamically when tick labels require more space.
21
+ */
22
+ const DEFAULT_MARGIN_BOTTOM = 20;
23
+
24
+ /**
25
+ * Base left margin used when no dynamic adjustments are necessary.
26
+ */
27
+ const DEFAULT_MARGIN_LEFT = 20;
28
+
29
+ /**
30
+ * Bottom margin to use when the X-axis is rendered at the top.
31
+ * We only need a small buffer below the chart in that case.
32
+ */
33
+ const DEFAULT_BOTTOM_FOR_TOP_AXIS = 10;
34
+
35
+ /**
36
+ * Fallback font size used when we cannot derive a font size
37
+ * from the theme or axis styles for X-axis tick labels.
38
+ */
39
+ const DEFAULT_FONT_SIZE = 12;
40
+
41
+ /**
42
+ * Fallback tick length used when tickLength is not provided
43
+ * by the theme for either axis.
44
+ */
45
+ const DEFAULT_TICK_LENGTH = 8;
46
+
47
+ /**
48
+ * Fallback width used for Y-axis tick labels when we cannot
49
+ * measure them via getLongestTickWidth.
50
+ */
51
+ const DEFAULT_Y_TICK_WIDTH = 40;
52
+
53
+ const resolveFontSize = ( val?: number | string ): number | undefined => {
54
+ if ( typeof val === 'number' && ! isNaN( val ) ) {
55
+ return val;
56
+ }
57
+
58
+ if ( typeof val === 'string' ) {
59
+ const parsed = parseFloat( val );
60
+ return isNaN( parsed ) ? undefined : parsed;
61
+ }
62
+
63
+ return undefined;
64
+ };
65
+
66
+ const getXAxisLabelMetrics = ( theme: XYChartTheme, orientation: 'top' | 'bottom' ) => {
67
+ const xAxisStyles =
68
+ orientation === 'top' ? theme.axisStyles?.x?.top : theme.axisStyles?.x?.bottom;
69
+
70
+ const fontSize =
71
+ resolveFontSize( xAxisStyles?.axisLabel?.fontSize ) ||
72
+ resolveFontSize( theme.svgLabelSmall?.fontSize ) ||
73
+ DEFAULT_FONT_SIZE;
74
+
75
+ const tickLength = xAxisStyles?.tickLength ?? DEFAULT_TICK_LENGTH;
76
+
77
+ return { fontSize, tickLength };
78
+ };
79
+
7
80
  export const useChartMargin = (
8
81
  height: number,
9
82
  options: BaseChartProps[ 'options' ],
@@ -34,8 +107,12 @@ export const useChartMargin = (
34
107
 
35
108
  return useMemo( () => {
36
109
  // Default margin is for bottom axis labels.
37
- const defaultMargin = { top: 10, right: 20, bottom: 20, left: 20 };
38
- const defaultTickWidth = 40;
110
+ const defaultMargin = {
111
+ top: DEFAULT_MARGIN_TOP,
112
+ right: DEFAULT_MARGIN_RIGHT,
113
+ bottom: DEFAULT_MARGIN_BOTTOM,
114
+ left: DEFAULT_MARGIN_LEFT,
115
+ };
39
116
 
40
117
  // Auto-calculate margin for y axis labels based on orientation and tick width.
41
118
  const yAxisOrientation = options.axis?.y?.orientation;
@@ -46,7 +123,7 @@ export const useChartMargin = (
46
123
  options.axis?.y?.tickFormat,
47
124
  yAxisStyles.axisLabel
48
125
  );
49
- const yMarginValue = ( yTickWidth ?? defaultTickWidth ) + ( yAxisStyles?.tickLength ?? 0 );
126
+ const yMarginValue = ( yTickWidth ?? DEFAULT_Y_TICK_WIDTH ) + ( yAxisStyles?.tickLength ?? 0 );
50
127
 
51
128
  if ( yAxisOrientation === 'right' ) {
52
129
  defaultMargin.right = yMarginValue;
@@ -54,9 +131,18 @@ export const useChartMargin = (
54
131
  defaultMargin.left = yMarginValue;
55
132
  }
56
133
 
57
- if ( options.axis?.x?.orientation === 'top' ) {
58
- defaultMargin.top = 20;
59
- defaultMargin.bottom = 10;
134
+ // Dynamically compute X-axis margin (bottom by default, or top if orientation is 'top').
135
+ // This mirrors Y-axis behavior where margin is based on label size and tick length,
136
+ // but keeps the padding minimal so consumers can control container spacing themselves.
137
+ const xOrientation = options.axis?.x?.orientation === 'top' ? 'top' : 'bottom';
138
+ const { fontSize, tickLength } = getXAxisLabelMetrics( theme, xOrientation );
139
+ const computedXMargin = fontSize + tickLength;
140
+
141
+ if ( xOrientation === 'top' ) {
142
+ defaultMargin.top = Math.max( defaultMargin.top, computedXMargin );
143
+ defaultMargin.bottom = DEFAULT_BOTTOM_FOR_TOP_AXIS;
144
+ } else {
145
+ defaultMargin.bottom = Math.max( defaultMargin.bottom, computedXMargin );
60
146
  }
61
147
 
62
148
  return defaultMargin;
@@ -0,0 +1,10 @@
1
+ .relocatedPortal {
2
+ position: fixed;
3
+ top: 0;
4
+ left: 0;
5
+ width: 0;
6
+ height: 0;
7
+ overflow: visible;
8
+ z-index: 1;
9
+ pointer-events: none;
10
+ }
@@ -0,0 +1,177 @@
1
+ import { useEffect } from 'react';
2
+ import styles from './use-tooltip-portal-relocator.module.scss';
3
+ import type { RefObject } from 'react';
4
+
5
+ /**
6
+ * Detects whether a DOM node is a visx chart tooltip portal.
7
+ *
8
+ * visx renders tooltips via `ReactDOM.createPortal` into plain `<div>` elements
9
+ * appended to `document.body`. These portals have no id or className and contain
10
+ * a child element with the class `visx-tooltip`.
11
+ * @param node - The DOM node to check.
12
+ * @return Whether the node is a visx tooltip portal div.
13
+ */
14
+ function isVisxPortalNode( node: Node ): node is HTMLDivElement {
15
+ return (
16
+ node instanceof HTMLDivElement &&
17
+ node.parentElement === document.body &&
18
+ ! node.id &&
19
+ ! node.className &&
20
+ node.querySelector( '.visx-tooltip' ) !== null
21
+ );
22
+ }
23
+
24
+ // Shared state for the document.body.removeChild patch.
25
+ // Reference-counted so multiple hook instances can coexist safely.
26
+ let patchRefCount = 0;
27
+ let origRemoveChild: typeof document.body.removeChild | null = null;
28
+ let patchedRemoveChild: typeof document.body.removeChild | null = null;
29
+ const relocatedNodes = new WeakSet< Node >();
30
+
31
+ /**
32
+ * Installs (or increments the ref count of) the shared removeChild patch.
33
+ */
34
+ function installRemoveChildPatch() {
35
+ if ( patchRefCount++ > 0 ) {
36
+ return;
37
+ }
38
+ origRemoveChild = document.body.removeChild;
39
+ patchedRemoveChild = function < T extends Node >( this: HTMLElement, child: T ): T {
40
+ if ( relocatedNodes.has( child ) && child.parentNode !== this ) {
41
+ relocatedNodes.delete( child );
42
+ child.parentNode?.removeChild( child );
43
+ return child;
44
+ }
45
+ return origRemoveChild!.call( this, child );
46
+ };
47
+ document.body.removeChild = patchedRemoveChild;
48
+ }
49
+
50
+ /**
51
+ * Decrements the ref count and removes the patch when no instances remain.
52
+ * If another library has since wrapped our patch, we leave it in place to
53
+ * avoid breaking their chain — our function becomes a transparent pass-through
54
+ * once all relocated nodes have been cleaned up.
55
+ */
56
+ function uninstallRemoveChildPatch() {
57
+ if ( --patchRefCount > 0 ) {
58
+ return;
59
+ }
60
+ // Only revert if removeChild is still our function. If something else
61
+ // has wrapped it, reverting would break their patch.
62
+ if ( document.body.removeChild === patchedRemoveChild ) {
63
+ document.body.removeChild = origRemoveChild!;
64
+ }
65
+ origRemoveChild = null;
66
+ patchedRemoveChild = null;
67
+ }
68
+
69
+ /**
70
+ * Relocates visx chart tooltip portals from `document.body` into a target
71
+ * container element. This allows the tooltips to participate in the same CSS
72
+ * stacking context as other elements in the container (e.g. a sticky header),
73
+ * so z-index ordering works correctly between them.
74
+ *
75
+ * The relocated portal divs use `position: fixed` at the viewport origin to
76
+ * preserve the tooltip coordinate system (visx calculates positions relative
77
+ * to the viewport).
78
+ *
79
+ * Because the visx Portal class calls `document.body.removeChild(node)` during
80
+ * unmount, we patch `document.body.removeChild` to gracefully handle nodes that
81
+ * were moved out of body. Without this, React throws a "not a child of this
82
+ * node" error when tooltips unmount.
83
+ *
84
+ * **Important:** The container and its ancestors must not have CSS `transform`,
85
+ * `perspective`, or `filter` properties set, as these create a new containing
86
+ * block for `position: fixed` children, breaking viewport-relative positioning.
87
+ *
88
+ * @param containerRef - Ref to the element that portals should be relocated into.
89
+ * The element referenced here, or one of its ancestors,
90
+ * should establish the desired stacking context (for example
91
+ * by using position and z-index).
92
+ */
93
+ export function useTooltipPortalRelocator(
94
+ containerRef: RefObject< HTMLElement | null > | undefined
95
+ ) {
96
+ useEffect( () => {
97
+ const container = containerRef?.current;
98
+ if ( ! container ) {
99
+ return;
100
+ }
101
+
102
+ // Track nodes relocated by this instance so we can move them back on cleanup.
103
+ const instanceNodes = new Set< Node >();
104
+
105
+ const relocateNode = ( node: Node ) => {
106
+ if ( ! isVisxPortalNode( node ) ) {
107
+ return;
108
+ }
109
+
110
+ // Position the portal at the viewport origin so visx's
111
+ // absolute-positioned tooltip coordinates remain correct.
112
+ // Zero-size with overflow: visible so it doesn't affect layout
113
+ // but tooltip content still renders. pointerEvents: none on the
114
+ // wrapper is intentional — tooltip inner elements manage their own.
115
+ node.classList.add( styles.relocatedPortal );
116
+
117
+ // Remember the focused element before moving the node — relocating
118
+ // a DOM subtree causes the browser to blur any focused descendants.
119
+ const { activeElement } = node.ownerDocument;
120
+ const focusedElement =
121
+ activeElement instanceof HTMLElement && node.contains( activeElement )
122
+ ? activeElement
123
+ : null;
124
+
125
+ // Insert at the start of the container (before header and content).
126
+ container.insertBefore( node, container.firstChild );
127
+ relocatedNodes.add( node );
128
+ instanceNodes.add( node );
129
+
130
+ // Restore focus that was lost due to the DOM move.
131
+ if ( focusedElement ) {
132
+ focusedElement.focus();
133
+ }
134
+ };
135
+
136
+ // Patch document.body.removeChild so visx Portal unmount doesn't throw
137
+ // when it tries to remove a node we already moved out of body.
138
+ installRemoveChildPatch();
139
+
140
+ // Relocate any portals that already exist.
141
+ for ( const child of Array.from( document.body.children ) ) {
142
+ relocateNode( child );
143
+ }
144
+
145
+ // Watch for new portals being appended to body.
146
+ const observer = new MutationObserver( mutations => {
147
+ for ( const mutation of mutations ) {
148
+ for ( const node of mutation.addedNodes ) {
149
+ relocateNode( node );
150
+ }
151
+ }
152
+ } );
153
+
154
+ observer.observe( document.body, { childList: true } );
155
+
156
+ return () => {
157
+ // Disconnect first to avoid the observer re-relocating nodes
158
+ // as we move them back to body.
159
+ observer.disconnect();
160
+
161
+ // Move relocated nodes back to body so visx can clean them up
162
+ // normally with the original removeChild.
163
+ for ( const node of instanceNodes ) {
164
+ if ( node instanceof HTMLElement ) {
165
+ node.classList.remove( styles.relocatedPortal );
166
+ }
167
+ if ( node.parentNode === container ) {
168
+ document.body.appendChild( node );
169
+ }
170
+ relocatedNodes.delete( node );
171
+ }
172
+ instanceNodes.clear();
173
+
174
+ uninstallRemoveChildPatch();
175
+ };
176
+ }, [ containerRef ] );
177
+ }
@@ -8,6 +8,7 @@ import {
8
8
  useLayoutEffect,
9
9
  useRef,
10
10
  } from 'react';
11
+ import { useTooltipPortalRelocator } from '../../hooks/use-tooltip-portal-relocator';
11
12
  import {
12
13
  getItemShapeStyles,
13
14
  getSeriesLineStyles,
@@ -26,9 +27,22 @@ export const GlobalChartsContext = createContext< GlobalChartsContextValue | nul
26
27
  export interface GlobalChartsProviderProps {
27
28
  children: ReactNode;
28
29
  theme?: Partial< ChartTheme >;
30
+ /**
31
+ * Optional ref to an element that chart tooltip portals should be relocated into.
32
+ * When provided, visx tooltip portals (normally appended to document.body) will be
33
+ * moved into this container so they participate in the same effective CSS stacking context.
34
+ * The element referenced here, or one of its ancestors, should establish the desired
35
+ * stacking context (for example by using `position` and `z-index`) so that tooltips
36
+ * appear above the relevant chart content.
37
+ */
38
+ portalContainer?: React.RefObject< HTMLElement | null >;
29
39
  }
30
40
 
31
- export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { children, theme } ) => {
41
+ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
42
+ children,
43
+ theme,
44
+ portalContainer,
45
+ } ) => {
32
46
  const [ charts, setCharts ] = useState< Map< string, ChartRegistration > >( () => new Map() );
33
47
  // Track hidden series per chart: chartId -> Set<seriesLabel>
34
48
  const [ hiddenSeries, setHiddenSeries ] = useState< Map< string, Set< string > > >(
@@ -38,6 +52,9 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
38
52
  // Ref to the wrapper element for resolving scoped CSS variables
39
53
  const wrapperRef = useRef< HTMLDivElement >( null );
40
54
 
55
+ // Relocate tooltip portals into the wrapper (or a consumer-provided container) for z-index control.
56
+ useTooltipPortalRelocator( portalContainer ?? wrapperRef );
57
+
41
58
  const providerTheme: CompleteChartTheme = useMemo( () => {
42
59
  return theme ? mergeThemes( defaultTheme, theme ) : defaultTheme;
43
60
  }, [ theme ] );
package/tsup.config.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import babel from 'esbuild-plugin-babel';
1
2
  import { sassPlugin, postcssModules } from 'esbuild-sass-plugin';
2
3
  import { defineConfig } from 'tsup';
3
4
  import pkg from './package.json';
@@ -23,6 +24,16 @@ export default defineConfig( {
23
24
  '.png': 'file',
24
25
  },
25
26
  esbuildPlugins: [
27
+ babel( {
28
+ filter: /\.tsx$/,
29
+ config: {
30
+ presets: [
31
+ '@babel/preset-typescript',
32
+ [ '@babel/preset-react', { runtime: 'automatic' } ],
33
+ ],
34
+ plugins: [ [ 'react-remove-properties', { properties: [ 'data-testid' ] } ] ],
35
+ },
36
+ } ),
26
37
  sassPlugin( {
27
38
  filter: /\.module\.(css|scss)$/,
28
39
  embedded: true,