@automattic/charts 3.1.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/TOKENS.md +80 -46
  3. package/dist/index.cjs +1224 -662
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +72 -23
  6. package/dist/index.d.cts +230 -162
  7. package/dist/index.d.ts +230 -162
  8. package/dist/index.js +1224 -663
  9. package/dist/index.js.map +1 -1
  10. package/package.json +10 -10
  11. package/src/charts/area-chart/area-chart.tsx +39 -25
  12. package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
  13. package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
  14. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
  15. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
  16. package/src/charts/area-chart/types.ts +4 -7
  17. package/src/charts/bar-chart/bar-chart.tsx +38 -11
  18. package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
  19. package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
  20. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +67 -0
  21. package/src/charts/bar-chart/test/bar-chart.test.tsx +79 -0
  22. package/src/charts/bar-list-chart/bar-list-chart.tsx +45 -2
  23. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +38 -0
  24. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  25. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  26. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  27. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  28. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  29. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  30. package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
  31. package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
  32. package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
  33. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  34. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
  35. package/src/charts/heatmap-chart/types.ts +2 -2
  36. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  37. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  38. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  39. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  40. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  41. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  42. package/src/charts/line-chart/line-chart.module.scss +10 -18
  43. package/src/charts/line-chart/line-chart.tsx +109 -25
  44. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  45. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  46. package/src/charts/line-chart/test/line-chart.test.tsx +74 -3
  47. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  48. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  49. package/src/charts/line-chart/types.ts +4 -1
  50. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  51. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  52. package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
  53. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  54. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  55. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  56. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  57. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  58. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  59. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  60. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  61. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  62. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  63. package/src/charts/private/test/time-axis.test.ts +9 -69
  64. package/src/charts/private/time-axis-options.ts +106 -0
  65. package/src/charts/private/time-axis.ts +318 -136
  66. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  67. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  68. package/src/charts/sparkline/sparkline.tsx +9 -3
  69. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  70. package/src/charts/sparkline/types.ts +2 -2
  71. package/src/components/legend/private/base-legend.module.scss +9 -5
  72. package/src/components/tooltip/accessible-tooltip.tsx +38 -10
  73. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  74. package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
  75. package/src/components/tooltip/test/accessible-tooltip.test.tsx +67 -0
  76. package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
  77. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
  78. package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
  79. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  80. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +17 -10
  81. package/src/hooks/test/use-xychart-theme.test.tsx +63 -5
  82. package/src/hooks/use-xychart-theme.ts +33 -23
  83. package/src/index.ts +4 -0
  84. package/src/providers/chart-context/global-charts-provider.tsx +64 -54
  85. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  86. package/src/providers/chart-context/index.ts +1 -0
  87. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  88. package/src/providers/chart-context/private/series-palette.ts +35 -0
  89. package/src/providers/chart-context/test/catalog-pointers.test.ts +61 -0
  90. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  91. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -18
  92. package/src/providers/chart-context/themes.ts +7 -47
  93. package/src/providers/chart-context/types.ts +3 -0
  94. package/src/styles/chart-scope.scss +63 -58
  95. package/src/styles/test/chart-scope.test.ts +25 -40
  96. package/src/test-utils/series-fixtures.ts +31 -0
  97. package/src/types.ts +103 -73
  98. package/src/utils/bucket-info.ts +87 -0
  99. package/src/utils/color-utils.ts +0 -27
  100. package/src/utils/date-formatting.ts +174 -0
  101. package/src/utils/get-styles.ts +7 -1
  102. package/src/utils/index.ts +3 -0
  103. package/src/utils/test/bucket-info.test.ts +185 -0
  104. package/src/utils/test/color-utils.test.ts +1 -48
  105. package/src/utils/test/date-formatting.test.ts +89 -0
  106. package/src/utils/test/get-styles.test.ts +10 -7
  107. package/src/utils/test/merge-themes.test.ts +18 -46
  108. package/src/visx/types.ts +29 -2
  109. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  110. package/src/providers/chart-context/private/theme-override-vars.ts +0 -87
  111. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -50
  112. package/src/providers/chart-context/test/sparse-theme.test.ts +0 -35
  113. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  114. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -118
  115. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -47
  116. package/src/styles/test/wpds-fallbacks.test.ts +0 -131
@@ -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
+ } );
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  BaseChartProps,
3
+ BucketInfo,
3
4
  DataPointDate,
4
5
  SeriesData,
5
6
  SeriesChartLegendConfig,
@@ -37,7 +38,9 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] >, SeriesVi
37
38
  withGradientFill: boolean;
38
39
  smoothing?: boolean;
39
40
  curveType?: CurveType;
40
- renderTooltip?: ( params: RenderTooltipParams< DataPointDate > ) => ReactNode;
41
+ renderTooltip?: (
42
+ params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo }
43
+ ) => ReactNode;
41
44
  withStartGlyphs?: boolean;
42
45
  withEndGlyphs?: boolean;
43
46
  renderGlyph?: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode;
@@ -1,5 +1,8 @@
1
1
  .pie-chart {
2
- overflow: hidden;
2
+ // Anchors the tooltip, which renders next to the SVG in here.
3
+ &__plot {
4
+ position: relative;
5
+ }
3
6
 
4
7
  // Fill parent when no explicit width/height provided
5
8
  &--responsive {
@@ -7,4 +10,13 @@
7
10
  width: 100%;
8
11
  }
9
12
 
13
+ // Both literals are load-bearing: an undeclared role makes the
14
+ // `fill` invalid, and `fill` starts at black.
15
+ &__label-plate {
16
+ fill: var(--a8c-charts-color-label-background, transparent);
17
+ }
18
+
19
+ &__label-text {
20
+ fill: var(--a8c-charts-color-label-inverse, #f0f0f0);
21
+ }
10
22
  }
@@ -1,10 +1,11 @@
1
1
  import { Group } from '@visx/group';
2
2
  import { Pie } from '@visx/shape';
3
- import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
3
+ import { useTooltip } from '@visx/tooltip';
4
4
  import clsx from 'clsx';
5
- import { useCallback, useContext, useMemo } from 'react';
5
+ import { useCallback, useContext, useMemo, useRef } from 'react';
6
6
  import { Legend, useChartLegendItems } from '../../components/legend';
7
7
  import { BaseTooltip } from '../../components/tooltip';
8
+ import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
8
9
  import {
9
10
  useDataWithPercentages,
10
11
  useLegendVisibilityData,
@@ -18,7 +19,7 @@ import {
18
19
  useGlobalChartsTheme,
19
20
  GlobalChartsContext,
20
21
  } from '../../providers';
21
- import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
22
+ import { useStandaloneScopeClass } from '../../providers/chart-scope';
22
23
  import { attachSubComponents, resolveFontSize } from '../../utils';
23
24
  import { getStringWidth } from '../../visx/text';
24
25
  import { Center } from '../private/center';
@@ -196,14 +197,10 @@ const PieChartInternal = ( {
196
197
  const chartId = useChartId( providedChartId );
197
198
  const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, hideTooltip, showTooltip } =
198
199
  useTooltip< DataPointPercentageCalculated >();
200
+ const standaloneScopeClass = useStandaloneScopeClass();
199
201
 
200
- // Set up portal tooltip for better z-index handling
201
- // We get containerBounds to cancel out stale offsets in the position calculation
202
- const { containerRef, TooltipInPortal, containerBounds } = useTooltipInPortal( {
203
- detectBounds: true,
204
- scroll: true,
205
- debounce: 0,
206
- } );
202
+ // The tooltip renders inside this element, so pointer coordinates are taken relative to it.
203
+ const containerRef = useRef< HTMLDivElement >( null );
207
204
 
208
205
  const onMouseLeave = useCallback( () => {
209
206
  if ( ! withTooltips ) {
@@ -325,13 +322,6 @@ const PieChartInternal = ( {
325
322
  } }
326
323
  trailingContent={
327
324
  <>
328
- { withTooltips && tooltipOpen && tooltipData && (
329
- <TooltipInPortal top={ tooltipTop || 0 } left={ tooltipLeft || 0 }>
330
- <div className={ CHART_SCOPE_CLASS } role="tooltip">
331
- { renderTooltip( { tooltipData } ) }
332
- </div>
333
- </TooltipInPortal>
334
- ) }
335
325
  { htmlChildren }
336
326
  { otherChildren }
337
327
  </>
@@ -359,7 +349,7 @@ const PieChartInternal = ( {
359
349
  : 0;
360
350
 
361
351
  return (
362
- <Center ref={ containerRef }>
352
+ <Center ref={ containerRef } className={ styles[ 'pie-chart__plot' ] }>
363
353
  <svg
364
354
  viewBox={ `0 0 ${ width } ${ height }` }
365
355
  preserveAspectRatio="xMidYMid meet"
@@ -403,20 +393,15 @@ const PieChartInternal = ( {
403
393
  return;
404
394
  }
405
395
 
406
- // Don't show tooltip until container bounds are measured
407
- if ( containerBounds.width === 0 || containerBounds.height === 0 ) {
396
+ const bounds = containerRef.current?.getBoundingClientRect();
397
+ if ( ! bounds ) {
408
398
  return;
409
399
  }
410
400
 
411
- // Use clientX/Y and subtract containerBounds to cancel out any stale offset.
412
- // TooltipInPortal calculates: tooltipLeft + containerBounds.left + scrollX
413
- // By passing (clientX - containerBounds.left), we get:
414
- // (clientX - containerBounds.left) + containerBounds.left + scrollX = clientX + scrollX
415
- // This gives correct page coordinates regardless of stale bounds.
416
401
  showTooltip( {
417
402
  tooltipData: arc.data,
418
- tooltipLeft: event.clientX - containerBounds.left + tooltipOffsetX,
419
- tooltipTop: event.clientY - containerBounds.top + tooltipOffsetY,
403
+ tooltipLeft: event.clientX - bounds.left + tooltipOffsetX,
404
+ tooltipTop: event.clientY - bounds.top + tooltipOffsetY,
420
405
  } );
421
406
  };
422
407
 
@@ -450,23 +435,21 @@ const PieChartInternal = ( {
450
435
  <path { ...pathProps } />
451
436
  { showLabels && hasSpaceForLabel && (
452
437
  <g>
453
- { providerTheme.labelBackgroundColor && (
454
- <rect
455
- x={ centroidX - backgroundWidth / 2 }
456
- y={ centroidY - backgroundHeight / 2 }
457
- width={ backgroundWidth }
458
- height={ backgroundHeight }
459
- fill={ providerTheme.labelBackgroundColor }
460
- rx={ 4 }
461
- ry={ 4 }
462
- pointerEvents="none"
463
- />
464
- ) }
438
+ <rect
439
+ className={ styles[ 'pie-chart__label-plate' ] }
440
+ x={ centroidX - backgroundWidth / 2 }
441
+ y={ centroidY - backgroundHeight / 2 }
442
+ width={ backgroundWidth }
443
+ height={ backgroundHeight }
444
+ rx={ 4 }
445
+ ry={ 4 }
446
+ pointerEvents="none"
447
+ />
465
448
  <text
449
+ className={ styles[ 'pie-chart__label-text' ] }
466
450
  x={ centroidX }
467
451
  y={ centroidY }
468
452
  dy=".33em"
469
- fill={ providerTheme.labelTextColor || '#333' }
470
453
  fontSize={ fontSize }
471
454
  textAnchor="middle"
472
455
  pointerEvents="none"
@@ -486,6 +469,13 @@ const PieChartInternal = ( {
486
469
  { ! allSegmentsHidden && svgChildren }
487
470
  </Group>
488
471
  </svg>
472
+ { withTooltips && tooltipOpen && tooltipData && (
473
+ <BoundedTooltip top={ tooltipTop || 0 } left={ tooltipLeft || 0 }>
474
+ <div className={ standaloneScopeClass } role="tooltip">
475
+ { renderTooltip( { tooltipData } ) }
476
+ </div>
477
+ </BoundedTooltip>
478
+ ) }
489
479
  </Center>
490
480
  );
491
481
  } }
@@ -621,3 +621,59 @@ describe( 'PieChart', () => {
621
621
  } );
622
622
  } );
623
623
  } );
624
+
625
+ // Chart container at (100, 50); the tooltip box measures 120x40. Everything
626
+ // else the charts measure (wrapper, clipping lookups) reports the container.
627
+ const mockRects = () =>
628
+ jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
629
+ this: Element
630
+ ) {
631
+ const box = this.classList.contains( 'visx-tooltip' );
632
+ return {
633
+ left: box ? 0 : 100,
634
+ top: box ? 0 : 50,
635
+ width: box ? 120 : 400,
636
+ height: box ? 40 : 300,
637
+ right: box ? 120 : 500,
638
+ bottom: box ? 40 : 350,
639
+ x: box ? 0 : 100,
640
+ y: box ? 0 : 50,
641
+ toJSON: () => ( {} ),
642
+ } as DOMRect;
643
+ } );
644
+
645
+ describe( 'PieChart tooltip position', () => {
646
+ afterEach( () => {
647
+ jest.restoreAllMocks();
648
+ } );
649
+
650
+ test( 'places the box relative to the chart container, at the pointer plus the offsets', async () => {
651
+ mockRects();
652
+ const user = userEvent.setup();
653
+ render(
654
+ <GlobalChartsProvider>
655
+ <PieChart
656
+ data={ [
657
+ { label: 'A', value: 60, valueDisplay: '60' },
658
+ { label: 'B', value: 40, valueDisplay: '40' },
659
+ ] }
660
+ width={ 400 }
661
+ height={ 300 }
662
+ withTooltips
663
+ />
664
+ </GlobalChartsProvider>
665
+ );
666
+
667
+ await user.pointer( {
668
+ target: screen.getAllByTestId( 'pie-segment' )[ 0 ],
669
+ coords: { clientX: 150, clientY: 120 },
670
+ } );
671
+
672
+ // Pointer at (150, 120) is (50, 70) inside the container; the default
673
+ // -15px vertical offset makes that (50, 55), and the box adds 10px each way.
674
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
675
+ expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( {
676
+ transform: 'translate(60px, 65px)',
677
+ } );
678
+ } );
679
+ } );
@@ -1,4 +1,9 @@
1
1
  .pie-semi-circle-chart {
2
+ // Anchors the tooltip, which renders next to the SVG in here.
3
+ &__plot {
4
+ position: relative;
5
+ }
6
+
2
7
  // Fill parent when no explicit width/height provided
3
8
  &--responsive {
4
9
  height: 100%;
@@ -6,11 +11,11 @@
6
11
  }
7
12
 
8
13
  .label {
9
- font-weight: var(--wpds-typography-font-weight-emphasis, 600);
10
- font-size: var(--wpds-typography-font-size-lg, 15px);
14
+ font-weight: var(--wpds-typography-font-weight-emphasis);
15
+ font-size: var(--wpds-typography-font-size-lg);
11
16
  }
12
17
 
13
18
  .note {
14
- font-size: var(--wpds-typography-font-size-md, 13px);
19
+ font-size: var(--wpds-typography-font-size-md);
15
20
  }
16
21
  }
@@ -1,11 +1,12 @@
1
1
  import { Group } from '@visx/group';
2
2
  import { Pie } from '@visx/shape';
3
3
  import { Text } from '@visx/text';
4
- import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
4
+ import { useTooltip } from '@visx/tooltip';
5
5
  import clsx from 'clsx';
6
- import { useCallback, useContext, useMemo } from 'react';
6
+ import { useCallback, useContext, useMemo, useRef } from 'react';
7
7
  import { Legend, useChartLegendItems } from '../../components/legend';
8
8
  import { BaseTooltip } from '../../components/tooltip';
9
+ import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
9
10
  import {
10
11
  useDataWithPercentages,
11
12
  useLegendVisibilityData,
@@ -18,7 +19,7 @@ import {
18
19
  useGlobalChartsContext,
19
20
  GlobalChartsContext,
20
21
  } from '../../providers';
21
- import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
22
+ import { useStandaloneScopeClass } from '../../providers/chart-scope';
22
23
  import { attachSubComponents } from '../../utils';
23
24
  import { Center } from '../private/center';
24
25
  import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition';
@@ -189,42 +190,25 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
189
190
  const chartId = useChartId( providedChartId );
190
191
  const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, hideTooltip, showTooltip } =
191
192
  useTooltip< DataPointPercentageCalculated >();
193
+ const standaloneScopeClass = useStandaloneScopeClass();
192
194
 
193
- // Set up portal tooltip for better z-index handling
194
- // We get containerBounds to cancel out stale offsets in the position calculation
195
- const { containerRef, TooltipInPortal, containerBounds } = useTooltipInPortal( {
196
- detectBounds: true,
197
- scroll: true,
198
- debounce: 0,
199
- } );
195
+ // The tooltip renders inside this element, so pointer coordinates are taken relative to it.
196
+ const containerRef = useRef< HTMLDivElement >( null );
200
197
 
201
198
  const handleMouseMove = useCallback(
202
199
  ( event: MouseEvent< SVGElement >, arc: ArcData ) => {
203
- // Don't show tooltip until container bounds are measured
204
- if ( containerBounds.width === 0 || containerBounds.height === 0 ) {
200
+ const bounds = containerRef.current?.getBoundingClientRect();
201
+ if ( ! bounds ) {
205
202
  return;
206
203
  }
207
204
 
208
- // Use clientX/Y and subtract containerBounds to cancel out any stale offset.
209
- // TooltipInPortal calculates: tooltipLeft + containerBounds.left + scrollX
210
- // By passing (clientX - containerBounds.left), we get:
211
- // (clientX - containerBounds.left) + containerBounds.left + scrollX = clientX + scrollX
212
- // This gives correct page coordinates regardless of stale bounds.
213
205
  showTooltip( {
214
206
  tooltipData: arc.data,
215
- tooltipLeft: event.clientX - containerBounds.left + tooltipOffsetX,
216
- tooltipTop: event.clientY - containerBounds.top + tooltipOffsetY,
207
+ tooltipLeft: event.clientX - bounds.left + tooltipOffsetX,
208
+ tooltipTop: event.clientY - bounds.top + tooltipOffsetY,
217
209
  } );
218
210
  },
219
- [
220
- containerBounds.width,
221
- containerBounds.height,
222
- containerBounds.left,
223
- containerBounds.top,
224
- showTooltip,
225
- tooltipOffsetX,
226
- tooltipOffsetY,
227
- ]
211
+ [ showTooltip, tooltipOffsetX, tooltipOffsetY ]
228
212
  );
229
213
 
230
214
  const handleMouseLeave = useCallback( () => {
@@ -311,7 +295,9 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
311
295
  const errorHeight = errorWidth / 2;
312
296
 
313
297
  return (
314
- <div className={ styles[ 'pie-semi-circle-chart' ] }>
298
+ <div
299
+ className={ clsx( 'pie-semi-circle-chart', styles[ 'pie-semi-circle-chart' ], className ) }
300
+ >
315
301
  <svg width={ errorWidth } height={ errorHeight } data-testid="pie-chart-svg">
316
302
  <text x="50%" y="50%" textAnchor="middle" className={ styles.error }>
317
303
  { message }
@@ -372,13 +358,6 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
372
358
  data-testid="pie-chart-container"
373
359
  trailingContent={
374
360
  <>
375
- { withTooltips && tooltipOpen && tooltipData && (
376
- <TooltipInPortal top={ tooltipTop || 0 } left={ tooltipLeft || 0 }>
377
- <div className={ CHART_SCOPE_CLASS } role="tooltip">
378
- { renderTooltip( { tooltipData } ) }
379
- </div>
380
- </TooltipInPortal>
381
- ) }
382
361
  { htmlChildren }
383
362
  { otherChildren }
384
363
  </>
@@ -398,7 +377,7 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
398
377
  const innerRadius = radius * ( 1 - thickness );
399
378
 
400
379
  return (
401
- <Center ref={ containerRef }>
380
+ <Center ref={ containerRef } className={ styles[ 'pie-semi-circle-chart__plot' ] }>
402
381
  <svg
403
382
  width={ width }
404
383
  height={ height }
@@ -484,6 +463,13 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
484
463
  ) }
485
464
  </Group>
486
465
  </svg>
466
+ { withTooltips && tooltipOpen && tooltipData && (
467
+ <BoundedTooltip top={ tooltipTop || 0 } left={ tooltipLeft || 0 }>
468
+ <div className={ standaloneScopeClass } role="tooltip">
469
+ { renderTooltip( { tooltipData } ) }
470
+ </div>
471
+ </BoundedTooltip>
472
+ ) }
487
473
  </Center>
488
474
  );
489
475
  } }
@@ -362,3 +362,53 @@ describe( 'PieSemiCircleChart', () => {
362
362
  } );
363
363
  } );
364
364
  } );
365
+
366
+ // Chart container at (100, 50); the tooltip box measures 120x40. Everything
367
+ // else the charts measure (wrapper, clipping lookups) reports the container.
368
+ const mockRects = () =>
369
+ jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
370
+ this: Element
371
+ ) {
372
+ const box = this.classList.contains( 'visx-tooltip' );
373
+ return {
374
+ left: box ? 0 : 100,
375
+ top: box ? 0 : 50,
376
+ width: box ? 120 : 400,
377
+ height: box ? 40 : 300,
378
+ right: box ? 120 : 500,
379
+ bottom: box ? 40 : 350,
380
+ x: box ? 0 : 100,
381
+ y: box ? 0 : 50,
382
+ toJSON: () => ( {} ),
383
+ } as DOMRect;
384
+ } );
385
+
386
+ describe( 'PieSemiCircleChart tooltip position', () => {
387
+ afterEach( () => {
388
+ jest.restoreAllMocks();
389
+ } );
390
+
391
+ it( 'places the box relative to the chart container, at the pointer plus the offsets', async () => {
392
+ mockRects();
393
+ const user = userEvent.setup();
394
+ renderPieChart( {
395
+ data: [
396
+ { label: 'A', value: 60, valueDisplay: '60' },
397
+ { label: 'B', value: 40, valueDisplay: '40' },
398
+ ],
399
+ withTooltips: true,
400
+ width: 400,
401
+ } );
402
+
403
+ await user.pointer( {
404
+ target: screen.getAllByTestId( 'pie-segment' )[ 0 ],
405
+ coords: { clientX: 150, clientY: 120 },
406
+ } );
407
+
408
+ // (150, 120) is (50, 70) inside the container; the default -15px vertical
409
+ // offset makes that (50, 55), and the box adds 10px each way.
410
+ expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( {
411
+ transform: 'translate(60px, 65px)',
412
+ } );
413
+ } );
414
+ } );
@@ -8,8 +8,9 @@
8
8
  // The svg displaying inline includes a few px for descenders, which
9
9
  // confuses some browser environments (e.g. headless Chrome) into
10
10
  // constantly growing the container. Setting it to display as block avoids
11
- // that.
12
- svg {
11
+ // that. The tooltip renders in here too, and an inline icon in its rows
12
+ // must stay inline.
13
+ svg:not(:global(.visx-tooltip) svg) {
13
14
  display: block;
14
15
  }
15
16
  }
@@ -1,5 +1,5 @@
1
1
  .svg-empty-state {
2
2
  text-align: center;
3
- font-size: var(--wpds-typography-font-size-md, 13px);
3
+ font-size: var(--wpds-typography-font-size-md);
4
4
  color: var(--a8c-charts-color-label-secondary);
5
5
  }