@automattic/charts 4.0.0 → 4.1.1

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 (61) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/SECURITY.md +1 -0
  3. package/TOKENS.md +7 -5
  4. package/dist/index.cjs +614 -259
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.css +30 -0
  7. package/dist/index.d.cts +88 -74
  8. package/dist/index.d.ts +88 -74
  9. package/dist/index.js +615 -261
  10. package/dist/index.js.map +1 -1
  11. package/package.json +5 -4
  12. package/src/charts/area-chart/area-chart.tsx +2 -1
  13. package/src/charts/area-chart/test/area-chart.test.tsx +31 -0
  14. package/src/charts/heatmap-chart/build-calendar-data.ts +189 -0
  15. package/src/charts/heatmap-chart/heatmap-chart.module.scss +31 -0
  16. package/src/charts/heatmap-chart/heatmap-chart.tsx +44 -9
  17. package/src/charts/heatmap-chart/index.ts +10 -3
  18. package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
  19. package/src/charts/heatmap-chart/private/index.ts +0 -2
  20. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
  21. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +4 -1
  22. package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
  23. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +91 -0
  24. package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
  25. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
  26. package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
  27. package/src/charts/heatmap-chart/types.ts +35 -0
  28. package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
  29. package/src/charts/leaderboard-chart/types.ts +2 -2
  30. package/src/charts/line-chart/line-chart.tsx +41 -7
  31. package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
  32. package/src/charts/line-chart/test/line-chart.test.tsx +178 -38
  33. package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
  34. package/src/charts/line-chart/types.ts +13 -2
  35. package/src/charts/pie-chart/test/pie-chart.test.tsx +3 -12
  36. package/src/components/tooltip/accessible-tooltip.tsx +40 -12
  37. package/src/components/tooltip/private/bounded-tooltip.tsx +71 -16
  38. package/src/components/tooltip/test/accessible-tooltip.test.tsx +60 -1
  39. package/src/components/tooltip/test/bounded-tooltip.test.tsx +102 -6
  40. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +122 -3
  41. package/src/components/tooltip/xy-chart-tooltip.tsx +53 -14
  42. package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
  43. package/src/hooks/test/use-chart-margin.test.tsx +173 -3
  44. package/src/hooks/use-chart-data-transform.ts +10 -3
  45. package/src/hooks/use-chart-margin.tsx +54 -9
  46. package/src/index.ts +4 -0
  47. package/src/styles/chart-scope.scss +11 -3
  48. package/src/styles/test/chart-scope.test.ts +35 -1
  49. package/src/test-utils/zoned-parsing-suite.ts +90 -0
  50. package/src/types.ts +2 -2
  51. package/src/utils/date-formatting.ts +1 -18
  52. package/src/utils/date-parsing.ts +103 -52
  53. package/src/utils/get-edge-tick-widths.ts +33 -0
  54. package/src/utils/index.ts +1 -0
  55. package/src/utils/test/date-parsing.auckland.test.ts +6 -0
  56. package/src/utils/test/date-parsing.santiago.test.ts +6 -0
  57. package/src/utils/test/date-parsing.test.ts +16 -1
  58. package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
  59. package/src/utils/warn-once.ts +23 -0
  60. package/src/visx/types.ts +27 -3
  61. package/src/charts/heatmap-chart/private/build-calendar-data.ts +0 -179
@@ -1,5 +1,6 @@
1
1
  import { Tooltip, defaultStyles } from '@visx/tooltip';
2
2
  import { useLayoutEffect, useRef, useState } from 'react';
3
+ import type { TooltipPlacement } from '../../../visx/types';
3
4
  import type { TooltipProps } from '@visx/tooltip';
4
5
 
5
6
  type Bounds = { left: number; top: number; right: number; bottom: number };
@@ -9,9 +10,14 @@ export type BoundedTooltipProps = Omit< TooltipProps, 'left' | 'top' | 'applyPos
9
10
  /** Anchor, in the coordinates of the positioned wrapper the box renders into. */
10
11
  left?: number;
11
12
  top?: number;
13
+ placement?: TooltipPlacement;
12
14
  };
13
15
 
14
16
  const DEFAULT_OFFSET = 10;
17
+ const POINTER_HEIGHT = 6;
18
+
19
+ const clamp = ( position: number, min: number, max: number, size: number ) =>
20
+ Math.min( Math.max( position, min ), Math.max( min, max - size ) );
15
21
 
16
22
  const isClipping = ( element: Element ) => {
17
23
  const { overflow, overflowX, overflowY } = getComputedStyle( element );
@@ -33,19 +39,25 @@ const findClippingAncestor = ( wrapper: Element ): Element | null => {
33
39
  };
34
40
 
35
41
  /**
36
- * Where the box goes, in wrapper coordinates. Below and to the right of the
37
- * anchor; flipped to the other side when that side clips less, as visx's
38
- * `TooltipWithBounds` decides it; then clamped so the box stays inside
39
- * `bounds` whenever it fits at all.
42
+ * Where the box goes, in wrapper coordinates. Automatic placement flips to
43
+ * the side that clips less, then clamps inside `bounds`; below-axis placement
44
+ * is centered on the anchor and clamped horizontally.
45
+ *
46
+ * The flip measures against the visible part of the wrapper, its intersection
47
+ * with `bounds`. Only the clamp is bound by the page: a box that fits inside the
48
+ * chart on one side stays there, so it does not reach past the chart into
49
+ * siblings that paint over it.
40
50
  *
41
- * @param params - Anchor, offsets, the box size and the bounds to keep inside.
51
+ * @param params - Anchor, offsets, the box size and the rects to keep inside.
42
52
  * @param params.left - Anchor x, in wrapper coordinates.
43
53
  * @param params.top - Anchor y, in wrapper coordinates.
44
54
  * @param params.offsetLeft - Gap between the anchor and the box, horizontally.
45
55
  * @param params.offsetTop - Gap between the anchor and the box, vertically.
46
56
  * @param params.box - Rendered size of the box.
57
+ * @param params.wrapper - The wrapper's own edges, in wrapper coordinates.
58
+ * @param params.placement - Fixed below-axis placement or automatic flipping.
47
59
  * @param params.bounds - Edges the box must keep inside, in wrapper coordinates.
48
- * @return The box's top-left corner, rounded to whole pixels.
60
+ * @return The box's top-left corner; fixed placement preserves the axis's subpixel y.
49
61
  */
50
62
  export const getBoundedPosition = ( {
51
63
  left,
@@ -53,31 +65,53 @@ export const getBoundedPosition = ( {
53
65
  offsetLeft,
54
66
  offsetTop,
55
67
  box,
68
+ wrapper,
56
69
  bounds,
70
+ placement = 'auto',
57
71
  }: {
58
72
  left: number;
59
73
  top: number;
60
74
  offsetLeft: number;
61
75
  offsetTop: number;
62
76
  box: Size;
77
+ wrapper: Bounds;
63
78
  bounds: Bounds;
79
+ placement?: BoundedTooltipProps[ 'placement' ];
64
80
  } ) => {
81
+ if ( placement === 'below-axis' ) {
82
+ return {
83
+ x: Math.round( clamp( left - box.width / 2, bounds.left, bounds.right, box.width ) ),
84
+ y: top + POINTER_HEIGHT,
85
+ };
86
+ }
87
+
88
+ // The part of the wrapper that is not cut off: the flip keeps the box inside it.
89
+ const fit = {
90
+ left: Math.max( wrapper.left, bounds.left ),
91
+ top: Math.max( wrapper.top, bounds.top ),
92
+ right: Math.min( wrapper.right, bounds.right ),
93
+ bottom: Math.min( wrapper.bottom, bounds.bottom ),
94
+ };
95
+
65
96
  const rightX = left + offsetLeft;
66
97
  const leftX = left - offsetLeft - box.width;
67
- const rightOverflow = rightX + box.width - bounds.right;
68
- const leftOverflow = bounds.left - leftX;
98
+ const rightOverflow = rightX + box.width - fit.right;
99
+ const leftOverflow = fit.left - leftX;
69
100
  let x = rightOverflow > 0 && rightOverflow > leftOverflow ? leftX : rightX;
70
101
 
71
102
  const downY = top + offsetTop;
72
103
  const upY = top - offsetTop - box.height;
73
- const downOverflow = downY + box.height - bounds.bottom;
74
- const upOverflow = bounds.top - upY;
104
+ const downOverflow = downY + box.height - fit.bottom;
105
+ const upOverflow = fit.top - upY;
75
106
  let y = downOverflow > 0 && downOverflow > upOverflow ? upY : downY;
76
107
 
77
- x = Math.min( Math.max( x, bounds.left ), Math.max( bounds.left, bounds.right - box.width ) );
78
- y = Math.min( Math.max( y, bounds.top ), Math.max( bounds.top, bounds.bottom - box.height ) );
108
+ x = clamp( x, bounds.left, bounds.right, box.width );
109
+ y = clamp( y, bounds.top, bounds.bottom, box.height );
79
110
 
80
- return { x: Math.round( x ), y: Math.round( y ) };
111
+ return {
112
+ x: Math.round( x ),
113
+ y: Math.round( y ),
114
+ };
81
115
  };
82
116
 
83
117
  /**
@@ -88,7 +122,8 @@ export const getBoundedPosition = ( {
88
122
  * alone would let the box spill into an `overflow: hidden` card and be cut off.
89
123
  *
90
124
  * Re-measures on every render, so a box whose content changes width between
91
- * two hovers is placed for its current size.
125
+ * two hovers is placed for its current size. See `getBoundedPosition` for the
126
+ * below-axis exception.
92
127
  *
93
128
  * @param props - visx `Tooltip` props.
94
129
  * @param props.left - Anchor x, in wrapper coordinates.
@@ -98,6 +133,7 @@ export const getBoundedPosition = ( {
98
133
  * @param props.style - Box styles; visx's defaults unless `unstyled`.
99
134
  * @param props.unstyled - Skip `style` and leave the box bare.
100
135
  * @param props.children - Box content.
136
+ * @param props.placement - Fixed below-axis placement or automatic flipping.
101
137
  * @return The tooltip box.
102
138
  */
103
139
  export const BoundedTooltip = ( {
@@ -108,6 +144,7 @@ export const BoundedTooltip = ( {
108
144
  style = defaultStyles,
109
145
  unstyled = false,
110
146
  children,
147
+ placement = 'auto',
111
148
  ...rest
112
149
  }: BoundedTooltipProps ) => {
113
150
  const nodeRef = useRef< HTMLDivElement >( null );
@@ -136,7 +173,9 @@ export const BoundedTooltip = ( {
136
173
  top,
137
174
  offsetLeft,
138
175
  offsetTop,
176
+ placement,
139
177
  box: { width: own.width, height: own.height },
178
+ wrapper: { left: 0, top: 0, right: wrapperRect.width, bottom: wrapperRect.height },
140
179
  bounds: {
141
180
  left: clipRect.left - wrapperRect.left,
142
181
  top: clipRect.top - wrapperRect.top,
@@ -148,10 +187,10 @@ export const BoundedTooltip = ( {
148
187
  current && current.x === next.x && current.y === next.y ? current : next
149
188
  );
150
189
  // `children` re-runs the measurement when the content, and so the box size, changes.
151
- }, [ left, top, offsetLeft, offsetTop, children ] );
190
+ }, [ left, top, offsetLeft, offsetTop, children, placement ] );
152
191
 
153
192
  const x = position?.x ?? left + offsetLeft;
154
- const y = position?.y ?? top + offsetTop;
193
+ const y = position?.y ?? top + ( placement === 'below-axis' ? POINTER_HEIGHT : offsetTop );
155
194
 
156
195
  return (
157
196
  <Tooltip
@@ -166,6 +205,22 @@ export const BoundedTooltip = ( {
166
205
  } }
167
206
  { ...rest }
168
207
  >
208
+ { placement === 'below-axis' && (
209
+ <span
210
+ aria-hidden="true"
211
+ data-testid="tooltip-axis-pointer"
212
+ style={ {
213
+ position: 'absolute',
214
+ left: left - x - POINTER_HEIGHT,
215
+ top: -POINTER_HEIGHT,
216
+ width: POINTER_HEIGHT * 2,
217
+ height: POINTER_HEIGHT,
218
+ background: 'inherit',
219
+ clipPath: 'polygon(50% 0, 100% 100%, 0 100%)',
220
+ pointerEvents: 'none',
221
+ } }
222
+ />
223
+ ) }
169
224
  { children }
170
225
  </Tooltip>
171
226
  );
@@ -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,64 @@ 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
+
89
+ it.each( [
90
+ [ 'Tab', '{Tab}' ],
91
+ [ 'Escape', '{Escape}' ],
92
+ ] )( 'requests scroll suppression when returning focus after %s', async ( _name, keys ) => {
93
+ const user = userEvent.setup();
94
+ renderChart( undefined, 'below-axis' );
95
+ await openTooltip();
96
+ const focus = jest.spyOn( screen.getByRole( 'grid' ), 'focus' );
97
+ try {
98
+ await user.keyboard( keys );
99
+ expect( focus ).toHaveBeenCalledWith( { preventScroll: true } );
100
+ } finally {
101
+ focus.mockRestore();
102
+ }
103
+ } );
104
+
105
+ it.each( [
106
+ [ 'Tab', '{Tab}' ],
107
+ [ 'Escape', '{Escape}' ],
108
+ ] )( 'keeps default focus scrolling when returning focus after %s', async ( _name, keys ) => {
109
+ const user = userEvent.setup();
110
+ renderChart();
111
+ await openTooltip();
112
+ const focus = jest.spyOn( screen.getByRole( 'grid' ), 'focus' );
113
+ try {
114
+ await user.keyboard( keys );
115
+ expect( focus ).toHaveBeenCalledWith();
116
+ } finally {
117
+ focus.mockRestore();
118
+ }
119
+ } );
120
+
62
121
  it( 'falls back to the catalog default when the role is unset', async () => {
63
122
  renderChart();
64
123
 
@@ -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
@@ -1,9 +1,9 @@
1
1
  import { render, screen, waitFor } from '@testing-library/react';
2
- import { BarSeries, LineSeries, TooltipContext, XYChart } from '@visx/xychart';
2
+ import { BarSeries, buildChartTheme, LineSeries, TooltipContext, XYChart } from '@visx/xychart';
3
3
  import { useContext, useEffect } from 'react';
4
4
  import { XyChartTooltip } from '../xy-chart-tooltip';
5
5
  import type { XyChartTooltipProps } from '../../../visx/types';
6
- import type { EventHandlerParams } from '@visx/xychart';
6
+ import type { EventHandlerParams, XYChartTheme } from '@visx/xychart';
7
7
 
8
8
  type Datum = { x: number; y: number };
9
9
 
@@ -76,7 +76,8 @@ const renderChart = (
76
76
  params: EventHandlerParams< Datum >[] = [
77
77
  { key: 'A', index: 1, datum: SERIES_A[ 1 ], svgPoint: { x: 123, y: 45 } },
78
78
  ],
79
- margin = NO_MARGIN
79
+ margin = NO_MARGIN,
80
+ theme?: XYChartTheme
80
81
  ) =>
81
82
  render(
82
83
  <div data-testid="wrapper" style={ { position: 'relative' } }>
@@ -84,6 +85,7 @@ const renderChart = (
84
85
  width={ 200 }
85
86
  height={ 100 }
86
87
  margin={ margin }
88
+ theme={ theme }
87
89
  xScale={ { type: 'linear', domain: [ 0, 10 ] } }
88
90
  yScale={ { type: 'linear', domain: [ 0, 100 ] } }
89
91
  >
@@ -100,6 +102,23 @@ const renderChart = (
100
102
  );
101
103
 
102
104
  describe( 'XyChartTooltip', () => {
105
+ test.each( [
106
+ [ 'CanvasText', '0 1px 2px #22222255' ],
107
+ [ '#123456', '0 1px 2px #12345655' ],
108
+ ] )( 'renders a valid shadow for htmlLabel color %s', async ( color, boxShadow ) => {
109
+ const theme = buildChartTheme( {
110
+ backgroundColor: '#ffffff',
111
+ colors: [ '#123456' ],
112
+ tickLength: 4,
113
+ gridColor: '#dddddd',
114
+ gridColorDark: '#222222',
115
+ htmlLabel: { color },
116
+ } );
117
+ renderChart( {}, undefined, undefined, theme );
118
+
119
+ await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { boxShadow } );
120
+ } );
121
+
103
122
  test( 'renders the tooltip box inside the chart wrapper, not in a body-level portal', async () => {
104
123
  const { container } = renderChart();
105
124
 
@@ -131,6 +150,54 @@ describe( 'XyChartTooltip', () => {
131
150
  );
132
151
  } );
133
152
 
153
+ test( 'anchors below the x-axis label band without moving datum glyphs or crosshairs', async () => {
154
+ const originalRect = Element.prototype.getBoundingClientRect;
155
+ const rectSpy = jest
156
+ .spyOn( Element.prototype, 'getBoundingClientRect' )
157
+ .mockImplementation( function ( this: Element ) {
158
+ const rect = originalRect.call( this );
159
+ return this.classList.contains( 'visx-tooltip' )
160
+ ? { ...rect, width: 80, height: 30, right: rect.left + 80, bottom: rect.top + 30 }
161
+ : rect;
162
+ } );
163
+
164
+ try {
165
+ renderChart(
166
+ {
167
+ tooltipPlacement: 'below-axis',
168
+ snapTooltipToDatumY: true,
169
+ showDatumGlyph: true,
170
+ showVerticalCrosshair: true,
171
+ showHorizontalCrosshair: true,
172
+ },
173
+ undefined,
174
+ { top: 10, right: 20, bottom: 30, left: 40 }
175
+ );
176
+
177
+ const box = await screen.findByTestId( 'tooltip-box' );
178
+ expect( box ).toHaveStyle( { transform: 'translate(70px, 106px)' } );
179
+ expect( screen.getByTestId( 'wrapper' ) ).toContainElement( box );
180
+ expect( screen.getByTestId( 'tooltip-axis-pointer' ) ).toHaveStyle( {
181
+ left: '34px',
182
+ top: '-6px',
183
+ } );
184
+ expect( screen.getByTestId( 'xy-chart-tooltip-glyph-group-A' ) ).toHaveAttribute(
185
+ 'transform',
186
+ 'translate(110, 40)'
187
+ );
188
+ expect( screen.getByTestId( 'xy-chart-tooltip-crosshair-vertical' ) ).toHaveAttribute(
189
+ 'y2',
190
+ '70'
191
+ );
192
+ expect( screen.getByTestId( 'xy-chart-tooltip-crosshair-horizontal' ) ).toHaveAttribute(
193
+ 'y1',
194
+ '40'
195
+ );
196
+ } finally {
197
+ rectSpy.mockRestore();
198
+ }
199
+ } );
200
+
134
201
  test( 'draws one glyph per series in the SVG at the datum position', async () => {
135
202
  renderChart( { showSeriesGlyphs: true }, BOTH_SERIES );
136
203
 
@@ -235,6 +302,38 @@ describe( 'XyChartTooltip', () => {
235
302
  expect( horizontal ).toHaveAttribute( 'x2', '180' );
236
303
  } );
237
304
 
305
+ test( 'applies crosshair paint without allowing geometry overrides', async () => {
306
+ const consumerStyle = {
307
+ stroke: 'purple',
308
+ strokeWidth: 8,
309
+ className: 'custom-crosshair',
310
+ x1: 999,
311
+ transform: 'translate(100 0)',
312
+ style: { strokeOpacity: 0.25, transform: 'translateX(100px)' },
313
+ };
314
+ renderChart( {
315
+ snapTooltipToDatumX: true,
316
+ snapTooltipToDatumY: true,
317
+ showVerticalCrosshair: true,
318
+ showHorizontalCrosshair: true,
319
+ verticalCrosshairStyle: consumerStyle,
320
+ horizontalCrosshairStyle: consumerStyle,
321
+ } );
322
+
323
+ for ( const direction of [ 'vertical', 'horizontal' ] ) {
324
+ const guide = await screen.findByTestId( `xy-chart-tooltip-crosshair-${ direction }` );
325
+ expect( guide ).toHaveAttribute( 'stroke', 'purple' );
326
+ expect( guide ).toHaveAttribute( 'stroke-width', '8' );
327
+ expect( guide ).toHaveClass( 'custom-crosshair' );
328
+ expect( guide ).toHaveStyle( { 'stroke-opacity': '0.25', transform: 'none' } );
329
+ expect( guide ).not.toHaveAttribute( 'transform' );
330
+ expect( guide ).toHaveAttribute( 'x1', direction === 'vertical' ? '100' : '0' );
331
+ expect( guide ).toHaveAttribute( 'x2', direction === 'vertical' ? '100' : '200' );
332
+ expect( guide ).toHaveAttribute( 'y1', direction === 'vertical' ? '0' : '50' );
333
+ expect( guide ).toHaveAttribute( 'y2', direction === 'vertical' ? '100' : '50' );
334
+ }
335
+ } );
336
+
238
337
  test( 'stacks the box above the chart overlays, with zIndex as the override', async () => {
239
338
  const { unmount } = renderChart();
240
339
  await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { zIndex: '3' } );
@@ -244,6 +343,26 @@ describe( 'XyChartTooltip', () => {
244
343
  await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { zIndex: '9' } );
245
344
  } );
246
345
 
346
+ test.each( [ 'auto', 'below-axis' ] as const )(
347
+ 'preserves %s background and stacking with a partial style override',
348
+ async tooltipPlacement => {
349
+ const { unmount } = renderChart( { tooltipPlacement } );
350
+ const defaultBox = await screen.findByTestId( 'tooltip-box' );
351
+ const background = defaultBox.style.backgroundColor;
352
+ expect( background ).not.toBe( '' );
353
+ expect( background ).not.toBe( 'transparent' );
354
+ unmount();
355
+
356
+ renderChart( { tooltipPlacement, style: { color: 'red' } } );
357
+
358
+ await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( {
359
+ backgroundColor: background,
360
+ zIndex: '3',
361
+ color: 'rgb(255, 0, 0)',
362
+ } );
363
+ }
364
+ );
365
+
247
366
  test( 'accepts the portal-era options without passing them to the box', async () => {
248
367
  renderChart( { scroll: true, debounce: 50, resizeObserverPolyfill: undefined } );
249
368