@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
@@ -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,31 @@
1
+ import type { LineChartProps } from '../types';
2
+
3
+ type Crosshairs = NonNullable< LineChartProps[ 'withTooltipCrosshairs' ] >;
4
+
5
+ describe( 'line chart crosshair style types', () => {
6
+ test( 'accepts paint attributes', () => {
7
+ const crosshairs: Crosshairs = { verticalStyle: { stroke: 'purple', strokeWidth: 2 } };
8
+
9
+ expect( crosshairs.verticalStyle?.stroke ).toBe( 'purple' );
10
+ } );
11
+
12
+ test( 'reserves geometry attributes for the chart', () => {
13
+ const crosshairs: Crosshairs = {
14
+ // @ts-expect-error Crosshair coordinates are computed from the active datum.
15
+ verticalStyle: { x1: 12 },
16
+ };
17
+
18
+ const transformedCrosshairs: Crosshairs = {
19
+ // @ts-expect-error Crosshair transforms are not paint attributes.
20
+ verticalStyle: { transform: 'translate(100 0)' },
21
+ };
22
+ const transformedStyles: Crosshairs = {
23
+ // @ts-expect-error Inline crosshair styles accept only paint properties.
24
+ horizontalStyle: { style: { transform: 'translateX(100px)' } },
25
+ };
26
+
27
+ expect( crosshairs ).toBeDefined();
28
+ expect( transformedCrosshairs ).toBeDefined();
29
+ expect( transformedStyles ).toBeDefined();
30
+ } );
31
+ } );
@@ -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
+ } );
@@ -104,6 +104,106 @@ describe( 'LineChart', () => {
104
104
  );
105
105
  };
106
106
 
107
+ test.each( [ 'auto', 'below-axis' ] as const )(
108
+ 'passes custom tooltip container and crosshair styles with %s placement',
109
+ async tooltipPlacement => {
110
+ const user = userEvent.setup();
111
+ renderWithTheme( {
112
+ withTooltipCrosshairs: {
113
+ showVertical: true,
114
+ showHorizontal: true,
115
+ verticalStyle: { stroke: 'purple', strokeWidth: 40, strokeOpacity: 0.12 },
116
+ horizontalStyle: { stroke: 'orange', strokeDasharray: '4 2' },
117
+ },
118
+ tooltipPlacement,
119
+ tooltipStyle: { backgroundColor: 'black', color: 'white', boxShadow: 'none' },
120
+ } );
121
+ screen.getByRole( 'grid', { name: /line chart/i } ).focus();
122
+ await user.keyboard( '{ArrowRight}' );
123
+
124
+ const vertical = screen.getByTestId( 'xy-chart-tooltip-crosshair-vertical' );
125
+ expect( vertical ).toHaveAttribute( 'stroke', 'purple' );
126
+ expect( vertical ).toHaveAttribute( 'stroke-width', '40' );
127
+ expect( vertical ).toHaveAttribute( 'stroke-opacity', '0.12' );
128
+ const horizontal = screen.getByTestId( 'xy-chart-tooltip-crosshair-horizontal' );
129
+ expect( horizontal ).toHaveAttribute( 'stroke', 'orange' );
130
+ expect( horizontal ).toHaveAttribute( 'stroke-dasharray', '4 2' );
131
+ expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( {
132
+ 'background-color': 'rgb(0, 0, 0)',
133
+ color: 'rgb(255, 255, 255)',
134
+ 'box-shadow': 'none',
135
+ } );
136
+ expect( screen.getByTestId( 'line-chart-tooltip-content' ) ).toHaveStyle( {
137
+ color: 'rgb(255, 255, 255)',
138
+ } );
139
+ }
140
+ );
141
+ test.each( [
142
+ [
143
+ { color: 'white' },
144
+ 'rgb(255, 255, 255)',
145
+ 'var(--a8c-charts-color-tooltip-surface, rgb(0 0 0 / 85%))',
146
+ { color: 'rgb(255, 255, 255)' },
147
+ ],
148
+ [ { backgroundColor: 'white' }, 'var(--a8c-charts-color-label)', 'rgb(255, 255, 255)', {} ],
149
+ [ { background: 'white' }, 'var(--a8c-charts-color-label)', 'white', {} ],
150
+ [
151
+ { color: 'white', background: 'black' },
152
+ 'rgb(255, 255, 255)',
153
+ 'black',
154
+ { color: 'rgb(255, 255, 255)' },
155
+ ],
156
+ ] )(
157
+ 'applies directional tooltip color defaults: %j',
158
+ async ( tooltipStyle, color, background, containerColorStyle ) => {
159
+ const user = userEvent.setup();
160
+ const defaults = document.createElement( 'style' );
161
+ defaults.textContent = `.line-chart__tooltip {
162
+ color: var(--a8c-charts-color-label);
163
+ background: var(--a8c-charts-color-surface);
164
+ }`;
165
+ renderWithTheme( { tooltipStyle } );
166
+ const chart = screen.getByTestId( 'line-chart' );
167
+ chart.appendChild( defaults );
168
+ chart.style.setProperty( '--a8c-charts-color-label', '#000' );
169
+ chart.style.setProperty( '--a8c-charts-color-label-axis', '#aaa' );
170
+ chart.style.setProperty( '--a8c-charts-color-surface', '#fff' );
171
+ screen.getByRole( 'grid', { name: /line chart/i } ).focus();
172
+ await user.keyboard( '{ArrowRight}' );
173
+
174
+ const content = screen.getByTestId( 'line-chart-tooltip-content' );
175
+ expect( content ).toHaveStyle( {
176
+ color,
177
+ [ 'backgroundColor' in tooltipStyle ? 'background-color' : 'background' ]:
178
+ 'background' in tooltipStyle || 'backgroundColor' in tooltipStyle
179
+ ? background
180
+ : 'var(--a8c-charts-color-surface)',
181
+ } );
182
+ const container = screen.getByTestId( 'bounded-tooltip' );
183
+ expect( container ).toHaveStyle( {
184
+ ...containerColorStyle,
185
+ [ 'background' in tooltipStyle ? 'background' : 'background-color' ]: background,
186
+ } );
187
+ }
188
+ );
189
+
190
+ test( 'preserves the existing tooltip colors for noncolor overrides', async () => {
191
+ const user = userEvent.setup();
192
+ renderWithTheme( { tooltipStyle: { boxShadow: 'none' } } );
193
+ screen.getByRole( 'grid', { name: /line chart/i } ).focus();
194
+ await user.keyboard( '{ArrowRight}' );
195
+
196
+ const content = screen.getByTestId( 'line-chart-tooltip-content' );
197
+ expect( content.style.getPropertyValue( 'color' ) ).toBe( '' );
198
+ expect( content.style.getPropertyValue( 'background' ) ).toBe( '' );
199
+ const container = screen.getByTestId( 'bounded-tooltip' );
200
+ expect( container ).toHaveStyle( { 'box-shadow': 'none' } );
201
+ expect( container.style.getPropertyValue( 'background' ) ).toBe( '' );
202
+ expect( container.style.getPropertyValue( 'color' ) ).not.toBe(
203
+ 'var(--a8c-charts-color-label-inverse)'
204
+ );
205
+ } );
206
+
107
207
  describe( 'Data Validation', () => {
108
208
  test( 'handles empty data array', () => {
109
209
  renderWithTheme( { data: [] } );
@@ -445,10 +545,11 @@ describe( 'LineChart', () => {
445
545
  data: [
446
546
  {
447
547
  label: 'Series A',
448
- // A day apart: spacing inference would read this as daily buckets.
548
+ // 23h apart (not 24, and not both midnight): spacing inference would
549
+ // read this as daily buckets, and the two hours format distinctly.
449
550
  data: [
450
551
  { date: new Date( '2024-01-01T00:00:00' ), value: 10 },
451
- { date: new Date( '2024-01-02T00:00:00' ), value: 20 },
552
+ { date: new Date( '2024-01-01T23:00:00' ), value: 20 },
452
553
  ],
453
554
  },
454
555
  ],
@@ -458,6 +559,27 @@ describe( 'LineChart', () => {
458
559
  expect( ticks.length ).toBeGreaterThan( 1 );
459
560
  } );
460
561
 
562
+ // Exactly 24 hours apart: bare hours would print "12 AM" twice and leave the
563
+ // second day unnamed, so the span reads as multi-day and dates its ticks.
564
+ test( 'names both days when two sub-daily points sit a full day apart', () => {
565
+ renderWithTheme( {
566
+ width: 800,
567
+ options: { axis: { x: { tickResolution: 'hour' } } },
568
+ data: [
569
+ {
570
+ label: 'Series A',
571
+ data: [
572
+ { date: new Date( '2024-01-01T00:00:00' ), value: 10 },
573
+ { date: new Date( '2024-01-02T00:00:00' ), value: 20 },
574
+ ],
575
+ },
576
+ ],
577
+ } );
578
+
579
+ expect( screen.getByText( 'Jan 1' ) ).toBeInTheDocument();
580
+ expect( screen.getByText( 'Jan 2' ) ).toBeInTheDocument();
581
+ } );
582
+
461
583
  test( 'renders ticks in short date format.', () => {
462
584
  renderWithTheme( {
463
585
  width: 800,
@@ -599,6 +721,8 @@ describe( 'LineChart', () => {
599
721
  expect( ticks.length ).toBeLessThan( 6 ); // Not much space
600
722
  } );
601
723
 
724
+ // One repeated instant carries no spacing, so the axis falls back to
725
+ // calendar dates rather than reading a zero gap as hourly buckets.
602
726
  test( 'renders only one tick when all ticks are the same', () => {
603
727
  renderWithTheme( {
604
728
  width: 800,
@@ -614,7 +738,7 @@ describe( 'LineChart', () => {
614
738
  ],
615
739
  } );
616
740
 
617
- const ticks = screen.getAllByText( /\d+ [AP]M/ );
741
+ const ticks = screen.getAllByText( /^[A-Z][a-z]{2} \d{1,2}$/ );
618
742
  expect( ticks ).toHaveLength( 1 );
619
743
  } );
620
744
  } );
@@ -1201,44 +1325,53 @@ describe( 'LineChart', () => {
1201
1325
  } );
1202
1326
 
1203
1327
  describe( 'Arrow Key Navigation', () => {
1204
- test( 'right arrow key navigates to next data point', async () => {
1205
- const user = userEvent.setup();
1206
- renderWithTheme( {
1207
- data: [
1208
- {
1209
- label: 'Series A',
1210
- data: [
1211
- { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
1212
- { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
1213
- ],
1214
- options: {},
1215
- },
1216
- {
1217
- label: 'Series B',
1218
- data: [
1219
- { date: new Date( '2024-01-01' ), value: 15, label: 'Jan 1' },
1220
- { date: new Date( '2024-01-02' ), value: 25, label: 'Jan 2' },
1221
- ],
1222
- options: {},
1223
- },
1224
- ],
1225
- } );
1226
-
1227
- const chart = screen.getByRole( 'grid', { name: /line chart/i } );
1228
- chart.focus();
1229
-
1230
- // Single tab should focus on the first tooltip.
1231
- await user.keyboard( '{ArrowRight}' );
1232
- expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
1233
- expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Series A' );
1234
- expect( screen.queryByTestId( 'chart-tooltip-1' ) ).not.toBeInTheDocument();
1235
-
1236
- // Second tab should focus on the second tooltip.
1237
- await user.keyboard( '{ArrowRight}' );
1238
- expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveFocus();
1239
- expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveTextContent( 'Series B' );
1240
- expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
1241
- } );
1328
+ test.each( [ 'auto', 'below-axis' ] as const )(
1329
+ 'right arrow key navigates with %s tooltips',
1330
+ async tooltipPlacement => {
1331
+ const user = userEvent.setup();
1332
+ renderWithTheme( {
1333
+ tooltipPlacement,
1334
+ data: [
1335
+ {
1336
+ label: 'Series A',
1337
+ data: [
1338
+ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
1339
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
1340
+ ],
1341
+ options: {},
1342
+ },
1343
+ {
1344
+ label: 'Series B',
1345
+ data: [
1346
+ { date: new Date( '2024-01-01' ), value: 15, label: 'Jan 1' },
1347
+ { date: new Date( '2024-01-02' ), value: 25, label: 'Jan 2' },
1348
+ ],
1349
+ options: {},
1350
+ },
1351
+ ],
1352
+ } );
1353
+
1354
+ const chart = screen.getByRole( 'grid', { name: /line chart/i } );
1355
+ chart.focus();
1356
+
1357
+ // Single tab should focus on the first tooltip.
1358
+ await user.keyboard( '{ArrowRight}' );
1359
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
1360
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveAttribute( 'role', 'tooltip' );
1361
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveAttribute(
1362
+ 'aria-atomic',
1363
+ 'true'
1364
+ );
1365
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Series A' );
1366
+ expect( screen.queryByTestId( 'chart-tooltip-1' ) ).not.toBeInTheDocument();
1367
+
1368
+ // Second tab should focus on the second tooltip.
1369
+ await user.keyboard( '{ArrowRight}' );
1370
+ expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveFocus();
1371
+ expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveTextContent( 'Series B' );
1372
+ expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
1373
+ }
1374
+ );
1242
1375
 
1243
1376
  test( 'left arrow key navigates to previous data point', async () => {
1244
1377
  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
+ } );
@@ -0,0 +1,140 @@
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 { BucketInfo, 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
+ const paramsFor = ( date: Date ) =>
17
+ ( {
18
+ tooltipData: {
19
+ nearestDatum: { key: 'Series A', datum: { date, value: 10 }, index: 0, distance: 0 },
20
+ datumByKey: { 'Series A': { key: 'Series A', datum: { date, value: 10 }, index: 0 } },
21
+ },
22
+ } ) as unknown as RenderTooltipParams< DataPointDate >;
23
+
24
+ const HOURLY: BucketInfo = { bucket: 'hour', displayResolution: 'hour' };
25
+ const DAILY: BucketInfo = { bucket: 'day', displayResolution: 'day' };
26
+ const MONTHLY: BucketInfo = { bucket: 'month', displayResolution: 'month' };
27
+ const YEARLY: BucketInfo = { bucket: 'year', displayResolution: 'year' };
28
+
29
+ // 09:30 and 13:30 on the same Tokyo calendar day.
30
+ const morning = new Date( '2026-08-02T00:30:00Z' );
31
+ const afternoon = new Date( '2026-08-02T04:30:00Z' );
32
+
33
+ const headingFor = ( date: Date, bucketInfo?: BucketInfo, locale = 'en-US' ) => {
34
+ const { container, unmount } = render(
35
+ <GlobalChartsProvider locale={ locale } timeZone="Asia/Tokyo">
36
+ { renderDefaultTooltip( { ...paramsFor( date ), bucketInfo } ) }
37
+ </GlobalChartsProvider>
38
+ );
39
+ const text = container.textContent ?? '';
40
+ unmount();
41
+ return text;
42
+ };
43
+
44
+ describe( 'default tooltip at hourly resolution', () => {
45
+ it( 'distinguishes two hours of the same day', () => {
46
+ expect( headingFor( morning, HOURLY ) ).not.toBe( headingFor( afternoon, HOURLY ) );
47
+ } );
48
+
49
+ it( 'names the hour in the host time zone', () => {
50
+ // Full de-DE/Asia/Tokyo heading: pins the date style and the hour together.
51
+ expect( headingFor( morning, HOURLY, 'de-DE' ) ).toContain( '2.8.2026, 09 Uhr' );
52
+ } );
53
+
54
+ it( 'keeps the day in the heading at month and year resolution', () => {
55
+ expect( headingFor( morning, MONTHLY, 'de-DE' ) ).toContain( '2.8.2026' );
56
+ expect( headingFor( morning, YEARLY, 'de-DE' ) ).toContain( '2.8.2026' );
57
+ } );
58
+
59
+ it( 'names no hour at daily resolution', () => {
60
+ expect( headingFor( morning, DAILY ) ).not.toContain( 'AM' );
61
+ } );
62
+
63
+ it( 'renders as a date when no bucketInfo is supplied', () => {
64
+ render(
65
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
66
+ { renderDefaultTooltip( paramsFor( new Date( '2026-08-02T15:30:00Z' ) ) ) }
67
+ </GlobalChartsProvider>
68
+ );
69
+
70
+ expect( screen.getByText( '3.8.2026' ) ).toBeInTheDocument();
71
+ } );
72
+
73
+ it( 'reaches the tooltip an hourly chart opens', async () => {
74
+ const user = userEvent.setup();
75
+ render(
76
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
77
+ <LineChart
78
+ width={ 500 }
79
+ height={ 300 }
80
+ withGradientFill={ false }
81
+ options={ { axis: { x: { tickResolution: 'hour' } } } }
82
+ data={ [
83
+ {
84
+ label: 'Series A',
85
+ data: [
86
+ { date: morning, value: 10 },
87
+ { date: afternoon, value: 20 },
88
+ ],
89
+ options: {},
90
+ },
91
+ ] }
92
+ />
93
+ </GlobalChartsProvider>
94
+ );
95
+
96
+ screen.getByRole( 'grid', { name: /line chart/i } ).focus();
97
+ await user.keyboard( '{ArrowRight}' );
98
+
99
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( '2.8.2026, 09 Uhr' );
100
+ } );
101
+
102
+ it( 'drops the hour once the hourly series is hidden, as the axis does', async () => {
103
+ const user = userEvent.setup();
104
+ render(
105
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
106
+ <LineChart
107
+ width={ 500 }
108
+ height={ 300 }
109
+ withGradientFill={ false }
110
+ defaultHiddenSeries={ [ 'Hourly' ] }
111
+ data={ [
112
+ {
113
+ label: 'Hourly',
114
+ data: [
115
+ { date: morning, value: 10 },
116
+ { date: afternoon, value: 20 },
117
+ ],
118
+ options: {},
119
+ },
120
+ {
121
+ label: 'Daily',
122
+ data: [
123
+ { date: morning, value: 5 },
124
+ { date: new Date( '2026-08-03T00:30:00Z' ), value: 6 },
125
+ ],
126
+ options: {},
127
+ },
128
+ ] }
129
+ />
130
+ </GlobalChartsProvider>
131
+ );
132
+
133
+ screen.getByRole( 'grid', { name: /line chart/i } ).focus();
134
+ await user.keyboard( '{ArrowRight}' );
135
+
136
+ const tooltip = screen.getByTestId( 'chart-tooltip-0' );
137
+ expect( tooltip ).toHaveTextContent( '2.8.2026' );
138
+ expect( tooltip ).not.toHaveTextContent( 'Uhr' );
139
+ } );
140
+ } );