@automattic/charts 3.2.0 → 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.
- package/CHANGELOG.md +29 -0
- package/TOKENS.md +53 -58
- package/dist/index.cjs +1012 -542
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +72 -29
- package/dist/index.d.cts +151 -179
- package/dist/index.d.ts +151 -179
- package/dist/index.js +1013 -544
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- package/src/charts/area-chart/area-chart.tsx +39 -25
- package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
- package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
- package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
- package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
- package/src/charts/area-chart/types.ts +4 -7
- package/src/charts/bar-chart/bar-chart.tsx +2 -1
- package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
- package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
- package/src/charts/geo-chart/geo-chart.module.scss +1 -0
- package/src/charts/geo-chart/geo-chart.tsx +5 -10
- package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
- package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
- package/src/charts/heatmap-chart/types.ts +2 -2
- package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
- package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
- package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
- package/src/charts/line-chart/line-chart.module.scss +10 -18
- package/src/charts/line-chart/line-chart.tsx +93 -24
- package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
- package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +27 -3
- package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
- package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
- package/src/charts/line-chart/types.ts +4 -1
- package/src/charts/pie-chart/pie-chart.module.scss +13 -1
- package/src/charts/pie-chart/pie-chart.tsx +30 -40
- package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
- package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
- package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
- package/src/charts/private/test/axis-formatting.test.tsx +85 -0
- package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
- package/src/charts/private/test/time-axis-options.test.ts +267 -0
- package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
- package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
- package/src/charts/private/test/time-axis.test.ts +9 -69
- package/src/charts/private/time-axis-options.ts +106 -0
- package/src/charts/private/time-axis.ts +318 -136
- package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
- package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
- package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
- package/src/charts/sparkline/types.ts +1 -1
- package/src/components/legend/private/base-legend.module.scss +9 -5
- package/src/components/tooltip/accessible-tooltip.tsx +13 -15
- package/src/components/tooltip/base-tooltip.module.scss +3 -3
- package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +2 -3
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
- package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
- package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
- package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
- package/src/hooks/use-xychart-theme.ts +25 -9
- package/src/index.ts +4 -0
- package/src/providers/chart-context/global-charts-provider.tsx +37 -38
- package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
- package/src/providers/chart-context/index.ts +1 -0
- package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
- package/src/providers/chart-context/private/series-palette.ts +3 -13
- package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
- package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
- package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -25
- package/src/providers/chart-context/themes.ts +7 -52
- package/src/providers/chart-context/types.ts +3 -0
- package/src/styles/chart-scope.scss +39 -62
- package/src/styles/test/chart-scope.test.ts +19 -47
- package/src/test-utils/series-fixtures.ts +31 -0
- package/src/types.ts +79 -166
- package/src/utils/bucket-info.ts +87 -0
- package/src/utils/color-utils.ts +0 -27
- package/src/utils/date-formatting.ts +174 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/test/bucket-info.test.ts +185 -0
- package/src/utils/test/color-utils.test.ts +1 -48
- package/src/utils/test/date-formatting.test.ts +89 -0
- package/src/utils/test/get-styles.test.ts +10 -7
- package/src/utils/test/merge-themes.test.ts +18 -46
- package/src/visx/types.ts +29 -2
- package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
- package/src/providers/chart-context/private/theme-override-vars.ts +0 -131
- package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
- package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
- package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -183
- package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
- 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 {
|
|
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 {
|
|
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'
|
|
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 = (
|
|
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
|
-
|
|
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
|
|
58
|
-
//
|
|
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 (
|
|
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
|
|
18
|
+
font-size: var(--wpds-typography-font-size-xl);
|
|
18
19
|
font-style: normal;
|
|
19
|
-
font-weight: var(--wpds-typography-font-weight-emphasis
|
|
20
|
-
line-height: var(--wpds-typography-line-height-sm
|
|
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
|
|
28
|
+
font-size: var(--wpds-typography-font-size-md);
|
|
28
29
|
font-style: normal;
|
|
29
|
-
font-weight: var(--wpds-typography-font-weight-emphasis
|
|
30
|
-
line-height: var(--wpds-typography-line-height-sm
|
|
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
|
|
48
|
-
var(--wpds-motion-easing-subtle
|
|
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
|
|
59
|
-
font-weight: var(--wpds-typography-font-weight-default
|
|
60
|
-
line-height: var(--wpds-typography-line-height-xs
|
|
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
|
|
69
|
-
font-weight: var(--wpds-typography-font-weight-emphasis
|
|
70
|
-
line-height: var(--wpds-typography-line-height-xs
|
|
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
|
|
112
|
-
padding: var(--wpds-dimension-padding-md
|
|
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
|
|
129
|
+
font-size: var(--wpds-typography-font-size-sm);
|
|
120
130
|
font-style: normal;
|
|
121
|
-
font-weight: var(--wpds-typography-font-weight-default
|
|
122
|
-
line-height: var(--wpds-typography-line-height-xs
|
|
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
|
|
137
|
+
font-size: var(--wpds-typography-font-size-md);
|
|
128
138
|
font-style: normal;
|
|
129
|
-
font-weight: var(--wpds-typography-font-weight-emphasis
|
|
130
|
-
line-height: var(--wpds-typography-line-height-sm
|
|
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
|
|
146
|
+
font-size: var(--wpds-typography-font-size-lg);
|
|
137
147
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { useTooltip
|
|
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 {
|
|
16
|
-
import {
|
|
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
|
-
|
|
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
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return
|
|
147
|
-
}
|
|
148
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
386
|
-
className={
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
444
|
-
</
|
|
445
|
-
</
|
|
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
|
-
<
|
|
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(
|
|
426
|
+
className={ clsx( standaloneScopeClass, styles[ 'tooltip-wrapper' ] ) }
|
|
471
427
|
>
|
|
472
428
|
{ tooltipContent }
|
|
473
|
-
</
|
|
429
|
+
</BoundedTooltip>
|
|
474
430
|
);
|
|
475
431
|
} )() }
|
|
476
|
-
|
|
432
|
+
</Stack>
|
|
477
433
|
);
|
|
478
434
|
};
|
|
479
435
|
|