@automattic/charts 3.1.1 → 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 (116) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/TOKENS.md +80 -46
  3. package/dist/index.cjs +1224 -662
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +72 -23
  6. package/dist/index.d.cts +230 -162
  7. package/dist/index.d.ts +230 -162
  8. package/dist/index.js +1224 -663
  9. package/dist/index.js.map +1 -1
  10. package/package.json +10 -10
  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 +38 -11
  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-chart/test/bar-chart-pointer-events.test.tsx +67 -0
  21. package/src/charts/bar-chart/test/bar-chart.test.tsx +79 -0
  22. package/src/charts/bar-list-chart/bar-list-chart.tsx +45 -2
  23. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +38 -0
  24. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  25. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  26. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  27. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  28. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  29. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  30. package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
  31. package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
  32. package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
  33. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  34. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
  35. package/src/charts/heatmap-chart/types.ts +2 -2
  36. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  37. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  38. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  39. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  40. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  41. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  42. package/src/charts/line-chart/line-chart.module.scss +10 -18
  43. package/src/charts/line-chart/line-chart.tsx +109 -25
  44. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  45. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  46. package/src/charts/line-chart/test/line-chart.test.tsx +74 -3
  47. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  48. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  49. package/src/charts/line-chart/types.ts +4 -1
  50. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  51. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  52. package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
  53. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  54. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  55. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  56. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  57. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  58. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  59. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  60. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  61. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  62. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  63. package/src/charts/private/test/time-axis.test.ts +9 -69
  64. package/src/charts/private/time-axis-options.ts +106 -0
  65. package/src/charts/private/time-axis.ts +318 -136
  66. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  67. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  68. package/src/charts/sparkline/sparkline.tsx +9 -3
  69. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  70. package/src/charts/sparkline/types.ts +2 -2
  71. package/src/components/legend/private/base-legend.module.scss +9 -5
  72. package/src/components/tooltip/accessible-tooltip.tsx +38 -10
  73. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  74. package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
  75. package/src/components/tooltip/test/accessible-tooltip.test.tsx +67 -0
  76. package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
  77. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
  78. package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
  79. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  80. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +17 -10
  81. package/src/hooks/test/use-xychart-theme.test.tsx +63 -5
  82. package/src/hooks/use-xychart-theme.ts +33 -23
  83. package/src/index.ts +4 -0
  84. package/src/providers/chart-context/global-charts-provider.tsx +64 -54
  85. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  86. package/src/providers/chart-context/index.ts +1 -0
  87. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  88. package/src/providers/chart-context/private/series-palette.ts +35 -0
  89. package/src/providers/chart-context/test/catalog-pointers.test.ts +61 -0
  90. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  91. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -18
  92. package/src/providers/chart-context/themes.ts +7 -47
  93. package/src/providers/chart-context/types.ts +3 -0
  94. package/src/styles/chart-scope.scss +63 -58
  95. package/src/styles/test/chart-scope.test.ts +25 -40
  96. package/src/test-utils/series-fixtures.ts +31 -0
  97. package/src/types.ts +103 -73
  98. package/src/utils/bucket-info.ts +87 -0
  99. package/src/utils/color-utils.ts +0 -27
  100. package/src/utils/date-formatting.ts +174 -0
  101. package/src/utils/get-styles.ts +7 -1
  102. package/src/utils/index.ts +3 -0
  103. package/src/utils/test/bucket-info.test.ts +185 -0
  104. package/src/utils/test/color-utils.test.ts +1 -48
  105. package/src/utils/test/date-formatting.test.ts +89 -0
  106. package/src/utils/test/get-styles.test.ts +10 -7
  107. package/src/utils/test/merge-themes.test.ts +18 -46
  108. package/src/visx/types.ts +29 -2
  109. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  110. package/src/providers/chart-context/private/theme-override-vars.ts +0 -87
  111. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -50
  112. package/src/providers/chart-context/test/sparse-theme.test.ts +0 -35
  113. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  114. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -118
  115. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -47
  116. package/src/styles/test/wpds-fallbacks.test.ts +0 -131
@@ -21,21 +21,21 @@
21
21
  &__annotation-label-popover {
22
22
  background: var(--a8c-charts-color-surface);
23
23
  color: var(--a8c-charts-color-label);
24
- padding: var(--wpds-dimension-padding-sm, 8px);
24
+ padding: var(--wpds-dimension-padding-sm);
25
25
  }
26
26
 
27
27
  &__tooltip-date {
28
- font-weight: var(--wpds-typography-font-weight-emphasis, 600);
29
- padding-bottom: var(--wpds-dimension-padding-md, 12px);
28
+ font-weight: var(--wpds-typography-font-weight-emphasis);
29
+ padding-bottom: var(--wpds-dimension-padding-md);
30
30
  }
31
31
 
32
32
  &__tooltip-row {
33
- padding: var(--wpds-dimension-padding-xs, 4px) 0;
33
+ padding: var(--wpds-dimension-padding-xs) 0;
34
34
  }
35
35
 
36
36
  &__tooltip-label {
37
- font-weight: var(--wpds-typography-font-weight-emphasis, 600);
38
- padding-right: var(--wpds-dimension-padding-lg, 16px);
37
+ font-weight: var(--wpds-typography-font-weight-emphasis);
38
+ padding-right: var(--wpds-dimension-padding-lg);
39
39
  }
40
40
 
41
41
  &__annotations-overlay {
@@ -64,14 +64,14 @@
64
64
  &__annotation-label-popover {
65
65
  min-width: 125px;
66
66
  border: none;
67
- border-radius: var(--wpds-border-radius-md, 4px);
68
- font-size: var(--wpds-typography-font-size-md, 13px);
67
+ border-radius: var(--wpds-border-radius-md);
68
+ font-size: var(--wpds-typography-font-size-md);
69
69
  box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
70
70
  position: fixed;
71
71
  // Without margin set, the popover has margin:auto which upsets the
72
72
  // positioning relative to the trigger button. A gap token is appropriate
73
73
  // because it creates a gap to the trigger button.
74
- margin: var(--wpds-dimension-gap-sm, 8px);
74
+ margin: var(--wpds-dimension-gap-sm);
75
75
  visibility: hidden;
76
76
 
77
77
  &--visible {
@@ -85,7 +85,7 @@
85
85
  }
86
86
 
87
87
  &__annotation-label-popover-content {
88
- padding: var(--wpds-dimension-padding-sm, 8px);
88
+ padding: var(--wpds-dimension-padding-sm);
89
89
  }
90
90
 
91
91
  &__annotation-label-popover-close-button {
@@ -101,14 +101,6 @@
101
101
  }
102
102
  }
103
103
 
104
- :global(.visx-tooltip-glyph) {
105
-
106
- svg {
107
- width: 10px;
108
- height: 10px;
109
- }
110
- }
111
-
112
104
  @keyframes rise {
113
105
 
114
106
  from {
@@ -24,25 +24,36 @@ import {
24
24
  import {
25
25
  GlobalChartsProvider,
26
26
  GlobalChartsContext,
27
+ useChartFormatting,
27
28
  useChartId,
28
29
  useChartRegistration,
29
30
  useGlobalChartsContext,
30
- useGlobalChartsTheme,
31
31
  } from '../../providers';
32
32
  import { useDefaultHiddenSeries } from '../../providers/chart-context/hooks/use-default-hidden-series';
33
33
  import { attachSubComponents } from '../../utils';
34
+ import { getBucketInfo } from '../../utils/bucket-info';
35
+ import { createDateFormatter } from '../../utils/date-formatting';
34
36
  import { useChartChildren } from '../private/chart-composition';
35
37
  import { ChartInstanceContext, type ChartInstanceRef } from '../private/chart-instance-context';
36
38
  import { ChartLayout } from '../private/chart-layout';
37
39
  import { DefaultGlyph } from '../private/default-glyph';
38
40
  import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
39
- import { getCurveType, getFormatter, guessOptimalNumTicks } from '../private/time-axis';
41
+ import { getCurveType } from '../private/time-axis';
42
+ import { buildTimeAxisOptions } from '../private/time-axis-options';
40
43
  import { withResponsive } from '../private/with-responsive';
41
44
  import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom';
45
+ import plotStyles from '../private/xy-plot/xy-plot.module.scss';
42
46
  import styles from './line-chart.module.scss';
43
47
  import { LineChartAnnotation, LineChartAnnotationsOverlay, LineChartGlyph } from './private';
44
48
  import type { RenderLineGlyphProps, LineChartProps, TooltipDatum } from './types';
45
- import type { DataPoint, DataPointDate, SeriesData, Optional } from '../../types';
49
+ import type {
50
+ BucketInfo,
51
+ DataPoint,
52
+ DataPointDate,
53
+ SeriesData,
54
+ Optional,
55
+ TickResolution,
56
+ } from '../../types';
46
57
  import type { RenderTooltipParams } from '../../visx/types';
47
58
  import type { ResponsiveConfig } from '../private/with-responsive';
48
59
  import type { TickFormatter } from '@visx/axis';
@@ -58,16 +69,52 @@ const toNumber = ( val?: number | string | null ): number | undefined => {
58
69
  return isNaN( num ) ? undefined : num;
59
70
  };
60
71
 
72
+ // No date-part options, which is what `Intl` defaults to a numeric date for —
73
+ // the heading the tooltip printed with `toLocaleDateString()` before it could
74
+ // be told a locale and a zone.
75
+ const TOOLTIP_DATE: Intl.DateTimeFormatOptions = {};
76
+
77
+ // Only the hour case adds to the plain date heading. A coarser bucket keeps the
78
+ // day: 'month' covers any spacing from 28 days up, so dropping it would give two
79
+ // distinct points one heading.
80
+ const TOOLTIP_FORMAT_BY_RESOLUTION: Record<
81
+ Exclude< TickResolution, 'week' >,
82
+ Intl.DateTimeFormatOptions
83
+ > = {
84
+ hour: { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric' },
85
+ day: TOOLTIP_DATE,
86
+ month: TOOLTIP_DATE,
87
+ year: TOOLTIP_DATE,
88
+ };
89
+
90
+ // A component rather than a call, because `renderDefaultTooltip` is a plain
91
+ // function a consumer may pass around: the context has to be read where the
92
+ // heading renders, not where the tooltip is built.
93
+ const TooltipDate: FC< { date?: Date; displayResolution: Exclude< TickResolution, 'week' > } > = ( {
94
+ date,
95
+ displayResolution,
96
+ } ) => {
97
+ const formatting = useChartFormatting();
98
+ const format = useMemo(
99
+ () => createDateFormatter( TOOLTIP_FORMAT_BY_RESOLUTION[ displayResolution ], formatting ),
100
+ [ displayResolution, formatting ]
101
+ );
102
+
103
+ return <>{ date ? format( date ) : null }</>;
104
+ };
105
+
61
106
  /**
62
107
  * Default visx-tooltip render that prints the hovered date as a heading and
63
108
  * one row per visible series (label + formatted value), sorted descending by
64
109
  * value. Reused by AreaChart, which has the same multi-series shape.
65
110
  *
66
- * @param params - visx `RenderTooltipParams< DataPointDate >`.
111
+ * @param params - visx `RenderTooltipParams< DataPointDate >`, plus the chart's optional `bucketInfo`.
67
112
  * @return Tooltip JSX, or `null` when no datum is hovered.
68
113
  */
69
- export const renderDefaultTooltip = ( params: RenderTooltipParams< DataPointDate > ) => {
70
- const { tooltipData } = params;
114
+ export const renderDefaultTooltip = (
115
+ params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo }
116
+ ) => {
117
+ const { tooltipData, bucketInfo } = params;
71
118
  const nearestDatum = tooltipData?.nearestDatum?.datum;
72
119
  if ( ! nearestDatum ) return null;
73
120
 
@@ -81,7 +128,10 @@ export const renderDefaultTooltip = ( params: RenderTooltipParams< DataPointDate
81
128
  return (
82
129
  <div className={ styles[ 'line-chart__tooltip' ] }>
83
130
  <div className={ styles[ 'line-chart__tooltip-date' ] }>
84
- { nearestDatum.date?.toLocaleDateString() }
131
+ <TooltipDate
132
+ date={ nearestDatum.date }
133
+ displayResolution={ bucketInfo?.displayResolution ?? 'day' }
134
+ />
85
135
  </div>
86
136
  { tooltipPoints.map( point => (
87
137
  <Stack
@@ -179,6 +229,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
179
229
  onPointerUp = undefined,
180
230
  onPointerMove = undefined,
181
231
  onPointerOut = undefined,
232
+ onDatumActivate = undefined,
182
233
  zoomable = false,
183
234
  rescaleYOnVisibilityChange = true,
184
235
  defaultHiddenSeries,
@@ -193,10 +244,11 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
193
244
  const legendShape = legend.shape ?? 'line';
194
245
  const legendPosition = legend.position ?? 'bottom';
195
246
 
196
- const providerTheme = useGlobalChartsTheme();
247
+ const formatting = useChartFormatting();
197
248
  const theme = useXYChartTheme( data );
198
- // Gradient stops apply this as an SVG attribute, where CSS var() cannot resolve. useXYChartTheme has already resolved the same role inside its memo, against the chart's scope element, so read it back rather than paying another getComputedStyle on every render.
199
- const resolvedBackgroundColor = theme.backgroundColor ?? providerTheme.backgroundColor;
249
+ // A gradient stop reads its color as a string, so it has to be resolved. Read back what
250
+ // `useXYChartTheme` already resolved rather than paying another getComputedStyle per render.
251
+ const resolvedBackgroundColor = theme.backgroundColor;
200
252
  const chartId = useChartId( providedChartId );
201
253
  const hiddenSeries = useDefaultHiddenSeries( chartId, defaultHiddenSeries );
202
254
  const isSeriesVisible = useCallback(
@@ -280,7 +332,20 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
280
332
  return min < max ? [ min, max ] : undefined;
281
333
  }, [ rescaleYOnVisibilityChange, dataSorted ] );
282
334
 
283
- // Use the keyboard navigation hook
335
+ // Keyboard navigation steps through x positions, and the grouped tooltip
336
+ // reads every series at that position; the first series names the point.
337
+ const activateSelectedPoint = useCallback(
338
+ ( index: number ) => {
339
+ const series = dataSorted[ 0 ];
340
+ const datum = series?.data[ index ];
341
+
342
+ if ( series && datum ) {
343
+ onDatumActivate?.( { datum, index, key: series.label } );
344
+ }
345
+ },
346
+ [ dataSorted, onDatumActivate ]
347
+ );
348
+
284
349
  const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
285
350
  selectedIndex,
286
351
  setSelectedIndex,
@@ -288,21 +353,22 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
288
353
  setIsNavigating,
289
354
  chartRef,
290
355
  totalPoints: dataSorted[ 0 ]?.data.length || 0,
356
+ onActivate: activateSelectedPoint,
291
357
  } );
292
358
 
293
359
  const chartOptions = useMemo( () => {
294
- const { tickResolution, tickFormat, ...xAxisOptions } = options?.axis?.x ?? {};
295
- const formatter = tickFormat || getFormatter( dataSorted, tickResolution );
296
-
297
360
  return {
298
361
  axis: {
299
- x: {
300
- orientation: 'bottom' as const,
301
- numTicks: guessOptimalNumTicks( dataSorted, width, formatter ),
302
- tickFormat: formatter,
303
- display: true,
304
- ...xAxisOptions,
305
- },
362
+ x: buildTimeAxisOptions( {
363
+ dataSorted,
364
+ width,
365
+ axisOptions: options?.axis?.x,
366
+ scaleDomain: options?.xScale?.domain,
367
+ zoomDomain: zoom.domain,
368
+ formatting,
369
+ // A hidden line is unmounted, so visx scales to the rest.
370
+ isSeriesRendered: series => isSeriesVisible( series.label ),
371
+ } ),
306
372
  y: {
307
373
  orientation: 'left' as const,
308
374
  numTicks: 4,
@@ -324,7 +390,18 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
324
390
  ...options?.yScale,
325
391
  },
326
392
  };
327
- }, [ options, dataSorted, width, zoom.domain, stableYDomain ] );
393
+ }, [ options, dataSorted, width, zoom.domain, stableYDomain, formatting, isSeriesVisible ] );
394
+
395
+ // Classified from the rendered series, like the axis above: a hidden
396
+ // hourly line must not leave the tooltip naming an hour the axis dropped.
397
+ const bucketInfo = useMemo(
398
+ () =>
399
+ getBucketInfo(
400
+ dataSorted.filter( series => isSeriesVisible( series.label ) ),
401
+ options?.axis?.x?.tickResolution
402
+ ),
403
+ [ dataSorted, isSeriesVisible, options?.axis?.x?.tickResolution ]
404
+ );
328
405
 
329
406
  const tooltipRenderGlyph = useMemo( () => {
330
407
  return ( props: GlyphProps< DataPointDate > ) => {
@@ -396,7 +473,14 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
396
473
  yAccessor: ( d: DataPointDate ) => d?.value,
397
474
  };
398
475
 
399
- // Create a custom renderTooltip that includes focus capability
476
+ // Augments every renderTooltip call with the chart's bucket classification,
477
+ // default or custom, so a heading keyed on it can't disagree with the axis.
478
+ const tooltipRenderer = useMemo(
479
+ () => ( params: RenderTooltipParams< DataPointDate > ) =>
480
+ renderTooltip( { ...params, bucketInfo } ),
481
+ [ renderTooltip, bucketInfo ]
482
+ );
483
+
400
484
  if ( error ) {
401
485
  return <div className={ clsx( 'line-chart', styles[ 'line-chart' ] ) }>{ error }</div>;
402
486
  }
@@ -457,7 +541,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
457
541
  onBlur={ onChartBlur }
458
542
  >
459
543
  { chartHeight > 0 && (
460
- <div ref={ chartRef } style={ { position: 'relative' } }>
544
+ <div ref={ chartRef } className={ plotStyles[ 'xy-plot' ] }>
461
545
  { zoomable && zoom.domain && <ZoomResetButton onClick={ zoom.reset } /> }
462
546
  <XYChart
463
547
  theme={ theme }
@@ -594,7 +678,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
594
678
  snapTooltipToDatumX
595
679
  snapTooltipToDatumY
596
680
  showSeriesGlyphs
597
- renderTooltip={ renderTooltip }
681
+ renderTooltip={ tooltipRenderer }
598
682
  renderGlyph={ tooltipRenderGlyph }
599
683
  glyphStyle={ glyphStyle }
600
684
  showVerticalCrosshair={ withTooltipCrosshairs?.showVertical }
@@ -9,11 +9,13 @@ import {
9
9
  import { DataContext } from '@visx/xychart';
10
10
  import merge from 'deepmerge';
11
11
  import { useContext, useRef, useEffect, useState, useMemo } from 'react';
12
- import { useChartScopeElement, useGlobalChartsTheme } from '../../../providers';
13
- import { isSafari, resolveCssVariable } from '../../../utils';
12
+ import { useGlobalChartsTheme } from '../../../providers';
13
+ import { ANNOTATION_POINTERS } from '../../../providers/chart-context/private/catalog-pointers';
14
+ import { isSafari } from '../../../utils';
14
15
  import LineChartAnnotationLabelWithPopover, {
15
16
  POPOVER_BUTTON_SIZE,
16
17
  } from './line-chart-annotation-label-popover';
18
+ import type { AnnotationStyles } from '../../../types';
17
19
  import type { LineChartAnnotationProps } from '../types';
18
20
  import type { LabelProps } from '@visx/annotation';
19
21
  import type { TextProps } from '@visx/text';
@@ -152,17 +154,22 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
152
154
  renderLabelPopover,
153
155
  } ) => {
154
156
  const providerTheme = useGlobalChartsTheme();
155
- const scopeElement = useChartScopeElement();
156
157
  const { xScale, yScale } = useContext( DataContext ) || {};
157
158
  const labelRef = useRef< SVGGElement >( null );
158
159
  const [ height, setHeight ] = useState< number | null >( null );
159
160
 
160
- // Deep merge styles to preserve nested object properties
161
- const styles = merge( providerTheme.annotationStyles ?? {}, datumStyles ?? {} );
162
-
163
- // visx annotation parts apply these colours as SVG presentation attributes, where CSS var() cannot resolve. Resolve against the chart's scope element, never :root.
164
- const resolveColor = ( value?: string ): string | undefined =>
165
- value ? resolveCssVariable( value, scopeElement ) ?? value : value;
161
+ // Deep merge to preserve nested object properties. Pointers first, so a per-datum color wins.
162
+ // Memoized because the result is spread into the visx annotation parts: a fresh object every
163
+ // render would give each of them new props whether or not a style changed.
164
+ const styles = useMemo(
165
+ () =>
166
+ merge.all( [
167
+ ANNOTATION_POINTERS,
168
+ providerTheme.annotationStyles ?? {},
169
+ datumStyles ?? {},
170
+ ] ) as AnnotationStyles,
171
+ [ providerTheme.annotationStyles, datumStyles ]
172
+ );
166
173
 
167
174
  // Measure the label height once after initial render
168
175
  useEffect( () => {
@@ -269,14 +276,8 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
269
276
  return (
270
277
  <g data-testid={ testId }>
271
278
  <Annotation x={ x } y={ y } dx={ dx } dy={ dy }>
272
- <Connector { ...styles?.connector } stroke={ resolveColor( styles?.connector?.stroke ) } />
273
- { subjectType === 'circle' && (
274
- <CircleSubject
275
- { ...styles?.circleSubject }
276
- fill={ resolveColor( styles?.circleSubject?.fill ) }
277
- stroke={ resolveColor( styles?.circleSubject?.stroke ) }
278
- />
279
- ) }
279
+ <Connector { ...styles?.connector } />
280
+ { subjectType === 'circle' && <CircleSubject { ...styles?.circleSubject } /> }
280
281
  { subjectType === 'line-vertical' && (
281
282
  <LineSubject
282
283
  min={ yMax }
@@ -312,8 +313,6 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
312
313
  title={ title }
313
314
  subtitle={ subtitle }
314
315
  { ...styles?.label }
315
- anchorLineStroke={ resolveColor( styles?.label?.anchorLineStroke ) }
316
- backgroundFill={ resolveColor( styles?.label?.backgroundFill ) }
317
316
  { ...labelPosition }
318
317
  horizontalAnchor={ getHorizontalAnchor( subjectType, isFlippedHorizontally ) }
319
318
  verticalAnchor={ getVerticalAnchor(
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-los-angeles.mjs
3
+ */
4
+ import { render, screen } from '@testing-library/react';
5
+ import userEvent from '@testing-library/user-event';
6
+ import { GlobalChartsProvider } from '../../../providers';
7
+ import LineChart, { renderDefaultTooltip } from '../line-chart';
8
+ import type { DataPointDate } from '../../../types';
9
+ import type { RenderTooltipParams } from '../../../visx/types';
10
+
11
+ const mockRefCallback = jest.fn();
12
+ jest.mock( '../../../hooks/use-element-size', () => ( {
13
+ useElementSize: () => [ mockRefCallback, 500, 300 ],
14
+ } ) );
15
+
16
+ // The instant below is Aug 2 in Los Angeles and Aug 3 in Tokyo, so a tooltip
17
+ // that reads the runtime zone prints a different day from the axis.
18
+
19
+ const date = new Date( '2026-08-02T15:30:00Z' );
20
+
21
+ const params = {
22
+ tooltipData: {
23
+ nearestDatum: { key: 'Series A', datum: { date, value: 10 }, index: 0, distance: 0 },
24
+ datumByKey: { 'Series A': { key: 'Series A', datum: { date, value: 10 }, index: 0 } },
25
+ },
26
+ } as unknown as RenderTooltipParams< DataPointDate >;
27
+
28
+ describe( 'renderDefaultTooltip date heading', () => {
29
+ it( 'uses the locale and time zone from the provider', () => {
30
+ render(
31
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
32
+ { renderDefaultTooltip( params ) }
33
+ </GlobalChartsProvider>
34
+ );
35
+
36
+ expect( screen.getByText( '3.8.2026' ) ).toBeInTheDocument();
37
+ } );
38
+
39
+ it( 'falls back to the runtime locale and zone when the provider supplies none', () => {
40
+ render( <GlobalChartsProvider>{ renderDefaultTooltip( params ) }</GlobalChartsProvider> );
41
+
42
+ expect( screen.getByText( '8/2/2026' ) ).toBeInTheDocument();
43
+ } );
44
+
45
+ // The tooltip renders through a portal, so this is the only check that the
46
+ // provider's context actually reaches it inside a real chart.
47
+ it( 'reaches the tooltip a mounted chart opens', async () => {
48
+ const user = userEvent.setup();
49
+ render(
50
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
51
+ <LineChart
52
+ width={ 500 }
53
+ height={ 300 }
54
+ withGradientFill={ false }
55
+ data={ [
56
+ {
57
+ label: 'Series A',
58
+ data: [
59
+ { date, value: 10 },
60
+ { date: new Date( date.getTime() + 24 * 60 * 60 * 1000 ), value: 20 },
61
+ ],
62
+ options: {},
63
+ },
64
+ ] }
65
+ />
66
+ </GlobalChartsProvider>
67
+ );
68
+
69
+ screen.getByRole( 'grid', { name: /line chart/i } ).focus();
70
+ await user.keyboard( '{ArrowRight}' );
71
+
72
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( '3.8.2026' );
73
+ } );
74
+ } );
@@ -445,10 +445,11 @@ describe( 'LineChart', () => {
445
445
  data: [
446
446
  {
447
447
  label: 'Series A',
448
- // A day apart: spacing inference would read this as daily buckets.
448
+ // 23h apart (not 24, and not both midnight): spacing inference would
449
+ // read this as daily buckets, and the two hours format distinctly.
449
450
  data: [
450
451
  { date: new Date( '2024-01-01T00:00:00' ), value: 10 },
451
- { date: new Date( '2024-01-02T00:00:00' ), value: 20 },
452
+ { date: new Date( '2024-01-01T23:00:00' ), value: 20 },
452
453
  ],
453
454
  },
454
455
  ],
@@ -458,6 +459,27 @@ describe( 'LineChart', () => {
458
459
  expect( ticks.length ).toBeGreaterThan( 1 );
459
460
  } );
460
461
 
462
+ // Exactly 24 hours apart: bare hours would print "12 AM" twice and leave the
463
+ // second day unnamed, so the span reads as multi-day and dates its ticks.
464
+ test( 'names both days when two sub-daily points sit a full day apart', () => {
465
+ renderWithTheme( {
466
+ width: 800,
467
+ options: { axis: { x: { tickResolution: 'hour' } } },
468
+ data: [
469
+ {
470
+ label: 'Series A',
471
+ data: [
472
+ { date: new Date( '2024-01-01T00:00:00' ), value: 10 },
473
+ { date: new Date( '2024-01-02T00:00:00' ), value: 20 },
474
+ ],
475
+ },
476
+ ],
477
+ } );
478
+
479
+ expect( screen.getByText( 'Jan 1' ) ).toBeInTheDocument();
480
+ expect( screen.getByText( 'Jan 2' ) ).toBeInTheDocument();
481
+ } );
482
+
461
483
  test( 'renders ticks in short date format.', () => {
462
484
  renderWithTheme( {
463
485
  width: 800,
@@ -599,6 +621,8 @@ describe( 'LineChart', () => {
599
621
  expect( ticks.length ).toBeLessThan( 6 ); // Not much space
600
622
  } );
601
623
 
624
+ // One repeated instant carries no spacing, so the axis falls back to
625
+ // calendar dates rather than reading a zero gap as hourly buckets.
602
626
  test( 'renders only one tick when all ticks are the same', () => {
603
627
  renderWithTheme( {
604
628
  width: 800,
@@ -614,7 +638,7 @@ describe( 'LineChart', () => {
614
638
  ],
615
639
  } );
616
640
 
617
- const ticks = screen.getAllByText( /\d+ [AP]M/ );
641
+ const ticks = screen.getAllByText( /^[A-Z][a-z]{2} \d{1,2}$/ );
618
642
  expect( ticks ).toHaveLength( 1 );
619
643
  } );
620
644
  } );
@@ -1153,6 +1177,53 @@ describe( 'LineChart', () => {
1153
1177
  } );
1154
1178
  } );
1155
1179
 
1180
+ describe( 'Activation', () => {
1181
+ const SERIES_A = {
1182
+ label: 'Series A',
1183
+ data: [
1184
+ { date: new Date( '2024-01-01' ), value: 10 },
1185
+ { date: new Date( '2024-01-02' ), value: 20 },
1186
+ ],
1187
+ options: {},
1188
+ };
1189
+ const SERIES_B = {
1190
+ label: 'Series B',
1191
+ data: [
1192
+ { date: new Date( '2024-01-01' ), value: 15 },
1193
+ { date: new Date( '2024-01-02' ), value: 25 },
1194
+ ],
1195
+ options: {},
1196
+ };
1197
+
1198
+ // Navigation steps through x positions; the first series names the point.
1199
+ test( 'Enter hands the selected point to onDatumActivate', async () => {
1200
+ const user = userEvent.setup();
1201
+ const onDatumActivate = jest.fn();
1202
+ renderWithTheme( { data: [ SERIES_A, SERIES_B ], onDatumActivate } );
1203
+
1204
+ screen.getByRole( 'grid', { name: /line chart/i } ).focus();
1205
+ await user.keyboard( '{ArrowRight}{ArrowRight}{Enter}' );
1206
+
1207
+ expect( onDatumActivate ).toHaveBeenCalledTimes( 1 );
1208
+ expect( onDatumActivate ).toHaveBeenCalledWith( {
1209
+ datum: SERIES_A.data[ 1 ],
1210
+ index: 1,
1211
+ key: 'Series A',
1212
+ } );
1213
+ } );
1214
+
1215
+ test( 'Enter with no point selected activates nothing', async () => {
1216
+ const user = userEvent.setup();
1217
+ const onDatumActivate = jest.fn();
1218
+ renderWithTheme( { data: [ SERIES_A ], onDatumActivate } );
1219
+
1220
+ screen.getByRole( 'grid', { name: /line chart/i } ).focus();
1221
+ await user.keyboard( '{Enter}' );
1222
+
1223
+ expect( onDatumActivate ).not.toHaveBeenCalled();
1224
+ } );
1225
+ } );
1226
+
1156
1227
  describe( 'Arrow Key Navigation', () => {
1157
1228
  test( 'right arrow key navigates to next data point', async () => {
1158
1229
  const user = userEvent.setup();
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-los-angeles.mjs
3
+ */
4
+ import { render, screen } from '@testing-library/react';
5
+ import { GlobalChartsProvider } from '../../../providers';
6
+ import LineChart from '../line-chart';
7
+
8
+ const mockRefCallback = jest.fn();
9
+ jest.mock( '../../../hooks/use-element-size', () => ( {
10
+ useElementSize: () => [ mockRefCallback, 500, 300 ],
11
+ } ) );
12
+
13
+ // Each instant below is one day in Los Angeles and the next in Tokyo.
14
+ const data = [
15
+ {
16
+ label: 'Series A',
17
+ data: Array.from( { length: 8 }, ( _, index ) => ( {
18
+ date: new Date( new Date( '2026-08-02T15:30:00Z' ).getTime() + index * 86400000 ),
19
+ value: index,
20
+ } ) ),
21
+ options: {},
22
+ },
23
+ ];
24
+
25
+ // A long series and a short one, so hiding the long one leaves visx a much
26
+ // narrower domain than the whole dataset would give.
27
+ const monthly = ( label: string, count: number ) => ( {
28
+ label,
29
+ data: Array.from( { length: count }, ( _, index ) => ( {
30
+ date: new Date( Date.UTC( 2026, index, 1, 12 ) ),
31
+ value: index,
32
+ } ) ),
33
+ options: {},
34
+ } );
35
+
36
+ describe( 'line chart tick placement', () => {
37
+ it( 'places one tick per host zone calendar day, with no gap', () => {
38
+ render(
39
+ <GlobalChartsProvider locale="en-US" timeZone="Asia/Tokyo">
40
+ <LineChart width={ 500 } height={ 300 } withGradientFill={ false } data={ data } />
41
+ </GlobalChartsProvider>
42
+ );
43
+
44
+ const ticks = screen.getAllByText( /^[A-Z][a-z]{2} \d{1,2}$/ );
45
+ expect( ticks.map( tick => tick.textContent ) ).toEqual( [
46
+ 'Aug 3',
47
+ 'Aug 4',
48
+ 'Aug 5',
49
+ 'Aug 6',
50
+ 'Aug 7',
51
+ 'Aug 8',
52
+ 'Aug 9',
53
+ 'Aug 10',
54
+ ] );
55
+ } );
56
+
57
+ it( 'keeps every tick inside the axis when a series is hidden', () => {
58
+ const { container } = render(
59
+ <GlobalChartsProvider locale="en-US" timeZone="Asia/Tokyo">
60
+ <LineChart
61
+ width={ 500 }
62
+ height={ 300 }
63
+ withGradientFill={ false }
64
+ data={ [ monthly( 'Long', 12 ), monthly( 'Short', 3 ) ] }
65
+ defaultHiddenSeries={ [ 'Long' ] }
66
+ />
67
+ </GlobalChartsProvider>
68
+ );
69
+
70
+ // The x axis renders before the y axis, and neither carries an orientation class.
71
+ /* eslint-disable testing-library/no-container, testing-library/no-node-access -- A tick's position is an SVG attribute no query reaches. */
72
+ const xAxis = container.querySelectorAll( '.visx-axis' )[ 0 ];
73
+ const offsets = Array.from( xAxis.querySelectorAll( '.visx-axis-tick text' ) ).map( tick =>
74
+ Number( tick.getAttribute( 'x' ) )
75
+ );
76
+ /* eslint-enable testing-library/no-container, testing-library/no-node-access */
77
+
78
+ expect( offsets.length ).toBeGreaterThan( 1 );
79
+ for ( const offset of offsets ) {
80
+ expect( offset ).toBeGreaterThanOrEqual( 0 );
81
+ expect( offset ).toBeLessThanOrEqual( 500 );
82
+ }
83
+ } );
84
+ } );