@automattic/charts 4.0.0 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/SECURITY.md +1 -0
  3. package/TOKENS.md +7 -5
  4. package/dist/index.cjs +614 -259
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.css +30 -0
  7. package/dist/index.d.cts +88 -74
  8. package/dist/index.d.ts +88 -74
  9. package/dist/index.js +615 -261
  10. package/dist/index.js.map +1 -1
  11. package/package.json +5 -4
  12. package/src/charts/area-chart/area-chart.tsx +2 -1
  13. package/src/charts/area-chart/test/area-chart.test.tsx +31 -0
  14. package/src/charts/heatmap-chart/build-calendar-data.ts +189 -0
  15. package/src/charts/heatmap-chart/heatmap-chart.module.scss +31 -0
  16. package/src/charts/heatmap-chart/heatmap-chart.tsx +44 -9
  17. package/src/charts/heatmap-chart/index.ts +10 -3
  18. package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
  19. package/src/charts/heatmap-chart/private/index.ts +0 -2
  20. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
  21. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +4 -1
  22. package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
  23. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +91 -0
  24. package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
  25. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
  26. package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
  27. package/src/charts/heatmap-chart/types.ts +35 -0
  28. package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
  29. package/src/charts/leaderboard-chart/types.ts +2 -2
  30. package/src/charts/line-chart/line-chart.tsx +41 -7
  31. package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
  32. package/src/charts/line-chart/test/line-chart.test.tsx +178 -38
  33. package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
  34. package/src/charts/line-chart/types.ts +13 -2
  35. package/src/charts/pie-chart/test/pie-chart.test.tsx +3 -12
  36. package/src/components/tooltip/accessible-tooltip.tsx +40 -12
  37. package/src/components/tooltip/private/bounded-tooltip.tsx +71 -16
  38. package/src/components/tooltip/test/accessible-tooltip.test.tsx +60 -1
  39. package/src/components/tooltip/test/bounded-tooltip.test.tsx +102 -6
  40. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +122 -3
  41. package/src/components/tooltip/xy-chart-tooltip.tsx +53 -14
  42. package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
  43. package/src/hooks/test/use-chart-margin.test.tsx +173 -3
  44. package/src/hooks/use-chart-data-transform.ts +10 -3
  45. package/src/hooks/use-chart-margin.tsx +54 -9
  46. package/src/index.ts +4 -0
  47. package/src/styles/chart-scope.scss +11 -3
  48. package/src/styles/test/chart-scope.test.ts +35 -1
  49. package/src/test-utils/zoned-parsing-suite.ts +90 -0
  50. package/src/types.ts +2 -2
  51. package/src/utils/date-formatting.ts +1 -18
  52. package/src/utils/date-parsing.ts +103 -52
  53. package/src/utils/get-edge-tick-widths.ts +33 -0
  54. package/src/utils/index.ts +1 -0
  55. package/src/utils/test/date-parsing.auckland.test.ts +6 -0
  56. package/src/utils/test/date-parsing.santiago.test.ts +6 -0
  57. package/src/utils/test/date-parsing.test.ts +16 -1
  58. package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
  59. package/src/utils/warn-once.ts +23 -0
  60. package/src/visx/types.ts +27 -3
  61. package/src/charts/heatmap-chart/private/build-calendar-data.ts +0 -179
@@ -58,7 +58,7 @@ import type { RenderTooltipParams } from '../../visx/types';
58
58
  import type { ResponsiveConfig } from '../private/with-responsive';
59
59
  import type { TickFormatter } from '@visx/axis';
60
60
  import type { GlyphProps } from '@visx/xychart';
61
- import type { FC, Ref } from 'react';
61
+ import type { CSSProperties, FC, Ref } from 'react';
62
62
 
63
63
  const defaultRenderGlyph = < Datum extends object >( props: RenderLineGlyphProps< Datum > ) => {
64
64
  return <DefaultGlyph { ...props } key={ props.key } />;
@@ -108,11 +108,13 @@ const TooltipDate: FC< { date?: Date; displayResolution: Exclude< TickResolution
108
108
  * one row per visible series (label + formatted value), sorted descending by
109
109
  * value. Reused by AreaChart, which has the same multi-series shape.
110
110
  *
111
- * @param params - visx `RenderTooltipParams< DataPointDate >`, plus the chart's optional `bucketInfo`.
111
+ * @param params - visx tooltip data and the chart's optional `bucketInfo`.
112
+ * @param contentStyle - Explicit tooltip content color overrides.
112
113
  * @return Tooltip JSX, or `null` when no datum is hovered.
113
114
  */
114
115
  export const renderDefaultTooltip = (
115
- params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo }
116
+ params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo },
117
+ contentStyle?: Pick< CSSProperties, 'color' | 'background' | 'backgroundColor' >
116
118
  ) => {
117
119
  const { tooltipData, bucketInfo } = params;
118
120
  const nearestDatum = tooltipData?.nearestDatum?.datum;
@@ -126,7 +128,11 @@ export const renderDefaultTooltip = (
126
128
  .sort( ( a, b ) => b.value - a.value );
127
129
 
128
130
  return (
129
- <div className={ styles[ 'line-chart__tooltip' ] }>
131
+ <div
132
+ className={ styles[ 'line-chart__tooltip' ] }
133
+ data-testid="line-chart-tooltip-content"
134
+ style={ contentStyle }
135
+ >
130
136
  <div className={ styles[ 'line-chart__tooltip-date' ] }>
131
137
  <TooltipDate
132
138
  date={ nearestDatum.date }
@@ -221,6 +227,8 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
221
227
  smoothing = true,
222
228
  curveType,
223
229
  renderTooltip = renderDefaultTooltip,
230
+ tooltipPlacement,
231
+ tooltipStyle,
224
232
  withStartGlyphs = false,
225
233
  withEndGlyphs = false,
226
234
  animation,
@@ -354,6 +362,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
354
362
  chartRef,
355
363
  totalPoints: dataSorted[ 0 ]?.data.length || 0,
356
364
  onActivate: activateSelectedPoint,
365
+ preventTooltipScroll: tooltipPlacement === 'below-axis',
357
366
  } );
358
367
 
359
368
  const chartOptions = useMemo( () => {
@@ -473,12 +482,32 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
473
482
  yAccessor: ( d: DataPointDate ) => d?.value,
474
483
  };
475
484
 
485
+ const resolvedTooltipStyle = useMemo( () => {
486
+ if ( renderTooltip !== renderDefaultTooltip || ! tooltipStyle ) return tooltipStyle;
487
+ if ( ! tooltipStyle.color || tooltipStyle.background || tooltipStyle.backgroundColor ) {
488
+ return tooltipStyle;
489
+ }
490
+ return {
491
+ backgroundColor: 'var(--a8c-charts-color-tooltip-surface, rgb(0 0 0 / 85%))',
492
+ ...tooltipStyle,
493
+ };
494
+ }, [ renderTooltip, tooltipStyle ] );
495
+
476
496
  // Augments every renderTooltip call with the chart's bucket classification,
477
497
  // default or custom, so a heading keyed on it can't disagree with the axis.
478
498
  const tooltipRenderer = useMemo(
479
499
  () => ( params: RenderTooltipParams< DataPointDate > ) =>
480
- renderTooltip( { ...params, bucketInfo } ),
481
- [ renderTooltip, bucketInfo ]
500
+ renderTooltip === renderDefaultTooltip
501
+ ? renderDefaultTooltip(
502
+ { ...params, bucketInfo },
503
+ {
504
+ color: tooltipStyle?.color,
505
+ background: tooltipStyle?.background,
506
+ backgroundColor: tooltipStyle?.backgroundColor,
507
+ }
508
+ )
509
+ : renderTooltip( { ...params, bucketInfo } ),
510
+ [ renderTooltip, bucketInfo, tooltipStyle ]
482
511
  );
483
512
 
484
513
  if ( error ) {
@@ -533,6 +562,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
533
562
 
534
563
  return (
535
564
  <div
565
+ ref={ chartRef }
536
566
  role="grid"
537
567
  aria-label={ __( 'Line chart', 'jetpack-charts' ) }
538
568
  tabIndex={ 0 }
@@ -541,7 +571,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
541
571
  onBlur={ onChartBlur }
542
572
  >
543
573
  { chartHeight > 0 && (
544
- <div ref={ chartRef } className={ plotStyles[ 'xy-plot' ] }>
574
+ <div className={ plotStyles[ 'xy-plot' ] }>
545
575
  { zoomable && zoom.domain && <ZoomResetButton onClick={ zoom.reset } /> }
546
576
  <XYChart
547
577
  theme={ theme }
@@ -676,6 +706,8 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
676
706
  <AccessibleTooltip
677
707
  detectBounds
678
708
  snapTooltipToDatumX
709
+ tooltipPlacement={ tooltipPlacement }
710
+ style={ resolvedTooltipStyle }
679
711
  snapTooltipToDatumY
680
712
  showSeriesGlyphs
681
713
  renderTooltip={ tooltipRenderer }
@@ -683,6 +715,8 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
683
715
  glyphStyle={ glyphStyle }
684
716
  showVerticalCrosshair={ withTooltipCrosshairs?.showVertical }
685
717
  showHorizontalCrosshair={ withTooltipCrosshairs?.showHorizontal }
718
+ verticalCrosshairStyle={ withTooltipCrosshairs?.verticalStyle }
719
+ horizontalCrosshairStyle={ withTooltipCrosshairs?.horizontalStyle }
686
720
  selectedIndex={ selectedIndex }
687
721
  tooltipRef={ tooltipRef }
688
722
  keyboardFocusedClassName={
@@ -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
+ } );
@@ -104,6 +104,137 @@ 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
+
207
+ test.each( [ [ 'Escape', '{Escape}' ] ] )(
208
+ 'returns focus to the grid after %s',
209
+ async ( _name, keys ) => {
210
+ jest.useFakeTimers();
211
+ try {
212
+ const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
213
+ renderWithTheme();
214
+ const chart = screen.getByRole( 'grid', { name: /line chart/i } );
215
+
216
+ await user.tab();
217
+ expect( chart ).toHaveFocus();
218
+ await user.keyboard( '{ArrowRight}' );
219
+ expect( screen.getByRole( 'tooltip' ) ).toHaveFocus();
220
+
221
+ await user.keyboard( keys );
222
+ await act( async () => {
223
+ jest.advanceTimersByTime( 5000 );
224
+ } );
225
+ expect( chart ).toHaveFocus();
226
+ expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
227
+
228
+ await user.keyboard( '{ArrowRight}' );
229
+ const tooltip = screen.getByRole( 'tooltip' );
230
+ expect( tooltip ).toHaveFocus();
231
+ expect( tooltip ).toHaveTextContent( 'Series A:10' );
232
+ } finally {
233
+ jest.useRealTimers();
234
+ }
235
+ }
236
+ );
237
+
107
238
  describe( 'Data Validation', () => {
108
239
  test( 'handles empty data array', () => {
109
240
  renderWithTheme( { data: [] } );
@@ -1225,44 +1356,53 @@ describe( 'LineChart', () => {
1225
1356
  } );
1226
1357
 
1227
1358
  describe( 'Arrow Key Navigation', () => {
1228
- test( 'right arrow key navigates to next data point', async () => {
1229
- const user = userEvent.setup();
1230
- renderWithTheme( {
1231
- data: [
1232
- {
1233
- label: 'Series A',
1234
- data: [
1235
- { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
1236
- { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
1237
- ],
1238
- options: {},
1239
- },
1240
- {
1241
- label: 'Series B',
1242
- data: [
1243
- { date: new Date( '2024-01-01' ), value: 15, label: 'Jan 1' },
1244
- { date: new Date( '2024-01-02' ), value: 25, label: 'Jan 2' },
1245
- ],
1246
- options: {},
1247
- },
1248
- ],
1249
- } );
1250
-
1251
- const chart = screen.getByRole( 'grid', { name: /line chart/i } );
1252
- chart.focus();
1253
-
1254
- // Single tab should focus on the first tooltip.
1255
- await user.keyboard( '{ArrowRight}' );
1256
- expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
1257
- expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Series A' );
1258
- expect( screen.queryByTestId( 'chart-tooltip-1' ) ).not.toBeInTheDocument();
1259
-
1260
- // Second tab should focus on the second tooltip.
1261
- await user.keyboard( '{ArrowRight}' );
1262
- expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveFocus();
1263
- expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveTextContent( 'Series B' );
1264
- expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
1265
- } );
1359
+ test.each( [ 'auto', 'below-axis' ] as const )(
1360
+ 'right arrow key navigates with %s tooltips',
1361
+ async tooltipPlacement => {
1362
+ const user = userEvent.setup();
1363
+ renderWithTheme( {
1364
+ tooltipPlacement,
1365
+ data: [
1366
+ {
1367
+ label: 'Series A',
1368
+ data: [
1369
+ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
1370
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
1371
+ ],
1372
+ options: {},
1373
+ },
1374
+ {
1375
+ label: 'Series B',
1376
+ data: [
1377
+ { date: new Date( '2024-01-01' ), value: 15, label: 'Jan 1' },
1378
+ { date: new Date( '2024-01-02' ), value: 25, label: 'Jan 2' },
1379
+ ],
1380
+ options: {},
1381
+ },
1382
+ ],
1383
+ } );
1384
+
1385
+ const chart = screen.getByRole( 'grid', { name: /line chart/i } );
1386
+ chart.focus();
1387
+
1388
+ // Single tab should focus on the first tooltip.
1389
+ await user.keyboard( '{ArrowRight}' );
1390
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
1391
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveAttribute( 'role', 'tooltip' );
1392
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveAttribute(
1393
+ 'aria-atomic',
1394
+ 'true'
1395
+ );
1396
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Series A' );
1397
+ expect( screen.queryByTestId( 'chart-tooltip-1' ) ).not.toBeInTheDocument();
1398
+
1399
+ // Second tab should focus on the second tooltip.
1400
+ await user.keyboard( '{ArrowRight}' );
1401
+ expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveFocus();
1402
+ expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveTextContent( 'Series B' );
1403
+ expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
1404
+ }
1405
+ );
1266
1406
 
1267
1407
  test( 'left arrow key navigates to previous data point', async () => {
1268
1408
  const user = userEvent.setup();
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-auckland.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
+ // Naive day strings, the shape the Stats payload gives for day buckets.
14
+ const data = [
15
+ {
16
+ label: 'Series A',
17
+ data: [
18
+ { dateString: '2026-08-02', value: 1 },
19
+ { dateString: '2026-08-03', value: 2 },
20
+ { dateString: '2026-08-04', value: 3 },
21
+ ],
22
+ options: {},
23
+ },
24
+ ];
25
+
26
+ describe( 'dateString under a host time zone', () => {
27
+ it( 'dates a naive string in the host zone, not the viewer browser zone', () => {
28
+ render(
29
+ <GlobalChartsProvider locale="en-US" timeZone="Asia/Tokyo">
30
+ <LineChart width={ 500 } height={ 300 } withGradientFill={ false } data={ data } />
31
+ </GlobalChartsProvider>
32
+ );
33
+
34
+ const ticks = screen.getAllByText( /^[A-Z][a-z]{2} \d{1,2}$/ );
35
+ expect( ticks.map( tick => tick.textContent ) ).toEqual( [ 'Aug 2', 'Aug 3', 'Aug 4' ] );
36
+ } );
37
+ } );
@@ -8,9 +8,9 @@ import type {
8
8
  AnnotationStyles,
9
9
  DataPoint,
10
10
  } from '../../types';
11
- import type { RenderTooltipParams } from '../../visx/types';
11
+ import type { CrosshairStyle, RenderTooltipParams, XyChartTooltipProps } from '../../visx/types';
12
12
  import type { GlyphProps } from '@visx/xychart';
13
- import type { ReactNode, SVGProps, FC } from 'react';
13
+ import type { ReactNode, SVGProps, FC, CSSProperties } from 'react';
14
14
 
15
15
  export type LineChartAnnotationProps = {
16
16
  datum: DataPointDate;
@@ -41,6 +41,15 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] >, SeriesVi
41
41
  renderTooltip?: (
42
42
  params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo }
43
43
  ) => ReactNode;
44
+ /**
45
+ * Place the panel below the x-axis tick labels with a pointer at the datum x; horizontal bounds still apply.
46
+ * @default 'auto'
47
+ */
48
+ tooltipPlacement?: XyChartTooltipProps< DataPointDate >[ 'tooltipPlacement' ];
49
+ /**
50
+ * Inline container styles; see Below-Axis Tooltips in stories/index.docs.mdx for content color overrides.
51
+ */
52
+ tooltipStyle?: CSSProperties;
44
53
  withStartGlyphs?: boolean;
45
54
  withEndGlyphs?: boolean;
46
55
  renderGlyph?: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode;
@@ -49,6 +58,8 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] >, SeriesVi
49
58
  withTooltipCrosshairs?: {
50
59
  showVertical?: boolean;
51
60
  showHorizontal?: boolean;
61
+ verticalStyle?: CrosshairStyle;
62
+ horizontalStyle?: CrosshairStyle;
52
63
  };
53
64
  /**
54
65
  * Enable drag-to-zoom on the X axis. The user drags horizontally to
@@ -123,18 +123,9 @@ describe( 'PieChart', () => {
123
123
  test( 'hides labels when showLabels is false', () => {
124
124
  renderWithTheme( { showLabels: false } );
125
125
 
126
- // When showLabels is false, the chart should not display the data labels
127
- // We filter out measurement elements by checking that text is not inside measurement element
128
- const labelElements = screen.queryAllByText( ( content, element ) => {
129
- // Check if this text element is not the measurement element
130
- return (
131
- ( content === 'A' || content === 'B' ) &&
132
- element?.id !== '__react_svg_text_measurement_id'
133
- );
134
- } );
135
-
136
- // Labels should not be present in the rendered output (excluding measurement text)
137
- expect( labelElements ).toHaveLength( 0 );
126
+ // A plain query, so this also fails if the measurement node stops being
127
+ // ignored — see tests/setup-text-measurement.js.
128
+ expect( screen.queryAllByText( /^[AB]$/ ) ).toHaveLength( 0 );
138
129
  } );
139
130
 
140
131
  test( 'shows labels when showLabels is explicitly true', () => {
@@ -200,6 +200,7 @@ interface UseKeyboardNavigationProps {
200
200
  * keyboard selection the way it treats a click.
201
201
  */
202
202
  onActivate?: ( index: number ) => void;
203
+ preventTooltipScroll?: boolean;
203
204
  }
204
205
 
205
206
  export const useKeyboardNavigation = ( {
@@ -210,23 +211,34 @@ export const useKeyboardNavigation = ( {
210
211
  chartRef,
211
212
  totalPoints,
212
213
  onActivate,
214
+ preventTooltipScroll = false,
213
215
  }: UseKeyboardNavigationProps ) => {
214
216
  // Focus the tooltip as soon as it is rendered
215
217
  const tooltipRef = useCallback(
216
218
  ( element: HTMLDivElement | null ) => {
217
219
  if ( element && selectedIndex !== undefined ) {
218
- element.focus();
220
+ if ( preventTooltipScroll ) {
221
+ element.focus( { preventScroll: true } );
222
+ } else {
223
+ element.focus();
224
+ }
219
225
  }
220
226
  },
221
- [ selectedIndex ]
227
+ [ preventTooltipScroll, selectedIndex ]
222
228
  );
223
229
 
224
- // On each focus of chart, reset the selectedIndex to 0, if keyboard navigation is not already active
225
- const onChartFocus = useCallback( () => {
226
- if ( ! isNavigating && selectedIndex !== undefined ) {
227
- setSelectedIndex( 0 );
228
- }
229
- }, [ isNavigating, selectedIndex, setSelectedIndex ] );
230
+ // Returning focus from the tooltip must not restore the selection Escape just cleared.
231
+ const onChartFocus = useCallback(
232
+ ( event: React.FocusEvent< HTMLDivElement > ) => {
233
+ if ( event.currentTarget.contains( event.relatedTarget ) ) {
234
+ return;
235
+ }
236
+ if ( ! isNavigating && selectedIndex !== undefined ) {
237
+ setSelectedIndex( 0 );
238
+ }
239
+ },
240
+ [ isNavigating, selectedIndex, setSelectedIndex ]
241
+ );
230
242
 
231
243
  // On each blur of chart, keyboard navigation should restart from first tooltip
232
244
  const onChartBlur = useCallback( () => {
@@ -237,9 +249,17 @@ export const useKeyboardNavigation = ( {
237
249
  ( event: React.KeyboardEvent< HTMLDivElement > ) => {
238
250
  if ( totalPoints === 0 ) return;
239
251
 
252
+ const focusChart = () => {
253
+ if ( preventTooltipScroll ) {
254
+ chartRef.current?.focus( { preventScroll: true } );
255
+ } else {
256
+ chartRef.current?.focus();
257
+ }
258
+ };
259
+
240
260
  // Keep focus on the chart if tab is pressed
241
261
  if ( event.key === 'Tab' ) {
242
- chartRef.current?.focus();
262
+ focusChart();
243
263
  setSelectedIndex( undefined );
244
264
  setIsNavigating( false );
245
265
  return;
@@ -248,7 +268,7 @@ export const useKeyboardNavigation = ( {
248
268
  const currentSelectedIndex = selectedIndex === undefined ? -1 : selectedIndex;
249
269
 
250
270
  if ( currentSelectedIndex + 1 >= totalPoints && [ 'ArrowRight' ].includes( event.key ) ) {
251
- chartRef.current?.focus();
271
+ focusChart();
252
272
  setSelectedIndex( undefined );
253
273
  setIsNavigating( false );
254
274
  return;
@@ -265,12 +285,20 @@ export const useKeyboardNavigation = ( {
265
285
  } else if ( event.key === 'Escape' ) {
266
286
  setSelectedIndex( undefined );
267
287
  setIsNavigating( false );
268
- chartRef.current?.focus();
288
+ focusChart();
269
289
  } else if ( ( event.key === 'Enter' || event.key === ' ' ) && selectedIndex !== undefined ) {
270
290
  onActivate?.( selectedIndex );
271
291
  }
272
292
  },
273
- [ totalPoints, selectedIndex, setSelectedIndex, setIsNavigating, chartRef, onActivate ]
293
+ [
294
+ totalPoints,
295
+ selectedIndex,
296
+ setSelectedIndex,
297
+ setIsNavigating,
298
+ chartRef,
299
+ onActivate,
300
+ preventTooltipScroll,
301
+ ]
274
302
  );
275
303
 
276
304
  return {