@automattic/charts 3.2.0 → 4.0.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 (109) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/TOKENS.md +53 -58
  3. package/dist/index.cjs +1012 -542
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +72 -29
  6. package/dist/index.d.cts +151 -179
  7. package/dist/index.d.ts +151 -179
  8. package/dist/index.js +1013 -544
  9. package/dist/index.js.map +1 -1
  10. package/package.json +6 -6
  11. package/src/charts/area-chart/area-chart.tsx +39 -25
  12. package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
  13. package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
  14. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
  15. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
  16. package/src/charts/area-chart/types.ts +4 -7
  17. package/src/charts/bar-chart/bar-chart.tsx +2 -1
  18. package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
  19. package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
  20. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -0
  21. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  23. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  24. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  25. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  26. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  27. package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
  28. package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
  29. package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
  30. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  31. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
  32. package/src/charts/heatmap-chart/types.ts +2 -2
  33. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  34. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  35. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  36. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  37. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  38. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  39. package/src/charts/line-chart/line-chart.module.scss +10 -18
  40. package/src/charts/line-chart/line-chart.tsx +93 -24
  41. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  42. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  43. package/src/charts/line-chart/test/line-chart.test.tsx +27 -3
  44. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  45. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  46. package/src/charts/line-chart/types.ts +4 -1
  47. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  48. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  49. package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
  50. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  51. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  52. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  53. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  54. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  55. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  56. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  57. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  58. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  59. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  60. package/src/charts/private/test/time-axis.test.ts +9 -69
  61. package/src/charts/private/time-axis-options.ts +106 -0
  62. package/src/charts/private/time-axis.ts +318 -136
  63. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  64. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  65. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  66. package/src/charts/sparkline/types.ts +1 -1
  67. package/src/components/legend/private/base-legend.module.scss +9 -5
  68. package/src/components/tooltip/accessible-tooltip.tsx +13 -15
  69. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  70. package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
  71. package/src/components/tooltip/test/accessible-tooltip.test.tsx +2 -3
  72. package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
  73. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
  74. package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
  75. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  76. package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
  77. package/src/hooks/use-xychart-theme.ts +25 -9
  78. package/src/index.ts +4 -0
  79. package/src/providers/chart-context/global-charts-provider.tsx +37 -38
  80. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  81. package/src/providers/chart-context/index.ts +1 -0
  82. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  83. package/src/providers/chart-context/private/series-palette.ts +3 -13
  84. package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
  85. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  86. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -25
  87. package/src/providers/chart-context/themes.ts +7 -52
  88. package/src/providers/chart-context/types.ts +3 -0
  89. package/src/styles/chart-scope.scss +39 -62
  90. package/src/styles/test/chart-scope.test.ts +19 -47
  91. package/src/test-utils/series-fixtures.ts +31 -0
  92. package/src/types.ts +79 -166
  93. package/src/utils/bucket-info.ts +87 -0
  94. package/src/utils/color-utils.ts +0 -27
  95. package/src/utils/date-formatting.ts +174 -0
  96. package/src/utils/index.ts +3 -0
  97. package/src/utils/test/bucket-info.test.ts +185 -0
  98. package/src/utils/test/color-utils.test.ts +1 -48
  99. package/src/utils/test/date-formatting.test.ts +89 -0
  100. package/src/utils/test/get-styles.test.ts +10 -7
  101. package/src/utils/test/merge-themes.test.ts +18 -46
  102. package/src/visx/types.ts +29 -2
  103. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  104. package/src/providers/chart-context/private/theme-override-vars.ts +0 -131
  105. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
  106. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  107. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -183
  108. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
  109. package/src/styles/test/wpds-fallbacks.test.ts +0 -131
@@ -0,0 +1,279 @@
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( 'draws one glyph per series in the SVG at the datum position', async () => {
135
+ renderChart( { showSeriesGlyphs: true }, BOTH_SERIES );
136
+
137
+ const glyphA = await screen.findByTestId( 'xy-chart-tooltip-glyph-A' );
138
+ const glyphB = await screen.findByTestId( 'xy-chart-tooltip-glyph-B' );
139
+
140
+ // The group is placed at the datum; the glyph itself draws at the origin.
141
+ expect( screen.getByTestId( 'xy-chart-tooltip-glyph-group-A' ) ).toHaveAttribute(
142
+ 'transform',
143
+ 'translate(100, 50)'
144
+ );
145
+ expect( screen.getByTestId( 'xy-chart-tooltip-glyph-group-B' ) ).toHaveAttribute(
146
+ 'transform',
147
+ 'translate(100, 75)'
148
+ );
149
+ expect( glyphA ).toHaveAttribute( 'cx', '0' );
150
+ expect( glyphA ).toHaveAttribute( 'cy', '0' );
151
+ expect( glyphB ).toHaveAttribute( 'cx', '0' );
152
+ expect( glyphB ).toHaveAttribute( 'cy', '0' );
153
+ // Drawn into the chart SVG, not portaled out as HTML.
154
+ expect( glyphA ).toBeInstanceOf( SVGElement );
155
+ } );
156
+
157
+ test( 'hands the glyph renderer the origin, like visx, and marks the nearest datum', async () => {
158
+ const renderGlyph = jest.fn( () => null );
159
+ renderChart( { showSeriesGlyphs: true, renderGlyph }, BOTH_SERIES );
160
+
161
+ await waitFor( () => expect( renderGlyph ).toHaveBeenCalledTimes( 2 ) );
162
+
163
+ const calls = renderGlyph.mock.calls as unknown as Array<
164
+ [ { key: string; x: number; y: number; isNearestDatum: boolean; datum: Datum } ]
165
+ >;
166
+ const byKey = Object.fromEntries( calls.map( ( [ p ] ) => [ p.key, p ] ) );
167
+ // A renderer written against visx's Tooltip ignores x and y, so both stay 0.
168
+ expect( byKey.A ).toMatchObject( { x: 0, y: 0, datum: SERIES_A[ 1 ] } );
169
+ expect( byKey.B ).toMatchObject( { x: 0, y: 0, datum: SERIES_B[ 1 ] } );
170
+ expect( byKey.A.isNearestDatum ).toBe( true );
171
+ expect( byKey.B.isNearestDatum ).toBe( false );
172
+ } );
173
+
174
+ test( 'centres a band-scale datum in its band', async () => {
175
+ render(
176
+ <div style={ { position: 'relative' } }>
177
+ <XYChart
178
+ width={ 200 }
179
+ height={ 100 }
180
+ margin={ NO_MARGIN }
181
+ xScale={ { type: 'band', domain: [ 'a', 'b' ], paddingInner: 0, paddingOuter: 0 } }
182
+ yScale={ { type: 'linear', domain: [ 0, 100 ] } }
183
+ >
184
+ <BarSeries
185
+ dataKey="bars"
186
+ data={ BAND_DATA }
187
+ xAccessor={ bandXAccessor }
188
+ yAccessor={ bandYAccessor }
189
+ />
190
+ <XyChartTooltip
191
+ renderTooltip={ renderBarTooltip }
192
+ data-testid="tooltip-box"
193
+ snapTooltipToDatumX
194
+ snapTooltipToDatumY
195
+ showDatumGlyph
196
+ />
197
+ <OpenTooltip params={ BAND_PARAMS } />
198
+ </XYChart>
199
+ </div>
200
+ );
201
+
202
+ // Band 'a' spans 0..100px; the datum sits at its centre, 50px, not its start.
203
+ await waitFor( () =>
204
+ expect( screen.getByTestId( 'xy-chart-tooltip-glyph-group-bars' ) ).toHaveAttribute(
205
+ 'transform',
206
+ 'translate(50, 50)'
207
+ )
208
+ );
209
+ expect( screen.getByTestId( 'tooltip-box' ) ).toHaveStyle( {
210
+ transform: 'translate(60px, 60px)',
211
+ } );
212
+ } );
213
+
214
+ test( 'draws the crosshairs across the plot area, inside the margins, at the snapped position', async () => {
215
+ renderChart(
216
+ {
217
+ snapTooltipToDatumX: true,
218
+ snapTooltipToDatumY: true,
219
+ showVerticalCrosshair: true,
220
+ showHorizontalCrosshair: true,
221
+ },
222
+ undefined,
223
+ { top: 10, right: 20, bottom: 30, left: 40 }
224
+ );
225
+
226
+ const vertical = await screen.findByTestId( 'xy-chart-tooltip-crosshair-vertical' );
227
+ const horizontal = await screen.findByTestId( 'xy-chart-tooltip-crosshair-horizontal' );
228
+
229
+ // Plot area is 140x60 at (40, 10); datum (5, 50) sits at its centre, (110, 40).
230
+ expect( vertical ).toHaveAttribute( 'x1', '110' );
231
+ expect( vertical ).toHaveAttribute( 'y1', '10' );
232
+ expect( vertical ).toHaveAttribute( 'y2', '70' );
233
+ expect( horizontal ).toHaveAttribute( 'y1', '40' );
234
+ expect( horizontal ).toHaveAttribute( 'x1', '40' );
235
+ expect( horizontal ).toHaveAttribute( 'x2', '180' );
236
+ } );
237
+
238
+ test( 'stacks the box above the chart overlays, with zIndex as the override', async () => {
239
+ const { unmount } = renderChart();
240
+ await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { zIndex: '3' } );
241
+ unmount();
242
+
243
+ renderChart( { zIndex: 9 } );
244
+ await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { zIndex: '9' } );
245
+ } );
246
+
247
+ test( 'accepts the portal-era options without passing them to the box', async () => {
248
+ renderChart( { scroll: true, debounce: 50, resizeObserverPolyfill: undefined } );
249
+
250
+ const box = await screen.findByTestId( 'tooltip-box' );
251
+
252
+ expect( box ).not.toHaveAttribute( 'scroll' );
253
+ expect( box ).not.toHaveAttribute( 'debounce' );
254
+ } );
255
+
256
+ test( 'renders no box when there is no tooltip renderer', async () => {
257
+ renderChart( { renderTooltip: undefined } );
258
+
259
+ await expect( screen.findByTestId( 'xy-chart-tooltip-anchor' ) ).resolves.toBeInTheDocument();
260
+ expect( screen.queryByTestId( 'tooltip-box' ) ).not.toBeInTheDocument();
261
+ } );
262
+
263
+ test( 'renders nothing when the tooltip content is empty', async () => {
264
+ renderChart( { renderTooltip: () => null, showSeriesGlyphs: true }, BOTH_SERIES );
265
+
266
+ // The anchor is the only thing rendered while there is nothing to show.
267
+ await expect( screen.findByTestId( 'xy-chart-tooltip-anchor' ) ).resolves.toBeInTheDocument();
268
+ expect( screen.queryByTestId( 'tooltip-box' ) ).not.toBeInTheDocument();
269
+ expect( screen.queryByTestId( 'xy-chart-tooltip-glyph-A' ) ).not.toBeInTheDocument();
270
+ } );
271
+
272
+ test( 'renders nothing while the tooltip is closed', () => {
273
+ renderChart( { showSeriesGlyphs: true, showVerticalCrosshair: true }, [] );
274
+
275
+ expect( screen.queryByTestId( 'tooltip-box' ) ).not.toBeInTheDocument();
276
+ expect( screen.queryByTestId( 'xy-chart-tooltip-glyph-A' ) ).not.toBeInTheDocument();
277
+ expect( screen.queryByTestId( 'xy-chart-tooltip-crosshair-vertical' ) ).not.toBeInTheDocument();
278
+ } );
279
+ } );
@@ -0,0 +1,288 @@
1
+ import { Tooltip, defaultStyles } from '@visx/tooltip';
2
+ import { DataContext, TooltipContext } from '@visx/xychart';
3
+ import { useCallback, useContext, useState } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { BoundedTooltip } from './private/bounded-tooltip';
6
+ import type { RenderTooltipGlyphProps, XyChartTooltipProps } from '../../visx/types';
7
+ import type { TooltipContextType } from '@visx/xychart';
8
+ import type { CSSProperties, ReactNode } from 'react';
9
+
10
+ const CROSSHAIR_STROKE_WIDTH = 1.5;
11
+ const DEFAULT_GLYPH_RADIUS = 4;
12
+ const FALLBACK_COLOR = '#222';
13
+ // Above the chart's own overlays (the zoom reset button sits at 2). The chart
14
+ // wrapper isolates its stacking context, so this never competes with page chrome.
15
+ const DEFAULT_TOOLTIP_Z_INDEX = 3;
16
+ // Portal-era options, accepted for compatibility and dropped before the box
17
+ // renders: it sits in the chart wrapper and moves with it, and nothing
18
+ // measures it any more.
19
+ const PORTAL_OPTIONS = new Set( [ 'scroll', 'debounce', 'resizeObserverPolyfill' ] );
20
+
21
+ type ScaleFn = ( value: unknown ) => number | undefined;
22
+
23
+ const isValidNumber = ( value: unknown ): value is number =>
24
+ typeof value === 'number' && Number.isFinite( value );
25
+
26
+ // Band scales place a datum at the start of its band; snapping targets the band's centre.
27
+ // A d3 scale is a function that carries `bandwidth` as a property.
28
+ const scaleBandwidth = ( scale: unknown ): number => {
29
+ const bandwidth = ( scale as { bandwidth?: unknown } | null | undefined )?.bandwidth;
30
+ return typeof bandwidth === 'function' ? Number( bandwidth() ) : 0;
31
+ };
32
+
33
+ const DefaultGlyph = < Datum extends object >( {
34
+ x,
35
+ y,
36
+ size,
37
+ color,
38
+ glyphStyle,
39
+ seriesKey,
40
+ }: Omit< RenderTooltipGlyphProps< Datum >, 'key' > & { seriesKey: string } ) => {
41
+ const { theme } = useContext( DataContext ) || {};
42
+
43
+ return (
44
+ <circle
45
+ data-testid={ `xy-chart-tooltip-glyph-${ seriesKey }` }
46
+ cx={ x }
47
+ cy={ y }
48
+ r={ size }
49
+ fill={ color }
50
+ stroke={ theme?.backgroundColor }
51
+ strokeWidth={ CROSSHAIR_STROKE_WIDTH }
52
+ paintOrder="fill"
53
+ { ...glyphStyle }
54
+ />
55
+ );
56
+ };
57
+
58
+ const defaultRenderGlyph = < Datum extends object >( {
59
+ key,
60
+ ...props
61
+ }: RenderTooltipGlyphProps< Datum > ) => (
62
+ <DefaultGlyph key={ key } seriesKey={ key } { ...props } />
63
+ );
64
+
65
+ type XyChartTooltipContentProps< Datum extends object > = XyChartTooltipProps< Datum > & {
66
+ tooltipContext: TooltipContextType< Datum >;
67
+ /** The SVG's HTML parent once found. The box waits for it; the SVG overlay does not. */
68
+ container: HTMLElement | null;
69
+ };
70
+
71
+ const XyChartTooltipContent = < Datum extends object >( {
72
+ tooltipContext,
73
+ container,
74
+ renderTooltip,
75
+ renderGlyph = defaultRenderGlyph,
76
+ glyphStyle,
77
+ snapTooltipToDatumX = false,
78
+ snapTooltipToDatumY = false,
79
+ showVerticalCrosshair = false,
80
+ showHorizontalCrosshair = false,
81
+ showDatumGlyph = false,
82
+ showSeriesGlyphs = false,
83
+ verticalCrosshairStyle,
84
+ horizontalCrosshairStyle,
85
+ detectBounds = true,
86
+ zIndex = DEFAULT_TOOLTIP_Z_INDEX,
87
+ ...rest
88
+ }: XyChartTooltipContentProps< Datum > ) => {
89
+ const tooltipProps = Object.fromEntries(
90
+ Object.entries( rest ).filter( ( [ key ] ) => ! PORTAL_OPTIONS.has( key ) )
91
+ ) as Omit< typeof rest, 'scroll' | 'debounce' | 'resizeObserverPolyfill' >;
92
+ const {
93
+ colorScale,
94
+ theme,
95
+ innerHeight = 0,
96
+ innerWidth = 0,
97
+ margin,
98
+ xScale,
99
+ yScale,
100
+ dataRegistry,
101
+ } = useContext( DataContext ) || {};
102
+
103
+ const tooltipContent = renderTooltip ? renderTooltip( { ...tooltipContext, colorScale } ) : null;
104
+ if ( tooltipContent == null ) {
105
+ return null;
106
+ }
107
+
108
+ const scaleX = xScale as ScaleFn | undefined;
109
+ const scaleY = yScale as ScaleFn | undefined;
110
+
111
+ // Datum position in SVG coordinates, from the accessors the series registered.
112
+ const getDatumLeftTop = ( key: string, datum: Datum ) => {
113
+ const entry = dataRegistry?.get( key );
114
+ const left =
115
+ scaleX && entry?.xAccessor
116
+ ? Number( scaleX( entry.xAccessor( datum ) ) ) + scaleBandwidth( xScale ) / 2
117
+ : undefined;
118
+ const top =
119
+ scaleY && entry?.yAccessor
120
+ ? Number( scaleY( entry.yAccessor( datum ) ) ) + scaleBandwidth( yScale ) / 2
121
+ : undefined;
122
+ return { left, top };
123
+ };
124
+
125
+ const nearestDatum = tooltipContext.tooltipData?.nearestDatum;
126
+ let { tooltipLeft, tooltipTop } = tooltipContext;
127
+
128
+ if ( nearestDatum && ( snapTooltipToDatumX || snapTooltipToDatumY ) ) {
129
+ const { left, top } = getDatumLeftTop( nearestDatum.key, nearestDatum.datum );
130
+ if ( snapTooltipToDatumX && isValidNumber( left ) ) {
131
+ tooltipLeft = left;
132
+ }
133
+ if ( snapTooltipToDatumY && isValidNumber( top ) ) {
134
+ tooltipTop = top;
135
+ }
136
+ }
137
+
138
+ const glyphs: ReactNode[] = [];
139
+ if ( showDatumGlyph || showSeriesGlyphs ) {
140
+ const size = Number( glyphStyle?.radius ?? DEFAULT_GLYPH_RADIUS );
141
+ const labelColor = theme?.htmlLabel?.color ?? FALLBACK_COLOR;
142
+ // visx colours a lone nearest-datum glyph like the gridlines and series glyphs like labels.
143
+ const fallbackColor = showSeriesGlyphs ? labelColor : theme?.gridStyles?.stroke ?? labelColor;
144
+ let entries: Array< { key: string; datum: Datum; index: number } > = [];
145
+ if ( showSeriesGlyphs ) {
146
+ entries = Object.values( tooltipContext.tooltipData?.datumByKey ?? {} );
147
+ } else if ( nearestDatum ) {
148
+ entries = [ nearestDatum ];
149
+ }
150
+
151
+ for ( const { key, datum, index } of entries ) {
152
+ const { left, top } = getDatumLeftTop( key, datum );
153
+ if ( ! isValidNumber( left ) || ! isValidNumber( top ) ) {
154
+ continue;
155
+ }
156
+ // The group carries the position and the renderer draws at the origin,
157
+ // as with visx's portal: a renderer that ignores `x` and `y` still lands
158
+ // on its datum.
159
+ glyphs.push(
160
+ <g
161
+ key={ key }
162
+ className="visx-tooltip-glyph"
163
+ data-testid={ `xy-chart-tooltip-glyph-group-${ key }` }
164
+ transform={ `translate(${ left }, ${ top })` }
165
+ >
166
+ { renderGlyph( {
167
+ key,
168
+ color: colorScale?.( key ) ?? fallbackColor,
169
+ datum,
170
+ index,
171
+ size,
172
+ x: 0,
173
+ y: 0,
174
+ glyphStyle,
175
+ isNearestDatum: nearestDatum?.key === key,
176
+ } ) }
177
+ </g>
178
+ );
179
+ }
180
+ }
181
+
182
+ const crosshairStroke = theme?.gridStyles?.stroke ?? theme?.htmlLabel?.color ?? FALLBACK_COLOR;
183
+ const marginTop = margin?.top ?? 0;
184
+ const marginLeft = margin?.left ?? 0;
185
+
186
+ const TooltipComponent = detectBounds ? BoundedTooltip : Tooltip;
187
+ const boxStyle: CSSProperties = {
188
+ ...defaultStyles,
189
+ zIndex,
190
+ background: theme?.backgroundColor ?? 'white',
191
+ boxShadow: `0 1px 2px ${
192
+ theme?.htmlLabel?.color ? `${ theme.htmlLabel.color }55` : '#22222255'
193
+ }`,
194
+ ...theme?.htmlLabel,
195
+ };
196
+
197
+ return (
198
+ <>
199
+ <g className="visx-tooltip-overlay" pointerEvents="none">
200
+ { showVerticalCrosshair && isValidNumber( tooltipLeft ) && (
201
+ <line
202
+ className="visx-crosshair visx-crosshair-vertical"
203
+ data-testid="xy-chart-tooltip-crosshair-vertical"
204
+ x1={ tooltipLeft }
205
+ x2={ tooltipLeft }
206
+ y1={ marginTop }
207
+ y2={ marginTop + innerHeight }
208
+ stroke={ crosshairStroke }
209
+ strokeWidth={ CROSSHAIR_STROKE_WIDTH }
210
+ { ...verticalCrosshairStyle }
211
+ />
212
+ ) }
213
+ { showHorizontalCrosshair && isValidNumber( tooltipTop ) && (
214
+ <line
215
+ className="visx-crosshair visx-crosshair-horizontal"
216
+ data-testid="xy-chart-tooltip-crosshair-horizontal"
217
+ x1={ marginLeft }
218
+ x2={ marginLeft + innerWidth }
219
+ y1={ tooltipTop }
220
+ y2={ tooltipTop }
221
+ stroke={ crosshairStroke }
222
+ strokeWidth={ CROSSHAIR_STROKE_WIDTH }
223
+ { ...horizontalCrosshairStyle }
224
+ />
225
+ ) }
226
+ { glyphs }
227
+ </g>
228
+ { container &&
229
+ createPortal(
230
+ <TooltipComponent
231
+ left={ tooltipLeft }
232
+ top={ tooltipTop }
233
+ style={ boxStyle }
234
+ applyPositionStyle
235
+ { ...tooltipProps }
236
+ >
237
+ { tooltipContent }
238
+ </TooltipComponent>,
239
+ container
240
+ ) }
241
+ </>
242
+ );
243
+ };
244
+
245
+ /**
246
+ * In-tree replacement for `@visx/xychart`'s `Tooltip`.
247
+ *
248
+ * visx renders its tooltip box, glyphs and crosshairs through portals appended
249
+ * to `document.body`. That puts them in the page's root stacking context, where
250
+ * they paint above every sticky or fixed element that lives inside a nested
251
+ * stacking context, and no z-index on that element can change the order. This
252
+ * component draws the glyphs and crosshairs straight into the chart SVG and
253
+ * renders the tooltip box into the SVG's parent element, so all of them stack
254
+ * as ordinary descendants of the chart.
255
+ *
256
+ * Render it as a child of `XYChart`. The element wrapping that `XYChart` must
257
+ * be `position: relative` with the SVG at its origin, and `isolation: isolate`:
258
+ * the box is placed with the SVG-local coordinates visx reports, and the
259
+ * isolation keeps the box's `zIndex` from competing with page chrome. The box
260
+ * flips and clamps to stay inside the nearest ancestor that clips its overflow
261
+ * (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.
263
+ *
264
+ * @param props - visx's `Tooltip` options. `scroll`, `debounce` and `resizeObserverPolyfill` are accepted and ignored.
265
+ * @return An anchor in the SVG, plus the overlay and the tooltip box while the tooltip is open.
266
+ */
267
+ export const XyChartTooltip = < Datum extends object >( props: XyChartTooltipProps< Datum > ) => {
268
+ const tooltipContext = useContext( TooltipContext ) as TooltipContextType< Datum > | null;
269
+ const [ container, setContainer ] = useState< HTMLElement | null >( null );
270
+
271
+ // Children of XYChart render inside its SVG, and the box needs an HTML parent.
272
+ const anchorRef = useCallback( ( node: SVGGElement | null ) => {
273
+ setContainer( node?.ownerSVGElement?.parentElement ?? null );
274
+ }, [] );
275
+
276
+ return (
277
+ <>
278
+ <g ref={ anchorRef } data-testid="xy-chart-tooltip-anchor" />
279
+ { tooltipContext?.tooltipOpen && (
280
+ <XyChartTooltipContent
281
+ { ...props }
282
+ tooltipContext={ tooltipContext }
283
+ container={ container }
284
+ />
285
+ ) }
286
+ </>
287
+ );
288
+ };
@@ -2,8 +2,8 @@
2
2
  display: inline-flex;
3
3
  align-items: center;
4
4
  gap: 0.125em;
5
- font-size: var(--wpds-typography-font-size-md, 13px);
6
- font-weight: var(--wpds-typography-font-weight-emphasis, 600);
5
+ font-size: var(--wpds-typography-font-size-md);
6
+ font-weight: var(--wpds-typography-font-weight-emphasis);
7
7
  line-height: 1;
8
8
 
9
9
  &--up {
@@ -23,7 +23,7 @@ describe( 'useXYChartTheme', () => {
23
23
  document.body.removeChild( scope );
24
24
  } );
25
25
 
26
- // Resolving a paint-only color here would freeze it: visx writes whatever it is handed onto the element, so a literal stops following the cascade and an override set below the provider wrapper never reaches it. Handing visx the pointer instead lets it resolve at the element it paints. jsdom does not compute `var()`, so what the pointer resolves *to* is covered in Storybook; this pins that the chain survives the theme build intact.
26
+ // jsdom does not compute `var()`, so what a pointer resolves *to* is covered in Storybook.
27
27
  it( 'hands visx the catalog pointer for paint-only colors rather than a resolved value', () => {
28
28
  const scope = document.createElement( 'div' );
29
29
  document.body.appendChild( scope );
@@ -36,10 +36,10 @@ describe( 'useXYChartTheme', () => {
36
36
 
37
37
  expect( result.current.gridStyles.stroke ).toBe( 'var(--a8c-charts-color-grid, #dbdbdb)' );
38
38
  expect( result.current.axisStyles.x.bottom.axisLine.stroke ).toBe(
39
- 'var(--a8c-charts-color-axis, #dbdbdb)'
39
+ 'var(--a8c-charts-color-axis-x, #dbdbdb)'
40
40
  );
41
41
  expect( result.current.axisStyles.x.bottom.tickLine.stroke ).toBe(
42
- 'var(--a8c-charts-color-tick, #dbdbdb)'
42
+ 'var(--a8c-charts-color-tick-x, #dbdbdb)'
43
43
  );
44
44
  expect( result.current.axisStyles.x.bottom.tickLabel.fill ).toBe(
45
45
  'var(--a8c-charts-color-label-axis, #1e1e1e)'
@@ -67,4 +67,17 @@ describe( 'useXYChartTheme', () => {
67
67
 
68
68
  document.body.removeChild( scope );
69
69
  } );
70
+
71
+ it( 'gives the y axis its own roles, resolving to none', () => {
72
+ const { result } = renderHook( () => useXYChartTheme( [] ) );
73
+
74
+ for ( const side of [ 'left', 'right' ] as const ) {
75
+ expect( result.current.axisStyles.y[ side ].axisLine.stroke ).toBe(
76
+ 'var(--a8c-charts-color-axis-y, none)'
77
+ );
78
+ expect( result.current.axisStyles.y[ side ].tickLine.stroke ).toBe(
79
+ 'var(--a8c-charts-color-tick-y, none)'
80
+ );
81
+ }
82
+ } );
70
83
  } );
@@ -1,8 +1,9 @@
1
1
  import { buildChartTheme } from '@visx/xychart';
2
2
  import { useMemo } from 'react';
3
3
  import { useGlobalChartsTheme } from '../providers';
4
+ import { CATALOG_POINTERS } from '../providers/chart-context/private/catalog-pointers';
4
5
  import { useChartScopeElement } from '../providers/chart-scope';
5
- import { createCssVariableResolver } from '../utils';
6
+ import { createCssVariableResolver, normalizeColorToHex } from '../utils';
6
7
  import type { SeriesData } from '../types';
7
8
 
8
9
  export const useXYChartTheme = ( data: SeriesData[] ) => {
@@ -17,29 +18,44 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
17
18
  );
18
19
 
19
20
  return useMemo( () => {
20
- // Only what is read as a string is resolved here — the palette and `backgroundColor` — plus `htmlLabel.color` below, which is painted outside the scope. Resolving against the chart's own scope element, never :root, is what reads an override set inside the provider tree.
21
- //
22
- // One resolver per theme build, so all three share a single getComputedStyle call.
21
+ // Only what is read as a string is resolved: the palette, `backgroundColor`, `htmlLabel.color`.
22
+ // At the chart's scope element, never :root, so an override inside the provider tree is seen.
23
+ // One resolver for all three, so they share a single getComputedStyle call.
23
24
  const resolve = createCssVariableResolver( scopeElement );
24
25
  const resolveColor = ( value?: string ): string | undefined =>
25
26
  value ? resolve( value ) ?? value : value;
26
27
 
27
28
  const seriesColors: string[] = JSON.parse( seriesColorKey );
28
29
 
29
- // The palette gets the same treatment as every other color here, and needs it more: `theme.colors` is the one field that is always a `var()` chain, and the four slots without a catalog default resolve to nothing at all. visx builds its `colorScale` from this array and uses it as the default stroke for a series rendered without an explicit one, so an unresolved entry paints nothing rather than degrading. Entries that resolve to nothing are dropped so the scale compacts the way the provider's own palette does.
30
- const paletteColors = [ ...seriesColors, ...( theme.colors ?? [] ) ]
30
+ // visx uses this as the default stroke for a series without one, so an
31
+ // unresolved entry paints nothing.
32
+ const paletteColors = [ ...seriesColors, ...CATALOG_POINTERS.series ]
31
33
  .map( color => resolveColor( color ) )
32
34
  .filter( ( color ): color is string => Boolean( color ) && ! color.includes( 'var(' ) );
33
35
 
34
36
  // The tooltip is painted in a portal outside the scope, and visx concatenates this color into a `box-shadow` where a chain cannot take a suffix; see TOKENS.md#the-svg-bridge. Passing it explicitly leaves `svgLabelSmall.fill`, which visx derives it from, a chain for the SVG tick labels.
35
- const htmlLabelColor = resolveColor( theme.svgLabelSmall?.fill );
37
+ // Hex specifically: that concatenation appends `55`, which only yields a color
38
+ // after a 6-digit hex. An `rgb()` computed value takes the whole shadow down.
39
+ const resolvedLabelColor = resolveColor( CATALOG_POINTERS.labelAxis );
40
+ const htmlLabelColor = resolvedLabelColor
41
+ ? normalizeColorToHex( resolvedLabelColor ) || resolvedLabelColor
42
+ : resolvedLabelColor;
36
43
 
37
- // The grid, axis, tick and label colors are spread through untouched, and that is the whole mechanism: visx writes each one onto the element it paints — as an inline style for the grid, a presentation attribute elsewhere — and a `var()` chain resolves there natively. So the role is read at the painted element rather than snapshot at the provider wrapper, which is what makes an override on a chart's own class work, keeps a theme change live without a re-render, and leaves nothing to resolve during SSR. Resolving them here would freeze the color instead.
44
+ // visx's fallbacks for whichever axis or grid style object it is not given. All four are
45
+ // supplied, so these reach nothing; they stay because the config type requires them.
38
46
  return buildChartTheme( {
39
47
  ...theme,
48
+ gridColor: '',
49
+ gridColorDark: '',
40
50
  colors: paletteColors,
41
- backgroundColor: resolveColor( theme.backgroundColor ),
51
+ backgroundColor: resolveColor( CATALOG_POINTERS.background ),
42
52
  htmlLabel: htmlLabelColor ? { color: htmlLabelColor } : undefined,
53
+ gridStyles: { ...theme.gridStyles, stroke: CATALOG_POINTERS.grid },
54
+ xAxisLineStyles: { ...theme.xAxisLineStyles, stroke: CATALOG_POINTERS.axisX },
55
+ xTickLineStyles: { ...theme.xTickLineStyles, stroke: CATALOG_POINTERS.tickX },
56
+ yAxisLineStyles: { stroke: CATALOG_POINTERS.axisY },
57
+ yTickLineStyles: { stroke: CATALOG_POINTERS.tickY },
58
+ svgLabelSmall: { ...theme.svgLabelSmall, fill: CATALOG_POINTERS.labelAxis },
43
59
  } );
44
60
  }, [ theme, seriesColorKey, scopeElement ] );
45
61
  };