@automattic/charts 3.2.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/SECURITY.md +1 -0
  3. package/TOKENS.md +53 -58
  4. package/dist/index.cjs +1483 -664
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.css +93 -29
  7. package/dist/index.d.cts +233 -247
  8. package/dist/index.d.ts +233 -247
  9. package/dist/index.js +1484 -667
  10. package/dist/index.js.map +1 -1
  11. package/package.json +7 -6
  12. package/src/charts/area-chart/area-chart.tsx +39 -25
  13. package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
  14. package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
  15. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
  16. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
  17. package/src/charts/area-chart/types.ts +4 -7
  18. package/src/charts/bar-chart/bar-chart.tsx +2 -1
  19. package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
  20. package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
  21. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -0
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  23. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  24. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  25. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  26. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  27. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  28. package/src/charts/heatmap-chart/heatmap-chart.module.scss +52 -16
  29. package/src/charts/heatmap-chart/heatmap-chart.tsx +89 -37
  30. package/src/charts/heatmap-chart/index.ts +2 -1
  31. package/src/charts/heatmap-chart/private/build-calendar-data.ts +126 -89
  32. package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
  33. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  34. package/src/charts/heatmap-chart/private/index.ts +1 -1
  35. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
  36. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +3 -0
  37. package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
  38. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +142 -12
  39. package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
  40. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
  41. package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
  42. package/src/charts/heatmap-chart/types.ts +8 -2
  43. package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
  44. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  45. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  46. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  47. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  48. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  49. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  50. package/src/charts/leaderboard-chart/types.ts +2 -2
  51. package/src/charts/line-chart/line-chart.module.scss +10 -18
  52. package/src/charts/line-chart/line-chart.tsx +128 -26
  53. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  54. package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
  55. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  56. package/src/charts/line-chart/test/line-chart.test.tsx +174 -41
  57. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  58. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  59. package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
  60. package/src/charts/line-chart/types.ts +17 -3
  61. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  62. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  63. package/src/charts/pie-chart/test/pie-chart.test.tsx +59 -12
  64. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  65. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  66. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  67. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  68. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  69. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  70. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  71. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  72. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  73. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  74. package/src/charts/private/test/time-axis.test.ts +9 -69
  75. package/src/charts/private/time-axis-options.ts +106 -0
  76. package/src/charts/private/time-axis.ts +318 -136
  77. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  78. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  79. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  80. package/src/charts/sparkline/types.ts +1 -1
  81. package/src/components/legend/private/base-legend.module.scss +9 -5
  82. package/src/components/tooltip/accessible-tooltip.tsx +21 -17
  83. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  84. package/src/components/tooltip/private/bounded-tooltip.tsx +227 -0
  85. package/src/components/tooltip/test/accessible-tooltip.test.tsx +30 -4
  86. package/src/components/tooltip/test/bounded-tooltip.test.tsx +225 -0
  87. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +379 -0
  88. package/src/components/tooltip/xy-chart-tooltip.tsx +326 -0
  89. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  90. package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
  91. package/src/hooks/test/use-chart-margin.test.tsx +173 -3
  92. package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
  93. package/src/hooks/use-chart-data-transform.ts +10 -3
  94. package/src/hooks/use-chart-margin.tsx +54 -9
  95. package/src/hooks/use-xychart-theme.ts +25 -9
  96. package/src/index.ts +8 -0
  97. package/src/providers/chart-context/global-charts-provider.tsx +37 -38
  98. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  99. package/src/providers/chart-context/index.ts +1 -0
  100. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  101. package/src/providers/chart-context/private/series-palette.ts +3 -13
  102. package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
  103. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  104. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -25
  105. package/src/providers/chart-context/themes.ts +7 -52
  106. package/src/providers/chart-context/types.ts +3 -0
  107. package/src/styles/chart-scope.scss +39 -62
  108. package/src/styles/test/chart-scope.test.ts +19 -47
  109. package/src/test-utils/series-fixtures.ts +31 -0
  110. package/src/test-utils/zoned-parsing-suite.ts +90 -0
  111. package/src/types.ts +79 -166
  112. package/src/utils/bucket-info.ts +87 -0
  113. package/src/utils/color-utils.ts +0 -27
  114. package/src/utils/date-formatting.ts +157 -0
  115. package/src/utils/date-parsing.ts +103 -52
  116. package/src/utils/get-edge-tick-widths.ts +33 -0
  117. package/src/utils/index.ts +4 -0
  118. package/src/utils/test/bucket-info.test.ts +185 -0
  119. package/src/utils/test/color-utils.test.ts +1 -48
  120. package/src/utils/test/date-formatting.test.ts +89 -0
  121. package/src/utils/test/date-parsing.auckland.test.ts +6 -0
  122. package/src/utils/test/date-parsing.santiago.test.ts +6 -0
  123. package/src/utils/test/date-parsing.test.ts +16 -1
  124. package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
  125. package/src/utils/test/get-styles.test.ts +10 -7
  126. package/src/utils/test/merge-themes.test.ts +18 -46
  127. package/src/utils/warn-once.ts +23 -0
  128. package/src/visx/types.ts +56 -5
  129. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  130. package/src/providers/chart-context/private/theme-override-vars.ts +0 -131
  131. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
  132. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  133. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -183
  134. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
  135. package/src/styles/test/wpds-fallbacks.test.ts +0 -131
@@ -0,0 +1,326 @@
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 {
7
+ CrosshairStyle,
8
+ RenderTooltipGlyphProps,
9
+ XyChartTooltipProps,
10
+ } from '../../visx/types';
11
+ import type { TooltipContextType } from '@visx/xychart';
12
+ import type { CSSProperties, ReactNode } from 'react';
13
+
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
+ };
36
+ const DEFAULT_GLYPH_RADIUS = 4;
37
+ const FALLBACK_COLOR = '#222';
38
+ // Above the chart's own overlays (the zoom reset button sits at 2). The chart
39
+ // wrapper isolates its stacking context, so this never competes with page chrome.
40
+ const DEFAULT_TOOLTIP_Z_INDEX = 3;
41
+ // Portal-era options, accepted for compatibility and dropped before the box
42
+ // renders: it sits in the chart wrapper and moves with it, and nothing
43
+ // measures it any more.
44
+ const PORTAL_OPTIONS = new Set( [ 'scroll', 'debounce', 'resizeObserverPolyfill' ] );
45
+
46
+ type ScaleFn = ( value: unknown ) => number | undefined;
47
+
48
+ const isValidNumber = ( value: unknown ): value is number =>
49
+ typeof value === 'number' && Number.isFinite( value );
50
+
51
+ // Band scales place a datum at the start of its band; snapping targets the band's centre.
52
+ // A d3 scale is a function that carries `bandwidth` as a property.
53
+ const scaleBandwidth = ( scale: unknown ): number => {
54
+ const bandwidth = ( scale as { bandwidth?: unknown } | null | undefined )?.bandwidth;
55
+ return typeof bandwidth === 'function' ? Number( bandwidth() ) : 0;
56
+ };
57
+
58
+ const DefaultGlyph = < Datum extends object >( {
59
+ x,
60
+ y,
61
+ size,
62
+ color,
63
+ glyphStyle,
64
+ seriesKey,
65
+ }: Omit< RenderTooltipGlyphProps< Datum >, 'key' > & { seriesKey: string } ) => {
66
+ const { theme } = useContext( DataContext ) || {};
67
+
68
+ return (
69
+ <circle
70
+ data-testid={ `xy-chart-tooltip-glyph-${ seriesKey }` }
71
+ cx={ x }
72
+ cy={ y }
73
+ r={ size }
74
+ fill={ color }
75
+ stroke={ theme?.backgroundColor }
76
+ strokeWidth={ CROSSHAIR_STROKE_WIDTH }
77
+ paintOrder="fill"
78
+ { ...glyphStyle }
79
+ />
80
+ );
81
+ };
82
+
83
+ const defaultRenderGlyph = < Datum extends object >( {
84
+ key,
85
+ ...props
86
+ }: RenderTooltipGlyphProps< Datum > ) => (
87
+ <DefaultGlyph key={ key } seriesKey={ key } { ...props } />
88
+ );
89
+
90
+ type XyChartTooltipContentProps< Datum extends object > = XyChartTooltipProps< Datum > & {
91
+ tooltipContext: TooltipContextType< Datum >;
92
+ /** The SVG's HTML parent once found. The box waits for it; the SVG overlay does not. */
93
+ container: HTMLElement | null;
94
+ };
95
+
96
+ const XyChartTooltipContent = < Datum extends object >( {
97
+ tooltipContext,
98
+ container,
99
+ renderTooltip,
100
+ renderGlyph = defaultRenderGlyph,
101
+ glyphStyle,
102
+ snapTooltipToDatumX = false,
103
+ snapTooltipToDatumY = false,
104
+ showVerticalCrosshair = false,
105
+ showHorizontalCrosshair = false,
106
+ showDatumGlyph = false,
107
+ showSeriesGlyphs = false,
108
+ verticalCrosshairStyle,
109
+ horizontalCrosshairStyle,
110
+ detectBounds = true,
111
+ tooltipPlacement = 'auto',
112
+ zIndex = DEFAULT_TOOLTIP_Z_INDEX,
113
+ style,
114
+ ...rest
115
+ }: XyChartTooltipContentProps< Datum > ) => {
116
+ const tooltipProps = Object.fromEntries(
117
+ Object.entries( rest ).filter( ( [ key ] ) => ! PORTAL_OPTIONS.has( key ) )
118
+ ) as Omit< typeof rest, 'scroll' | 'debounce' | 'resizeObserverPolyfill' >;
119
+ const {
120
+ colorScale,
121
+ theme,
122
+ innerHeight = 0,
123
+ innerWidth = 0,
124
+ margin,
125
+ xScale,
126
+ yScale,
127
+ dataRegistry,
128
+ } = useContext( DataContext ) || {};
129
+
130
+ const tooltipContent = renderTooltip ? renderTooltip( { ...tooltipContext, colorScale } ) : null;
131
+ if ( tooltipContent == null ) {
132
+ return null;
133
+ }
134
+
135
+ const scaleX = xScale as ScaleFn | undefined;
136
+ const scaleY = yScale as ScaleFn | undefined;
137
+
138
+ // Datum position in SVG coordinates, from the accessors the series registered.
139
+ const getDatumLeftTop = ( key: string, datum: Datum ) => {
140
+ const entry = dataRegistry?.get( key );
141
+ const left =
142
+ scaleX && entry?.xAccessor
143
+ ? Number( scaleX( entry.xAccessor( datum ) ) ) + scaleBandwidth( xScale ) / 2
144
+ : undefined;
145
+ const top =
146
+ scaleY && entry?.yAccessor
147
+ ? Number( scaleY( entry.yAccessor( datum ) ) ) + scaleBandwidth( yScale ) / 2
148
+ : undefined;
149
+ return { left, top };
150
+ };
151
+
152
+ const nearestDatum = tooltipContext.tooltipData?.nearestDatum;
153
+ let { tooltipLeft, tooltipTop } = tooltipContext;
154
+
155
+ if (
156
+ nearestDatum &&
157
+ ( snapTooltipToDatumX || snapTooltipToDatumY || tooltipPlacement === 'below-axis' )
158
+ ) {
159
+ const { left, top } = getDatumLeftTop( nearestDatum.key, nearestDatum.datum );
160
+ if ( ( snapTooltipToDatumX || tooltipPlacement === 'below-axis' ) && isValidNumber( left ) ) {
161
+ tooltipLeft = left;
162
+ }
163
+ if ( snapTooltipToDatumY && isValidNumber( top ) ) {
164
+ tooltipTop = top;
165
+ }
166
+ }
167
+
168
+ const glyphs: ReactNode[] = [];
169
+ if ( showDatumGlyph || showSeriesGlyphs ) {
170
+ const size = Number( glyphStyle?.radius ?? DEFAULT_GLYPH_RADIUS );
171
+ const labelColor = theme?.htmlLabel?.color ?? FALLBACK_COLOR;
172
+ // visx colours a lone nearest-datum glyph like the gridlines and series glyphs like labels.
173
+ const fallbackColor = showSeriesGlyphs ? labelColor : theme?.gridStyles?.stroke ?? labelColor;
174
+ let entries: Array< { key: string; datum: Datum; index: number } > = [];
175
+ if ( showSeriesGlyphs ) {
176
+ entries = Object.values( tooltipContext.tooltipData?.datumByKey ?? {} );
177
+ } else if ( nearestDatum ) {
178
+ entries = [ nearestDatum ];
179
+ }
180
+
181
+ for ( const { key, datum, index } of entries ) {
182
+ const { left, top } = getDatumLeftTop( key, datum );
183
+ if ( ! isValidNumber( left ) || ! isValidNumber( top ) ) {
184
+ continue;
185
+ }
186
+ // The group carries the position and the renderer draws at the origin,
187
+ // as with visx's portal: a renderer that ignores `x` and `y` still lands
188
+ // on its datum.
189
+ glyphs.push(
190
+ <g
191
+ key={ key }
192
+ className="visx-tooltip-glyph"
193
+ data-testid={ `xy-chart-tooltip-glyph-group-${ key }` }
194
+ transform={ `translate(${ left }, ${ top })` }
195
+ >
196
+ { renderGlyph( {
197
+ key,
198
+ color: colorScale?.( key ) ?? fallbackColor,
199
+ datum,
200
+ index,
201
+ size,
202
+ x: 0,
203
+ y: 0,
204
+ glyphStyle,
205
+ isNearestDatum: nearestDatum?.key === key,
206
+ } ) }
207
+ </g>
208
+ );
209
+ }
210
+ }
211
+
212
+ const crosshairStroke = theme?.gridStyles?.stroke ?? theme?.htmlLabel?.color ?? FALLBACK_COLOR;
213
+ const marginTop = margin?.top ?? 0;
214
+ const marginLeft = margin?.left ?? 0;
215
+
216
+ const TooltipComponent =
217
+ detectBounds || tooltipPlacement === 'below-axis' ? BoundedTooltip : Tooltip;
218
+ const boxStyle: CSSProperties = {
219
+ ...defaultStyles,
220
+ zIndex,
221
+ backgroundColor: theme?.backgroundColor ?? 'white',
222
+ boxShadow: `0 1px 2px ${
223
+ theme?.htmlLabel?.color ? `${ theme.htmlLabel.color }55` : '#22222255'
224
+ }`,
225
+ ...theme?.htmlLabel,
226
+ ...style,
227
+ };
228
+
229
+ return (
230
+ <>
231
+ <g className="visx-tooltip-overlay" pointerEvents="none">
232
+ { showVerticalCrosshair && isValidNumber( tooltipLeft ) && (
233
+ <line
234
+ className="visx-crosshair visx-crosshair-vertical"
235
+ data-testid="xy-chart-tooltip-crosshair-vertical"
236
+ stroke={ crosshairStroke }
237
+ strokeWidth={ CROSSHAIR_STROKE_WIDTH }
238
+ { ...crosshairPaintProps( verticalCrosshairStyle ) }
239
+ x1={ tooltipLeft }
240
+ x2={ tooltipLeft }
241
+ y1={ marginTop }
242
+ y2={ marginTop + innerHeight }
243
+ />
244
+ ) }
245
+ { showHorizontalCrosshair && isValidNumber( tooltipTop ) && (
246
+ <line
247
+ className="visx-crosshair visx-crosshair-horizontal"
248
+ data-testid="xy-chart-tooltip-crosshair-horizontal"
249
+ stroke={ crosshairStroke }
250
+ strokeWidth={ CROSSHAIR_STROKE_WIDTH }
251
+ { ...crosshairPaintProps( horizontalCrosshairStyle ) }
252
+ x1={ marginLeft }
253
+ x2={ marginLeft + innerWidth }
254
+ y1={ tooltipTop }
255
+ y2={ tooltipTop }
256
+ />
257
+ ) }
258
+ { glyphs }
259
+ </g>
260
+ { container &&
261
+ createPortal(
262
+ <TooltipComponent
263
+ left={ tooltipLeft }
264
+ top={
265
+ tooltipPlacement === 'below-axis'
266
+ ? marginTop + innerHeight + ( margin?.bottom ?? 0 )
267
+ : tooltipTop
268
+ }
269
+ style={ boxStyle }
270
+ applyPositionStyle
271
+ { ...tooltipProps }
272
+ { ...( tooltipPlacement === 'below-axis' && { placement: tooltipPlacement } ) }
273
+ >
274
+ { tooltipContent }
275
+ </TooltipComponent>,
276
+ container
277
+ ) }
278
+ </>
279
+ );
280
+ };
281
+
282
+ /**
283
+ * In-tree replacement for `@visx/xychart`'s `Tooltip`.
284
+ *
285
+ * visx renders its tooltip box, glyphs and crosshairs through portals appended
286
+ * to `document.body`. That puts them in the page's root stacking context, where
287
+ * they paint above every sticky or fixed element that lives inside a nested
288
+ * stacking context, and no z-index on that element can change the order. This
289
+ * component draws the glyphs and crosshairs straight into the chart SVG and
290
+ * renders the tooltip box into the SVG's parent element, so all of them stack
291
+ * as ordinary descendants of the chart.
292
+ *
293
+ * Render it as a child of `XYChart`. The element wrapping that `XYChart` must
294
+ * be `position: relative` with the SVG at its origin, and `isolation: isolate`:
295
+ * the box is placed with the SVG-local coordinates visx reports, and the
296
+ * isolation keeps the box's `zIndex` from competing with page chrome. The box
297
+ * flips and clamps to stay inside the nearest ancestor that clips its overflow
298
+ * (or the viewport), so it may extend past the chart wrapper but is never cut
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.
301
+ *
302
+ * @param props - visx's `Tooltip` options. `scroll`, `debounce` and `resizeObserverPolyfill` are accepted and ignored.
303
+ * @return An anchor in the SVG, plus the overlay and the tooltip box while the tooltip is open.
304
+ */
305
+ export const XyChartTooltip = < Datum extends object >( props: XyChartTooltipProps< Datum > ) => {
306
+ const tooltipContext = useContext( TooltipContext ) as TooltipContextType< Datum > | null;
307
+ const [ container, setContainer ] = useState< HTMLElement | null >( null );
308
+
309
+ // Children of XYChart render inside its SVG, and the box needs an HTML parent.
310
+ const anchorRef = useCallback( ( node: SVGGElement | null ) => {
311
+ setContainer( node?.ownerSVGElement?.parentElement ?? null );
312
+ }, [] );
313
+
314
+ return (
315
+ <>
316
+ <g ref={ anchorRef } data-testid="xy-chart-tooltip-anchor" />
317
+ { tooltipContext?.tooltipOpen && (
318
+ <XyChartTooltipContent
319
+ { ...props }
320
+ tooltipContext={ tooltipContext }
321
+ container={ container }
322
+ />
323
+ ) }
324
+ </>
325
+ );
326
+ };
@@ -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 {
@@ -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
+ } );
@@ -9,6 +9,13 @@ jest.mock( '../../utils/get-longest-tick-width', () => ( {
9
9
  getLongestTickWidth: ( ...args: unknown[] ) => mockGetLongestTickWidth( ...args ),
10
10
  } ) );
11
11
 
12
+ // jsdom has no getComputedTextLength, so the real measurement always returns null.
13
+ const mockGetEdgeTickWidths = jest.fn();
14
+ jest.mock( '../../utils/get-edge-tick-widths', () => ( {
15
+ ...jest.requireActual( '../../utils/get-edge-tick-widths' ),
16
+ getEdgeTickWidths: ( ...args: unknown[] ) => mockGetEdgeTickWidths( ...args ),
17
+ } ) );
18
+
12
19
  describe( 'useChartMargin', () => {
13
20
  const baseTheme = {
14
21
  axisStyles: {
@@ -43,6 +50,8 @@ describe( 'useChartMargin', () => {
43
50
  beforeEach( () => {
44
51
  mockGetLongestTickWidth.mockReset();
45
52
  mockGetLongestTickWidth.mockReturnValue( 40 );
53
+ mockGetEdgeTickWidths.mockReset();
54
+ mockGetEdgeTickWidths.mockReturnValue( { first: 0, last: 0 } );
46
55
  } );
47
56
 
48
57
  it( 'calculates left margin for left y axis', () => {
@@ -62,7 +71,7 @@ describe( 'useChartMargin', () => {
62
71
  expect( mockGetLongestTickWidth ).toHaveBeenCalledWith(
63
72
  expect.any( Array ),
64
73
  options.axis.y.tickFormat,
65
- theme.axisStyles.y.left.axisLabel
74
+ { fontSize: '12px' }
66
75
  );
67
76
  // 40 label width + 8 tick length + ceil(11 * 0.25) label dx offset
68
77
  expect( result.current.left ).toBe( 51 );
@@ -85,7 +94,7 @@ describe( 'useChartMargin', () => {
85
94
  expect( mockGetLongestTickWidth ).toHaveBeenCalledWith(
86
95
  expect.any( Array ),
87
96
  options.axis.y.tickFormat,
88
- theme.axisStyles.y.right.axisLabel
97
+ { fontSize: '12px' }
89
98
  );
90
99
  // 40 label width + 8 tick length + ceil(11 * 0.25) label dx offset
91
100
  expect( result.current.right ).toBe( 51 );
@@ -108,7 +117,7 @@ describe( 'useChartMargin', () => {
108
117
  expect( mockGetLongestTickWidth ).toHaveBeenCalledWith(
109
118
  [ 0, 1000 ],
110
119
  options.axis.y.tickFormat,
111
- theme.axisStyles.y.left.axisLabel
120
+ { fontSize: '12px' }
112
121
  );
113
122
  } );
114
123
 
@@ -185,6 +194,167 @@ describe( 'useChartMargin', () => {
185
194
  expect( result.current.bottom ).toBe( 25 );
186
195
  } );
187
196
 
197
+ describe( 'x-axis edge tick labels', () => {
198
+ const tickFormat = ( value: number ) => new Date( value ).toDateString();
199
+ const tickValues = [ 1, 2, 3 ];
200
+ const datedXOptions = ( xOverrides = {} ) => ( {
201
+ ...optionsBase,
202
+ axis: { ...optionsBase.axis, x: { tickValues, tickFormat, ...xOverrides } },
203
+ } );
204
+
205
+ it( 'reserves half of the last label on the right', () => {
206
+ mockGetEdgeTickWidths.mockReturnValue( { first: 0, last: 60 } );
207
+
208
+ const { result } = renderHook( () =>
209
+ useChartMargin( 300, datedXOptions(), data, baseTheme )
210
+ );
211
+
212
+ expect( result.current.right ).toBe( 30 );
213
+ } );
214
+
215
+ it( 'keeps the default right margin when the last label fits inside it', () => {
216
+ mockGetEdgeTickWidths.mockReturnValue( { first: 0, last: 30 } );
217
+
218
+ const { result } = renderHook( () =>
219
+ useChartMargin( 300, datedXOptions(), data, baseTheme )
220
+ );
221
+
222
+ expect( result.current.right ).toBe( 20 );
223
+ } );
224
+
225
+ it( 'widens the left margin past the y-axis reservation when the first label needs it', () => {
226
+ mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: 0 } );
227
+
228
+ const { result } = renderHook( () =>
229
+ useChartMargin( 300, datedXOptions(), data, baseTheme )
230
+ );
231
+
232
+ // 60 for the label's overhanging half, over the 51 the y-axis ticks need.
233
+ expect( result.current.left ).toBe( 60 );
234
+ } );
235
+
236
+ it( 'measures the axis tick values with the x tick label style', () => {
237
+ const theme = {
238
+ ...baseTheme,
239
+ axisStyles: {
240
+ ...baseTheme.axisStyles,
241
+ x: {
242
+ bottom: { tickLabel: { fontSize: 11 }, tickLength: 8 } as unknown as never,
243
+ top: {} as unknown as never,
244
+ },
245
+ },
246
+ } as XYChartTheme;
247
+
248
+ renderHook( () => useChartMargin( 300, datedXOptions(), data, theme ) );
249
+
250
+ expect( mockGetEdgeTickWidths ).toHaveBeenCalledWith( tickValues, tickFormat, {
251
+ fontSize: '11px',
252
+ } );
253
+ } );
254
+
255
+ it( 'falls back to the raw tick label style when its font size is a relative unit', () => {
256
+ const theme = {
257
+ ...baseTheme,
258
+ axisStyles: {
259
+ ...baseTheme.axisStyles,
260
+ x: {
261
+ bottom: { tickLabel: { fontSize: '0.875rem' }, tickLength: 8 } as unknown as never,
262
+ top: {} as unknown as never,
263
+ },
264
+ },
265
+ } as XYChartTheme;
266
+
267
+ renderHook( () => useChartMargin( 300, datedXOptions(), data, theme ) );
268
+
269
+ expect( mockGetEdgeTickWidths ).toHaveBeenCalledWith( tickValues, tickFormat, {
270
+ fontSize: '0.875rem',
271
+ } );
272
+ } );
273
+
274
+ it( 'reserves the edge labels on a top x axis too', () => {
275
+ mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: 60 } );
276
+
277
+ const { result } = renderHook( () =>
278
+ useChartMargin( 300, datedXOptions( { orientation: 'top' } ), data, baseTheme )
279
+ );
280
+
281
+ expect( result.current.right ).toBe( 30 );
282
+ expect( result.current.left ).toBe( 60 );
283
+ } );
284
+
285
+ it( 'reserves nothing for a hidden x axis', () => {
286
+ mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: 60 } );
287
+
288
+ const { result } = renderHook( () =>
289
+ useChartMargin( 300, datedXOptions( { display: false } ), data, baseTheme )
290
+ );
291
+
292
+ expect( mockGetEdgeTickWidths ).not.toHaveBeenCalled();
293
+ expect( result.current.right ).toBe( 20 );
294
+ expect( result.current.left ).toBe( 51 );
295
+ } );
296
+ } );
297
+
298
+ describe( 'real measurement', () => {
299
+ // Everything else here mocks the measurer out; this block runs it for real,
300
+ // so that a width that never reaches the margin would fail something.
301
+ const actual = jest.requireActual( '../../utils/get-edge-tick-widths' );
302
+ type Measurable = { getComputedTextLength?: () => number };
303
+
304
+ afterEach( () => {
305
+ delete ( window.SVGElement.prototype as Measurable ).getComputedTextLength;
306
+ } );
307
+
308
+ it( 'turns a measured edge label into a reserved margin', () => {
309
+ // jsdom ships no getComputedTextLength, so @visx/text cannot measure at all.
310
+ ( window.SVGElement.prototype as Measurable ).getComputedTextLength = function (
311
+ this: SVGElement
312
+ ) {
313
+ return ( this.textContent ?? '' ).length * 8;
314
+ };
315
+ mockGetEdgeTickWidths.mockImplementation( actual.getEdgeTickWidths );
316
+
317
+ const options = {
318
+ ...optionsBase,
319
+ axis: {
320
+ ...optionsBase.axis,
321
+ x: {
322
+ tickValues: [ 1, 2 ],
323
+ tickFormat: ( _value: number, index: number ) =>
324
+ index === 0 ? 'AA' : 'MEASURED-LAST',
325
+ },
326
+ },
327
+ };
328
+
329
+ const { result } = renderHook( () => useChartMargin( 300, options, data, baseTheme ) );
330
+
331
+ // 'MEASURED-LAST' is 13 characters, so 104px wide, and half of it is reserved.
332
+ expect( result.current.right ).toBe( 52 );
333
+ } );
334
+ } );
335
+
336
+ describe( 'y axis gutter', () => {
337
+ it( 'measures a caller-pinned domain rather than the data range', () => {
338
+ const options = { ...optionsBase, yScale: { domain: [ 0, 1 ] as [ number, number ] } };
339
+
340
+ renderHook( () => useChartMargin( 300, options, data, baseTheme ) );
341
+
342
+ const ticks = mockGetLongestTickWidth.mock.calls[ 0 ][ 0 ] as number[];
343
+ expect( Math.max( ...ticks ) ).toBeLessThanOrEqual( 1 );
344
+ } );
345
+
346
+ it( 'reserves no gutter for a hidden y axis', () => {
347
+ const options = {
348
+ ...optionsBase,
349
+ axis: { ...optionsBase.axis, y: { ...optionsBase.axis.y, display: false } },
350
+ };
351
+
352
+ const { result } = renderHook( () => useChartMargin( 300, options, data, baseTheme ) );
353
+
354
+ expect( result.current.left ).toBe( 20 );
355
+ } );
356
+ } );
357
+
188
358
  describe( 'horizontal y ticks', () => {
189
359
  const horizontalOptions = ( tickFormat: ( value: string | number ) => string ) => ( {
190
360
  ...optionsBase,
@@ -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,4 +1,7 @@
1
1
  import { useMemo } from 'react';
2
+ // Imported from the module rather than the `providers` barrel: that barrel pulls
3
+ // `use-chart-registration`, which imports this file's own barrel back.
4
+ import { useChartFormatting } from '../providers/chart-context/hooks/use-chart-formatting';
2
5
  import { parseAsLocalDate } from '../utils';
3
6
  import type { SeriesData } from '../types';
4
7
 
@@ -7,7 +10,7 @@ import type { SeriesData } from '../types';
7
10
  *
8
11
  * This hook extracts the common data transformation logic used in both line-chart
9
12
  * and bar-chart components. It:
10
- * 1. Parses date strings into Date objects using parseAsLocalDate
13
+ * 1. Parses date strings into Date objects, dated in the provider's time zone
11
14
  * 2. Sorts data points by date when date properties are present
12
15
  * 3. Returns the original data unchanged when no date properties are found
13
16
  *
@@ -15,6 +18,10 @@ import type { SeriesData } from '../types';
15
18
  * @return {SeriesData[]} The transformed and sorted data
16
19
  */
17
20
  export const useChartDataTransform = ( data: SeriesData[] ) => {
21
+ // A naive `dateString` names no zone, so without this it would be read in the
22
+ // viewer's and then relabeled in the host's, and the two never cancel.
23
+ const { timeZone } = useChartFormatting();
24
+
18
25
  return useMemo( () => {
19
26
  // Check if the first data point has date or dateString properties
20
27
  const firstPoint = data?.[ 0 ]?.data?.[ 0 ];
@@ -35,7 +42,7 @@ export const useChartDataTransform = ( data: SeriesData[] ) => {
35
42
  if ( 'date' in point && point.date ) {
36
43
  date = point.date;
37
44
  } else if ( 'dateString' in point && point.dateString ) {
38
- date = parseAsLocalDate( point.dateString );
45
+ date = parseAsLocalDate( point.dateString, timeZone );
39
46
  }
40
47
 
41
48
  return {
@@ -48,5 +55,5 @@ export const useChartDataTransform = ( data: SeriesData[] ) => {
48
55
  return a.date.getTime() - b.date.getTime();
49
56
  } ),
50
57
  } ) );
51
- }, [ data ] );
58
+ }, [ data, timeZone ] );
52
59
  };