@automattic/charts 4.11.1 → 4.13.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 +24 -0
- package/dist/index.cjs +1412 -787
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +59 -25
- package/dist/index.d.cts +64 -120
- package/dist/index.d.ts +64 -120
- package/dist/index.js +1400 -765
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/charts/area-chart/area-chart.tsx +16 -6
- package/src/charts/area-chart/test/area-chart.test.tsx +42 -9
- package/src/charts/bar-chart/bar-chart.tsx +143 -142
- package/src/charts/bar-chart/index.ts +3 -0
- package/src/charts/bar-chart/private/band-tooltip.tsx +41 -15
- package/src/charts/bar-chart/private/bar-layer.tsx +132 -0
- package/src/charts/bar-chart/private/comparison-bars-geometry.ts +22 -34
- package/src/charts/bar-chart/private/comparison-bars.tsx +9 -18
- package/src/charts/bar-chart/private/comparison-constants.ts +4 -10
- package/src/charts/bar-chart/private/grouped-bars.ts +68 -0
- package/src/charts/bar-chart/private/index.ts +7 -1
- package/src/charts/bar-chart/private/overlap-bars.ts +183 -0
- package/src/charts/bar-chart/private/selected-bar-ring.tsx +84 -0
- package/src/charts/bar-chart/private/slot-tooltip-members.tsx +28 -0
- package/src/charts/bar-chart/private/slotted-bar-series.tsx +84 -0
- package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +4 -11
- package/src/charts/bar-chart/private/test/comparison-bars.test.tsx +2 -3
- package/src/charts/bar-chart/private/test/grouped-bars.test.ts +87 -0
- package/src/charts/bar-chart/private/test/overlap-bars.test.ts +159 -0
- package/src/charts/bar-chart/private/use-bar-stops.ts +87 -0
- package/src/charts/bar-chart/private/use-overlap-slots.ts +213 -0
- package/src/charts/bar-chart/private/value-domain.ts +0 -10
- package/src/charts/bar-chart/test/bar-chart-overlap.test.tsx +347 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +181 -19
- package/src/charts/bar-chart/test/use-bar-legend-items.test.tsx +81 -0
- package/src/charts/bar-chart/types.ts +3 -0
- package/src/charts/bar-chart/use-bar-colors.ts +46 -0
- package/src/charts/bar-chart/use-bar-legend-items.ts +87 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +24 -11
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +235 -182
- package/src/charts/conversion-funnel-chart/index.ts +2 -0
- package/src/charts/conversion-funnel-chart/private/index.ts +1 -0
- package/src/charts/conversion-funnel-chart/private/use-funnel-selection.ts +9 -47
- package/src/charts/conversion-funnel-chart/private/use-roving-step-focus.ts +83 -0
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +312 -92
- package/src/charts/conversion-funnel-chart/test/use-funnel-selection.test.ts +23 -188
- package/src/charts/conversion-funnel-chart/types.ts +22 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +41 -1
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +101 -30
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +47 -0
- package/src/charts/line-chart/line-chart.tsx +25 -18
- package/src/charts/line-chart/test/line-chart.test.tsx +240 -1
- package/src/charts/pie-chart/pie-chart.tsx +7 -2
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +7 -2
- package/src/charts/private/pie-keyboard-navigation/index.ts +0 -1
- package/src/charts/private/{pie-keyboard-navigation/pie-keyboard-navigation.module.scss → selected-ring/selected-ring.module.scss} +11 -6
- package/src/charts/private/selected-ring/selected-ring.tsx +73 -0
- package/src/charts/private/{pie-keyboard-navigation/test/selected-segment-ring.test.ts → selected-ring/test/selected-ring.test.ts} +1 -1
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +20 -0
- package/src/components/legend/hooks/use-chart-legend-items.ts +27 -3
- package/src/components/tooltip/private/at-nearest-date.ts +49 -0
- package/src/components/tooltip/private/keyboard-stops.ts +67 -0
- package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +10 -2
- package/src/components/tooltip/test/keyboard-stops.test.ts +36 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +135 -1
- package/src/components/tooltip/xy-chart-tooltip.tsx +11 -14
- package/src/index.ts +10 -2
- package/src/providers/chart-context/global-charts-provider.tsx +2 -0
- package/src/providers/chart-context/test/chart-context.test.tsx +0 -13
- package/src/providers/chart-context/themes.ts +0 -1
- package/src/providers/chart-context/types.ts +2 -0
- package/src/test-utils/selected-bar-ring.ts +14 -0
- package/src/types.ts +14 -5
- package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +0 -47
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useTooltip } from '@visx/tooltip';
|
|
2
|
-
import { __ } from '@wordpress/i18n';
|
|
2
|
+
import { __, _n, sprintf } from '@wordpress/i18n';
|
|
3
3
|
import { Stack } from '@wordpress/ui';
|
|
4
4
|
import clsx from 'clsx';
|
|
5
|
-
import { type FC, useRef, useMemo,
|
|
5
|
+
import { type FC, useRef, useMemo, useState, useCallback, useContext, useId } from 'react';
|
|
6
6
|
import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
|
|
7
7
|
import { useElementSize, usePrefersReducedMotion } from '../../hooks';
|
|
8
8
|
import {
|
|
@@ -15,33 +15,65 @@ import {
|
|
|
15
15
|
} from '../../providers';
|
|
16
16
|
import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
|
|
17
17
|
import { useStandaloneScopeClass } from '../../providers/chart-scope';
|
|
18
|
-
import { formatPercentage } from '../../utils';
|
|
18
|
+
import { formatMetricValue, formatPercentage } from '../../utils';
|
|
19
19
|
import styles from './conversion-funnel-chart.module.scss';
|
|
20
|
-
import { useFunnelSelection } from './private';
|
|
21
|
-
import type { FunnelStep, ConversionFunnelChartProps } from './types';
|
|
20
|
+
import { useFunnelSelection, useRovingStepFocus } from './private';
|
|
21
|
+
import type { FunnelStep, FunnelTooltipContent, ConversionFunnelChartProps } from './types';
|
|
22
22
|
|
|
23
23
|
/** Chart height (px) below which the funnel tightens its gaps so the bars keep their room. */
|
|
24
24
|
const SHORT_CHART_HEIGHT = 180;
|
|
25
25
|
|
|
26
|
+
const formatStepCount = ( count: number ) =>
|
|
27
|
+
sprintf(
|
|
28
|
+
/* translators: %s: Number of items that reached the funnel step. */
|
|
29
|
+
_n( '%s item', '%s items', count, 'jetpack-charts' ),
|
|
30
|
+
formatMetricValue( count )
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const getDefaultTooltipContent = ( step: FunnelStep ): FunnelTooltipContent => ( {
|
|
34
|
+
value: step.count === undefined ? undefined : formatStepCount( step.count ),
|
|
35
|
+
rows: [
|
|
36
|
+
{ label: __( 'Conversion rate', 'jetpack-charts' ), value: formatPercentage( step.rate ) },
|
|
37
|
+
],
|
|
38
|
+
} );
|
|
39
|
+
|
|
40
|
+
const getDefaultTooltipIds = ( tooltipId: string, { rows = [] }: FunnelTooltipContent ) => ( {
|
|
41
|
+
value: `${ tooltipId }-value`,
|
|
42
|
+
rows: rows.map( ( _, index ) => ( {
|
|
43
|
+
label: `${ tooltipId }-row-${ index }-label`,
|
|
44
|
+
value: `${ tooltipId }-row-${ index }-value`,
|
|
45
|
+
} ) ),
|
|
46
|
+
} );
|
|
47
|
+
|
|
48
|
+
// The step's name is its title line, so the description skips it.
|
|
49
|
+
const getDefaultTooltipDescriptionIds = ( tooltipId: string, content: FunnelTooltipContent ) => {
|
|
50
|
+
const ids = getDefaultTooltipIds( tooltipId, content );
|
|
51
|
+
return [
|
|
52
|
+
...( content.value ? [ ids.value ] : [] ),
|
|
53
|
+
...ids.rows.flatMap( row => [ row.label, row.value ] ),
|
|
54
|
+
].join( ' ' );
|
|
55
|
+
};
|
|
56
|
+
|
|
26
57
|
/**
|
|
27
58
|
* Internal ConversionFunnelChart component with chart registration
|
|
28
59
|
*
|
|
29
|
-
* @param props
|
|
30
|
-
* @param props.chartId
|
|
31
|
-
* @param props.mainRate
|
|
32
|
-
* @param props.changeIndicator
|
|
33
|
-
* @param props.steps
|
|
34
|
-
* @param props.loading
|
|
35
|
-
* @param props.animation
|
|
36
|
-
* @param props.className
|
|
37
|
-
* @param props.ariaLabel
|
|
38
|
-
* @param props.height
|
|
39
|
-
* @param props.style
|
|
40
|
-
* @param props.renderStepLabel
|
|
41
|
-
* @param props.renderStepRate
|
|
42
|
-
* @param props.renderMainMetric
|
|
43
|
-
* @param props.renderTooltip
|
|
44
|
-
* @param props.tooltipStyle
|
|
60
|
+
* @param props - Component props
|
|
61
|
+
* @param props.chartId - Optional unique identifier for the chart
|
|
62
|
+
* @param props.mainRate - Main conversion rate to highlight
|
|
63
|
+
* @param props.changeIndicator - Change indicator (e.g., +2%, -1.5%)
|
|
64
|
+
* @param props.steps - Array of funnel steps
|
|
65
|
+
* @param props.loading - Whether the chart is in loading state
|
|
66
|
+
* @param props.animation - Whether to show chart animation on initial render or not
|
|
67
|
+
* @param props.className - Additional CSS class name
|
|
68
|
+
* @param props.ariaLabel - Accessible name for the chart group
|
|
69
|
+
* @param props.height - Height of the chart container. Falls back to style.height if set, otherwise defaults to "100%".
|
|
70
|
+
* @param props.style - Custom styling
|
|
71
|
+
* @param props.renderStepLabel - Custom render function for step labels
|
|
72
|
+
* @param props.renderStepRate - Custom render function for step rates
|
|
73
|
+
* @param props.renderMainMetric - Custom render function for the entire main metric section
|
|
74
|
+
* @param props.renderTooltip - Custom render function for tooltip content
|
|
75
|
+
* @param props.tooltipStyle - Inline styles merged over the tooltip box defaults
|
|
76
|
+
* @param props.getTooltipContent - What each step's tooltip shows, which screen readers also read
|
|
45
77
|
* @return JSX element representing the conversion funnel chart
|
|
46
78
|
*/
|
|
47
79
|
const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
|
|
@@ -60,20 +92,26 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
|
|
|
60
92
|
renderMainMetric,
|
|
61
93
|
renderTooltip,
|
|
62
94
|
tooltipStyle,
|
|
95
|
+
getTooltipContent = getDefaultTooltipContent,
|
|
63
96
|
} ) => {
|
|
64
97
|
const chartId = useChartId( providedChartId );
|
|
65
98
|
const { getElementStyles, isColorPaletteResolved } = useGlobalChartsContext();
|
|
66
99
|
const chartRef = useRef< HTMLDivElement >( null );
|
|
67
|
-
const
|
|
100
|
+
const isPointerFocusRef = useRef( false );
|
|
68
101
|
const [ scopeNode, setScopeNode ] = useState< HTMLElement | null >( null );
|
|
102
|
+
const tooltipId = useId();
|
|
69
103
|
|
|
70
104
|
// Use @visx/tooltip hooks for tooltip positioning
|
|
71
105
|
const { tooltipData, tooltipLeft, tooltipTop, tooltipOpen, showTooltip, hideTooltip } =
|
|
72
106
|
useTooltip();
|
|
73
107
|
|
|
74
|
-
|
|
75
|
-
const { handleBarClick, handleBarKeyDown, clearSelection, getStepState } =
|
|
108
|
+
const { selectedStepId, selectStep, clearSelection, getStepState } =
|
|
76
109
|
useFunnelSelection( hideTooltip );
|
|
110
|
+
const stepIds = useMemo( () => steps?.map( step => step.id ) ?? [], [ steps ] );
|
|
111
|
+
const { getTabIndex, setStepElement, handleStepFocus, handleNavigationKey } = useRovingStepFocus(
|
|
112
|
+
stepIds,
|
|
113
|
+
selectedStepId
|
|
114
|
+
);
|
|
77
115
|
const standaloneScopeClass = useStandaloneScopeClass();
|
|
78
116
|
const [ measureChartRef, , chartHeight ] = useElementSize< HTMLDivElement >();
|
|
79
117
|
// A zero height is an unmeasured or hidden chart, not a short one.
|
|
@@ -89,146 +127,105 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
|
|
|
89
127
|
[ measureChartRef ]
|
|
90
128
|
);
|
|
91
129
|
|
|
92
|
-
//
|
|
93
|
-
const
|
|
94
|
-
clearSelection();
|
|
95
|
-
selectedBarRef.current = null;
|
|
96
|
-
hideTooltip();
|
|
97
|
-
}, [ clearSelection, hideTooltip ] );
|
|
98
|
-
|
|
99
|
-
// Helper function to show tooltip at specific coordinates
|
|
100
|
-
const showTooltipAt = useCallback(
|
|
130
|
+
// Selecting a step shows its tooltip, anchored at the given point relative to the chart root.
|
|
131
|
+
const selectStepAt = useCallback(
|
|
101
132
|
( step: FunnelStep, x: number, y: number ) => {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
133
|
+
selectStep( step.id );
|
|
134
|
+
const bounds = chartRef.current?.getBoundingClientRect();
|
|
135
|
+
if ( bounds ) {
|
|
136
|
+
showTooltip( {
|
|
137
|
+
tooltipData: step,
|
|
138
|
+
tooltipLeft: x - bounds.left,
|
|
139
|
+
tooltipTop: y - bounds.top - 10,
|
|
140
|
+
} );
|
|
141
|
+
}
|
|
107
142
|
},
|
|
108
|
-
[ showTooltip ]
|
|
143
|
+
[ selectStep, showTooltip ]
|
|
109
144
|
);
|
|
110
145
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return {
|
|
119
|
-
x: event.clientX - bounds.left,
|
|
120
|
-
y: event.clientY - bounds.top,
|
|
121
|
-
};
|
|
122
|
-
}, [] );
|
|
123
|
-
|
|
124
|
-
// Keyboard interaction anchors the tooltip at the top centre of the focused step.
|
|
125
|
-
const getKeyboardTooltipCoords = useCallback( ( event: React.KeyboardEvent ) => {
|
|
126
|
-
const bounds = chartRef.current?.getBoundingClientRect();
|
|
127
|
-
if ( ! bounds ) {
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const rect = event.currentTarget.getBoundingClientRect();
|
|
132
|
-
return {
|
|
133
|
-
x: rect.left + rect.width / 2 - bounds.left,
|
|
134
|
-
y: rect.top - bounds.top,
|
|
135
|
-
};
|
|
136
|
-
}, [] );
|
|
137
|
-
|
|
138
|
-
// Helper function to handle step interaction (both click and keyboard)
|
|
139
|
-
const handleStepInteraction = useCallback(
|
|
140
|
-
(
|
|
141
|
-
step: FunnelStep,
|
|
142
|
-
event: React.MouseEvent | React.KeyboardEvent,
|
|
143
|
-
interactionType: 'click' | 'keyboard'
|
|
144
|
-
) => {
|
|
145
|
-
// Store reference to the interacted element
|
|
146
|
-
selectedBarRef.current = event.currentTarget as HTMLDivElement;
|
|
147
|
-
|
|
148
|
-
// Check if deselecting the same step
|
|
149
|
-
const { isClicked } = getStepState( step.id );
|
|
150
|
-
if ( isClicked ) {
|
|
151
|
-
// Deselecting - clear selection (tooltip will be hidden by hook)
|
|
152
|
-
if ( interactionType === 'click' ) {
|
|
153
|
-
handleBarClick( step.id );
|
|
154
|
-
} else {
|
|
155
|
-
handleBarKeyDown( step.id, event as React.KeyboardEvent );
|
|
156
|
-
}
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Selecting - handle selection and show tooltip
|
|
161
|
-
if ( interactionType === 'click' ) {
|
|
162
|
-
handleBarClick( step.id );
|
|
163
|
-
const coords = getMouseTooltipCoords( event as React.MouseEvent );
|
|
164
|
-
if ( coords ) {
|
|
165
|
-
showTooltipAt( step, coords.x, coords.y );
|
|
166
|
-
}
|
|
167
|
-
} else {
|
|
168
|
-
handleBarKeyDown( step.id, event as React.KeyboardEvent );
|
|
169
|
-
const coords = getKeyboardTooltipCoords( event as React.KeyboardEvent );
|
|
170
|
-
if ( coords ) {
|
|
171
|
-
showTooltipAt( step, coords.x, coords.y );
|
|
172
|
-
}
|
|
173
|
-
}
|
|
146
|
+
const selectStepAtElement = useCallback(
|
|
147
|
+
( step: FunnelStep, element: HTMLElement ) => {
|
|
148
|
+
const rect = element.getBoundingClientRect();
|
|
149
|
+
selectStepAt( step, rect.left + rect.width / 2, rect.top );
|
|
174
150
|
},
|
|
175
|
-
[
|
|
176
|
-
getStepState,
|
|
177
|
-
handleBarClick,
|
|
178
|
-
handleBarKeyDown,
|
|
179
|
-
showTooltipAt,
|
|
180
|
-
getMouseTooltipCoords,
|
|
181
|
-
getKeyboardTooltipCoords,
|
|
182
|
-
]
|
|
151
|
+
[ selectStepAt ]
|
|
183
152
|
);
|
|
184
153
|
|
|
185
|
-
// Create handler factories to avoid arrow functions in JSX
|
|
186
154
|
const stepHandlers = useMemo( () => {
|
|
187
155
|
const handlers = new Map<
|
|
188
156
|
string,
|
|
189
157
|
{
|
|
158
|
+
onPointerDown: () => void;
|
|
190
159
|
onClick: ( event: React.MouseEvent ) => void;
|
|
191
|
-
onKeyDown: ( event: React.KeyboardEvent ) => void;
|
|
160
|
+
onKeyDown: ( event: React.KeyboardEvent< HTMLElement > ) => void;
|
|
161
|
+
onFocus: ( event: React.FocusEvent< HTMLElement > ) => void;
|
|
162
|
+
ref: ( element: HTMLDivElement | null ) => void;
|
|
192
163
|
}
|
|
193
164
|
>();
|
|
194
165
|
|
|
195
166
|
steps.forEach( step => {
|
|
167
|
+
// A press focuses the step before its click, which places the tooltip at the pointer instead.
|
|
168
|
+
const onPointerDown = () => {
|
|
169
|
+
isPointerFocusRef.current = true;
|
|
170
|
+
};
|
|
171
|
+
|
|
196
172
|
const onClick = ( event: React.MouseEvent ) => {
|
|
197
|
-
|
|
198
|
-
|
|
173
|
+
isPointerFocusRef.current = false;
|
|
174
|
+
selectStepAt( step, event.clientX, event.clientY );
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const onFocus = ( event: React.FocusEvent< HTMLElement > ) => {
|
|
178
|
+
handleStepFocus( step.id );
|
|
179
|
+
if ( isPointerFocusRef.current ) {
|
|
180
|
+
isPointerFocusRef.current = false;
|
|
181
|
+
} else if ( step.id !== selectedStepId ) {
|
|
182
|
+
selectStepAtElement( step, event.currentTarget );
|
|
183
|
+
}
|
|
199
184
|
};
|
|
200
185
|
|
|
201
|
-
const onKeyDown = ( event: React.KeyboardEvent ) => {
|
|
186
|
+
const onKeyDown = ( event: React.KeyboardEvent< HTMLElement > ) => {
|
|
187
|
+
isPointerFocusRef.current = false;
|
|
188
|
+
if ( handleNavigationKey( step.id, event ) ) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
202
192
|
if ( event.key === 'Enter' || event.key === ' ' ) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
193
|
+
event.preventDefault();
|
|
194
|
+
selectStepAtElement( step, event.currentTarget );
|
|
195
|
+
} else if ( event.key === 'Escape' && selectedStepId !== null ) {
|
|
196
|
+
event.preventDefault();
|
|
197
|
+
clearSelection();
|
|
208
198
|
}
|
|
209
199
|
};
|
|
210
200
|
|
|
211
|
-
|
|
201
|
+
const ref = ( element: HTMLDivElement | null ) => setStepElement( step.id, element );
|
|
202
|
+
|
|
203
|
+
handlers.set( step.id, { onPointerDown, onClick, onKeyDown, onFocus, ref } );
|
|
212
204
|
} );
|
|
213
205
|
|
|
214
206
|
return handlers;
|
|
215
|
-
}, [
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
207
|
+
}, [
|
|
208
|
+
steps,
|
|
209
|
+
selectedStepId,
|
|
210
|
+
selectStepAt,
|
|
211
|
+
selectStepAtElement,
|
|
212
|
+
clearSelection,
|
|
213
|
+
handleNavigationKey,
|
|
214
|
+
handleStepFocus,
|
|
215
|
+
setStepElement,
|
|
216
|
+
] );
|
|
217
|
+
|
|
218
|
+
const handleStepsBlur = useCallback(
|
|
219
|
+
( event: React.FocusEvent< HTMLElement > ) => {
|
|
220
|
+
const leavesFunnel =
|
|
221
|
+
! event.currentTarget.contains( event.relatedTarget ) && document.hasFocus();
|
|
222
|
+
if ( leavesFunnel ) {
|
|
223
|
+
isPointerFocusRef.current = false;
|
|
224
|
+
clearSelection();
|
|
223
225
|
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
return () => {
|
|
229
|
-
document.removeEventListener( 'mousedown', handleDocumentClick );
|
|
230
|
-
};
|
|
231
|
-
}, [ clearSelectionAndRef ] );
|
|
226
|
+
},
|
|
227
|
+
[ clearSelection ]
|
|
228
|
+
);
|
|
232
229
|
|
|
233
230
|
// Resolve height: explicit height prop > style.height > default 100%
|
|
234
231
|
const resolvedHeight = height ?? style?.height ?? '100%';
|
|
@@ -259,16 +256,37 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
|
|
|
259
256
|
</>
|
|
260
257
|
);
|
|
261
258
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
<
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
259
|
+
const renderDefaultTooltip = ( step: FunnelStep, content: FunnelTooltipContent ) => {
|
|
260
|
+
const ids = getDefaultTooltipIds( tooltipId, content );
|
|
261
|
+
return (
|
|
262
|
+
<Stack direction="column" gap="xs">
|
|
263
|
+
<Stack direction="row" justify="space-between" align="baseline" gap="sm">
|
|
264
|
+
<span className={ styles[ 'tooltip-title' ] }>{ step.label }</span>
|
|
265
|
+
{ content.value && (
|
|
266
|
+
<span id={ ids.value } className={ styles[ 'tooltip-value' ] }>
|
|
267
|
+
{ content.value }
|
|
268
|
+
</span>
|
|
269
|
+
) }
|
|
270
|
+
</Stack>
|
|
271
|
+
{ content.rows?.map( ( row, index ) => (
|
|
272
|
+
<Stack
|
|
273
|
+
key={ row.label }
|
|
274
|
+
direction="row"
|
|
275
|
+
justify="space-between"
|
|
276
|
+
align="baseline"
|
|
277
|
+
gap="sm"
|
|
278
|
+
>
|
|
279
|
+
<span id={ ids.rows[ index ].label } className={ styles[ 'tooltip-label' ] }>
|
|
280
|
+
{ row.label }
|
|
281
|
+
</span>
|
|
282
|
+
<span id={ ids.rows[ index ].value } className={ styles[ 'tooltip-value' ] }>
|
|
283
|
+
{ row.value }
|
|
284
|
+
</span>
|
|
285
|
+
</Stack>
|
|
286
|
+
) ) }
|
|
287
|
+
</Stack>
|
|
288
|
+
);
|
|
289
|
+
};
|
|
272
290
|
|
|
273
291
|
// Validate data
|
|
274
292
|
const isDataValid = Boolean( steps && steps.length > 0 );
|
|
@@ -320,6 +338,27 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
|
|
|
320
338
|
// Calculate bar heights relative to the maximum (first step)
|
|
321
339
|
const maxRate = Math.max( ...steps.map( step => step.rate ) );
|
|
322
340
|
|
|
341
|
+
const tooltipStep = tooltipOpen ? ( tooltipData as FunnelStep | undefined ) : undefined;
|
|
342
|
+
let tooltipContent: React.ReactNode = null;
|
|
343
|
+
let tooltipDescriptionIds = '';
|
|
344
|
+
if ( tooltipStep ) {
|
|
345
|
+
const index = steps.findIndex( s => s.id === tooltipStep.id );
|
|
346
|
+
const content = getTooltipContent( tooltipStep, index );
|
|
347
|
+
if ( renderTooltip ) {
|
|
348
|
+
tooltipContent = renderTooltip( {
|
|
349
|
+
step: tooltipStep,
|
|
350
|
+
index,
|
|
351
|
+
content,
|
|
352
|
+
top: tooltipTop,
|
|
353
|
+
left: tooltipLeft,
|
|
354
|
+
} );
|
|
355
|
+
tooltipDescriptionIds = tooltipId;
|
|
356
|
+
} else {
|
|
357
|
+
tooltipContent = renderDefaultTooltip( tooltipStep, content );
|
|
358
|
+
tooltipDescriptionIds = getDefaultTooltipDescriptionIds( tooltipId, content );
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
323
362
|
return (
|
|
324
363
|
<Stack
|
|
325
364
|
direction="column"
|
|
@@ -352,10 +391,22 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
|
|
|
352
391
|
) }
|
|
353
392
|
|
|
354
393
|
{ /* Funnel Steps */ }
|
|
355
|
-
<Stack
|
|
394
|
+
<Stack
|
|
395
|
+
direction="row"
|
|
396
|
+
align="flex-end"
|
|
397
|
+
gap="lg"
|
|
398
|
+
role="listbox"
|
|
399
|
+
aria-label={ __( 'Funnel steps', 'jetpack-charts' ) }
|
|
400
|
+
aria-orientation="horizontal"
|
|
401
|
+
onBlur={ handleStepsBlur }
|
|
402
|
+
className={ styles[ 'funnel-container' ] }
|
|
403
|
+
>
|
|
356
404
|
{ steps.map( ( step, index ) => {
|
|
357
405
|
const barHeight = ( step.rate / maxRate ) * 100;
|
|
358
|
-
const { isBlurred } = getStepState( step.id );
|
|
406
|
+
const { isSelected, isBlurred } = getStepState( step.id );
|
|
407
|
+
const handlers = stepHandlers.get( step.id );
|
|
408
|
+
const isDescribedByTooltip =
|
|
409
|
+
Boolean( tooltipContent && tooltipDescriptionIds ) && tooltipStep?.id === step.id;
|
|
359
410
|
|
|
360
411
|
return (
|
|
361
412
|
<Stack
|
|
@@ -369,8 +420,13 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
|
|
|
369
420
|
) }
|
|
370
421
|
gap={ gap }
|
|
371
422
|
>
|
|
372
|
-
{ /* Step
|
|
373
|
-
<Stack
|
|
423
|
+
{ /* Step label and rate. The option's name and description carry them, and a listbox may hold only options. */ }
|
|
424
|
+
<Stack
|
|
425
|
+
direction="column"
|
|
426
|
+
gap="xs"
|
|
427
|
+
className={ styles[ 'step-text' ] }
|
|
428
|
+
aria-hidden="true"
|
|
429
|
+
>
|
|
374
430
|
{ renderStepLabel ? (
|
|
375
431
|
renderStepLabel( {
|
|
376
432
|
step,
|
|
@@ -398,11 +454,21 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
|
|
|
398
454
|
direction="column"
|
|
399
455
|
justify="flex-end"
|
|
400
456
|
className={ styles[ 'bar-container' ] }
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
457
|
+
ref={ handlers?.ref }
|
|
458
|
+
onPointerDown={ handlers?.onPointerDown }
|
|
459
|
+
onClick={ handlers?.onClick }
|
|
460
|
+
onKeyDown={ handlers?.onKeyDown }
|
|
461
|
+
onFocus={ handlers?.onFocus }
|
|
462
|
+
role="option"
|
|
463
|
+
tabIndex={ getTabIndex( step.id ) }
|
|
464
|
+
aria-label={ sprintf(
|
|
465
|
+
/* translators: 1: Funnel step name, such as "Cart". 2: Its rate, such as "71.1%". */
|
|
466
|
+
__( '%1$s, %2$s', 'jetpack-charts' ),
|
|
467
|
+
step.label,
|
|
468
|
+
formatPercentage( step.rate )
|
|
469
|
+
) }
|
|
470
|
+
aria-selected={ isSelected }
|
|
471
|
+
aria-describedby={ isDescribedByTooltip ? tooltipDescriptionIds : undefined }
|
|
406
472
|
>
|
|
407
473
|
<div
|
|
408
474
|
className={ clsx( styles[ 'funnel-bar' ], {
|
|
@@ -421,32 +487,19 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
|
|
|
421
487
|
</Stack>
|
|
422
488
|
</ChartScopeContext.Provider>
|
|
423
489
|
|
|
424
|
-
{
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if ( ! tooltipContent ) return null;
|
|
438
|
-
|
|
439
|
-
return (
|
|
440
|
-
<BoundedTooltip
|
|
441
|
-
top={ tooltipTop }
|
|
442
|
-
left={ tooltipLeft }
|
|
443
|
-
className={ standaloneScopeClass }
|
|
444
|
-
style={ tooltipStyle }
|
|
445
|
-
>
|
|
446
|
-
<div role="tooltip">{ tooltipContent }</div>
|
|
447
|
-
</BoundedTooltip>
|
|
448
|
-
);
|
|
449
|
-
} )() }
|
|
490
|
+
{ tooltipContent && (
|
|
491
|
+
<BoundedTooltip
|
|
492
|
+
top={ tooltipTop }
|
|
493
|
+
left={ tooltipLeft }
|
|
494
|
+
className={ standaloneScopeClass }
|
|
495
|
+
style={ tooltipStyle }
|
|
496
|
+
>
|
|
497
|
+
{ /* Read through the selected step's description, never on its own. */ }
|
|
498
|
+
<div id={ tooltipId } aria-hidden="true" data-testid="funnel-tooltip">
|
|
499
|
+
{ tooltipContent }
|
|
500
|
+
</div>
|
|
501
|
+
</BoundedTooltip>
|
|
502
|
+
) }
|
|
450
503
|
</Stack>
|
|
451
504
|
);
|
|
452
505
|
};
|
|
@@ -1,67 +1,29 @@
|
|
|
1
1
|
import { useCallback, useState } from 'react';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Custom hook to manage funnel
|
|
4
|
+
* Custom hook to manage funnel step selection state
|
|
5
5
|
* @param hideTooltip - Function to hide tooltip when selection is cleared
|
|
6
|
-
* @return Object containing selection state and
|
|
6
|
+
* @return Object containing selection state and its setters
|
|
7
7
|
*/
|
|
8
8
|
export const useFunnelSelection = ( hideTooltip?: () => void ) => {
|
|
9
|
-
const [
|
|
9
|
+
const [ selectedStepId, setSelectedStepId ] = useState< string | null >( null );
|
|
10
10
|
|
|
11
|
-
// Handle bar click
|
|
12
|
-
const handleBarClick = useCallback(
|
|
13
|
-
( stepId: string ) => {
|
|
14
|
-
if ( clickedStep === stepId ) {
|
|
15
|
-
// If clicking the same step, deselect it
|
|
16
|
-
setClickedStep( null );
|
|
17
|
-
hideTooltip?.();
|
|
18
|
-
} else {
|
|
19
|
-
// Otherwise, select this step
|
|
20
|
-
setClickedStep( stepId );
|
|
21
|
-
}
|
|
22
|
-
},
|
|
23
|
-
[ clickedStep, hideTooltip ]
|
|
24
|
-
);
|
|
25
|
-
|
|
26
|
-
// Handle bar keydown
|
|
27
|
-
const handleBarKeyDown = useCallback(
|
|
28
|
-
( stepId: string, event: React.KeyboardEvent ) => {
|
|
29
|
-
if ( event.key === 'Enter' || event.key === ' ' ) {
|
|
30
|
-
event.preventDefault();
|
|
31
|
-
if ( clickedStep === stepId ) {
|
|
32
|
-
setClickedStep( null );
|
|
33
|
-
hideTooltip?.();
|
|
34
|
-
} else {
|
|
35
|
-
setClickedStep( stepId );
|
|
36
|
-
}
|
|
37
|
-
} else if ( event.key === 'Escape' ) {
|
|
38
|
-
event.preventDefault();
|
|
39
|
-
setClickedStep( null );
|
|
40
|
-
hideTooltip?.();
|
|
41
|
-
}
|
|
42
|
-
},
|
|
43
|
-
[ clickedStep, hideTooltip ]
|
|
44
|
-
);
|
|
45
|
-
|
|
46
|
-
// Clear selection (for chart-level click)
|
|
47
11
|
const clearSelection = useCallback( () => {
|
|
48
|
-
|
|
12
|
+
setSelectedStepId( null );
|
|
49
13
|
hideTooltip?.();
|
|
50
14
|
}, [ hideTooltip ] );
|
|
51
15
|
|
|
52
|
-
// Get step state helpers
|
|
53
16
|
const getStepState = useCallback(
|
|
54
17
|
( stepId: string ) => ( {
|
|
55
|
-
|
|
56
|
-
isBlurred:
|
|
18
|
+
isSelected: selectedStepId === stepId,
|
|
19
|
+
isBlurred: selectedStepId !== null && selectedStepId !== stepId,
|
|
57
20
|
} ),
|
|
58
|
-
[
|
|
21
|
+
[ selectedStepId ]
|
|
59
22
|
);
|
|
60
23
|
|
|
61
24
|
return {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
handleBarKeyDown,
|
|
25
|
+
selectedStepId,
|
|
26
|
+
selectStep: setSelectedStepId,
|
|
65
27
|
clearSelection,
|
|
66
28
|
getStepState,
|
|
67
29
|
};
|