@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
@@ -2,10 +2,19 @@ import { formatNumberCompact } from '@automattic/number-formatters';
2
2
  import { __, sprintf } from '@wordpress/i18n';
3
3
  import { useMemo } from 'react';
4
4
  import { useDeepMemo } from '../../../hooks';
5
- import { getBandTickValues, getBucketResolution, getFormatter } from '../../private/time-axis';
5
+ import { useChartFormatting } from '../../../providers';
6
+ import { getBucketResolution } from '../../../utils/bucket-info';
7
+ import { createDateFormatter } from '../../../utils/date-formatting';
8
+ import { getBandTickValues, getFormatter } from '../../private/time-axis';
6
9
  import { TruncatedXTickComponent, TruncatedYTickComponent } from './truncated-tick-component';
7
10
  import type { EnhancedDataPoint } from '../../../hooks/use-zero-value-display';
8
- import type { DataPointDate, BaseChartProps, SeriesData, TickResolution } from '../../../types';
11
+ import type {
12
+ DataPointDate,
13
+ BaseChartProps,
14
+ ChartFormatting,
15
+ SeriesData,
16
+ TickResolution,
17
+ } from '../../../types';
9
18
  import type { TickFormatter } from '@visx/axis';
10
19
 
11
20
  /** Outer padding of the category band scale (space at the chart edges). */
@@ -24,7 +33,7 @@ const TOOLTIP_FORMAT_BY_RESOLUTION: Record<
24
33
  Exclude< TickResolution, 'week' >,
25
34
  Intl.DateTimeFormatOptions
26
35
  > = {
27
- hour: { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', hour12: true },
36
+ hour: { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric' },
28
37
  day: { year: 'numeric', month: 'long', day: 'numeric' },
29
38
  month: { year: 'numeric', month: 'long' },
30
39
  year: { year: 'numeric' },
@@ -36,31 +45,33 @@ const TOOLTIP_FORMAT_BY_RESOLUTION: Record<
36
45
  *
37
46
  * @param data - Date-based series, already parsed and sorted by `useChartDataTransform`.
38
47
  * @param tickResolution - Caller-declared bucket resolution, when known.
48
+ * @param formatting - Host locale and time zone.
39
49
  * @return Tooltip label formatter.
40
50
  */
41
- const getTooltipFormatter = ( data: SeriesData[], tickResolution?: TickResolution ) => {
51
+ const getTooltipFormatter = (
52
+ data: SeriesData[],
53
+ tickResolution: TickResolution | undefined,
54
+ formatting: ChartFormatting
55
+ ) => {
42
56
  // Only a declared 'week' reaches this branch: seven-day spacing is
43
57
  // indistinguishable from sparse daily data, so inference reports 'day'.
44
58
  if ( tickResolution === 'week' ) {
59
+ const formatDay = createDateFormatter( TOOLTIP_FORMAT_BY_RESOLUTION.day, formatting );
45
60
  return ( timestamp: number ) =>
46
61
  sprintf(
47
62
  /* translators: %s is the first day of the week the bar covers. */
48
63
  __( 'Week of %s', 'jetpack-charts' ),
49
- new Date( timestamp ).toLocaleDateString( undefined, {
50
- year: 'numeric',
51
- month: 'long',
52
- day: 'numeric',
53
- } )
64
+ formatDay( timestamp )
54
65
  );
55
66
  }
56
67
 
57
- // Fall back to the day format rather than `undefined` options, which would
58
- // print a full locale date-time for an unrecognised `tickResolution`.
68
+ // Fall back to the day format rather than empty options, which would print a
69
+ // bare numeric date for an unrecognized `tickResolution`.
59
70
  const format =
60
71
  TOOLTIP_FORMAT_BY_RESOLUTION[ getBucketResolution( data, tickResolution ) ] ??
61
72
  TOOLTIP_FORMAT_BY_RESOLUTION.day;
62
73
 
63
- return ( timestamp: number ) => new Date( timestamp ).toLocaleString( undefined, format );
74
+ return createDateFormatter( format, formatting );
64
75
  };
65
76
 
66
77
  const identity = ( label: string ) => label;
@@ -152,6 +163,7 @@ export function useBarChartOptions(
152
163
  // Callers reasonably pass an object literal, which is a fresh reference every
153
164
  // render and would defeat every memo below.
154
165
  const stableOptions = useDeepMemo( options );
166
+ const formatting = useChartFormatting();
155
167
 
156
168
  // `labelOverflow` and `tickResolution` are consumed by this hook rather than
157
169
  // forwarded — visx has an axis prop for neither — and `tickFormat` is merged
@@ -202,11 +214,11 @@ export function useBarChartOptions(
202
214
  // formatter, which narrows with the overall span as well as the bucket
203
215
  // size; the tooltip stays at the bucket's own granularity.
204
216
  const hasLabels = Boolean( data?.[ 0 ]?.data?.[ 0 ]?.label );
205
- const timeTickFormatter = hasLabels ? null : getFormatter( data, tickResolution );
217
+ const timeTickFormatter = hasLabels ? null : getFormatter( data, tickResolution, formatting );
206
218
  const labelFormatter = timeTickFormatter ? byBucket( timeTickFormatter ) : identity;
207
219
  const tooltipDatumFormatter = hasLabels
208
220
  ? labelFormatter
209
- : byBucket( getTooltipFormatter( data, tickResolution ) );
221
+ : byBucket( getTooltipFormatter( data, tickResolution, formatting ) );
210
222
  const valueFormatter = formatNumberCompact as TickFormatter< unknown >;
211
223
 
212
224
  const bandDomain = timeTickFormatter ? getBandDomain( data, isSeriesRendered ) : null;
@@ -244,7 +256,7 @@ export function useBarChartOptions(
244
256
  yScale: bandScale,
245
257
  },
246
258
  };
247
- }, [ data, tickResolution, isSeriesRendered ] );
259
+ }, [ data, tickResolution, isSeriesRendered, formatting ] );
248
260
 
249
261
  return useMemo( () => {
250
262
  const orientationKey = horizontal ? 'horizontal' : 'vertical';
@@ -2,6 +2,7 @@ import { formatNumberCompact } from '@automattic/number-formatters';
2
2
  import { Group } from '@visx/group';
3
3
  import { createScale, scaleBand } from '@visx/scale';
4
4
  import { Text, type TextProps } from '@visx/text';
5
+ import clsx from 'clsx';
5
6
  import { useContext, useMemo } from 'react';
6
7
  import { GlobalChartsContext, GlobalChartsProvider, useGlobalChartsContext } from '../../providers';
7
8
  import { isValidHexColor, lightenHexColor } from '../../utils';
@@ -231,6 +232,7 @@ const BarListChartInternal: FC< BarListChartProps > = ( {
231
232
  bottom: 0,
232
233
  top: 0,
233
234
  },
235
+ className,
234
236
  ...rest
235
237
  } ) => {
236
238
  const { getElementStyles } = useGlobalChartsContext();
@@ -298,6 +300,9 @@ const BarListChartInternal: FC< BarListChartProps > = ( {
298
300
  <BarChartUnresponsive
299
301
  orientation="horizontal"
300
302
  gridVisibility={ 'none' }
303
+ // The root carries `bar-chart` too, since a bar list renders through one. This class is
304
+ // how a consumer reaches a bar list without reaching every bar chart.
305
+ className={ clsx( 'bar-list-chart', className ) }
301
306
  data={ tintedData }
302
307
  width={ width }
303
308
  height={ height }
@@ -1,4 +1,5 @@
1
1
  .conversion-funnel-chart {
2
+ position: relative;
2
3
 
3
4
  &--loading {
4
5
  opacity: 0.6;
@@ -14,21 +15,29 @@
14
15
  overflow: hidden;
15
16
  color: var(--a8c-charts-color-label);
16
17
  text-overflow: ellipsis;
17
- font-size: var(--wpds-typography-font-size-xl, 20px);
18
+ font-size: var(--wpds-typography-font-size-xl);
18
19
  font-style: normal;
19
- font-weight: var(--wpds-typography-font-weight-emphasis, 600);
20
- line-height: var(--wpds-typography-line-height-sm, 20px);
20
+ font-weight: var(--wpds-typography-font-weight-emphasis);
21
+ line-height: var(--wpds-typography-line-height-sm);
21
22
  margin: 0;
22
23
  }
23
24
 
24
25
  .change-indicator {
25
26
  overflow: hidden;
26
27
  text-overflow: ellipsis;
27
- font-size: var(--wpds-typography-font-size-md, 13px);
28
+ font-size: var(--wpds-typography-font-size-md);
28
29
  font-style: normal;
29
- font-weight: var(--wpds-typography-font-weight-emphasis, 600);
30
- line-height: var(--wpds-typography-line-height-sm, 20px);
30
+ font-weight: var(--wpds-typography-font-weight-emphasis);
31
+ line-height: var(--wpds-typography-line-height-sm);
31
32
  margin: 0;
33
+
34
+ &--positive {
35
+ color: var(--a8c-charts-color-trend-up);
36
+ }
37
+
38
+ &--negative {
39
+ color: var(--a8c-charts-color-trend-down);
40
+ }
32
41
  }
33
42
 
34
43
  .funnel-container {
@@ -44,8 +53,8 @@
44
53
 
45
54
  &--animated {
46
55
  transition:
47
- opacity var(--wpds-motion-duration-lg, 300ms)
48
- var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
56
+ opacity var(--wpds-motion-duration-lg)
57
+ var(--wpds-motion-easing-subtle);
49
58
  }
50
59
 
51
60
  &--blurred {
@@ -55,9 +64,9 @@
55
64
 
56
65
  .step-label {
57
66
  color: var(--a8c-charts-color-label-secondary);
58
- font-size: var(--wpds-typography-font-size-sm, 12px);
59
- font-weight: var(--wpds-typography-font-weight-default, 400);
60
- line-height: var(--wpds-typography-line-height-xs, 16px);
67
+ font-size: var(--wpds-typography-font-size-sm);
68
+ font-weight: var(--wpds-typography-font-weight-default);
69
+ line-height: var(--wpds-typography-line-height-xs);
61
70
  white-space: nowrap;
62
71
  overflow: hidden;
63
72
  text-overflow: ellipsis;
@@ -65,14 +74,15 @@
65
74
 
66
75
  .step-rate {
67
76
  color: var(--a8c-charts-color-label);
68
- font-size: var(--wpds-typography-font-size-md, 13px);
69
- font-weight: var(--wpds-typography-font-weight-emphasis, 600);
70
- line-height: var(--wpds-typography-line-height-xs, 16px);
77
+ font-size: var(--wpds-typography-font-size-md);
78
+ font-weight: var(--wpds-typography-font-weight-emphasis);
79
+ line-height: var(--wpds-typography-line-height-xs);
71
80
  }
72
81
 
73
82
  .bar-container {
74
83
  flex: 1;
75
84
  border-radius: var(--a8c-charts-border-radius-bar);
85
+ background-color: var(--a8c-charts-color-surface-secondary);
76
86
  position: relative;
77
87
  cursor: pointer;
78
88
  }
@@ -108,30 +118,30 @@
108
118
  background: var(--a8c-charts-color-surface);
109
119
 
110
120
  // Override .visx-tooltip inline styles.
111
- border-radius: var(--wpds-border-radius-md, 4px) !important;
112
- padding: var(--wpds-dimension-padding-md, 12px) !important;
121
+ border-radius: var(--wpds-border-radius-md) !important;
122
+ padding: var(--wpds-dimension-padding-md) !important;
113
123
  box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005) !important;
114
124
 
115
125
  }
116
126
 
117
127
  .tooltip-title {
118
128
  color: var(--a8c-charts-color-label);
119
- font-size: var(--wpds-typography-font-size-sm, 12px);
129
+ font-size: var(--wpds-typography-font-size-sm);
120
130
  font-style: normal;
121
- font-weight: var(--wpds-typography-font-weight-default, 400);
122
- line-height: var(--wpds-typography-line-height-xs, 16px);
131
+ font-weight: var(--wpds-typography-font-weight-default);
132
+ line-height: var(--wpds-typography-line-height-xs);
123
133
  }
124
134
 
125
135
  .tooltip-content {
126
136
  color: var(--a8c-charts-color-label);
127
- font-size: var(--wpds-typography-font-size-md, 13px);
137
+ font-size: var(--wpds-typography-font-size-md);
128
138
  font-style: normal;
129
- font-weight: var(--wpds-typography-font-weight-emphasis, 600);
130
- line-height: var(--wpds-typography-line-height-sm, 20px);
139
+ font-weight: var(--wpds-typography-font-weight-emphasis);
140
+ line-height: var(--wpds-typography-line-height-sm);
131
141
  }
132
142
 
133
143
  .empty-state {
134
144
  text-align: center;
135
145
  color: var(--a8c-charts-color-label-secondary);
136
- font-size: var(--wpds-typography-font-size-lg, 15px);
146
+ font-size: var(--wpds-typography-font-size-lg);
137
147
  }
@@ -1,7 +1,8 @@
1
- import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
1
+ import { useTooltip } from '@visx/tooltip';
2
2
  import { Stack } from '@wordpress/ui';
3
3
  import clsx from 'clsx';
4
4
  import { type FC, useRef, useMemo, useEffect, useState, useCallback, useContext } from 'react';
5
+ import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
5
6
  import { usePrefersReducedMotion } from '../../hooks';
6
7
  import {
7
8
  GlobalChartsProvider,
@@ -9,11 +10,11 @@ import {
9
10
  ChartScopeContext,
10
11
  useChartId,
11
12
  useChartRegistration,
12
- useGlobalChartsTheme,
13
13
  useGlobalChartsContext,
14
14
  } from '../../providers';
15
- import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
16
- import { formatPercentage, hexToRgba } from '../../utils';
15
+ import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
16
+ import { useStandaloneScopeClass } from '../../providers/chart-scope';
17
+ import { formatPercentage } from '../../utils';
17
18
  import styles from './conversion-funnel-chart.module.scss';
18
19
  import { useFunnelSelection } from './private';
19
20
  import type { FunnelStep, ConversionFunnelChartProps } from './types';
@@ -53,7 +54,6 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
53
54
  renderTooltip,
54
55
  } ) => {
55
56
  const chartId = useChartId( providedChartId );
56
- const { conversionFunnelChart: conversionFunnelChartSettings } = useGlobalChartsTheme();
57
57
  const { getElementStyles, isColorPaletteResolved } = useGlobalChartsContext();
58
58
  const chartRef = useRef< HTMLDivElement >( null );
59
59
  const selectedBarRef = useRef< HTMLDivElement | null >( null );
@@ -66,27 +66,9 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
66
66
  // Use custom hook for selection management
67
67
  const { handleBarClick, handleBarKeyDown, clearSelection, getStepState } =
68
68
  useFunnelSelection( hideTooltip );
69
- const {
70
- containerRef: portalContainerRef,
71
- TooltipInPortal,
72
- containerBounds,
73
- } = useTooltipInPortal( {
74
- // use TooltipWithBounds for boundary detection
75
- detectBounds: true,
76
- // when tooltip containers are scrolled, this will correctly update the Tooltip position
77
- scroll: true,
78
- } );
79
-
80
- // `useTooltipInPortal` returns a fresh `containerRef` closure on every render, so depending on it directly would give this callback a new identity every render — React then detaches and reattaches the ref on every commit, flushing the scope context to null and back. Reading it through a ref keeps the callback's identity genuinely stable.
81
- const portalContainerRefRef = useRef( portalContainerRef );
82
- useEffect( () => {
83
- portalContainerRefRef.current = portalContainerRef;
84
- }, [ portalContainerRef ] );
85
-
86
- // Stable identity so React doesn't detach/reattach (and re-render the scope context) on every commit. Keep all three assignments, in the same order, and keep it firing on unmount (node === null).
69
+ const standaloneScopeClass = useStandaloneScopeClass();
70
+ // Stable identity so React doesn't detach/reattach (and re-render the scope context) on every commit. Keep it firing on unmount (node === null).
87
71
  const setChartRef = useCallback( ( node: HTMLDivElement | null ) => {
88
- // Set containerRef for @visx coordinate system
89
- portalContainerRefRef.current( node );
90
72
  chartRef.current = node;
91
73
  setScopeNode( node );
92
74
  }, [] );
@@ -110,43 +92,32 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
110
92
  [ showTooltip ]
111
93
  );
112
94
 
113
- // Helper function to get tooltip coordinates for mouse events
114
- // Use clientX/Y and subtract containerBounds to cancel out any stale offset.
115
- // TooltipInPortal calculates: tooltipLeft + containerBounds.left + scrollX
116
- // By passing (clientX - containerBounds.left), we get correct page coordinates
117
- // regardless of whether bounds are stale (e.g., after dashboard customization).
118
- const getMouseTooltipCoords = useCallback(
119
- ( event: React.MouseEvent ) => {
120
- // Don't return coords until container bounds are measured
121
- if ( containerBounds.width === 0 || containerBounds.height === 0 ) {
122
- return null;
123
- }
95
+ // The chart root positions the tooltip, so coordinates are taken relative to it.
96
+ const getMouseTooltipCoords = useCallback( ( event: React.MouseEvent ) => {
97
+ const bounds = chartRef.current?.getBoundingClientRect();
98
+ if ( ! bounds ) {
99
+ return null;
100
+ }
124
101
 
125
- return {
126
- x: event.clientX - containerBounds.left,
127
- y: event.clientY - containerBounds.top,
128
- };
129
- },
130
- [ containerBounds.width, containerBounds.height, containerBounds.left, containerBounds.top ]
131
- );
132
-
133
- // Helper function to get tooltip coordinates for keyboard events
134
- // Use fresh getBoundingClientRect() and subtract containerBounds to cancel out stale offset.
135
- const getKeyboardTooltipCoords = useCallback(
136
- ( event: React.KeyboardEvent ) => {
137
- // Don't return coords until container bounds are measured
138
- if ( containerBounds.width === 0 || containerBounds.height === 0 ) {
139
- return null;
140
- }
102
+ return {
103
+ x: event.clientX - bounds.left,
104
+ y: event.clientY - bounds.top,
105
+ };
106
+ }, [] );
141
107
 
142
- const rect = event.currentTarget.getBoundingClientRect();
143
- // Calculate center of element in viewport coordinates, then subtract containerBounds
144
- const x = rect.left + rect.width / 2 - containerBounds.left;
145
- const y = rect.top - containerBounds.top;
146
- return { x, y };
147
- },
148
- [ containerBounds.width, containerBounds.height, containerBounds.left, containerBounds.top ]
149
- );
108
+ // Keyboard interaction anchors the tooltip at the top centre of the focused step.
109
+ const getKeyboardTooltipCoords = useCallback( ( event: React.KeyboardEvent ) => {
110
+ const bounds = chartRef.current?.getBoundingClientRect();
111
+ if ( ! bounds ) {
112
+ return null;
113
+ }
114
+
115
+ const rect = event.currentTarget.getBoundingClientRect();
116
+ return {
117
+ x: rect.left + rect.width / 2 - bounds.left,
118
+ y: rect.top - bounds.top,
119
+ };
120
+ }, [] );
150
121
 
151
122
  // Helper function to handle step interaction (both click and keyboard)
152
123
  const handleStepInteraction = useCallback(
@@ -246,32 +217,26 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
246
217
  // Resolve height: explicit height prop > style.height > default 100%
247
218
  const resolvedHeight = height ?? style?.height ?? '100%';
248
219
 
249
- // Get component settings from theme with fallbacks
250
- const { primaryColor, backgroundColor, positiveChangeColor, negativeChangeColor } =
251
- conversionFunnelChartSettings;
252
-
253
- // Resolve bar color using getElementStyles with primaryColor as override
220
+ // The bar takes the first palette slot; move `--a8c-charts-color-series-1` to change it.
254
221
  const { color: barColor } = getElementStyles
255
- ? getElementStyles( {
256
- index: 0,
257
- overrideColor: primaryColor,
258
- } )
259
- : { color: primaryColor || '#000000' };
222
+ ? getElementStyles( { index: 0 } )
223
+ : { color: '#000000' };
260
224
 
261
- // Determine change indicator color
262
225
  const isPositiveChange = changeIndicator?.startsWith( '+' );
263
- const changeColor = isPositiveChange ? positiveChangeColor : negativeChangeColor;
264
-
265
- // Create light background version of primary color if not set
266
- const barBackgroundColor =
267
- backgroundColor || hexToRgba( barColor, 0.08 ) || 'rgba(0, 0, 0, 0.08)';
226
+ // Only for `renderMainMetric`, which hands a consumer's own markup something to paint with.
227
+ const changeColor = isPositiveChange ? CATALOG_POINTERS.trendUp : CATALOG_POINTERS.trendDown;
268
228
 
269
229
  // Default main metric rendering function
270
230
  const renderDefaultMainMetric = () => (
271
231
  <>
272
232
  <span className={ styles[ 'main-rate' ] }>{ formatPercentage( mainRate ) }</span>
273
233
  { changeIndicator && (
274
- <span className={ styles[ 'change-indicator' ] } style={ { color: changeColor } }>
234
+ <span
235
+ className={ clsx(
236
+ styles[ 'change-indicator' ],
237
+ styles[ isPositiveChange ? 'change-indicator--positive' : 'change-indicator--negative' ]
238
+ ) }
239
+ >
275
240
  { changeIndicator }
276
241
  </span>
277
242
  ) }
@@ -322,6 +287,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
322
287
  data-testid="conversion-funnel-chart"
323
288
  ref={ setScopeNode }
324
289
  className={ clsx(
290
+ 'conversion-funnel-chart',
325
291
  styles[ 'conversion-funnel-chart' ],
326
292
  loading && styles[ 'conversion-funnel-chart--loading' ],
327
293
  className
@@ -339,112 +305,104 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
339
305
  const maxRate = Math.max( ...steps.map( step => step.rate ) );
340
306
 
341
307
  return (
342
- <>
343
- <Stack
344
- direction="column"
345
- gap="xl"
346
- data-testid="conversion-funnel-chart"
347
- ref={ setChartRef }
348
- className={ clsx(
349
- styles[ 'conversion-funnel-chart' ],
350
- loading && styles[ 'conversion-funnel-chart--loading' ],
351
- className
308
+ <Stack
309
+ direction="column"
310
+ gap="xl"
311
+ data-testid="conversion-funnel-chart"
312
+ ref={ setChartRef }
313
+ className={ clsx(
314
+ 'conversion-funnel-chart',
315
+ styles[ 'conversion-funnel-chart' ],
316
+ loading && styles[ 'conversion-funnel-chart--loading' ],
317
+ className
318
+ ) }
319
+ style={ { ...style, height: resolvedHeight } }
320
+ >
321
+ <ChartScopeContext.Provider value={ scopeNode }>
322
+ { /* Main Metric */ }
323
+ { renderMainMetric ? (
324
+ renderMainMetric( {
325
+ mainRate,
326
+ changeIndicator,
327
+ className: styles[ 'main-metric' ],
328
+ changeColor,
329
+ } )
330
+ ) : (
331
+ <Stack direction="row" align="baseline" gap="sm" className={ styles[ 'main-metric' ] }>
332
+ { renderDefaultMainMetric() }
333
+ </Stack>
352
334
  ) }
353
- style={ { ...style, height: resolvedHeight } }
354
- >
355
- <ChartScopeContext.Provider value={ scopeNode }>
356
- { /* Main Metric */ }
357
- { renderMainMetric ? (
358
- renderMainMetric( {
359
- mainRate,
360
- changeIndicator,
361
- className: styles[ 'main-metric' ],
362
- changeColor,
363
- } )
364
- ) : (
365
- <Stack direction="row" align="baseline" gap="sm" className={ styles[ 'main-metric' ] }>
366
- { renderDefaultMainMetric() }
367
- </Stack>
368
- ) }
369
335
 
370
- { /* Funnel Steps */ }
371
- <Stack
372
- direction="row"
373
- align="flex-end"
374
- gap="lg"
375
- className={ styles[ 'funnel-container' ] }
376
- >
377
- { steps.map( ( step, index ) => {
378
- const barHeight = ( step.rate / maxRate ) * 100;
379
- const { isBlurred } = getStepState( step.id );
380
-
381
- return (
336
+ { /* Funnel Steps */ }
337
+ <Stack direction="row" align="flex-end" gap="lg" className={ styles[ 'funnel-container' ] }>
338
+ { steps.map( ( step, index ) => {
339
+ const barHeight = ( step.rate / maxRate ) * 100;
340
+ const { isBlurred } = getStepState( step.id );
341
+
342
+ return (
343
+ <Stack
344
+ key={ step.id }
345
+ direction="column"
346
+ data-testid="funnel-step"
347
+ className={ clsx(
348
+ styles[ 'funnel-step' ],
349
+ isColorPaletteResolved && styles[ 'funnel-step--animated' ],
350
+ isBlurred && styles[ 'funnel-step--blurred' ]
351
+ ) }
352
+ gap="xl"
353
+ >
354
+ { /* Step Label and Rate */ }
355
+ <Stack direction="column" gap="xs">
356
+ { renderStepLabel ? (
357
+ renderStepLabel( {
358
+ step,
359
+ index,
360
+ className: styles[ 'step-label' ],
361
+ } )
362
+ ) : (
363
+ <span className={ styles[ 'step-label' ] }>{ step.label }</span>
364
+ ) }
365
+ { renderStepRate ? (
366
+ renderStepRate( {
367
+ step,
368
+ index,
369
+ className: styles[ 'step-rate' ],
370
+ } )
371
+ ) : (
372
+ <span className={ styles[ 'step-rate' ] }>
373
+ { formatPercentage( step.rate ) }
374
+ </span>
375
+ ) }
376
+ </Stack>
377
+
378
+ { /* Funnel Bar */ }
382
379
  <Stack
383
- key={ step.id }
384
380
  direction="column"
385
- data-testid="funnel-step"
386
- className={ clsx(
387
- styles[ 'funnel-step' ],
388
- isColorPaletteResolved && styles[ 'funnel-step--animated' ],
389
- isBlurred && styles[ 'funnel-step--blurred' ]
390
- ) }
391
- gap="xl"
381
+ justify="flex-end"
382
+ className={ styles[ 'bar-container' ] }
383
+ onClick={ stepHandlers.get( step.id )?.onClick }
384
+ onKeyDown={ stepHandlers.get( step.id )?.onKeyDown }
385
+ role="button"
386
+ tabIndex={ isBlurred ? -1 : 0 }
387
+ aria-label={ step.label }
392
388
  >
393
- { /* Step Label and Rate */ }
394
- <Stack direction="column" gap="xs">
395
- { renderStepLabel ? (
396
- renderStepLabel( {
397
- step,
398
- index,
399
- className: styles[ 'step-label' ],
400
- } )
401
- ) : (
402
- <span className={ styles[ 'step-label' ] }>{ step.label }</span>
403
- ) }
404
- { renderStepRate ? (
405
- renderStepRate( {
406
- step,
407
- index,
408
- className: styles[ 'step-rate' ],
409
- } )
410
- ) : (
411
- <span className={ styles[ 'step-rate' ] }>
412
- { formatPercentage( step.rate ) }
413
- </span>
414
- ) }
415
- </Stack>
416
-
417
- { /* Funnel Bar */ }
418
- <Stack
419
- direction="column"
420
- justify="flex-end"
421
- className={ styles[ 'bar-container' ] }
422
- onClick={ stepHandlers.get( step.id )?.onClick }
423
- onKeyDown={ stepHandlers.get( step.id )?.onKeyDown }
424
- role="button"
425
- tabIndex={ isBlurred ? -1 : 0 }
426
- aria-label={ step.label }
427
- style={ { backgroundColor: barBackgroundColor } }
428
- >
429
- <div
430
- className={ clsx( styles[ 'funnel-bar' ], {
431
- [ styles[ 'funnel-bar--animated' ] ]:
432
- animation && ! loading && ! prefersReducedMotion,
433
- } ) }
434
- style={ {
435
- height: `${ barHeight }%`,
436
- backgroundColor: barColor,
437
- } }
438
- />
439
- </Stack>
389
+ <div
390
+ className={ clsx( styles[ 'funnel-bar' ], {
391
+ [ styles[ 'funnel-bar--animated' ] ]:
392
+ animation && ! loading && ! prefersReducedMotion,
393
+ } ) }
394
+ style={ {
395
+ height: `${ barHeight }%`,
396
+ backgroundColor: barColor,
397
+ } }
398
+ />
440
399
  </Stack>
441
- );
442
- } ) }
443
- </Stack>
444
- </ChartScopeContext.Provider>
445
- </Stack>
400
+ </Stack>
401
+ );
402
+ } ) }
403
+ </Stack>
404
+ </ChartScopeContext.Provider>
446
405
 
447
- { /* Tooltip Portal */ }
448
406
  { tooltipOpen &&
449
407
  tooltipData &&
450
408
  ( () => {
@@ -462,18 +420,16 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
462
420
  if ( ! tooltipContent ) return null;
463
421
 
464
422
  return (
465
- <TooltipInPortal
466
- // set this to random so it correctly updates with parent bounds
467
- key={ Math.random() }
423
+ <BoundedTooltip
468
424
  top={ tooltipTop }
469
425
  left={ tooltipLeft }
470
- className={ clsx( CHART_SCOPE_CLASS, styles[ 'tooltip-wrapper' ] ) }
426
+ className={ clsx( standaloneScopeClass, styles[ 'tooltip-wrapper' ] ) }
471
427
  >
472
428
  { tooltipContent }
473
- </TooltipInPortal>
429
+ </BoundedTooltip>
474
430
  );
475
431
  } )() }
476
- </>
432
+ </Stack>
477
433
  );
478
434
  };
479
435