@automattic/charts 4.12.0 → 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.
Files changed (39) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/index.cjs +641 -636
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +40 -12
  5. package/dist/index.d.cts +26 -121
  6. package/dist/index.d.ts +26 -121
  7. package/dist/index.js +630 -613
  8. package/dist/index.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/charts/area-chart/area-chart.tsx +16 -6
  11. package/src/charts/area-chart/test/area-chart.test.tsx +42 -9
  12. package/src/charts/bar-chart/bar-chart.tsx +17 -12
  13. package/src/charts/bar-chart/private/index.ts +1 -1
  14. package/src/charts/bar-chart/private/slot-tooltip-members.tsx +4 -5
  15. package/src/charts/bar-chart/private/use-bar-stops.ts +1 -3
  16. package/src/charts/bar-chart/private/use-overlap-slots.ts +35 -16
  17. package/src/charts/bar-chart/test/bar-chart.test.tsx +115 -0
  18. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +24 -11
  19. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +235 -182
  20. package/src/charts/conversion-funnel-chart/index.ts +2 -0
  21. package/src/charts/conversion-funnel-chart/private/index.ts +1 -0
  22. package/src/charts/conversion-funnel-chart/private/use-funnel-selection.ts +9 -47
  23. package/src/charts/conversion-funnel-chart/private/use-roving-step-focus.ts +83 -0
  24. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +312 -92
  25. package/src/charts/conversion-funnel-chart/test/use-funnel-selection.test.ts +23 -188
  26. package/src/charts/conversion-funnel-chart/types.ts +22 -0
  27. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +41 -1
  28. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +101 -30
  29. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +47 -0
  30. package/src/charts/line-chart/line-chart.tsx +10 -8
  31. package/src/charts/line-chart/test/line-chart.test.tsx +75 -0
  32. package/src/components/tooltip/private/at-nearest-date.ts +49 -0
  33. package/src/components/tooltip/private/keyboard-stops.ts +67 -0
  34. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +10 -2
  35. package/src/components/tooltip/test/keyboard-stops.test.ts +36 -0
  36. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +135 -1
  37. package/src/components/tooltip/xy-chart-tooltip.tsx +8 -12
  38. package/src/index.ts +2 -0
  39. package/src/types.ts +4 -3
@@ -0,0 +1,83 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+
3
+ const getTargetIndex = (
4
+ key: string,
5
+ index: number,
6
+ count: number,
7
+ isRtl: boolean
8
+ ): number | undefined => {
9
+ const nextKey = isRtl ? 'ArrowLeft' : 'ArrowRight';
10
+ const previousKey = isRtl ? 'ArrowRight' : 'ArrowLeft';
11
+
12
+ switch ( key ) {
13
+ case nextKey:
14
+ return Math.min( index + 1, count - 1 );
15
+ case previousKey:
16
+ return Math.max( index - 1, 0 );
17
+ case 'Home':
18
+ return 0;
19
+ case 'End':
20
+ return count - 1;
21
+ default:
22
+ return undefined;
23
+ }
24
+ };
25
+
26
+ /**
27
+ * Roving tabindex for the funnel steps: one Tab stop, and arrow keys, Home and End move focus between steps.
28
+ *
29
+ * @param stepIds - Step ids in visual order.
30
+ * @param selectedStepId - The selected step, which holds the Tab stop until any step has taken focus.
31
+ * @return Tab index, element registration, focus and key handlers for each step.
32
+ */
33
+ export const useRovingStepFocus = ( stepIds: string[], selectedStepId: string | null ) => {
34
+ const [ focusedStepId, setFocusedStepId ] = useState< string | null >( null );
35
+ const stepElements = useRef( new Map< string, HTMLElement >() );
36
+
37
+ const tabStopId =
38
+ [ focusedStepId, selectedStepId ].find( id => id !== null && stepIds.includes( id ) ) ??
39
+ stepIds[ 0 ];
40
+
41
+ const getTabIndex = useCallback(
42
+ ( stepId: string ) => ( stepId === tabStopId ? 0 : -1 ),
43
+ [ tabStopId ]
44
+ );
45
+
46
+ const setStepElement = useCallback( ( stepId: string, element: HTMLElement | null ) => {
47
+ if ( element ) {
48
+ stepElements.current.set( stepId, element );
49
+ } else {
50
+ stepElements.current.delete( stepId );
51
+ }
52
+ }, [] );
53
+
54
+ const handleStepFocus = useCallback( ( stepId: string ) => {
55
+ setFocusedStepId( stepId );
56
+ }, [] );
57
+
58
+ const handleNavigationKey = useCallback(
59
+ ( stepId: string, event: React.KeyboardEvent< HTMLElement > ) => {
60
+ if ( event.altKey || event.ctrlKey || event.metaKey ) {
61
+ return false;
62
+ }
63
+
64
+ const isRtl = getComputedStyle( event.currentTarget ).direction === 'rtl';
65
+ const targetIndex = getTargetIndex(
66
+ event.key,
67
+ stepIds.indexOf( stepId ),
68
+ stepIds.length,
69
+ isRtl
70
+ );
71
+ if ( targetIndex === undefined ) {
72
+ return false;
73
+ }
74
+
75
+ event.preventDefault();
76
+ stepElements.current.get( stepIds[ targetIndex ] )?.focus();
77
+ return true;
78
+ },
79
+ [ stepIds ]
80
+ );
81
+
82
+ return { getTabIndex, setStepElement, handleStepFocus, handleNavigationKey };
83
+ };
@@ -1,7 +1,7 @@
1
- import { render, screen } from '@testing-library/react';
1
+ import { act, isInaccessible, render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import ConversionFunnelChart from '../conversion-funnel-chart';
4
- import type { FunnelStep } from '../types';
4
+ import type { ConversionFunnelChartProps, FunnelStep } from '../types';
5
5
 
6
6
  let mockChartHeight = 0;
7
7
  jest.mock( '../../../hooks/use-element-size', () => ( {
@@ -46,6 +46,36 @@ const renderWithoutTheme = ( component: React.ReactElement ) => {
46
46
  return render( component );
47
47
  };
48
48
 
49
+ // jsdom's window never has focus, so without this the funnel never sees focus leave it.
50
+ beforeEach( () => {
51
+ jest.spyOn( document, 'hasFocus' ).mockReturnValue( true );
52
+ } );
53
+
54
+ afterEach( () => {
55
+ jest.restoreAllMocks();
56
+ } );
57
+
58
+ const focus = ( element: HTMLElement ) => act( () => element.focus() );
59
+
60
+ const stepName = ( label: string ) => new RegExp( `^${ label }, ` );
61
+
62
+ /**
63
+ * Shows a step's position and id.
64
+ *
65
+ * @param step - The step.
66
+ * @param index - Its position.
67
+ * @return The tooltip content.
68
+ */
69
+ function getTooltipContent( step: FunnelStep, index: number ) {
70
+ return { value: `#${ index }`, rows: [ { label: 'Id', value: step.id } ] };
71
+ }
72
+
73
+ const getEmptyTooltipContent = () => ( {} );
74
+
75
+ const renderNamedTooltip = ( { step }: { step: FunnelStep } ) => (
76
+ <span>Custom tooltip: { step.label }</span>
77
+ );
78
+
49
79
  describe( 'ConversionFunnelChart', () => {
50
80
  afterEach( () => {
51
81
  mockChartHeight = 0;
@@ -159,58 +189,36 @@ describe( 'ConversionFunnelChart', () => {
159
189
  const user = userEvent.setup();
160
190
  renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
161
191
 
162
- const cartBar = screen.getByRole( 'button', { name: /cart/i } );
192
+ const cartBar = screen.getByRole( 'option', { name: /cart/i } );
163
193
  await user.click( cartBar );
164
194
 
165
- // Check that the component still renders correctly after click
166
- // After clicking, there will be multiple 'Cart' texts (header + tooltip)
167
- expect( screen.getAllByText( 'Cart' ) ).toHaveLength( 2 );
168
- // Rate appears once in header, and in tooltip it's combined with count text
169
- expect( screen.getByText( '71.1%' ) ).toBeInTheDocument();
170
- expect( screen.getByText( /71\.1% • .* items/ ) ).toBeInTheDocument();
171
- } );
172
-
173
- it( 'handles keyboard navigation with Enter key', async () => {
174
- const user = userEvent.setup();
175
- renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
176
-
177
- const cartBar = screen.getByRole( 'button', { name: /cart/i } );
178
- cartBar.focus();
179
- await user.keyboard( '{Enter}' );
180
-
181
- // Check that component still works after keyboard interaction
182
- // Keyboard navigation now shows tooltip, so label appears twice
183
- expect( screen.getAllByText( 'Cart' ) ).toHaveLength( 2 );
195
+ expect( screen.getByTestId( 'funnel-tooltip' ) ).toHaveTextContent(
196
+ 'Cart7,110 itemsConversion rate71.1%'
197
+ );
184
198
  } );
185
199
 
186
- it( 'handles keyboard navigation with Space key', async () => {
187
- const user = userEvent.setup();
200
+ it( 'selects a step and shows its tooltip when the step takes focus', () => {
188
201
  renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
189
202
 
190
- const cartBar = screen.getByRole( 'button', { name: /cart/i } );
191
- cartBar.focus();
192
- await user.keyboard( ' ' );
203
+ focus( screen.getByRole( 'option', { name: stepName( 'Cart' ) } ) );
193
204
 
194
- // Check that component still works after keyboard interaction
195
- // Keyboard navigation now shows tooltip, so label appears twice
196
- expect( screen.getAllByText( 'Cart' ) ).toHaveLength( 2 );
205
+ expect(
206
+ screen.getByRole( 'option', { name: stepName( 'Cart' ), selected: true } )
207
+ ).toBeInTheDocument();
208
+ expect( screen.getByTestId( 'funnel-tooltip' ) ).toHaveTextContent( 'Cart' );
197
209
  } );
198
210
 
199
- it( 'maintains component state after bar interactions', async () => {
211
+ it.each( [ '{Enter}', ' ' ] )( 'selects the focused step again with %s', async key => {
200
212
  const user = userEvent.setup();
201
213
  renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
202
214
 
203
- const cartBar = screen.getByRole( 'button', { name: /cart/i } );
204
- await user.click( cartBar );
205
-
206
- // Check that all bars are still accessible
207
- const sessionsBar = screen.getByRole( 'button', { name: /sessions/i } );
208
- const checkoutBar = screen.getByRole( 'button', { name: /checkout/i } );
209
- const purchaseBar = screen.getByRole( 'button', { name: /purchase/i } );
215
+ focus( screen.getByRole( 'option', { name: stepName( 'Cart' ) } ) );
216
+ await user.keyboard( `{Escape}${ key }` );
210
217
 
211
- expect( sessionsBar ).toBeInTheDocument();
212
- expect( checkoutBar ).toBeInTheDocument();
213
- expect( purchaseBar ).toBeInTheDocument();
218
+ expect(
219
+ screen.getByRole( 'option', { name: stepName( 'Cart' ), selected: true } )
220
+ ).toBeInTheDocument();
221
+ expect( screen.getByTestId( 'funnel-tooltip' ) ).toBeInTheDocument();
214
222
  } );
215
223
 
216
224
  it( 'allows multiple bar interactions', async () => {
@@ -218,10 +226,10 @@ describe( 'ConversionFunnelChart', () => {
218
226
  renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
219
227
 
220
228
  // Click on different bars in sequence
221
- const cartBar = screen.getByRole( 'button', { name: /cart/i } );
229
+ const cartBar = screen.getByRole( 'option', { name: /cart/i } );
222
230
  await user.click( cartBar );
223
231
 
224
- const sessionsBar = screen.getByRole( 'button', { name: /sessions/i } );
232
+ const sessionsBar = screen.getByRole( 'option', { name: /sessions/i } );
225
233
  await user.click( sessionsBar );
226
234
 
227
235
  // Component should still render correctly
@@ -236,7 +244,7 @@ describe( 'ConversionFunnelChart', () => {
236
244
  renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
237
245
 
238
246
  // First, click on a bar to select it
239
- const cartBar = screen.getByRole( 'button', { name: /cart/i } );
247
+ const cartBar = screen.getByRole( 'option', { name: /cart/i } );
240
248
  await user.click( cartBar );
241
249
 
242
250
  // Verify the bar is selected (tooltip appears, so label appears twice)
@@ -254,7 +262,7 @@ describe( 'ConversionFunnelChart', () => {
254
262
  renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
255
263
 
256
264
  // First, click on a bar to select it
257
- const cartBar = screen.getByRole( 'button', { name: /cart/i } );
265
+ const cartBar = screen.getByRole( 'option', { name: /cart/i } );
258
266
  await user.click( cartBar );
259
267
 
260
268
  // Verify the bar is selected (tooltip appears, so label appears twice)
@@ -268,48 +276,250 @@ describe( 'ConversionFunnelChart', () => {
268
276
  expect( screen.getAllByText( 'Cart' ) ).toHaveLength( 1 );
269
277
  } );
270
278
 
271
- it( 'toggles selection when clicking the same bar', async () => {
279
+ it( 'keeps the step selected when it is clicked again', async () => {
272
280
  const user = userEvent.setup();
273
281
  renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
274
282
 
275
- // First, click on a bar to select it
276
- const cartBar = screen.getByRole( 'button', { name: /cart/i } );
283
+ const cartBar = screen.getByRole( 'option', { name: /cart/i } );
277
284
  await user.click( cartBar );
278
-
279
- // Verify the bar is selected (tooltip appears, so label appears twice)
280
- expect( screen.getAllByText( 'Cart' ) ).toHaveLength( 2 );
281
-
282
- // Click the same bar again to deselect
283
285
  await user.click( cartBar );
284
286
 
285
- // Verify selection is cleared (tooltip disappears, so only one label remains)
286
- expect( screen.getAllByText( 'Cart' ) ).toHaveLength( 1 );
287
+ expect(
288
+ screen.getByRole( 'option', { name: stepName( 'Cart' ), selected: true } )
289
+ ).toBeInTheDocument();
287
290
  } );
288
291
  } );
289
292
 
290
293
  describe( 'Accessibility', () => {
291
- it( 'has proper ARIA roles for interactive elements', () => {
294
+ it( 'lists the steps as options of a horizontal listbox, named with their rates', () => {
292
295
  renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
293
296
 
294
- // Should have 4 bars as interactive elements
295
- const allButtons = screen.getAllByRole( 'button' );
296
- expect( allButtons ).toHaveLength( 4 ); // 4 bars
297
+ const listbox = screen.getByRole( 'listbox', { name: 'Funnel steps' } );
298
+ expect( listbox ).toHaveAttribute( 'aria-orientation', 'horizontal' );
299
+ expect(
300
+ screen.getAllByRole( 'option' ).map( option => option.getAttribute( 'aria-label' ) )
301
+ ).toEqual( [ 'Sessions, 100%', 'Cart, 71.1%', 'Checkout, 52.5%', 'Purchase, 10.3%' ] );
302
+ } );
297
303
 
298
- // Each bar should have button role
299
- mockSteps.forEach( step => {
300
- const bar = screen.getByRole( 'button', { name: new RegExp( step.label, 'i' ) } );
301
- expect( bar ).toBeInTheDocument();
302
- } );
304
+ it( 'hides the visible step labels and rates, so the listbox holds only its options', () => {
305
+ renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
306
+
307
+ expect( isInaccessible( screen.getByText( 'Cart' ) ) ).toBe( true );
308
+ expect( isInaccessible( screen.getByText( '71.1%' ) ) ).toBe( true );
303
309
  } );
304
310
 
305
- it( 'has proper tabIndex for keyboard navigation', () => {
311
+ it( 'describes the selected step with its tooltip, leaving out the name', () => {
312
+ renderWithoutTheme(
313
+ <ConversionFunnelChart
314
+ mainRate={ 50 }
315
+ steps={ [ ...mockSteps.slice( 0, 2 ), { id: 'none', label: 'None', rate: 50 } ] }
316
+ />
317
+ );
318
+
319
+ const cart = screen.getByRole( 'option', { name: stepName( 'Cart' ) } );
320
+ focus( cart );
321
+ expect( cart ).toHaveAccessibleDescription( '7,110 items Conversion rate 71.1%' );
322
+
323
+ const none = screen.getByRole( 'option', { name: stepName( 'None' ) } );
324
+ focus( none );
325
+ expect( none ).toHaveAccessibleDescription( 'Conversion rate 50%' );
326
+ expect( cart ).not.toHaveAttribute( 'aria-describedby' );
327
+ } );
328
+
329
+ it( 'shows and describes each step with getTooltipContent when given', () => {
330
+ renderWithoutTheme(
331
+ <ConversionFunnelChart { ...defaultProps } getTooltipContent={ getTooltipContent } />
332
+ );
333
+
334
+ const cart = screen.getByRole( 'option', { name: stepName( 'Cart' ) } );
335
+ focus( cart );
336
+
337
+ expect( cart ).toHaveAccessibleDescription( '#1 Id cart' );
338
+ expect( screen.getByTestId( 'funnel-tooltip' ) ).toHaveTextContent( 'Cart#1Idcart' );
339
+ } );
340
+
341
+ it( 'leaves a step undescribed when its tooltip content is empty', () => {
342
+ renderWithoutTheme(
343
+ <ConversionFunnelChart { ...defaultProps } getTooltipContent={ getEmptyTooltipContent } />
344
+ );
345
+
346
+ const cart = screen.getByRole( 'option', { name: stepName( 'Cart' ) } );
347
+ focus( cart );
348
+
349
+ expect( cart ).not.toHaveAttribute( 'aria-describedby' );
350
+ } );
351
+
352
+ it( 'describes the selected step with the whole custom tooltip', () => {
353
+ renderWithoutTheme(
354
+ <ConversionFunnelChart { ...defaultProps } renderTooltip={ renderNamedTooltip } />
355
+ );
356
+
357
+ const cart = screen.getByRole( 'option', { name: stepName( 'Cart' ) } );
358
+ focus( cart );
359
+
360
+ expect( cart ).toHaveAccessibleDescription( 'Custom tooltip: Cart' );
361
+ } );
362
+
363
+ it( 'hides the tooltip itself from screen readers', () => {
306
364
  renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
307
365
 
308
- // Bars should be focusable
309
- mockSteps.forEach( step => {
310
- const bar = screen.getByRole( 'button', { name: new RegExp( step.label, 'i' ) } );
311
- expect( bar ).toHaveAttribute( 'tabIndex', '0' );
366
+ focus( screen.getByRole( 'option', { name: stepName( 'Cart' ) } ) );
367
+
368
+ expect( screen.getByTestId( 'funnel-tooltip' ) ).toHaveAttribute( 'aria-hidden', 'true' );
369
+ } );
370
+
371
+ it( 'marks only the selected step as selected', async () => {
372
+ const user = userEvent.setup();
373
+ renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
374
+
375
+ await user.click( screen.getByRole( 'option', { name: stepName( 'Cart' ) } ) );
376
+
377
+ expect(
378
+ screen.getByRole( 'option', { name: stepName( 'Cart' ), selected: true } )
379
+ ).toBeInTheDocument();
380
+ expect( screen.getAllByRole( 'option', { selected: false } ) ).toHaveLength( 3 );
381
+ } );
382
+ } );
383
+
384
+ describe( 'Keyboard navigation', () => {
385
+ const renderBetweenButtons = ( props: Partial< ConversionFunnelChartProps > = {} ) =>
386
+ renderWithoutTheme(
387
+ <>
388
+ <button>Before</button>
389
+ <ConversionFunnelChart { ...defaultProps } { ...props } />
390
+ <button>After</button>
391
+ </>
392
+ );
393
+
394
+ it( 'enters the funnel on the first step and leaves it on the next Tab', async () => {
395
+ const user = userEvent.setup();
396
+ renderBetweenButtons();
397
+
398
+ screen.getByRole( 'button', { name: 'Before' } ).focus();
399
+ await user.tab();
400
+ expect( screen.getByRole( 'option', { name: stepName( 'Sessions' ) } ) ).toHaveFocus();
401
+
402
+ await user.tab();
403
+ expect( screen.getByRole( 'button', { name: 'After' } ) ).toHaveFocus();
404
+ } );
405
+
406
+ it( 'returns to the last focused step', async () => {
407
+ const user = userEvent.setup();
408
+ renderBetweenButtons();
409
+
410
+ focus( screen.getByRole( 'option', { name: stepName( 'Sessions' ) } ) );
411
+ await user.keyboard( '{ArrowRight}{ArrowRight}' );
412
+ await user.tab();
413
+ await user.tab( { shift: true } );
414
+
415
+ expect( screen.getByRole( 'option', { name: stepName( 'Checkout' ) } ) ).toHaveFocus();
416
+ } );
417
+
418
+ it( 'selects the step on focus after a press that ended off the step', async () => {
419
+ const user = userEvent.setup();
420
+ renderBetweenButtons();
421
+
422
+ const cart = screen.getByRole( 'option', { name: stepName( 'Cart' ) } );
423
+ focus( cart );
424
+ await user.pointer( { keys: '[MouseLeft>]', target: cart } );
425
+ await user.pointer( { keys: '[/MouseLeft]', target: document.body } );
426
+ await user.click( screen.getByRole( 'button', { name: 'After' } ) );
427
+ await user.tab( { shift: true } );
428
+
429
+ expect(
430
+ screen.getByRole( 'option', { name: stepName( 'Cart' ), selected: true } )
431
+ ).toHaveFocus();
432
+ } );
433
+
434
+ it( 'waits for the click to select a step pressed from another step', async () => {
435
+ const user = userEvent.setup();
436
+ renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
437
+
438
+ await user.click( screen.getByRole( 'option', { name: stepName( 'Sessions' ) } ) );
439
+ await user.pointer( {
440
+ keys: '[MouseLeft>]',
441
+ target: screen.getByRole( 'option', { name: stepName( 'Cart' ) } ),
312
442
  } );
443
+
444
+ expect(
445
+ screen.getByRole( 'option', { name: stepName( 'Sessions' ), selected: true } )
446
+ ).toBeInTheDocument();
447
+ } );
448
+
449
+ it( 'clears the selection when focus leaves the funnel', async () => {
450
+ const user = userEvent.setup();
451
+ renderBetweenButtons();
452
+
453
+ focus( screen.getByRole( 'option', { name: stepName( 'Cart' ) } ) );
454
+ await user.tab();
455
+
456
+ expect( screen.getAllByRole( 'option', { selected: false } ) ).toHaveLength( 4 );
457
+ expect( screen.queryByTestId( 'funnel-tooltip' ) ).not.toBeInTheDocument();
458
+ } );
459
+
460
+ it( 'moves the selection and its tooltip with the arrow keys', async () => {
461
+ const user = userEvent.setup();
462
+ renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
463
+
464
+ focus( screen.getByRole( 'option', { name: stepName( 'Cart' ) } ) );
465
+ await user.keyboard( '{ArrowRight}' );
466
+
467
+ expect(
468
+ screen.getByRole( 'option', { name: stepName( 'Checkout' ), selected: true } )
469
+ ).toHaveFocus();
470
+ expect( screen.getByTestId( 'funnel-tooltip' ) ).toHaveTextContent( 'Checkout' );
471
+ } );
472
+
473
+ it.each( [
474
+ [ 'Sessions', '{End}', 'Purchase' ],
475
+ [ 'Purchase', '{Home}', 'Sessions' ],
476
+ [ 'Purchase', '{ArrowRight}', 'Purchase' ],
477
+ [ 'Sessions', '{ArrowLeft}', 'Sessions' ],
478
+ ] )( 'from %s, %s focuses %s', async ( from, keys, to ) => {
479
+ const user = userEvent.setup();
480
+ renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
481
+
482
+ focus( screen.getByRole( 'option', { name: stepName( from ) } ) );
483
+ await user.keyboard( keys );
484
+
485
+ expect( screen.getByRole( 'option', { name: stepName( to ) } ) ).toHaveFocus();
486
+ } );
487
+
488
+ it( 'swaps Left and Right in a right-to-left layout', async () => {
489
+ const user = userEvent.setup();
490
+ renderWithoutTheme(
491
+ <ConversionFunnelChart { ...defaultProps } style={ { direction: 'rtl' } } />
492
+ );
493
+
494
+ focus( screen.getByRole( 'option', { name: stepName( 'Sessions' ) } ) );
495
+ await user.keyboard( '{ArrowLeft}' );
496
+
497
+ expect( screen.getByRole( 'option', { name: stepName( 'Cart' ) } ) ).toHaveFocus();
498
+ } );
499
+
500
+ it( 'clears the selection on Escape and keeps focus on the step', async () => {
501
+ const user = userEvent.setup();
502
+ renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
503
+
504
+ const cart = screen.getByRole( 'option', { name: stepName( 'Cart' ) } );
505
+ focus( cart );
506
+ await user.keyboard( '{Escape}' );
507
+
508
+ expect( cart ).toHaveFocus();
509
+ expect( screen.getAllByRole( 'option', { selected: false } ) ).toHaveLength( 4 );
510
+ } );
511
+
512
+ it( 'leaves Escape to the page when no step is selected', async () => {
513
+ const user = userEvent.setup();
514
+ const isPrevented = jest.fn( ( event: KeyboardEvent ) => event.defaultPrevented );
515
+ document.addEventListener( 'keydown', isPrevented );
516
+ renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
517
+
518
+ focus( screen.getByRole( 'option', { name: stepName( 'Cart' ) } ) );
519
+ await user.keyboard( '{Escape}{Escape}' );
520
+ document.removeEventListener( 'keydown', isPrevented );
521
+
522
+ expect( isPrevented.mock.results.map( result => result.value ) ).toEqual( [ true, false ] );
313
523
  } );
314
524
  } );
315
525
 
@@ -337,13 +547,10 @@ describe( 'ConversionFunnelChart', () => {
337
547
  <ConversionFunnelChart mainRate={ 50 } steps={ stepsWithLargeCounts } />
338
548
  );
339
549
 
340
- const bar = screen.getByRole( 'button', { name: /test/i } );
550
+ const bar = screen.getByRole( 'option', { name: /test/i } );
341
551
  await user.click( bar );
342
552
 
343
- // Check that component renders correctly with large numbers
344
- // After clicking, there will be multiple 'Test' texts (header + tooltip)
345
- expect( screen.getAllByText( 'Test' ) ).toHaveLength( 2 );
346
- expect( screen.getAllByText( '50%' ) ).toHaveLength( 2 );
553
+ expect( screen.getByTestId( 'funnel-tooltip' ) ).toHaveTextContent( '1,234,567 items' );
347
554
  } );
348
555
 
349
556
  it( 'handles steps without count in tooltip', async () => {
@@ -352,7 +559,7 @@ describe( 'ConversionFunnelChart', () => {
352
559
 
353
560
  renderWithoutTheme( <ConversionFunnelChart mainRate={ 75 } steps={ stepsWithoutCount } /> );
354
561
 
355
- const bar = screen.getByRole( 'button', { name: /test/i } );
562
+ const bar = screen.getByRole( 'option', { name: /test/i } );
356
563
  await user.click( bar );
357
564
 
358
565
  // Should show rate in tooltip, but we have multiple 75% (main + step)
@@ -439,7 +646,7 @@ describe( 'ConversionFunnelChart', () => {
439
646
  <ConversionFunnelChart { ...defaultProps } renderTooltip={ customRenderTooltip } />
440
647
  );
441
648
 
442
- const cartBar = screen.getByRole( 'button', { name: /cart/i } );
649
+ const cartBar = screen.getByRole( 'option', { name: /cart/i } );
443
650
  await user.click( cartBar );
444
651
 
445
652
  expect( screen.getByTestId( 'custom-tooltip' ) ).toBeInTheDocument();
@@ -447,27 +654,22 @@ describe( 'ConversionFunnelChart', () => {
447
654
  expect( customRenderTooltip ).toHaveBeenCalledWith( {
448
655
  step: expect.objectContaining( { id: 'cart', label: 'Cart' } ),
449
656
  index: 1,
657
+ content: {
658
+ value: '7,110 items',
659
+ rows: [ { label: 'Conversion rate', value: '71.1%' } ],
660
+ },
450
661
  top: expect.any( Number ),
451
662
  left: expect.any( Number ),
452
663
  } );
453
664
  } );
454
665
 
455
- it( 'exposes the tooltip content with role="tooltip"', async () => {
456
- const user = userEvent.setup();
457
- renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
458
-
459
- await user.click( screen.getByRole( 'button', { name: /cart/i } ) );
460
-
461
- expect( screen.getByRole( 'tooltip' ) ).toHaveTextContent( 'Cart' );
462
- } );
463
-
464
666
  it( 'merges tooltipStyle onto the tooltip box', async () => {
465
667
  const user = userEvent.setup();
466
668
  renderWithoutTheme(
467
669
  <ConversionFunnelChart { ...defaultProps } tooltipStyle={ { background: 'purple' } } />
468
670
  );
469
671
 
470
- await user.click( screen.getByRole( 'button', { name: /cart/i } ) );
672
+ await user.click( screen.getByRole( 'option', { name: /cart/i } ) );
471
673
 
472
674
  expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( { background: 'purple' } );
473
675
  } );
@@ -480,11 +682,12 @@ describe( 'ConversionFunnelChart', () => {
480
682
  <ConversionFunnelChart { ...defaultProps } renderTooltip={ customRenderTooltip } />
481
683
  );
482
684
 
483
- const cartBar = screen.getByRole( 'button', { name: /cart/i } );
685
+ const cartBar = screen.getByRole( 'option', { name: /cart/i } );
484
686
  await user.click( cartBar );
485
687
 
486
688
  // Should only have one 'Cart' text (header only, no tooltip)
487
689
  expect( screen.getAllByText( 'Cart' ) ).toHaveLength( 1 );
690
+ expect( cartBar ).not.toHaveAttribute( 'aria-describedby' );
488
691
  expect( customRenderTooltip ).toHaveBeenCalled();
489
692
  } );
490
693
 
@@ -536,16 +739,12 @@ const mockRects = () =>
536
739
  } );
537
740
 
538
741
  describe( 'ConversionFunnelChart tooltip position', () => {
539
- afterEach( () => {
540
- jest.restoreAllMocks();
541
- } );
542
-
543
742
  it( 'places the box relative to the chart root, at the click plus the offsets', async () => {
544
743
  mockRects();
545
744
  const user = userEvent.setup();
546
745
  renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
547
746
 
548
- const bar = screen.getAllByRole( 'button' )[ 0 ];
747
+ const bar = screen.getAllByRole( 'option' )[ 0 ];
549
748
  await user.pointer( [
550
749
  { target: bar, coords: { clientX: 180, clientY: 140 } },
551
750
  { keys: '[MouseLeft]', target: bar, coords: { clientX: 180, clientY: 140 } },
@@ -559,4 +758,25 @@ describe( 'ConversionFunnelChart tooltip position', () => {
559
758
  transform: 'translate(90px, 90px)',
560
759
  } );
561
760
  } );
761
+
762
+ it( 'keeps the selection and its place when the window loses focus and gets it back', async () => {
763
+ mockRects();
764
+ const user = userEvent.setup();
765
+ renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
766
+
767
+ const bar = screen.getAllByRole( 'option' )[ 0 ];
768
+ await user.pointer( [
769
+ { target: bar, coords: { clientX: 180, clientY: 140 } },
770
+ { keys: '[MouseLeft]', target: bar, coords: { clientX: 180, clientY: 140 } },
771
+ ] );
772
+ const hasFocus = jest.spyOn( document, 'hasFocus' ).mockReturnValue( false );
773
+ act( () => bar.blur() );
774
+ hasFocus.mockReturnValue( true );
775
+ focus( bar );
776
+
777
+ expect( bar ).toHaveAttribute( 'aria-selected', 'true' );
778
+ expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( {
779
+ transform: 'translate(90px, 90px)',
780
+ } );
781
+ } );
562
782
  } );