@automattic/charts 4.8.0 → 4.9.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 (56) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.cjs +624 -228
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +58 -8
  5. package/dist/index.d.cts +42 -8
  6. package/dist/index.d.ts +42 -8
  7. package/dist/index.js +628 -232
  8. package/dist/index.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/charts/area-chart/area-chart.tsx +5 -2
  11. package/src/charts/area-chart/test/area-chart.test.tsx +23 -14
  12. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +2 -2
  13. package/src/charts/area-chart/types.ts +2 -0
  14. package/src/charts/bar-chart/bar-chart.tsx +5 -2
  15. package/src/charts/bar-chart/test/bar-chart.test.tsx +82 -63
  16. package/src/charts/bar-chart/types.ts +2 -0
  17. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +3 -1
  18. package/src/charts/heatmap-chart/heatmap-chart.module.scss +10 -4
  19. package/src/charts/heatmap-chart/heatmap-chart.tsx +144 -37
  20. package/src/charts/heatmap-chart/private/column-groups.ts +79 -8
  21. package/src/charts/heatmap-chart/private/fit-compact-cells.ts +72 -0
  22. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +14 -2
  23. package/src/charts/heatmap-chart/private/index.ts +4 -2
  24. package/src/charts/heatmap-chart/test/column-groups.test.ts +41 -4
  25. package/src/charts/heatmap-chart/test/fit-compact-cells.test.ts +59 -0
  26. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +132 -1
  27. package/src/charts/heatmap-chart/types.ts +2 -0
  28. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +46 -4
  29. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +37 -1
  30. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +53 -0
  31. package/src/charts/leaderboard-chart/types.ts +6 -1
  32. package/src/charts/line-chart/line-chart.tsx +5 -2
  33. package/src/charts/line-chart/private/line-chart-annotation-label-popover.tsx +19 -9
  34. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +1 -1
  35. package/src/charts/line-chart/test/line-chart.test.tsx +45 -35
  36. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +2 -2
  37. package/src/charts/line-chart/types.ts +5 -3
  38. package/src/charts/pie-chart/pie-chart.tsx +149 -139
  39. package/src/charts/pie-chart/test/pie-chart.test.tsx +171 -0
  40. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +122 -120
  41. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +54 -0
  42. package/src/charts/private/chart-composition/use-chart-children.ts +11 -6
  43. package/src/charts/private/pie-keyboard-navigation/index.ts +4 -0
  44. package/src/charts/private/pie-keyboard-navigation/order-arcs-for-navigation.ts +10 -0
  45. package/src/charts/private/pie-keyboard-navigation/pie-keyboard-navigation.module.scss +16 -0
  46. package/src/charts/private/pie-keyboard-navigation/pie-selection-announcement.tsx +14 -0
  47. package/src/charts/private/pie-keyboard-navigation/pie-selection-output.tsx +82 -0
  48. package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +44 -0
  49. package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +47 -0
  50. package/src/charts/private/pie-keyboard-navigation/test/order-arcs-for-navigation.test.ts +34 -0
  51. package/src/charts/private/pie-keyboard-navigation/test/selected-segment-ring.test.ts +14 -0
  52. package/src/charts/private/pie-keyboard-navigation/use-pie-keyboard-navigation.ts +139 -0
  53. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +3 -2
  54. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +20 -19
  55. package/src/components/tooltip/xy-chart-tooltip.tsx +3 -3
  56. package/src/types.ts +18 -2
@@ -1,12 +1,11 @@
1
1
  import { Group } from '@visx/group';
2
- import { Pie } from '@visx/shape';
3
- import { useTooltip } from '@visx/tooltip';
2
+ import { arc, pie } from '@visx/shape';
4
3
  import { color as d3Color } from '@visx/vendor/d3-color';
4
+ import { __ } from '@wordpress/i18n';
5
5
  import clsx from 'clsx';
6
6
  import isEqual from 'fast-deep-equal';
7
- import { useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';
7
+ import { useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';
8
8
  import { Legend, useChartLegendItems } from '../../components/legend';
9
- import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
10
9
  import { LabelValueContent } from '../../components/tooltip/private/label-value-content';
11
10
  import {
12
11
  useDataWithPercentages,
@@ -22,7 +21,6 @@ import {
22
21
  GlobalChartsContext,
23
22
  } from '../../providers';
24
23
  import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
25
- import { useStandaloneScopeClass } from '../../providers/chart-scope';
26
24
  import { attachSubComponents, createCssVariableResolver, resolveFontSize } from '../../utils';
27
25
  import { getStringWidth } from '../../visx/text';
28
26
  import { Center } from '../private/center';
@@ -30,6 +28,12 @@ import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composit
30
28
  import { ChartInstanceContext } from '../private/chart-instance-context';
31
29
  import { ChartLayout } from '../private/chart-layout';
32
30
  import { pickLabelTextColor, resolveLabelRoles } from '../private/label-text-color';
31
+ import {
32
+ orderArcsForNavigation,
33
+ PieSelectionOutput,
34
+ SelectedSegmentRing,
35
+ usePieKeyboardNavigation,
36
+ } from '../private/pie-keyboard-navigation';
33
37
  import { RadialWipeAnimation } from '../private/radial-wipe-animation/';
34
38
  import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
35
39
  import { withResponsive, ResponsiveConfig } from '../private/with-responsive';
@@ -43,7 +47,11 @@ import type {
43
47
  } from '../../types';
44
48
  import type { ChartComponentWithComposition } from '../private/chart-composition';
45
49
  import type { LabelRoles, LabelTextColor } from '../private/label-text-color';
46
- import type { JSX, SVGProps, MouseEvent, ReactNode, FC } from 'react';
50
+ import type { PieProvidedProps } from '@visx/shape';
51
+ import type { JSX, ReactNode, FC } from 'react';
52
+
53
+ type PieDatum = DataPointPercentageCalculated & { index: number };
54
+ type PieArcDatum = PieProvidedProps< PieDatum >[ 'arcs' ][ number ];
47
55
 
48
56
  /**
49
57
  * Parameters passed to the renderTooltip function for pie charts.
@@ -130,6 +138,11 @@ export interface PieChartProps extends BaseChartProps< DataPointPercentage[] > {
130
138
  * When provided, replaces the default `label: value` tooltip with custom content.
131
139
  */
132
140
  renderTooltip?: ( params: PieChartRenderTooltipParams ) => ReactNode;
141
+
142
+ /**
143
+ * Accessible name of the chart. Defaults to a localized "Pie chart".
144
+ */
145
+ ariaLabel?: string;
133
146
  }
134
147
 
135
148
  // Base props type with optional responsive properties
@@ -201,6 +214,7 @@ const PieChartInternal = ( {
201
214
  tooltipOffsetX = 0,
202
215
  tooltipOffsetY = -15,
203
216
  renderTooltip = renderDefaultPieTooltip,
217
+ ariaLabel,
204
218
  gap = 'md',
205
219
  }: PieChartProps ) => {
206
220
  const legendInteractive = legend.interactive ?? false;
@@ -208,12 +222,6 @@ const PieChartInternal = ( {
208
222
 
209
223
  const providerTheme = useGlobalChartsTheme();
210
224
  const chartId = useChartId( providedChartId );
211
- const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, hideTooltip, showTooltip } =
212
- useTooltip< DataPointPercentageCalculated >();
213
- const standaloneScopeClass = useStandaloneScopeClass();
214
-
215
- // The tooltip renders inside this element, so pointer coordinates are taken relative to it.
216
- const containerRef = useRef< HTMLDivElement >( null );
217
225
 
218
226
  // The element the chart's own `className` lands on, so an override set there reaches this
219
227
  // decision the same way it reaches CSS.
@@ -221,13 +229,6 @@ const PieChartInternal = ( {
221
229
  // Null until read, and while a label plate is set: text on the plate keeps the inverse role.
222
230
  const [ labelRoles, setLabelRoles ] = useState< LabelRoles | null >( null );
223
231
 
224
- const onMouseLeave = useCallback( () => {
225
- if ( ! withTooltips ) {
226
- return;
227
- }
228
- hideTooltip();
229
- }, [ withTooltips, hideTooltip ] );
230
-
231
232
  const { getElementStyles, isSeriesVisible, isColorPaletteResolved } = useGlobalChartsContext();
232
233
  const { isValid, message } = validateData( data );
233
234
 
@@ -292,14 +293,7 @@ const PieChartInternal = ( {
292
293
  } );
293
294
 
294
295
  const prefersReducedMotion = usePrefersReducedMotion();
295
-
296
- if ( ! isValid ) {
297
- return (
298
- <div className={ clsx( 'pie-chart', styles[ 'pie-chart' ], className ) }>
299
- <div className={ styles[ 'error-message' ] }>{ message }</div>
300
- </div>
301
- );
302
- }
296
+ const wipeMask = animation && ! prefersReducedMotion ? `url(#radial-wipe-${ chartId })` : null;
303
297
 
304
298
  // Calculate the angle between each (use original data length for consistent spacing)
305
299
  const padAngle = gapScale * ( ( 2 * Math.PI ) / data.length );
@@ -321,6 +315,32 @@ const PieChartInternal = ( {
321
315
  },
322
316
  };
323
317
 
318
+ const arcs = pie< PieDatum >( { value: accessors.value, padAngle } )( dataWithIndex );
319
+ const navigationArcs = orderArcsForNavigation( arcs );
320
+
321
+ const {
322
+ chartRef,
323
+ svgRef,
324
+ selectedArc,
325
+ getSegmentHandlers,
326
+ getKeyboardTooltipAnchor,
327
+ selectionOutputProps,
328
+ chartProps,
329
+ } = usePieKeyboardNavigation( {
330
+ navigationArcs,
331
+ withTooltips,
332
+ tooltipOffsetX,
333
+ tooltipOffsetY,
334
+ } );
335
+
336
+ if ( ! isValid ) {
337
+ return (
338
+ <div className={ clsx( 'pie-chart', styles[ 'pie-chart' ], className ) }>
339
+ <div className={ styles[ 'error-message' ] }>{ message }</div>
340
+ </div>
341
+ );
342
+ }
343
+
324
344
  const legendElement = showLegend && (
325
345
  <Legend
326
346
  orientation={ legend.orientation ?? 'horizontal' }
@@ -383,9 +403,72 @@ const PieChartInternal = ( {
383
403
  ? Math.min( cornerScale * outerRadius, maxCornerRadius )
384
404
  : 0;
385
405
 
406
+ const path = arc< PieArcDatum >( { innerRadius, outerRadius, cornerRadius } );
407
+
408
+ const svgLabelSmall = providerTheme.svgLabelSmall;
409
+ const fontSize = resolveFontSize( svgLabelSmall?.fontSize ) ?? 12;
410
+
411
+ const renderLabel = ( arcDatum: PieArcDatum, index: number ) => {
412
+ if ( arcDatum.endAngle - arcDatum.startAngle < 0.25 ) {
413
+ return null;
414
+ }
415
+
416
+ const [ centroidX, centroidY ] = path.centroid( arcDatum );
417
+ const estimatedTextWidth = getStringWidth( arcDatum.data.label, {
418
+ fontSize,
419
+ fontFamily: svgLabelSmall?.fontFamily,
420
+ fontWeight: svgLabelSmall?.fontWeight,
421
+ } );
422
+ const labelPadding = 6;
423
+ const backgroundWidth = estimatedTextWidth + labelPadding * 2;
424
+ const backgroundHeight = fontSize + labelPadding * 2;
425
+
426
+ return (
427
+ <g key={ `label-${ index }` }>
428
+ <rect
429
+ className={ styles[ 'pie-chart__label-plate' ] }
430
+ x={ centroidX - backgroundWidth / 2 }
431
+ y={ centroidY - backgroundHeight / 2 }
432
+ width={ backgroundWidth }
433
+ height={ backgroundHeight }
434
+ rx={ 4 }
435
+ ry={ 4 }
436
+ pointerEvents="none"
437
+ />
438
+ <text
439
+ className={ clsx(
440
+ styles[ 'pie-chart__label-text' ],
441
+ LABEL_TEXT_MODIFIER[
442
+ pickLabelTextColor(
443
+ accessors.fill( arcDatum.data ),
444
+ labelRoles,
445
+ 'label-inverse'
446
+ )
447
+ ]
448
+ ) }
449
+ data-testid="pie-label"
450
+ x={ centroidX }
451
+ y={ centroidY }
452
+ dy=".33em"
453
+ fontSize={ fontSize }
454
+ textAnchor="middle"
455
+ pointerEvents="none"
456
+ >
457
+ { arcDatum.data.label }
458
+ </text>
459
+ </g>
460
+ );
461
+ };
462
+
386
463
  return (
387
- <Center ref={ containerRef } className={ styles[ 'pie-chart__plot' ] }>
464
+ <Center
465
+ ref={ chartRef }
466
+ className={ styles[ 'pie-chart__plot' ] }
467
+ { ...chartProps }
468
+ aria-label={ ariaLabel ?? __( 'Pie chart', 'jetpack-charts' ) }
469
+ >
388
470
  <svg
471
+ ref={ svgRef }
389
472
  viewBox={ `0 0 ${ width } ${ height }` }
390
473
  preserveAspectRatio="xMidYMid meet"
391
474
  width={ width }
@@ -399,125 +482,52 @@ const PieChartInternal = ( {
399
482
  />
400
483
  </defs>
401
484
 
402
- <Group
403
- top={ centerY }
404
- left={ centerX }
405
- mask={
406
- animation && ! prefersReducedMotion ? `url(#radial-wipe-${ chartId })` : null
407
- }
408
- >
485
+ <Group top={ centerY } left={ centerX } mask={ wipeMask }>
409
486
  { allSegmentsHidden ? (
410
487
  <SvgEmptyState x={ 0 } y={ 0 } width={ width } height={ height }>
411
488
  { getAllHiddenMessage( legendInteractive, 'segments' ) }
412
489
  </SvgEmptyState>
413
490
  ) : (
414
- <Pie< DataPointPercentageCalculated & { index: number } >
415
- data={ dataWithIndex }
416
- pieValue={ accessors.value }
417
- outerRadius={ outerRadius }
418
- innerRadius={ innerRadius }
419
- padAngle={ padAngle }
420
- cornerRadius={ cornerRadius }
421
- >
422
- { pie => {
423
- return pie.arcs.map( ( arc, index ) => {
424
- const [ centroidX, centroidY ] = pie.path.centroid( arc );
425
- const hasSpaceForLabel = arc.endAngle - arc.startAngle >= 0.25;
426
- const handleMouseMove = ( event: MouseEvent< SVGElement > ) => {
427
- if ( ! withTooltips ) {
428
- return;
429
- }
430
-
431
- const bounds = containerRef.current?.getBoundingClientRect();
432
- if ( ! bounds ) {
433
- return;
434
- }
435
-
436
- showTooltip( {
437
- tooltipData: arc.data,
438
- tooltipLeft: event.clientX - bounds.left + tooltipOffsetX,
439
- tooltipTop: event.clientY - bounds.top + tooltipOffsetY,
440
- } );
441
- };
442
-
443
- const fill = accessors.fill( arc.data );
444
- const pathProps: SVGProps< SVGPathElement > & {
445
- 'data-testid'?: string;
446
- } = {
447
- d: pie.path( arc ) || '',
448
- fill,
449
- 'data-testid': 'pie-segment',
450
- };
451
-
452
- const groupProps: SVGProps< SVGGElement > = {};
453
- if ( withTooltips ) {
454
- groupProps.onMouseMove = handleMouseMove;
455
- groupProps.onMouseLeave = onMouseLeave;
456
- }
457
-
458
- const svgLabelSmall = providerTheme.svgLabelSmall;
459
- const fontSize = resolveFontSize( svgLabelSmall?.fontSize ) ?? 12;
460
- const estimatedTextWidth = getStringWidth( arc.data.label, {
461
- fontSize,
462
- fontFamily: svgLabelSmall?.fontFamily,
463
- fontWeight: svgLabelSmall?.fontWeight,
464
- } );
465
- const labelPadding = 6;
466
- const backgroundWidth = estimatedTextWidth + labelPadding * 2;
467
- const backgroundHeight = fontSize + labelPadding * 2;
468
-
469
- return (
470
- <g key={ `arc-${ index }` } { ...groupProps }>
471
- <path { ...pathProps } />
472
- { showLabels && hasSpaceForLabel && (
473
- <g>
474
- <rect
475
- className={ styles[ 'pie-chart__label-plate' ] }
476
- x={ centroidX - backgroundWidth / 2 }
477
- y={ centroidY - backgroundHeight / 2 }
478
- width={ backgroundWidth }
479
- height={ backgroundHeight }
480
- rx={ 4 }
481
- ry={ 4 }
482
- pointerEvents="none"
483
- />
484
- <text
485
- className={ clsx(
486
- styles[ 'pie-chart__label-text' ],
487
- LABEL_TEXT_MODIFIER[
488
- pickLabelTextColor( fill, labelRoles, 'label-inverse' )
489
- ]
490
- ) }
491
- data-testid="pie-label"
492
- x={ centroidX }
493
- y={ centroidY }
494
- dy=".33em"
495
- fontSize={ fontSize }
496
- textAnchor="middle"
497
- pointerEvents="none"
498
- >
499
- { arc.data.label }
500
- </text>
501
- </g>
502
- ) }
503
- </g>
504
- );
505
- } );
506
- } }
507
- </Pie>
491
+ arcs.map( ( arcDatum, index ) => (
492
+ <g
493
+ key={ `arc-${ index }` }
494
+ { ...getSegmentHandlers(
495
+ arcDatum.data,
496
+ navigationArcs.indexOf( arcDatum )
497
+ ) }
498
+ >
499
+ <path
500
+ d={ path( arcDatum ) || '' }
501
+ fill={ accessors.fill( arcDatum.data ) }
502
+ data-testid="pie-segment"
503
+ />
504
+ </g>
505
+ ) )
508
506
  ) }
509
-
510
- { /* Render SVG children (like Group, Text) inside the SVG */ }
511
- { ! allSegmentsHidden && svgChildren }
512
507
  </Group>
508
+ { /* Unmasked so the entry wipe cannot hide the focus indicator. */ }
509
+ { selectedArc && (
510
+ <Group top={ centerY } left={ centerX }>
511
+ <SelectedSegmentRing chartId={ chartId } d={ path( selectedArc ) || '' } />
512
+ </Group>
513
+ ) }
514
+ { ! allSegmentsHidden && (
515
+ <Group top={ centerY } left={ centerX } mask={ wipeMask }>
516
+ { showLabels && arcs.map( renderLabel ) }
517
+
518
+ { /* Render SVG children (like Group, Text) inside the SVG */ }
519
+ { svgChildren }
520
+ </Group>
521
+ ) }
513
522
  </svg>
514
- { withTooltips && tooltipOpen && tooltipData && (
515
- <BoundedTooltip top={ tooltipTop || 0 } left={ tooltipLeft || 0 }>
516
- <div className={ standaloneScopeClass } role="tooltip">
517
- { renderTooltip( { tooltipData } ) }
518
- </div>
519
- </BoundedTooltip>
520
- ) }
523
+ <PieSelectionOutput
524
+ { ...selectionOutputProps }
525
+ keyboardTooltipAnchor={
526
+ selectedArc &&
527
+ getKeyboardTooltipAnchor( centerX, centerY, path.centroid( selectedArc ) )
528
+ }
529
+ renderTooltip={ renderTooltip }
530
+ />
521
531
  </Center>
522
532
  );
523
533
  } }
@@ -773,6 +773,121 @@ describe( 'PieChart', () => {
773
773
  expect( screen.queryByText( /Click legend items to show data/i ) ).not.toBeInTheDocument();
774
774
  } );
775
775
  } );
776
+
777
+ describe( 'Keyboard navigation', () => {
778
+ const data = [
779
+ { label: 'MacOS', value: 30000, valueDisplay: '30K' },
780
+ { label: 'Linux', value: 22000, valueDisplay: '22K' },
781
+ ];
782
+
783
+ test.each( [
784
+ [ undefined, 'Pie chart' ],
785
+ [ 'Devices', 'Devices' ],
786
+ ] )( 'names the focusable chart from ariaLabel %p', ( ariaLabel, name ) => {
787
+ render( <PieChart data={ data } ariaLabel={ ariaLabel } /> );
788
+ expect( screen.getByRole( 'application', { name } ) ).toHaveAttribute( 'tabindex', '0' );
789
+ } );
790
+
791
+ const expectRingOn = ( segment: HTMLElement ) =>
792
+ expect( screen.getByTestId( 'pie-selected-ring' ) ).toHaveAttribute(
793
+ 'd',
794
+ segment.getAttribute( 'd' )
795
+ );
796
+
797
+ test( 'arrow keys move the focused tooltip and highlight across segments', async () => {
798
+ const user = userEvent.setup();
799
+ render( <PieChart data={ data } withTooltips /> );
800
+ const [ macos, linux ] = screen.getAllByTestId( 'pie-segment' );
801
+ expect( screen.queryByTestId( 'pie-selected-ring' ) ).not.toBeInTheDocument();
802
+
803
+ await user.tab();
804
+ await user.keyboard( '{ArrowRight}' );
805
+ await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toHaveTextContent(
806
+ 'MacOS'
807
+ );
808
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
809
+ expectRingOn( macos );
810
+
811
+ await user.keyboard( '{ArrowRight}' );
812
+ await expect( screen.findByTestId( 'chart-tooltip-1' ) ).resolves.toHaveTextContent(
813
+ 'Linux'
814
+ );
815
+ expectRingOn( linux );
816
+ } );
817
+
818
+ test( 'announces and highlights a segment without a tooltip when withTooltips is false', async () => {
819
+ const user = userEvent.setup();
820
+ render( <PieChart data={ data } /> );
821
+ const status = screen.getByRole( 'status' );
822
+ expect( status ).toBeEmptyDOMElement();
823
+
824
+ await user.tab();
825
+ await user.keyboard( '{ArrowRight}' );
826
+ expectRingOn( screen.getAllByTestId( 'pie-segment' )[ 0 ] );
827
+ expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
828
+ expect( status ).toHaveTextContent( 'MacOS: 30K' );
829
+ } );
830
+
831
+ test( 'leaves the announcement to the focused tooltip when withTooltips is true', async () => {
832
+ const user = userEvent.setup();
833
+ render( <PieChart data={ data } withTooltips /> );
834
+
835
+ await user.tab();
836
+ await user.keyboard( '{ArrowRight}' );
837
+ await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toBeInTheDocument();
838
+ expect( screen.getByRole( 'status' ) ).toBeEmptyDOMElement();
839
+ } );
840
+
841
+ test.each( [
842
+ [ true, [ 'Linux: 22K' ] ],
843
+ [ false, [] ],
844
+ ] )(
845
+ 'hovering a segment ends the keyboard selection (withTooltips %p)',
846
+ async ( withTooltips, tooltips ) => {
847
+ const user = userEvent.setup();
848
+ render( <PieChart data={ data } withTooltips={ withTooltips } /> );
849
+
850
+ await user.tab();
851
+ await user.keyboard( '{ArrowRight}' );
852
+ expect( screen.getByTestId( 'pie-selected-ring' ) ).toBeInTheDocument();
853
+
854
+ await user.hover( screen.getAllByTestId( 'pie-segment' )[ 1 ] );
855
+
856
+ expect( screen.queryByTestId( 'pie-selected-ring' ) ).not.toBeInTheDocument();
857
+ expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
858
+ expect( screen.queryAllByRole( 'tooltip' ).map( tooltip => tooltip.textContent ) ).toEqual(
859
+ tooltips
860
+ );
861
+ }
862
+ );
863
+
864
+ test( 'keeps focus in the chart when tooltips are turned off under a keyboard selection', async () => {
865
+ const user = userEvent.setup();
866
+ const { rerender } = render( <PieChart data={ data } withTooltips /> );
867
+
868
+ await user.tab();
869
+ await user.keyboard( '{ArrowRight}{ArrowRight}' );
870
+ await expect( screen.findByTestId( 'chart-tooltip-1' ) ).resolves.toHaveFocus();
871
+
872
+ rerender( <PieChart data={ data } withTooltips={ false } /> );
873
+
874
+ expect( screen.getByRole( 'application' ) ).toHaveFocus();
875
+ expectRingOn( screen.getAllByTestId( 'pie-segment' )[ 1 ] );
876
+ } );
877
+
878
+ test( 'keeps focus in the chart when an update removes the selected segment', async () => {
879
+ const user = userEvent.setup();
880
+ const { rerender } = render( <PieChart data={ data } withTooltips /> );
881
+
882
+ await user.tab();
883
+ await user.keyboard( '{ArrowRight}{ArrowRight}' );
884
+ await expect( screen.findByTestId( 'chart-tooltip-1' ) ).resolves.toHaveFocus();
885
+
886
+ rerender( <PieChart data={ data.slice( 0, 1 ) } withTooltips /> );
887
+
888
+ await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toHaveFocus();
889
+ } );
890
+ } );
776
891
  } );
777
892
 
778
893
  // Chart container at (100, 50); the tooltip box measures 120x40. Everything
@@ -829,4 +944,60 @@ describe( 'PieChart tooltip position', () => {
829
944
  transform: 'translate(60px, 65px)',
830
945
  } );
831
946
  } );
947
+
948
+ test( 'moves the keyboard tooltip with the segment when the chart resizes', async () => {
949
+ const rects = mockRects();
950
+ const rectOf = rects.getMockImplementation();
951
+ let svgShift = 0;
952
+ // The plot is centered in the 400x300 container, so its offset follows its committed size.
953
+ rects.mockImplementation( function ( this: Element ) {
954
+ const rect = rectOf.call( this );
955
+ if ( ! ( this instanceof SVGSVGElement && this.hasAttribute( 'viewBox' ) ) ) {
956
+ return rect;
957
+ }
958
+ const size = Number( this.getAttribute( 'width' ) );
959
+ return {
960
+ ...rect,
961
+ left: 100 + ( 400 - size ) / 2 + svgShift,
962
+ top: 50 + ( 300 - size ) / 2 + svgShift,
963
+ };
964
+ } );
965
+ const user = userEvent.setup();
966
+ const data = [
967
+ { label: 'A', value: 60, valueDisplay: '60' },
968
+ { label: 'B', value: 40, valueDisplay: '40' },
969
+ ];
970
+ const renderAtSize = ( size: number ) => (
971
+ <GlobalChartsProvider>
972
+ <PieChart data={ data } width={ 400 } height={ 300 } size={ size } withTooltips />
973
+ </GlobalChartsProvider>
974
+ );
975
+ const selectFirstSegment = async () => {
976
+ await user.tab();
977
+ await user.keyboard( '{ArrowRight}' );
978
+ await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toHaveFocus();
979
+ return screen.getByTestId( 'bounded-tooltip' ).style.transform;
980
+ };
981
+
982
+ const translation = ( transform: string ) => transform.match( /-?[\d.]+/g )?.map( Number );
983
+
984
+ const view = render( renderAtSize( 100 ) );
985
+ const expected = await selectFirstSegment();
986
+ view.unmount();
987
+
988
+ svgShift = 7;
989
+ const { unmount } = render( renderAtSize( 100 ) );
990
+ expect( translation( await selectFirstSegment() ) ).toEqual(
991
+ translation( expected )?.map( value => value + 7 )
992
+ );
993
+ unmount();
994
+ svgShift = 0;
995
+
996
+ const { rerender } = render( renderAtSize( 200 ) );
997
+ await expect( selectFirstSegment() ).resolves.not.toBe( expected );
998
+ rerender( renderAtSize( 100 ) );
999
+
1000
+ await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toHaveFocus();
1001
+ expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( { transform: expected } );
1002
+ } );
832
1003
  } );