@automattic/charts 4.12.0 → 4.14.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 (47) hide show
  1. package/CHANGELOG.md +35 -29
  2. package/TOKENS.md +3 -0
  3. package/dist/index.cjs +733 -2203
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +42 -13
  6. package/dist/index.d.cts +41 -125
  7. package/dist/index.d.ts +41 -125
  8. package/dist/index.js +756 -1881
  9. package/dist/index.js.map +1 -1
  10. package/package.json +9 -9
  11. package/src/charts/area-chart/area-chart.tsx +16 -6
  12. package/src/charts/area-chart/test/area-chart.test.tsx +42 -9
  13. package/src/charts/bar-chart/bar-chart.tsx +17 -12
  14. package/src/charts/bar-chart/private/index.ts +1 -1
  15. package/src/charts/bar-chart/private/slot-tooltip-members.tsx +4 -5
  16. package/src/charts/bar-chart/private/use-bar-stops.ts +1 -3
  17. package/src/charts/bar-chart/private/use-overlap-slots.ts +35 -16
  18. package/src/charts/bar-chart/test/bar-chart.test.tsx +115 -0
  19. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +24 -11
  20. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +235 -182
  21. package/src/charts/conversion-funnel-chart/index.ts +2 -0
  22. package/src/charts/conversion-funnel-chart/private/index.ts +1 -0
  23. package/src/charts/conversion-funnel-chart/private/use-funnel-selection.ts +9 -47
  24. package/src/charts/conversion-funnel-chart/private/use-roving-step-focus.ts +83 -0
  25. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +312 -92
  26. package/src/charts/conversion-funnel-chart/test/use-funnel-selection.test.ts +23 -188
  27. package/src/charts/conversion-funnel-chart/types.ts +22 -0
  28. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +42 -2
  29. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +124 -36
  30. package/src/charts/leaderboard-chart/private/resolve-leaderboard-entries.ts +53 -0
  31. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +88 -0
  32. package/src/charts/leaderboard-chart/test/resolve-leaderboard-entries.test.ts +146 -0
  33. package/src/charts/leaderboard-chart/types.ts +6 -0
  34. package/src/charts/line-chart/line-chart.tsx +10 -8
  35. package/src/charts/line-chart/test/line-chart.test.tsx +75 -0
  36. package/src/components/legend/private/base-legend.tsx +13 -8
  37. package/src/components/legend/test/legend.test.tsx +125 -0
  38. package/src/components/legend/types.ts +9 -1
  39. package/src/components/tooltip/private/at-nearest-date.ts +49 -0
  40. package/src/components/tooltip/private/keyboard-stops.ts +67 -0
  41. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +10 -2
  42. package/src/components/tooltip/test/keyboard-stops.test.ts +36 -0
  43. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +135 -1
  44. package/src/components/tooltip/xy-chart-tooltip.tsx +8 -12
  45. package/src/index.ts +2 -0
  46. package/src/styles/chart-scope.scss +1 -0
  47. package/src/types.ts +5 -6
@@ -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, useEffect, useState, useCallback, useContext } from 'react';
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 - Component props
30
- * @param props.chartId - Optional unique identifier for the chart
31
- * @param props.mainRate - Main conversion rate to highlight
32
- * @param props.changeIndicator - Change indicator (e.g., +2%, -1.5%)
33
- * @param props.steps - Array of funnel steps
34
- * @param props.loading - Whether the chart is in loading state
35
- * @param props.animation - Whether to show chart animation on initial render or not
36
- * @param props.className - Additional CSS class name
37
- * @param props.ariaLabel - Accessible name for the chart group
38
- * @param props.height - Height of the chart container. Falls back to style.height if set, otherwise defaults to "100%".
39
- * @param props.style - Custom styling
40
- * @param props.renderStepLabel - Custom render function for step labels
41
- * @param props.renderStepRate - Custom render function for step rates
42
- * @param props.renderMainMetric - Custom render function for the entire main metric section
43
- * @param props.renderTooltip - Custom render function for tooltip content
44
- * @param props.tooltipStyle - Inline styles merged over the tooltip box defaults
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 selectedBarRef = useRef< HTMLDivElement | null >( null );
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
- // Use custom hook for selection management
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
- // Wrapper to clear selectedBarRef after clearing selection
93
- const clearSelectionAndRef = useCallback( () => {
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
- showTooltip( {
103
- tooltipData: step,
104
- tooltipLeft: x,
105
- tooltipTop: y - 10,
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
- // The chart root positions the tooltip, so coordinates are taken relative to it.
112
- const getMouseTooltipCoords = useCallback( ( event: React.MouseEvent ) => {
113
- const bounds = chartRef.current?.getBoundingClientRect();
114
- if ( ! bounds ) {
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
- event.stopPropagation();
198
- handleStepInteraction( step, event, 'click' );
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
- handleStepInteraction( step, event, 'keyboard' );
204
- } else {
205
- // For other keys (like Escape), just handle the selection
206
- selectedBarRef.current = event.currentTarget as HTMLDivElement;
207
- handleBarKeyDown( step.id, event );
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
- handlers.set( step.id, { onClick, onKeyDown } );
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
- }, [ steps, handleStepInteraction, handleBarKeyDown ] );
216
-
217
- // Handle document-level click to clear selection when clicking outside selected bar
218
- useEffect( () => {
219
- const handleDocumentClick = ( event: MouseEvent ) => {
220
- // Only clear selection if there's an active selection and click is outside the selected bar
221
- if ( selectedBarRef.current && ! selectedBarRef.current.contains( event.target as Node ) ) {
222
- clearSelectionAndRef();
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
- document.addEventListener( 'mousedown', handleDocumentClick );
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
- // Default tooltip rendering function
263
- const renderDefaultTooltip = ( step: FunnelStep ) => (
264
- <Stack direction="column" align="flex-start" gap="xs">
265
- <div className={ styles[ 'tooltip-title' ] }>{ step.label }</div>
266
- <div className={ styles[ 'tooltip-content' ] }>
267
- { formatPercentage( step.rate ) }
268
- { ` • ${ step.count ?? 'no' } items` }
269
- </div>
270
- </Stack>
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 direction="row" align="flex-end" gap="lg" className={ styles[ 'funnel-container' ] }>
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 Label and Rate */ }
373
- <Stack direction="column" gap="xs">
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
- onClick={ stepHandlers.get( step.id )?.onClick }
402
- onKeyDown={ stepHandlers.get( step.id )?.onKeyDown }
403
- role="button"
404
- tabIndex={ isBlurred ? -1 : 0 }
405
- aria-label={ step.label }
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
- { tooltipOpen &&
425
- tooltipData &&
426
- ( () => {
427
- const tooltipContent = renderTooltip
428
- ? renderTooltip( {
429
- step: tooltipData as FunnelStep,
430
- index: steps.findIndex( s => s.id === ( tooltipData as FunnelStep ).id ),
431
- top: tooltipTop,
432
- left: tooltipLeft,
433
- } )
434
- : renderDefaultTooltip( tooltipData as FunnelStep );
435
-
436
- // Don't render tooltip if renderTooltip returns falsy
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
  };
@@ -2,6 +2,8 @@ export { default as ConversionFunnelChart } from './conversion-funnel-chart';
2
2
  export type {
3
3
  ConversionFunnelChartProps,
4
4
  FunnelStep,
5
+ FunnelTooltipContent,
6
+ FunnelTooltipRow,
5
7
  StepLabelRenderProps,
6
8
  StepRateRenderProps,
7
9
  MainMetricRenderProps,
@@ -1 +1,2 @@
1
1
  export { useFunnelSelection } from './use-funnel-selection';
2
+ export { useRovingStepFocus } from './use-roving-step-focus';
@@ -1,67 +1,29 @@
1
1
  import { useCallback, useState } from 'react';
2
2
 
3
3
  /**
4
- * Custom hook to manage funnel bar selection state and interactions
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 event handlers
6
+ * @return Object containing selection state and its setters
7
7
  */
8
8
  export const useFunnelSelection = ( hideTooltip?: () => void ) => {
9
- const [ clickedStep, setClickedStep ] = useState< string | null >( null );
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
- setClickedStep( null );
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
- isClicked: clickedStep === stepId,
56
- isBlurred: clickedStep !== null && clickedStep !== stepId,
18
+ isSelected: selectedStepId === stepId,
19
+ isBlurred: selectedStepId !== null && selectedStepId !== stepId,
57
20
  } ),
58
- [ clickedStep ]
21
+ [ selectedStepId ]
59
22
  );
60
23
 
61
24
  return {
62
- clickedStep,
63
- handleBarClick,
64
- handleBarKeyDown,
25
+ selectedStepId,
26
+ selectStep: setSelectedStepId,
65
27
  clearSelection,
66
28
  getStepState,
67
29
  };