@automattic/charts 4.0.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 (56) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/SECURITY.md +1 -0
  3. package/TOKENS.md +1 -1
  4. package/dist/index.cjs +548 -199
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.css +21 -0
  7. package/dist/index.d.cts +86 -72
  8. package/dist/index.d.ts +86 -72
  9. package/dist/index.js +549 -201
  10. package/dist/index.js.map +1 -1
  11. package/package.json +2 -1
  12. package/src/charts/heatmap-chart/heatmap-chart.module.scss +31 -0
  13. package/src/charts/heatmap-chart/heatmap-chart.tsx +44 -9
  14. package/src/charts/heatmap-chart/index.ts +2 -1
  15. package/src/charts/heatmap-chart/private/build-calendar-data.ts +126 -92
  16. package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
  17. package/src/charts/heatmap-chart/private/index.ts +1 -1
  18. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
  19. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +3 -0
  20. package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
  21. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +91 -0
  22. package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
  23. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
  24. package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
  25. package/src/charts/heatmap-chart/types.ts +6 -0
  26. package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
  27. package/src/charts/leaderboard-chart/types.ts +2 -2
  28. package/src/charts/line-chart/line-chart.tsx +39 -6
  29. package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
  30. package/src/charts/line-chart/test/line-chart.test.tsx +147 -38
  31. package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
  32. package/src/charts/line-chart/types.ts +13 -2
  33. package/src/charts/pie-chart/test/pie-chart.test.tsx +3 -12
  34. package/src/components/tooltip/accessible-tooltip.tsx +8 -2
  35. package/src/components/tooltip/private/bounded-tooltip.tsx +71 -16
  36. package/src/components/tooltip/test/accessible-tooltip.test.tsx +28 -1
  37. package/src/components/tooltip/test/bounded-tooltip.test.tsx +102 -6
  38. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +100 -0
  39. package/src/components/tooltip/xy-chart-tooltip.tsx +51 -13
  40. package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
  41. package/src/hooks/test/use-chart-margin.test.tsx +173 -3
  42. package/src/hooks/use-chart-data-transform.ts +10 -3
  43. package/src/hooks/use-chart-margin.tsx +54 -9
  44. package/src/index.ts +4 -0
  45. package/src/test-utils/zoned-parsing-suite.ts +90 -0
  46. package/src/types.ts +2 -2
  47. package/src/utils/date-formatting.ts +1 -18
  48. package/src/utils/date-parsing.ts +103 -52
  49. package/src/utils/get-edge-tick-widths.ts +33 -0
  50. package/src/utils/index.ts +1 -0
  51. package/src/utils/test/date-parsing.auckland.test.ts +6 -0
  52. package/src/utils/test/date-parsing.santiago.test.ts +6 -0
  53. package/src/utils/test/date-parsing.test.ts +16 -1
  54. package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
  55. package/src/utils/warn-once.ts +23 -0
  56. package/src/visx/types.ts +27 -3
@@ -6,7 +6,7 @@ import { ChartScopeContext } from '../../../providers/chart-scope';
6
6
  import type { ReactNode } from 'react';
7
7
 
8
8
  // A real chart is the harness rather than the subject: the crosshairs render only once visx has a data context and an open tooltip. The unresponsive export is what lets the test own the scope element — `withResponsive` otherwise provides its own wrapper as the scope.
9
- const renderChart = ( scope?: HTMLElement ) => {
9
+ const renderChart = ( scope?: HTMLElement, tooltipPlacement?: 'auto' | 'below-axis' ) => {
10
10
  const chart = (
11
11
  <LineChartUnresponsive
12
12
  width={ 500 }
@@ -22,6 +22,7 @@ const renderChart = ( scope?: HTMLElement ) => {
22
22
  },
23
23
  ] }
24
24
  withTooltips
25
+ tooltipPlacement={ tooltipPlacement }
25
26
  withTooltipCrosshairs={ { showVertical: true } }
26
27
  withGradientFill={ false }
27
28
  />
@@ -59,6 +60,32 @@ describe( 'AccessibleTooltip', () => {
59
60
  document.body.removeChild( scope );
60
61
  } );
61
62
 
63
+ it( 'keeps the existing focus scrolling for automatic tooltips', async () => {
64
+ const focus = jest.spyOn( HTMLElement.prototype, 'focus' );
65
+ try {
66
+ renderChart();
67
+ await openTooltip();
68
+
69
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
70
+ expect( focus ).toHaveBeenLastCalledWith();
71
+ } finally {
72
+ focus.mockRestore();
73
+ }
74
+ } );
75
+
76
+ it( 'focuses below-axis keyboard tooltips without scrolling their ancestors', async () => {
77
+ const focus = jest.spyOn( HTMLElement.prototype, 'focus' );
78
+ try {
79
+ renderChart( undefined, 'below-axis' );
80
+ await openTooltip();
81
+
82
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
83
+ expect( focus ).toHaveBeenCalledWith( { preventScroll: true } );
84
+ } finally {
85
+ focus.mockRestore();
86
+ }
87
+ } );
88
+
62
89
  it( 'falls back to the catalog default when the role is unset', async () => {
63
90
  renderChart();
64
91
 
@@ -12,6 +12,7 @@ describe( 'getBoundedPosition', () => {
12
12
  top: 20,
13
13
  ...OFFSETS,
14
14
  box: BOX,
15
+ wrapper: { left: 0, top: 0, right: 600, bottom: 300 },
15
16
  bounds: { left: 0, top: 0, right: 600, bottom: 300 },
16
17
  } )
17
18
  ).toEqual( { x: 50, y: 30 } );
@@ -24,6 +25,7 @@ describe( 'getBoundedPosition', () => {
24
25
  top: 280,
25
26
  ...OFFSETS,
26
27
  box: BOX,
28
+ wrapper: { left: 0, top: 0, right: 600, bottom: 300 },
27
29
  bounds: { left: 0, top: 0, right: 600, bottom: 300 },
28
30
  } )
29
31
  ).toEqual( { x: 282, y: 234 } );
@@ -38,23 +40,54 @@ describe( 'getBoundedPosition', () => {
38
40
  top: 50,
39
41
  ...OFFSETS,
40
42
  box: BOX,
43
+ wrapper: { left: 0, top: 0, right: 346, bottom: 200 },
41
44
  bounds: { left: 0, top: 0, right: 346, bottom: 200 },
42
45
  } )
43
46
  ).toEqual( { x: 138, y: 60 } );
44
47
  } );
45
48
 
46
49
  test( 'may leave the wrapper as long as it stays inside the clipping ancestor', () => {
47
- // The wrapper starts 40px inside its clipping card, so the box can reach
48
- // 40px past the wrapper's left edge, and stops there.
50
+ // Neither side fits the 346px wrapper, and the clipping card ends 14px
51
+ // past its right edge, so the box keeps the near side and stops there.
49
52
  expect(
50
53
  getBoundedPosition( {
51
54
  left: 173,
52
55
  top: 50,
53
56
  ...OFFSETS,
54
57
  box: BOX,
58
+ wrapper: { left: 0, top: 0, right: 346, bottom: 200 },
55
59
  bounds: { left: -40, top: -60, right: 360, bottom: 200 },
56
60
  } )
57
- ).toEqual( { x: -40, y: 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 } );
58
91
  } );
59
92
  } );
60
93
 
@@ -100,11 +133,74 @@ describe( 'BoundedTooltip', () => {
100
133
  </div>
101
134
  );
102
135
 
103
- // Neither side of the anchor fits inside the card, so the box stops at
104
- // the card's left edge, 40px outside the wrapper.
105
- expect( screen.getByTestId( 'box' ) ).toHaveStyle( { transform: 'translate(-40px, 60px)' } );
136
+ // Neither side of the anchor fits the wrapper, so the box keeps the near
137
+ // side and stops at the card's right edge, 14px outside the wrapper.
138
+ expect( screen.getByTestId( 'box' ) ).toHaveStyle( { transform: 'translate(152px, 60px)' } );
106
139
  } );
107
140
 
141
+ test( 'keeps the box inside the wrapper when nothing clips and the anchor sits low', () => {
142
+ jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
143
+ this: Element
144
+ ) {
145
+ return this.classList.contains( 'visx-tooltip' )
146
+ ? rect( 0, 0, 150, 100 )
147
+ : rect( 100, 100, 600, 200 );
148
+ } );
149
+
150
+ render(
151
+ <div style={ { position: 'relative' } }>
152
+ <BoundedTooltip left={ 300 } top={ 180 } data-testid="box">
153
+ content
154
+ </BoundedTooltip>
155
+ </div>
156
+ );
157
+
158
+ // The viewport has room below the wrapper, but that is where the page's
159
+ // own content paints over the box; above the anchor it fits in the chart.
160
+ expect( screen.getByTestId( 'box' ) ).toHaveStyle( { transform: 'translate(310px, 70px)' } );
161
+ } );
162
+
163
+ test.each( [
164
+ { anchor: 20, x: 0, pointerLeft: 14 },
165
+ { anchor: 150, x: 46, pointerLeft: 98 },
166
+ { anchor: 280, x: 92, pointerLeft: 182 },
167
+ ] )(
168
+ 'keeps the below-axis pointer at x=$anchor after clamping',
169
+ ( { anchor, x, pointerLeft } ) => {
170
+ jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
171
+ this: Element
172
+ ) {
173
+ return this.classList.contains( 'visx-tooltip' )
174
+ ? rect( 0, 0, 208, 36 )
175
+ : rect( 0, 0, 300, 100 );
176
+ } );
177
+
178
+ render(
179
+ <div style={ { overflow: 'hidden' } }>
180
+ <div style={ { position: 'relative' } }>
181
+ <BoundedTooltip placement="below-axis" left={ anchor } top={ 100 } data-testid="box">
182
+ content
183
+ </BoundedTooltip>
184
+ </div>
185
+ </div>
186
+ );
187
+
188
+ expect( screen.getByTestId( 'box' ) ).toHaveStyle( {
189
+ transform: `translate(${ x }px, 106px)`,
190
+ } );
191
+ expect( screen.getByTestId( 'tooltip-axis-pointer' ) ).toHaveStyle( {
192
+ left: `${ pointerLeft }px`,
193
+ top: '-6px',
194
+ width: '12px',
195
+ height: '6px',
196
+ } );
197
+ expect( screen.getByTestId( 'tooltip-axis-pointer' ) ).toHaveAttribute(
198
+ 'aria-hidden',
199
+ 'true'
200
+ );
201
+ }
202
+ );
203
+
108
204
  test( 'falls back to the viewport when nothing clips', () => {
109
205
  jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
110
206
  this: Element
@@ -131,6 +131,54 @@ describe( 'XyChartTooltip', () => {
131
131
  );
132
132
  } );
133
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
+
134
182
  test( 'draws one glyph per series in the SVG at the datum position', async () => {
135
183
  renderChart( { showSeriesGlyphs: true }, BOTH_SERIES );
136
184
 
@@ -235,6 +283,38 @@ describe( 'XyChartTooltip', () => {
235
283
  expect( horizontal ).toHaveAttribute( 'x2', '180' );
236
284
  } );
237
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
+
238
318
  test( 'stacks the box above the chart overlays, with zIndex as the override', async () => {
239
319
  const { unmount } = renderChart();
240
320
  await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { zIndex: '3' } );
@@ -244,6 +324,26 @@ describe( 'XyChartTooltip', () => {
244
324
  await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { zIndex: '9' } );
245
325
  } );
246
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
+
247
347
  test( 'accepts the portal-era options without passing them to the box', async () => {
248
348
  renderChart( { scroll: true, debounce: 50, resizeObserverPolyfill: undefined } );
249
349
 
@@ -3,11 +3,36 @@ import { DataContext, TooltipContext } from '@visx/xychart';
3
3
  import { useCallback, useContext, useState } from 'react';
4
4
  import { createPortal } from 'react-dom';
5
5
  import { BoundedTooltip } from './private/bounded-tooltip';
6
- import type { RenderTooltipGlyphProps, XyChartTooltipProps } from '../../visx/types';
6
+ import type {
7
+ CrosshairStyle,
8
+ RenderTooltipGlyphProps,
9
+ XyChartTooltipProps,
10
+ } from '../../visx/types';
7
11
  import type { TooltipContextType } from '@visx/xychart';
8
12
  import type { CSSProperties, ReactNode } from 'react';
9
13
 
10
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
+ };
11
36
  const DEFAULT_GLYPH_RADIUS = 4;
12
37
  const FALLBACK_COLOR = '#222';
13
38
  // Above the chart's own overlays (the zoom reset button sits at 2). The chart
@@ -83,7 +108,9 @@ const XyChartTooltipContent = < Datum extends object >( {
83
108
  verticalCrosshairStyle,
84
109
  horizontalCrosshairStyle,
85
110
  detectBounds = true,
111
+ tooltipPlacement = 'auto',
86
112
  zIndex = DEFAULT_TOOLTIP_Z_INDEX,
113
+ style,
87
114
  ...rest
88
115
  }: XyChartTooltipContentProps< Datum > ) => {
89
116
  const tooltipProps = Object.fromEntries(
@@ -125,9 +152,12 @@ const XyChartTooltipContent = < Datum extends object >( {
125
152
  const nearestDatum = tooltipContext.tooltipData?.nearestDatum;
126
153
  let { tooltipLeft, tooltipTop } = tooltipContext;
127
154
 
128
- if ( nearestDatum && ( snapTooltipToDatumX || snapTooltipToDatumY ) ) {
155
+ if (
156
+ nearestDatum &&
157
+ ( snapTooltipToDatumX || snapTooltipToDatumY || tooltipPlacement === 'below-axis' )
158
+ ) {
129
159
  const { left, top } = getDatumLeftTop( nearestDatum.key, nearestDatum.datum );
130
- if ( snapTooltipToDatumX && isValidNumber( left ) ) {
160
+ if ( ( snapTooltipToDatumX || tooltipPlacement === 'below-axis' ) && isValidNumber( left ) ) {
131
161
  tooltipLeft = left;
132
162
  }
133
163
  if ( snapTooltipToDatumY && isValidNumber( top ) ) {
@@ -183,15 +213,17 @@ const XyChartTooltipContent = < Datum extends object >( {
183
213
  const marginTop = margin?.top ?? 0;
184
214
  const marginLeft = margin?.left ?? 0;
185
215
 
186
- const TooltipComponent = detectBounds ? BoundedTooltip : Tooltip;
216
+ const TooltipComponent =
217
+ detectBounds || tooltipPlacement === 'below-axis' ? BoundedTooltip : Tooltip;
187
218
  const boxStyle: CSSProperties = {
188
219
  ...defaultStyles,
189
220
  zIndex,
190
- background: theme?.backgroundColor ?? 'white',
221
+ backgroundColor: theme?.backgroundColor ?? 'white',
191
222
  boxShadow: `0 1px 2px ${
192
223
  theme?.htmlLabel?.color ? `${ theme.htmlLabel.color }55` : '#22222255'
193
224
  }`,
194
225
  ...theme?.htmlLabel,
226
+ ...style,
195
227
  };
196
228
 
197
229
  return (
@@ -201,26 +233,26 @@ const XyChartTooltipContent = < Datum extends object >( {
201
233
  <line
202
234
  className="visx-crosshair visx-crosshair-vertical"
203
235
  data-testid="xy-chart-tooltip-crosshair-vertical"
236
+ stroke={ crosshairStroke }
237
+ strokeWidth={ CROSSHAIR_STROKE_WIDTH }
238
+ { ...crosshairPaintProps( verticalCrosshairStyle ) }
204
239
  x1={ tooltipLeft }
205
240
  x2={ tooltipLeft }
206
241
  y1={ marginTop }
207
242
  y2={ marginTop + innerHeight }
208
- stroke={ crosshairStroke }
209
- strokeWidth={ CROSSHAIR_STROKE_WIDTH }
210
- { ...verticalCrosshairStyle }
211
243
  />
212
244
  ) }
213
245
  { showHorizontalCrosshair && isValidNumber( tooltipTop ) && (
214
246
  <line
215
247
  className="visx-crosshair visx-crosshair-horizontal"
216
248
  data-testid="xy-chart-tooltip-crosshair-horizontal"
249
+ stroke={ crosshairStroke }
250
+ strokeWidth={ CROSSHAIR_STROKE_WIDTH }
251
+ { ...crosshairPaintProps( horizontalCrosshairStyle ) }
217
252
  x1={ marginLeft }
218
253
  x2={ marginLeft + innerWidth }
219
254
  y1={ tooltipTop }
220
255
  y2={ tooltipTop }
221
- stroke={ crosshairStroke }
222
- strokeWidth={ CROSSHAIR_STROKE_WIDTH }
223
- { ...horizontalCrosshairStyle }
224
256
  />
225
257
  ) }
226
258
  { glyphs }
@@ -229,10 +261,15 @@ const XyChartTooltipContent = < Datum extends object >( {
229
261
  createPortal(
230
262
  <TooltipComponent
231
263
  left={ tooltipLeft }
232
- top={ tooltipTop }
264
+ top={
265
+ tooltipPlacement === 'below-axis'
266
+ ? marginTop + innerHeight + ( margin?.bottom ?? 0 )
267
+ : tooltipTop
268
+ }
233
269
  style={ boxStyle }
234
270
  applyPositionStyle
235
271
  { ...tooltipProps }
272
+ { ...( tooltipPlacement === 'below-axis' && { placement: tooltipPlacement } ) }
236
273
  >
237
274
  { tooltipContent }
238
275
  </TooltipComponent>,
@@ -259,7 +296,8 @@ const XyChartTooltipContent = < Datum extends object >( {
259
296
  * isolation keeps the box's `zIndex` from competing with page chrome. The box
260
297
  * flips and clamps to stay inside the nearest ancestor that clips its overflow
261
298
  * (or the viewport), so it may extend past the chart wrapper but is never cut
262
- * off unless that ancestor is smaller than the box itself.
299
+ * off unless that ancestor is smaller than the box itself. Below-axis placement
300
+ * pins it beneath the x-axis and applies horizontal bounds only.
263
301
  *
264
302
  * @param props - visx's `Tooltip` options. `scroll`, `debounce` and `resizeObserverPolyfill` are accepted and ignored.
265
303
  * @return An anchor in the SVG, plus the overlay and the tooltip box while the tooltip is open.
@@ -0,0 +1,36 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { GlobalChartsProvider } from '../../providers';
3
+ import { useChartDataTransform } from '../use-chart-data-transform';
4
+ import type { SeriesData } from '../../types';
5
+
6
+ // One reference, reused across both renders, so only the zone can invalidate the memo.
7
+ const data: SeriesData[] = [
8
+ { label: 'Series A', data: [ { dateString: '2026-08-02', value: 1 } ] },
9
+ ];
10
+
11
+ const Probe = () => {
12
+ const [ series ] = useChartDataTransform( data );
13
+ const point = series.data[ 0 ] as { date?: Date };
14
+
15
+ return <span data-testid="instant">{ point.date?.toISOString() }</span>;
16
+ };
17
+
18
+ describe( 'useChartDataTransform', () => {
19
+ it( 're-dates its points when only the provider time zone changes', () => {
20
+ const { rerender } = render(
21
+ <GlobalChartsProvider timeZone="Asia/Tokyo">
22
+ <Probe />
23
+ </GlobalChartsProvider>
24
+ );
25
+
26
+ expect( screen.getByTestId( 'instant' ) ).toHaveTextContent( '2026-08-01T15:00:00.000Z' );
27
+
28
+ rerender(
29
+ <GlobalChartsProvider timeZone="Pacific/Auckland">
30
+ <Probe />
31
+ </GlobalChartsProvider>
32
+ );
33
+
34
+ expect( screen.getByTestId( 'instant' ) ).toHaveTextContent( '2026-08-01T12:00:00.000Z' );
35
+ } );
36
+ } );