@automattic/charts 4.8.0 → 4.10.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 (75) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/index.cjs +766 -277
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +72 -8
  5. package/dist/index.d.cts +54 -15
  6. package/dist/index.d.ts +54 -15
  7. package/dist/index.js +770 -281
  8. package/dist/index.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/charts/area-chart/area-chart.tsx +7 -3
  11. package/src/charts/area-chart/test/area-chart.test.tsx +15 -14
  12. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +2 -2
  13. package/src/charts/bar-chart/bar-chart.tsx +12 -4
  14. package/src/charts/bar-chart/test/bar-chart.test.tsx +74 -63
  15. package/src/charts/bar-list-chart/bar-list-chart.tsx +3 -0
  16. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +3 -1
  17. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +23 -7
  18. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +22 -0
  19. package/src/charts/conversion-funnel-chart/types.ts +1 -1
  20. package/src/charts/geo-chart/geo-chart.tsx +17 -2
  21. package/src/charts/geo-chart/test/geo-chart.test.tsx +9 -4
  22. package/src/charts/geo-chart/types.ts +1 -1
  23. package/src/charts/heatmap-chart/heatmap-chart.module.scss +10 -4
  24. package/src/charts/heatmap-chart/heatmap-chart.tsx +144 -37
  25. package/src/charts/heatmap-chart/private/column-groups.ts +79 -8
  26. package/src/charts/heatmap-chart/private/fit-compact-cells.ts +72 -0
  27. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +14 -2
  28. package/src/charts/heatmap-chart/private/index.ts +4 -2
  29. package/src/charts/heatmap-chart/test/column-groups.test.ts +41 -4
  30. package/src/charts/heatmap-chart/test/fit-compact-cells.test.ts +59 -0
  31. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +132 -7
  32. package/src/charts/heatmap-chart/types.ts +2 -2
  33. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  34. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +52 -4
  35. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +60 -9
  36. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +80 -1
  37. package/src/charts/leaderboard-chart/types.ts +7 -1
  38. package/src/charts/line-chart/line-chart.tsx +8 -2
  39. package/src/charts/line-chart/private/line-chart-annotation-label-popover.tsx +19 -9
  40. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +1 -1
  41. package/src/charts/line-chart/test/line-chart.test.tsx +37 -35
  42. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +2 -2
  43. package/src/charts/line-chart/types.ts +3 -3
  44. package/src/charts/pie-chart/pie-chart.module.scss +3 -0
  45. package/src/charts/pie-chart/pie-chart.tsx +144 -139
  46. package/src/charts/pie-chart/test/pie-chart.test.tsx +167 -0
  47. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +3 -0
  48. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +117 -120
  49. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +54 -0
  50. package/src/charts/private/chart-composition/use-chart-children.ts +11 -6
  51. package/src/charts/private/pie-keyboard-navigation/index.ts +4 -0
  52. package/src/charts/private/pie-keyboard-navigation/order-arcs-for-navigation.ts +10 -0
  53. package/src/charts/private/pie-keyboard-navigation/pie-keyboard-navigation.module.scss +16 -0
  54. package/src/charts/private/pie-keyboard-navigation/pie-selection-announcement.tsx +14 -0
  55. package/src/charts/private/pie-keyboard-navigation/pie-selection-output.tsx +82 -0
  56. package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +49 -0
  57. package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +47 -0
  58. package/src/charts/private/pie-keyboard-navigation/test/order-arcs-for-navigation.test.ts +34 -0
  59. package/src/charts/private/pie-keyboard-navigation/test/selected-segment-ring.test.ts +14 -0
  60. package/src/charts/private/pie-keyboard-navigation/use-pie-keyboard-navigation.ts +139 -0
  61. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +3 -2
  62. package/src/charts/test/accessible-name.test.tsx +181 -0
  63. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +63 -3
  64. package/src/components/legend/hooks/use-chart-legend-items.ts +63 -1
  65. package/src/components/legend/private/base-legend.tsx +27 -15
  66. package/src/components/legend/test/legend.test.tsx +42 -0
  67. package/src/components/legend/types.ts +2 -0
  68. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +20 -19
  69. package/src/components/tooltip/xy-chart-tooltip.tsx +3 -3
  70. package/src/hooks/test/use-xychart-theme.test.tsx +22 -0
  71. package/src/hooks/use-xychart-theme.ts +33 -2
  72. package/src/index.ts +1 -0
  73. package/src/types.ts +37 -3
  74. package/src/utils/resolve-font-size.ts +9 -10
  75. package/src/utils/test/resolve-font-size.test.ts +11 -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.
@@ -201,6 +209,7 @@ const PieChartInternal = ( {
201
209
  tooltipOffsetX = 0,
202
210
  tooltipOffsetY = -15,
203
211
  renderTooltip = renderDefaultPieTooltip,
212
+ ariaLabel,
204
213
  gap = 'md',
205
214
  }: PieChartProps ) => {
206
215
  const legendInteractive = legend.interactive ?? false;
@@ -208,12 +217,6 @@ const PieChartInternal = ( {
208
217
 
209
218
  const providerTheme = useGlobalChartsTheme();
210
219
  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
220
 
218
221
  // The element the chart's own `className` lands on, so an override set there reaches this
219
222
  // decision the same way it reaches CSS.
@@ -221,13 +224,6 @@ const PieChartInternal = ( {
221
224
  // Null until read, and while a label plate is set: text on the plate keeps the inverse role.
222
225
  const [ labelRoles, setLabelRoles ] = useState< LabelRoles | null >( null );
223
226
 
224
- const onMouseLeave = useCallback( () => {
225
- if ( ! withTooltips ) {
226
- return;
227
- }
228
- hideTooltip();
229
- }, [ withTooltips, hideTooltip ] );
230
-
231
227
  const { getElementStyles, isSeriesVisible, isColorPaletteResolved } = useGlobalChartsContext();
232
228
  const { isValid, message } = validateData( data );
233
229
 
@@ -292,14 +288,7 @@ const PieChartInternal = ( {
292
288
  } );
293
289
 
294
290
  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
- }
291
+ const wipeMask = animation && ! prefersReducedMotion ? `url(#radial-wipe-${ chartId })` : null;
303
292
 
304
293
  // Calculate the angle between each (use original data length for consistent spacing)
305
294
  const padAngle = gapScale * ( ( 2 * Math.PI ) / data.length );
@@ -321,6 +310,32 @@ const PieChartInternal = ( {
321
310
  },
322
311
  };
323
312
 
313
+ const arcs = pie< PieDatum >( { value: accessors.value, padAngle } )( dataWithIndex );
314
+ const navigationArcs = orderArcsForNavigation( arcs );
315
+
316
+ const {
317
+ chartRef,
318
+ svgRef,
319
+ selectedArc,
320
+ getSegmentHandlers,
321
+ getKeyboardTooltipAnchor,
322
+ selectionOutputProps,
323
+ chartProps,
324
+ } = usePieKeyboardNavigation( {
325
+ navigationArcs,
326
+ withTooltips,
327
+ tooltipOffsetX,
328
+ tooltipOffsetY,
329
+ } );
330
+
331
+ if ( ! isValid ) {
332
+ return (
333
+ <div className={ clsx( 'pie-chart', styles[ 'pie-chart' ], className ) }>
334
+ <div className={ styles[ 'error-message' ] }>{ message }</div>
335
+ </div>
336
+ );
337
+ }
338
+
324
339
  const legendElement = showLegend && (
325
340
  <Legend
326
341
  orientation={ legend.orientation ?? 'horizontal' }
@@ -383,9 +398,72 @@ const PieChartInternal = ( {
383
398
  ? Math.min( cornerScale * outerRadius, maxCornerRadius )
384
399
  : 0;
385
400
 
401
+ const path = arc< PieArcDatum >( { innerRadius, outerRadius, cornerRadius } );
402
+
403
+ const svgLabelSmall = providerTheme.svgLabelSmall;
404
+ const fontSize = resolveFontSize( svgLabelSmall?.fontSize ) ?? 12;
405
+
406
+ const renderLabel = ( arcDatum: PieArcDatum, index: number ) => {
407
+ if ( arcDatum.endAngle - arcDatum.startAngle < 0.25 ) {
408
+ return null;
409
+ }
410
+
411
+ const [ centroidX, centroidY ] = path.centroid( arcDatum );
412
+ const estimatedTextWidth = getStringWidth( arcDatum.data.label, {
413
+ fontSize,
414
+ fontFamily: svgLabelSmall?.fontFamily,
415
+ fontWeight: svgLabelSmall?.fontWeight,
416
+ } );
417
+ const labelPadding = 6;
418
+ const backgroundWidth = estimatedTextWidth + labelPadding * 2;
419
+ const backgroundHeight = fontSize + labelPadding * 2;
420
+
421
+ return (
422
+ <g key={ `label-${ index }` }>
423
+ <rect
424
+ className={ styles[ 'pie-chart__label-plate' ] }
425
+ x={ centroidX - backgroundWidth / 2 }
426
+ y={ centroidY - backgroundHeight / 2 }
427
+ width={ backgroundWidth }
428
+ height={ backgroundHeight }
429
+ rx={ 4 }
430
+ ry={ 4 }
431
+ pointerEvents="none"
432
+ />
433
+ <text
434
+ className={ clsx(
435
+ styles[ 'pie-chart__label-text' ],
436
+ LABEL_TEXT_MODIFIER[
437
+ pickLabelTextColor(
438
+ accessors.fill( arcDatum.data ),
439
+ labelRoles,
440
+ 'label-inverse'
441
+ )
442
+ ]
443
+ ) }
444
+ data-testid="pie-label"
445
+ x={ centroidX }
446
+ y={ centroidY }
447
+ dy=".33em"
448
+ fontSize={ fontSize }
449
+ textAnchor="middle"
450
+ pointerEvents="none"
451
+ >
452
+ { arcDatum.data.label }
453
+ </text>
454
+ </g>
455
+ );
456
+ };
457
+
386
458
  return (
387
- <Center ref={ containerRef } className={ styles[ 'pie-chart__plot' ] }>
459
+ <Center
460
+ ref={ chartRef }
461
+ className={ styles[ 'pie-chart__plot' ] }
462
+ { ...chartProps }
463
+ aria-label={ ariaLabel ?? __( 'Pie chart', 'jetpack-charts' ) }
464
+ >
388
465
  <svg
466
+ ref={ svgRef }
389
467
  viewBox={ `0 0 ${ width } ${ height }` }
390
468
  preserveAspectRatio="xMidYMid meet"
391
469
  width={ width }
@@ -399,125 +477,52 @@ const PieChartInternal = ( {
399
477
  />
400
478
  </defs>
401
479
 
402
- <Group
403
- top={ centerY }
404
- left={ centerX }
405
- mask={
406
- animation && ! prefersReducedMotion ? `url(#radial-wipe-${ chartId })` : null
407
- }
408
- >
480
+ <Group top={ centerY } left={ centerX } mask={ wipeMask }>
409
481
  { allSegmentsHidden ? (
410
482
  <SvgEmptyState x={ 0 } y={ 0 } width={ width } height={ height }>
411
483
  { getAllHiddenMessage( legendInteractive, 'segments' ) }
412
484
  </SvgEmptyState>
413
485
  ) : (
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>
486
+ arcs.map( ( arcDatum, index ) => (
487
+ <g
488
+ key={ `arc-${ index }` }
489
+ { ...getSegmentHandlers(
490
+ arcDatum.data,
491
+ navigationArcs.indexOf( arcDatum )
492
+ ) }
493
+ >
494
+ <path
495
+ d={ path( arcDatum ) || '' }
496
+ fill={ accessors.fill( arcDatum.data ) }
497
+ data-testid="pie-segment"
498
+ />
499
+ </g>
500
+ ) )
508
501
  ) }
509
-
510
- { /* Render SVG children (like Group, Text) inside the SVG */ }
511
- { ! allSegmentsHidden && svgChildren }
512
502
  </Group>
503
+ { /* Unmasked so the entry wipe cannot hide the focus indicator. */ }
504
+ { selectedArc && (
505
+ <Group top={ centerY } left={ centerX }>
506
+ <SelectedSegmentRing chartId={ chartId } d={ path( selectedArc ) || '' } />
507
+ </Group>
508
+ ) }
509
+ { ! allSegmentsHidden && (
510
+ <Group top={ centerY } left={ centerX } mask={ wipeMask }>
511
+ { showLabels && arcs.map( renderLabel ) }
512
+
513
+ { /* Render SVG children (like Group, Text) inside the SVG */ }
514
+ { svgChildren }
515
+ </Group>
516
+ ) }
513
517
  </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
- ) }
518
+ <PieSelectionOutput
519
+ { ...selectionOutputProps }
520
+ keyboardTooltipAnchor={
521
+ selectedArc &&
522
+ getKeyboardTooltipAnchor( centerX, centerY, path.centroid( selectedArc ) )
523
+ }
524
+ renderTooltip={ renderTooltip }
525
+ />
521
526
  </Center>
522
527
  );
523
528
  } }
@@ -773,6 +773,117 @@ 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
+ const expectRingOn = ( segment: HTMLElement ) =>
784
+ expect( screen.getByTestId( 'pie-selected-ring' ) ).toHaveAttribute(
785
+ 'd',
786
+ segment.getAttribute( 'd' )
787
+ );
788
+
789
+ test( 'arrow keys move the focused tooltip and highlight across segments', async () => {
790
+ const user = userEvent.setup();
791
+ render( <PieChart data={ data } withTooltips /> );
792
+ const [ macos, linux ] = screen.getAllByTestId( 'pie-segment' );
793
+ expect( screen.queryByTestId( 'pie-selected-ring' ) ).not.toBeInTheDocument();
794
+ expect( screen.getByRole( 'application', { name: 'Pie chart' } ) ).toHaveAttribute(
795
+ 'tabindex',
796
+ '0'
797
+ );
798
+
799
+ await user.tab();
800
+ await user.keyboard( '{ArrowRight}' );
801
+ await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toHaveTextContent(
802
+ 'MacOS'
803
+ );
804
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
805
+ expectRingOn( macos );
806
+
807
+ await user.keyboard( '{ArrowRight}' );
808
+ await expect( screen.findByTestId( 'chart-tooltip-1' ) ).resolves.toHaveTextContent(
809
+ 'Linux'
810
+ );
811
+ expectRingOn( linux );
812
+ } );
813
+
814
+ test( 'announces and highlights a segment without a tooltip when withTooltips is false', async () => {
815
+ const user = userEvent.setup();
816
+ render( <PieChart data={ data } /> );
817
+ const status = screen.getByRole( 'status' );
818
+ expect( status ).toBeEmptyDOMElement();
819
+
820
+ await user.tab();
821
+ await user.keyboard( '{ArrowRight}' );
822
+ expectRingOn( screen.getAllByTestId( 'pie-segment' )[ 0 ] );
823
+ expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
824
+ expect( status ).toHaveTextContent( 'MacOS: 30K' );
825
+ } );
826
+
827
+ test( 'leaves the announcement to the focused tooltip when withTooltips is true', async () => {
828
+ const user = userEvent.setup();
829
+ render( <PieChart data={ data } withTooltips /> );
830
+
831
+ await user.tab();
832
+ await user.keyboard( '{ArrowRight}' );
833
+ await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toBeInTheDocument();
834
+ expect( screen.getByRole( 'status' ) ).toBeEmptyDOMElement();
835
+ } );
836
+
837
+ test.each( [
838
+ [ true, [ 'Linux: 22K' ] ],
839
+ [ false, [] ],
840
+ ] )(
841
+ 'hovering a segment ends the keyboard selection (withTooltips %p)',
842
+ async ( withTooltips, tooltips ) => {
843
+ const user = userEvent.setup();
844
+ render( <PieChart data={ data } withTooltips={ withTooltips } /> );
845
+
846
+ await user.tab();
847
+ await user.keyboard( '{ArrowRight}' );
848
+ expect( screen.getByTestId( 'pie-selected-ring' ) ).toBeInTheDocument();
849
+
850
+ await user.hover( screen.getAllByTestId( 'pie-segment' )[ 1 ] );
851
+
852
+ expect( screen.queryByTestId( 'pie-selected-ring' ) ).not.toBeInTheDocument();
853
+ expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
854
+ expect( screen.queryAllByRole( 'tooltip' ).map( tooltip => tooltip.textContent ) ).toEqual(
855
+ tooltips
856
+ );
857
+ }
858
+ );
859
+
860
+ test( 'keeps focus in the chart when tooltips are turned off under a keyboard selection', async () => {
861
+ const user = userEvent.setup();
862
+ const { rerender } = render( <PieChart data={ data } withTooltips /> );
863
+
864
+ await user.tab();
865
+ await user.keyboard( '{ArrowRight}{ArrowRight}' );
866
+ await expect( screen.findByTestId( 'chart-tooltip-1' ) ).resolves.toHaveFocus();
867
+
868
+ rerender( <PieChart data={ data } withTooltips={ false } /> );
869
+
870
+ expect( screen.getByRole( 'application' ) ).toHaveFocus();
871
+ expectRingOn( screen.getAllByTestId( 'pie-segment' )[ 1 ] );
872
+ } );
873
+
874
+ test( 'keeps focus in the chart when an update removes the selected segment', async () => {
875
+ const user = userEvent.setup();
876
+ const { rerender } = render( <PieChart data={ data } withTooltips /> );
877
+
878
+ await user.tab();
879
+ await user.keyboard( '{ArrowRight}{ArrowRight}' );
880
+ await expect( screen.findByTestId( 'chart-tooltip-1' ) ).resolves.toHaveFocus();
881
+
882
+ rerender( <PieChart data={ data.slice( 0, 1 ) } withTooltips /> );
883
+
884
+ await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toHaveFocus();
885
+ } );
886
+ } );
776
887
  } );
777
888
 
778
889
  // Chart container at (100, 50); the tooltip box measures 120x40. Everything
@@ -829,4 +940,60 @@ describe( 'PieChart tooltip position', () => {
829
940
  transform: 'translate(60px, 65px)',
830
941
  } );
831
942
  } );
943
+
944
+ test( 'moves the keyboard tooltip with the segment when the chart resizes', async () => {
945
+ const rects = mockRects();
946
+ const rectOf = rects.getMockImplementation();
947
+ let svgShift = 0;
948
+ // The plot is centered in the 400x300 container, so its offset follows its committed size.
949
+ rects.mockImplementation( function ( this: Element ) {
950
+ const rect = rectOf.call( this );
951
+ if ( ! ( this instanceof SVGSVGElement && this.hasAttribute( 'viewBox' ) ) ) {
952
+ return rect;
953
+ }
954
+ const size = Number( this.getAttribute( 'width' ) );
955
+ return {
956
+ ...rect,
957
+ left: 100 + ( 400 - size ) / 2 + svgShift,
958
+ top: 50 + ( 300 - size ) / 2 + svgShift,
959
+ };
960
+ } );
961
+ const user = userEvent.setup();
962
+ const data = [
963
+ { label: 'A', value: 60, valueDisplay: '60' },
964
+ { label: 'B', value: 40, valueDisplay: '40' },
965
+ ];
966
+ const renderAtSize = ( size: number ) => (
967
+ <GlobalChartsProvider>
968
+ <PieChart data={ data } width={ 400 } height={ 300 } size={ size } withTooltips />
969
+ </GlobalChartsProvider>
970
+ );
971
+ const selectFirstSegment = async () => {
972
+ await user.tab();
973
+ await user.keyboard( '{ArrowRight}' );
974
+ await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toHaveFocus();
975
+ return screen.getByTestId( 'bounded-tooltip' ).style.transform;
976
+ };
977
+
978
+ const translation = ( transform: string ) => transform.match( /-?[\d.]+/g )?.map( Number );
979
+
980
+ const view = render( renderAtSize( 100 ) );
981
+ const expected = await selectFirstSegment();
982
+ view.unmount();
983
+
984
+ svgShift = 7;
985
+ const { unmount } = render( renderAtSize( 100 ) );
986
+ expect( translation( await selectFirstSegment() ) ).toEqual(
987
+ translation( expected )?.map( value => value + 7 )
988
+ );
989
+ unmount();
990
+ svgShift = 0;
991
+
992
+ const { rerender } = render( renderAtSize( 200 ) );
993
+ await expect( selectFirstSegment() ).resolves.not.toBe( expected );
994
+ rerender( renderAtSize( 100 ) );
995
+
996
+ await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toHaveFocus();
997
+ expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( { transform: expected } );
998
+ } );
832
999
  } );
@@ -1,4 +1,7 @@
1
1
  .pie-semi-circle-chart {
2
+ // Keeps the tooltip's z-index inside the chart, as `.xy-plot` does.
3
+ isolation: isolate;
4
+
2
5
  // Anchors the tooltip, which renders next to the SVG in here.
3
6
  &__plot {
4
7
  position: relative;