@automattic/charts 4.0.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 (56) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/SECURITY.md +1 -0
  3. package/TOKENS.md +1 -1
  4. package/dist/index.cjs +548 -199
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.css +21 -0
  7. package/dist/index.d.cts +86 -72
  8. package/dist/index.d.ts +86 -72
  9. package/dist/index.js +549 -201
  10. package/dist/index.js.map +1 -1
  11. package/package.json +2 -1
  12. package/src/charts/heatmap-chart/heatmap-chart.module.scss +31 -0
  13. package/src/charts/heatmap-chart/heatmap-chart.tsx +44 -9
  14. package/src/charts/heatmap-chart/index.ts +2 -1
  15. package/src/charts/heatmap-chart/private/build-calendar-data.ts +126 -92
  16. package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
  17. package/src/charts/heatmap-chart/private/index.ts +1 -1
  18. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
  19. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +3 -0
  20. package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
  21. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +91 -0
  22. package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
  23. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
  24. package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
  25. package/src/charts/heatmap-chart/types.ts +6 -0
  26. package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
  27. package/src/charts/leaderboard-chart/types.ts +2 -2
  28. package/src/charts/line-chart/line-chart.tsx +39 -6
  29. package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
  30. package/src/charts/line-chart/test/line-chart.test.tsx +147 -38
  31. package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
  32. package/src/charts/line-chart/types.ts +13 -2
  33. package/src/charts/pie-chart/test/pie-chart.test.tsx +3 -12
  34. package/src/components/tooltip/accessible-tooltip.tsx +8 -2
  35. package/src/components/tooltip/private/bounded-tooltip.tsx +71 -16
  36. package/src/components/tooltip/test/accessible-tooltip.test.tsx +28 -1
  37. package/src/components/tooltip/test/bounded-tooltip.test.tsx +102 -6
  38. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +100 -0
  39. package/src/components/tooltip/xy-chart-tooltip.tsx +51 -13
  40. package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
  41. package/src/hooks/test/use-chart-margin.test.tsx +173 -3
  42. package/src/hooks/use-chart-data-transform.ts +10 -3
  43. package/src/hooks/use-chart-margin.tsx +54 -9
  44. package/src/index.ts +4 -0
  45. package/src/test-utils/zoned-parsing-suite.ts +90 -0
  46. package/src/types.ts +2 -2
  47. package/src/utils/date-formatting.ts +1 -18
  48. package/src/utils/date-parsing.ts +103 -52
  49. package/src/utils/get-edge-tick-widths.ts +33 -0
  50. package/src/utils/index.ts +1 -0
  51. package/src/utils/test/date-parsing.auckland.test.ts +6 -0
  52. package/src/utils/test/date-parsing.santiago.test.ts +6 -0
  53. package/src/utils/test/date-parsing.test.ts +16 -1
  54. package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
  55. package/src/utils/warn-once.ts +23 -0
  56. package/src/visx/types.ts +27 -3
@@ -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 ) {
@@ -676,6 +705,8 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
676
705
  <AccessibleTooltip
677
706
  detectBounds
678
707
  snapTooltipToDatumX
708
+ tooltipPlacement={ tooltipPlacement }
709
+ style={ resolvedTooltipStyle }
679
710
  snapTooltipToDatumY
680
711
  showSeriesGlyphs
681
712
  renderTooltip={ tooltipRenderer }
@@ -683,6 +714,8 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
683
714
  glyphStyle={ glyphStyle }
684
715
  showVerticalCrosshair={ withTooltipCrosshairs?.showVertical }
685
716
  showHorizontalCrosshair={ withTooltipCrosshairs?.showHorizontal }
717
+ verticalCrosshairStyle={ withTooltipCrosshairs?.verticalStyle }
718
+ horizontalCrosshairStyle={ withTooltipCrosshairs?.horizontalStyle }
686
719
  selectedIndex={ selectedIndex }
687
720
  tooltipRef={ tooltipRef }
688
721
  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,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: [] } );
@@ -1225,44 +1325,53 @@ describe( 'LineChart', () => {
1225
1325
  } );
1226
1326
 
1227
1327
  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
- } );
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
+ );
1266
1375
 
1267
1376
  test( 'left arrow key navigates to previous data point', async () => {
1268
1377
  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,15 +211,20 @@ 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
230
  // On each focus of chart, reset the selectedIndex to 0, if keyboard navigation is not already active
@@ -1,5 +1,6 @@
1
1
  import { Tooltip, defaultStyles } from '@visx/tooltip';
2
2
  import { useLayoutEffect, useRef, useState } from 'react';
3
+ import type { TooltipPlacement } from '../../../visx/types';
3
4
  import type { TooltipProps } from '@visx/tooltip';
4
5
 
5
6
  type Bounds = { left: number; top: number; right: number; bottom: number };
@@ -9,9 +10,14 @@ export type BoundedTooltipProps = Omit< TooltipProps, 'left' | 'top' | 'applyPos
9
10
  /** Anchor, in the coordinates of the positioned wrapper the box renders into. */
10
11
  left?: number;
11
12
  top?: number;
13
+ placement?: TooltipPlacement;
12
14
  };
13
15
 
14
16
  const DEFAULT_OFFSET = 10;
17
+ const POINTER_HEIGHT = 6;
18
+
19
+ const clamp = ( position: number, min: number, max: number, size: number ) =>
20
+ Math.min( Math.max( position, min ), Math.max( min, max - size ) );
15
21
 
16
22
  const isClipping = ( element: Element ) => {
17
23
  const { overflow, overflowX, overflowY } = getComputedStyle( element );
@@ -33,19 +39,25 @@ const findClippingAncestor = ( wrapper: Element ): Element | null => {
33
39
  };
34
40
 
35
41
  /**
36
- * Where the box goes, in wrapper coordinates. Below and to the right of the
37
- * anchor; flipped to the other side when that side clips less, as visx's
38
- * `TooltipWithBounds` decides it; then clamped so the box stays inside
39
- * `bounds` whenever it fits at all.
42
+ * Where the box goes, in wrapper coordinates. Automatic placement flips to
43
+ * the side that clips less, then clamps inside `bounds`; below-axis placement
44
+ * is centered on the anchor and clamped horizontally.
45
+ *
46
+ * The flip measures against the visible part of the wrapper, its intersection
47
+ * with `bounds`. Only the clamp is bound by the page: a box that fits inside the
48
+ * chart on one side stays there, so it does not reach past the chart into
49
+ * siblings that paint over it.
40
50
  *
41
- * @param params - Anchor, offsets, the box size and the bounds to keep inside.
51
+ * @param params - Anchor, offsets, the box size and the rects to keep inside.
42
52
  * @param params.left - Anchor x, in wrapper coordinates.
43
53
  * @param params.top - Anchor y, in wrapper coordinates.
44
54
  * @param params.offsetLeft - Gap between the anchor and the box, horizontally.
45
55
  * @param params.offsetTop - Gap between the anchor and the box, vertically.
46
56
  * @param params.box - Rendered size of the box.
57
+ * @param params.wrapper - The wrapper's own edges, in wrapper coordinates.
58
+ * @param params.placement - Fixed below-axis placement or automatic flipping.
47
59
  * @param params.bounds - Edges the box must keep inside, in wrapper coordinates.
48
- * @return The box's top-left corner, rounded to whole pixels.
60
+ * @return The box's top-left corner; fixed placement preserves the axis's subpixel y.
49
61
  */
50
62
  export const getBoundedPosition = ( {
51
63
  left,
@@ -53,31 +65,53 @@ export const getBoundedPosition = ( {
53
65
  offsetLeft,
54
66
  offsetTop,
55
67
  box,
68
+ wrapper,
56
69
  bounds,
70
+ placement = 'auto',
57
71
  }: {
58
72
  left: number;
59
73
  top: number;
60
74
  offsetLeft: number;
61
75
  offsetTop: number;
62
76
  box: Size;
77
+ wrapper: Bounds;
63
78
  bounds: Bounds;
79
+ placement?: BoundedTooltipProps[ 'placement' ];
64
80
  } ) => {
81
+ if ( placement === 'below-axis' ) {
82
+ return {
83
+ x: Math.round( clamp( left - box.width / 2, bounds.left, bounds.right, box.width ) ),
84
+ y: top + POINTER_HEIGHT,
85
+ };
86
+ }
87
+
88
+ // The part of the wrapper that is not cut off: the flip keeps the box inside it.
89
+ const fit = {
90
+ left: Math.max( wrapper.left, bounds.left ),
91
+ top: Math.max( wrapper.top, bounds.top ),
92
+ right: Math.min( wrapper.right, bounds.right ),
93
+ bottom: Math.min( wrapper.bottom, bounds.bottom ),
94
+ };
95
+
65
96
  const rightX = left + offsetLeft;
66
97
  const leftX = left - offsetLeft - box.width;
67
- const rightOverflow = rightX + box.width - bounds.right;
68
- const leftOverflow = bounds.left - leftX;
98
+ const rightOverflow = rightX + box.width - fit.right;
99
+ const leftOverflow = fit.left - leftX;
69
100
  let x = rightOverflow > 0 && rightOverflow > leftOverflow ? leftX : rightX;
70
101
 
71
102
  const downY = top + offsetTop;
72
103
  const upY = top - offsetTop - box.height;
73
- const downOverflow = downY + box.height - bounds.bottom;
74
- const upOverflow = bounds.top - upY;
104
+ const downOverflow = downY + box.height - fit.bottom;
105
+ const upOverflow = fit.top - upY;
75
106
  let y = downOverflow > 0 && downOverflow > upOverflow ? upY : downY;
76
107
 
77
- x = Math.min( Math.max( x, bounds.left ), Math.max( bounds.left, bounds.right - box.width ) );
78
- y = Math.min( Math.max( y, bounds.top ), Math.max( bounds.top, bounds.bottom - box.height ) );
108
+ x = clamp( x, bounds.left, bounds.right, box.width );
109
+ y = clamp( y, bounds.top, bounds.bottom, box.height );
79
110
 
80
- return { x: Math.round( x ), y: Math.round( y ) };
111
+ return {
112
+ x: Math.round( x ),
113
+ y: Math.round( y ),
114
+ };
81
115
  };
82
116
 
83
117
  /**
@@ -88,7 +122,8 @@ export const getBoundedPosition = ( {
88
122
  * alone would let the box spill into an `overflow: hidden` card and be cut off.
89
123
  *
90
124
  * Re-measures on every render, so a box whose content changes width between
91
- * two hovers is placed for its current size.
125
+ * two hovers is placed for its current size. See `getBoundedPosition` for the
126
+ * below-axis exception.
92
127
  *
93
128
  * @param props - visx `Tooltip` props.
94
129
  * @param props.left - Anchor x, in wrapper coordinates.
@@ -98,6 +133,7 @@ export const getBoundedPosition = ( {
98
133
  * @param props.style - Box styles; visx's defaults unless `unstyled`.
99
134
  * @param props.unstyled - Skip `style` and leave the box bare.
100
135
  * @param props.children - Box content.
136
+ * @param props.placement - Fixed below-axis placement or automatic flipping.
101
137
  * @return The tooltip box.
102
138
  */
103
139
  export const BoundedTooltip = ( {
@@ -108,6 +144,7 @@ export const BoundedTooltip = ( {
108
144
  style = defaultStyles,
109
145
  unstyled = false,
110
146
  children,
147
+ placement = 'auto',
111
148
  ...rest
112
149
  }: BoundedTooltipProps ) => {
113
150
  const nodeRef = useRef< HTMLDivElement >( null );
@@ -136,7 +173,9 @@ export const BoundedTooltip = ( {
136
173
  top,
137
174
  offsetLeft,
138
175
  offsetTop,
176
+ placement,
139
177
  box: { width: own.width, height: own.height },
178
+ wrapper: { left: 0, top: 0, right: wrapperRect.width, bottom: wrapperRect.height },
140
179
  bounds: {
141
180
  left: clipRect.left - wrapperRect.left,
142
181
  top: clipRect.top - wrapperRect.top,
@@ -148,10 +187,10 @@ export const BoundedTooltip = ( {
148
187
  current && current.x === next.x && current.y === next.y ? current : next
149
188
  );
150
189
  // `children` re-runs the measurement when the content, and so the box size, changes.
151
- }, [ left, top, offsetLeft, offsetTop, children ] );
190
+ }, [ left, top, offsetLeft, offsetTop, children, placement ] );
152
191
 
153
192
  const x = position?.x ?? left + offsetLeft;
154
- const y = position?.y ?? top + offsetTop;
193
+ const y = position?.y ?? top + ( placement === 'below-axis' ? POINTER_HEIGHT : offsetTop );
155
194
 
156
195
  return (
157
196
  <Tooltip
@@ -166,6 +205,22 @@ export const BoundedTooltip = ( {
166
205
  } }
167
206
  { ...rest }
168
207
  >
208
+ { placement === 'below-axis' && (
209
+ <span
210
+ aria-hidden="true"
211
+ data-testid="tooltip-axis-pointer"
212
+ style={ {
213
+ position: 'absolute',
214
+ left: left - x - POINTER_HEIGHT,
215
+ top: -POINTER_HEIGHT,
216
+ width: POINTER_HEIGHT * 2,
217
+ height: POINTER_HEIGHT,
218
+ background: 'inherit',
219
+ clipPath: 'polygon(50% 0, 100% 100%, 0 100%)',
220
+ pointerEvents: 'none',
221
+ } }
222
+ />
223
+ ) }
169
224
  { children }
170
225
  </Tooltip>
171
226
  );