@automattic/charts 4.7.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 (92) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/TOKENS.md +6 -3
  3. package/dist/index.cjs +834 -321
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +68 -17
  6. package/dist/index.d.cts +102 -28
  7. package/dist/index.d.ts +102 -28
  8. package/dist/index.js +838 -327
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.tsx +7 -4
  12. package/src/charts/area-chart/test/area-chart.test.tsx +24 -15
  13. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +1 -1
  14. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +2 -2
  15. package/src/charts/area-chart/types.ts +2 -0
  16. package/src/charts/bar-chart/bar-chart.module.scss +7 -0
  17. package/src/charts/bar-chart/bar-chart.tsx +25 -16
  18. package/src/charts/bar-chart/private/comparison-bars.tsx +1 -0
  19. package/src/charts/bar-chart/test/bar-chart.test.tsx +106 -62
  20. package/src/charts/bar-chart/types.ts +2 -0
  21. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +3 -1
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
  23. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +9 -0
  24. package/src/charts/heatmap-chart/heatmap-chart.module.scss +16 -8
  25. package/src/charts/heatmap-chart/heatmap-chart.tsx +183 -66
  26. package/src/charts/heatmap-chart/private/column-groups.ts +79 -8
  27. package/src/charts/heatmap-chart/private/fit-compact-cells.ts +72 -0
  28. package/src/charts/heatmap-chart/private/heatmap-context.ts +2 -2
  29. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +15 -3
  30. package/src/charts/heatmap-chart/private/heatmap-scale.ts +74 -0
  31. package/src/charts/heatmap-chart/private/index.ts +6 -3
  32. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +24 -6
  33. package/src/charts/heatmap-chart/test/column-groups.test.ts +41 -4
  34. package/src/charts/heatmap-chart/test/fit-compact-cells.test.ts +59 -0
  35. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +231 -8
  36. package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +78 -0
  37. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +18 -6
  38. package/src/charts/heatmap-chart/types.ts +6 -3
  39. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +46 -4
  40. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +37 -1
  41. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +53 -0
  42. package/src/charts/leaderboard-chart/types.ts +6 -1
  43. package/src/charts/line-chart/line-chart.tsx +7 -4
  44. package/src/charts/line-chart/private/line-chart-annotation-label-popover.tsx +19 -9
  45. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +1 -1
  46. package/src/charts/line-chart/test/line-chart.test.tsx +45 -35
  47. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +2 -2
  48. package/src/charts/line-chart/types.ts +5 -3
  49. package/src/charts/pie-chart/pie-chart.tsx +153 -143
  50. package/src/charts/pie-chart/test/pie-chart.test.tsx +171 -0
  51. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +126 -124
  52. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +55 -1
  53. package/src/charts/private/chart-composition/use-chart-children.ts +11 -6
  54. package/src/charts/private/pie-keyboard-navigation/index.ts +4 -0
  55. package/src/charts/private/pie-keyboard-navigation/order-arcs-for-navigation.ts +10 -0
  56. package/src/charts/private/pie-keyboard-navigation/pie-keyboard-navigation.module.scss +16 -0
  57. package/src/charts/private/pie-keyboard-navigation/pie-selection-announcement.tsx +14 -0
  58. package/src/charts/private/pie-keyboard-navigation/pie-selection-output.tsx +82 -0
  59. package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +44 -0
  60. package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +47 -0
  61. package/src/charts/private/pie-keyboard-navigation/test/order-arcs-for-navigation.test.ts +34 -0
  62. package/src/charts/private/pie-keyboard-navigation/test/selected-segment-ring.test.ts +14 -0
  63. package/src/charts/private/pie-keyboard-navigation/use-pie-keyboard-navigation.ts +139 -0
  64. package/src/charts/private/test/label-text-color.test.ts +6 -8
  65. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +3 -2
  66. package/src/components/tooltip/base-tooltip.tsx +31 -32
  67. package/src/components/tooltip/index.ts +3 -1
  68. package/src/components/tooltip/private/bounded-tooltip.tsx +15 -15
  69. package/src/components/tooltip/private/label-value-content.tsx +8 -0
  70. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +336 -0
  71. package/src/components/tooltip/test/tool-tip.test.tsx +8 -6
  72. package/src/components/tooltip/test/tooltip-box.test.tsx +53 -0
  73. package/src/components/tooltip/test/xy-chart-tooltip-overlay.test.tsx +445 -0
  74. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +283 -365
  75. package/src/components/tooltip/{base-tooltip.module.scss → tooltip-box.module.scss} +5 -8
  76. package/src/components/tooltip/tooltip-box.tsx +43 -0
  77. package/src/components/tooltip/types.ts +7 -1
  78. package/src/components/tooltip/xy-chart-tooltip.tsx +410 -300
  79. package/src/hooks/test/use-chart-margin.test.tsx +8 -4
  80. package/src/hooks/use-chart-margin.tsx +21 -18
  81. package/src/index.ts +8 -3
  82. package/src/providers/chart-context/global-charts-provider.tsx +5 -14
  83. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +1 -1
  84. package/src/providers/chart-context/private/catalog-pointers.ts +1 -0
  85. package/src/providers/chart-context/private/resolve-opaque-hex.ts +16 -0
  86. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +1 -1
  87. package/src/styles/chart-scope.scss +1 -0
  88. package/src/test-utils/zoned-parsing-suite.ts +0 -10
  89. package/src/types.ts +18 -2
  90. package/src/utils/test/date-formatting.test.ts +0 -10
  91. package/src/components/tooltip/accessible-tooltip.tsx +0 -433
  92. package/src/components/tooltip/test/accessible-tooltip.test.tsx +0 -331
@@ -1,12 +1,11 @@
1
1
  import { Group } from '@visx/group';
2
- import { Pie } from '@visx/shape';
2
+ import { arc, pie } from '@visx/shape';
3
3
  import { Text } from '@visx/text';
4
- import { useTooltip } from '@visx/tooltip';
4
+ import { __ } from '@wordpress/i18n';
5
5
  import clsx from 'clsx';
6
- import { useCallback, useContext, useMemo, useRef } from 'react';
6
+ import { useContext, useMemo } from 'react';
7
7
  import { Legend, useChartLegendItems } from '../../components/legend';
8
- import { BaseTooltip } from '../../components/tooltip';
9
- import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
8
+ import { LabelValueContent } from '../../components/tooltip/private/label-value-content';
10
9
  import {
11
10
  useDataWithPercentages,
12
11
  useLegendVisibilityData,
@@ -19,12 +18,17 @@ import {
19
18
  useGlobalChartsContext,
20
19
  GlobalChartsContext,
21
20
  } from '../../providers';
22
- import { useStandaloneScopeClass } from '../../providers/chart-scope';
23
21
  import { attachSubComponents } from '../../utils';
24
22
  import { Center } from '../private/center';
25
23
  import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition';
26
24
  import { ChartInstanceContext } from '../private/chart-instance-context';
27
25
  import { ChartLayout } from '../private/chart-layout';
26
+ import {
27
+ orderArcsForNavigation,
28
+ PieSelectionOutput,
29
+ SelectedSegmentRing,
30
+ usePieKeyboardNavigation,
31
+ } from '../private/pie-keyboard-navigation';
28
32
  import { RadialWipeAnimation } from '../private/radial-wipe-animation';
29
33
  import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
30
34
  import { withResponsive } from '../private/with-responsive';
@@ -39,7 +43,7 @@ import type {
39
43
  import type { ChartComponentWithComposition } from '../private/chart-composition';
40
44
  import type { ResponsiveConfig } from '../private/with-responsive';
41
45
  import type { PieProvidedProps } from '@visx/shape';
42
- import type { FC, MouseEvent, ReactNode } from 'react';
46
+ import type { FC, ReactNode } from 'react';
43
47
 
44
48
  /**
45
49
  * Parameters passed to the renderTooltip function for semi-circle charts.
@@ -53,7 +57,7 @@ export type PieSemiCircleChartRenderTooltipParams = {
53
57
 
54
58
  /**
55
59
  * Default tooltip renderer for semi-circle pie charts.
56
- * Renders a BaseTooltip with the hovered segment's data.
60
+ * Renders the default `label: value` tooltip content for the hovered segment.
57
61
  *
58
62
  * @param {PieSemiCircleChartRenderTooltipParams} params - The tooltip parameters containing the hovered data point
59
63
  * @return {ReactNode} The rendered tooltip content
@@ -61,7 +65,7 @@ export type PieSemiCircleChartRenderTooltipParams = {
61
65
  const renderDefaultPieSemiCircleTooltip = ( {
62
66
  tooltipData,
63
67
  }: PieSemiCircleChartRenderTooltipParams ): ReactNode => {
64
- return <BaseTooltip data={ tooltipData } top={ 0 } left={ 0 } renderContainer={ false } />;
68
+ return <LabelValueContent data={ tooltipData } />;
65
69
  };
66
70
 
67
71
  const PAD_ANGLE = 0.03; // Padding between segments
@@ -122,9 +126,14 @@ export interface PieSemiCircleChartProps extends BaseChartProps< DataPointPercen
122
126
 
123
127
  /**
124
128
  * Custom render function for tooltip content.
125
- * When provided, replaces the default BaseTooltip with custom content.
129
+ * When provided, replaces the default `label: value` tooltip with custom content.
126
130
  */
127
131
  renderTooltip?: ( params: PieSemiCircleChartRenderTooltipParams ) => ReactNode;
132
+
133
+ /**
134
+ * Accessible name of the chart. Defaults to a localized "Semi-circle chart".
135
+ */
136
+ ariaLabel?: string;
128
137
  }
129
138
 
130
139
  // Base props type with optional responsive properties
@@ -138,6 +147,9 @@ type PieSemiCircleChartResponsiveComponent = ChartComponentWithComposition<
138
147
 
139
148
  export type ArcData = PieProvidedProps< DataPointPercentageCalculated >[ 'arcs' ][ number ];
140
149
 
150
+ type PieDatum = DataPointPercentageCalculated & { index: number };
151
+ type PieArcDatum = PieProvidedProps< PieDatum >[ 'arcs' ][ number ];
152
+
141
153
  /**
142
154
  * Validates the semi-circle pie chart data
143
155
  * @param data - The data to validate
@@ -182,45 +194,13 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
182
194
  tooltipOffsetX = 0,
183
195
  tooltipOffsetY = -15,
184
196
  renderTooltip = renderDefaultPieSemiCircleTooltip,
197
+ ariaLabel,
185
198
  gap = 'md',
186
199
  } ) => {
187
200
  const legendInteractive = legend.interactive ?? false;
188
201
  const legendPosition = legend.position ?? 'bottom';
189
202
 
190
203
  const chartId = useChartId( providedChartId );
191
- const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, hideTooltip, showTooltip } =
192
- useTooltip< DataPointPercentageCalculated >();
193
- const standaloneScopeClass = useStandaloneScopeClass();
194
-
195
- // The tooltip renders inside this element, so pointer coordinates are taken relative to it.
196
- const containerRef = useRef< HTMLDivElement >( null );
197
-
198
- const handleMouseMove = useCallback(
199
- ( event: MouseEvent< SVGElement >, arc: ArcData ) => {
200
- const bounds = containerRef.current?.getBoundingClientRect();
201
- if ( ! bounds ) {
202
- return;
203
- }
204
-
205
- showTooltip( {
206
- tooltipData: arc.data,
207
- tooltipLeft: event.clientX - bounds.left + tooltipOffsetX,
208
- tooltipTop: event.clientY - bounds.top + tooltipOffsetY,
209
- } );
210
- },
211
- [ showTooltip, tooltipOffsetX, tooltipOffsetY ]
212
- );
213
-
214
- const handleMouseLeave = useCallback( () => {
215
- hideTooltip();
216
- }, [ hideTooltip ] );
217
-
218
- const handleArcMouseMove = useCallback(
219
- ( arc: ArcData ) => ( event: MouseEvent< SVGElement > ) => {
220
- handleMouseMove( event, arc );
221
- },
222
- [ handleMouseMove ]
223
- );
224
204
 
225
205
  // Validate data first to get validation result
226
206
  const { isValid, message } = validateData( data );
@@ -285,9 +265,48 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
285
265
  } );
286
266
 
287
267
  const prefersReducedMotion = usePrefersReducedMotion();
268
+ const wipeMask = animation && ! prefersReducedMotion ? `url(#radial-wipe-${ chartId })` : null;
288
269
 
289
270
  const effectiveWidth = propWidth || DEFAULT_WIDTH;
290
271
 
272
+ // Map data with index for color assignment
273
+ // When interactive, we need to find the original index to maintain consistent colors
274
+ const dataWithIndex = visibleData.map( d => {
275
+ const originalIndex = data.findIndex( item => item.label === d.label );
276
+ return {
277
+ ...d,
278
+ index: originalIndex >= 0 ? originalIndex : 0,
279
+ };
280
+ } );
281
+
282
+ // Configure pie angles based on clockwise direction
283
+ const startAngle = clockwise ? -Math.PI / 2 : Math.PI / 2;
284
+ const endAngle = clockwise ? Math.PI / 2 : -Math.PI / 2;
285
+
286
+ const arcs = pie< PieDatum >( {
287
+ value: accessors.value,
288
+ sort: accessors.sort,
289
+ padAngle: PAD_ANGLE,
290
+ startAngle,
291
+ endAngle,
292
+ } )( dataWithIndex );
293
+ const navigationArcs = orderArcsForNavigation( arcs );
294
+
295
+ const {
296
+ chartRef,
297
+ svgRef,
298
+ selectedArc,
299
+ getSegmentHandlers,
300
+ getKeyboardTooltipAnchor,
301
+ selectionOutputProps,
302
+ chartProps,
303
+ } = usePieKeyboardNavigation( {
304
+ navigationArcs,
305
+ withTooltips,
306
+ tooltipOffsetX,
307
+ tooltipOffsetY,
308
+ } );
309
+
291
310
  if ( ! isValid ) {
292
311
  const errorWidth = propHeight
293
312
  ? Math.min( propWidth || propHeight * 2, propHeight * 2 )
@@ -307,20 +326,6 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
307
326
  );
308
327
  }
309
328
 
310
- // Map data with index for color assignment
311
- // When interactive, we need to find the original index to maintain consistent colors
312
- const dataWithIndex = visibleData.map( d => {
313
- const originalIndex = data.findIndex( item => item.label === d.label );
314
- return {
315
- ...d,
316
- index: originalIndex >= 0 ? originalIndex : 0,
317
- };
318
- } );
319
-
320
- // Configure pie angles based on clockwise direction
321
- const startAngle = clockwise ? -Math.PI / 2 : Math.PI / 2;
322
- const endAngle = clockwise ? Math.PI / 2 : -Math.PI / 2;
323
-
324
329
  const legendElement = showLegend && (
325
330
  <Legend
326
331
  orientation={ legend.orientation ?? 'horizontal' }
@@ -376,9 +381,17 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
376
381
  const radius = height; // For a semi-circle, radius equals the SVG height
377
382
  const innerRadius = radius * ( 1 - thickness );
378
383
 
384
+ const path = arc< PieArcDatum >( { innerRadius, outerRadius: radius, cornerRadius: 3 } );
385
+
379
386
  return (
380
- <Center ref={ containerRef } className={ styles[ 'pie-semi-circle-chart__plot' ] }>
387
+ <Center
388
+ ref={ chartRef }
389
+ className={ styles[ 'pie-semi-circle-chart__plot' ] }
390
+ { ...chartProps }
391
+ aria-label={ ariaLabel ?? __( 'Semi-circle chart', 'jetpack-charts' ) }
392
+ >
381
393
  <svg
394
+ ref={ svgRef }
382
395
  width={ width }
383
396
  height={ height }
384
397
  viewBox={ `0 0 ${ width } ${ height }` }
@@ -395,81 +408,70 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
395
408
  </defs>
396
409
 
397
410
  { /* Main chart group centered horizontally and positioned at bottom */ }
398
- <Group
399
- top={ height }
400
- left={ width / 2 }
401
- mask={
402
- animation && ! prefersReducedMotion ? `url(#radial-wipe-${ chartId })` : null
403
- }
404
- >
411
+ <Group top={ height } left={ width / 2 } mask={ wipeMask }>
405
412
  { allSegmentsHidden ? (
406
413
  <SvgEmptyState x={ 0 } y={ -radius / 2 } width={ width } height={ height }>
407
414
  { getAllHiddenMessage( legendInteractive, 'segments' ) }
408
415
  </SvgEmptyState>
409
416
  ) : (
410
- <>
411
- { /* Pie chart */ }
412
- <Pie< DataPointPercentageCalculated & { index: number } >
413
- data={ dataWithIndex }
414
- pieValue={ accessors.value }
415
- outerRadius={ radius }
416
- innerRadius={ innerRadius }
417
- cornerRadius={ 3 }
418
- padAngle={ PAD_ANGLE }
419
- startAngle={ startAngle }
420
- endAngle={ endAngle }
421
- pieSort={ accessors.sort }
417
+ arcs.map( arcDatum => (
418
+ <g
419
+ key={ arcDatum.data.label }
420
+ { ...getSegmentHandlers(
421
+ arcDatum.data,
422
+ navigationArcs.indexOf( arcDatum )
423
+ ) }
422
424
  >
423
- { pie => {
424
- return pie.arcs.map( arc => (
425
- <g
426
- key={ arc.data.label }
427
- onMouseMove={ withTooltips ? handleArcMouseMove( arc ) : undefined }
428
- onMouseLeave={ withTooltips ? handleMouseLeave : undefined }
429
- >
430
- <path
431
- d={ pie.path( arc ) || '' }
432
- fill={ accessors.fill( arc.data ) }
433
- data-testid="pie-segment"
434
- />
435
- </g>
436
- ) );
437
- } }
438
- </Pie>
439
-
440
- { /* Label and note text */ }
441
- <Group>
442
- <Text
443
- textAnchor="middle"
444
- verticalAnchor="start"
445
- y={ -40 } // Position above the chart with space for note
446
- className={ styles.label }
447
- >
448
- { label }
449
- </Text>
450
- <Text
451
- textAnchor="middle"
452
- verticalAnchor="start"
453
- y={ -20 } // Position between label and chart
454
- className={ styles.note }
455
- >
456
- { note }
457
- </Text>
458
- </Group>
459
-
460
- { /* Render SVG children from composition API */ }
461
- { ! allSegmentsHidden && svgChildren }
462
- </>
425
+ <path
426
+ d={ path( arcDatum ) || '' }
427
+ fill={ accessors.fill( arcDatum.data ) }
428
+ data-testid="pie-segment"
429
+ />
430
+ </g>
431
+ ) )
463
432
  ) }
464
433
  </Group>
434
+ { /* Unmasked so the entry wipe cannot hide the focus indicator. */ }
435
+ { selectedArc && (
436
+ <Group top={ height } left={ width / 2 }>
437
+ <SelectedSegmentRing chartId={ chartId } d={ path( selectedArc ) || '' } />
438
+ </Group>
439
+ ) }
440
+ { ! allSegmentsHidden && (
441
+ <Group top={ height } left={ width / 2 } mask={ wipeMask }>
442
+ { /* Label and note text */ }
443
+ <Group>
444
+ <Text
445
+ textAnchor="middle"
446
+ verticalAnchor="start"
447
+ y={ -40 } // Position above the chart with space for note
448
+ className={ styles.label }
449
+ >
450
+ { label }
451
+ </Text>
452
+ <Text
453
+ textAnchor="middle"
454
+ verticalAnchor="start"
455
+ y={ -20 } // Position between label and chart
456
+ className={ styles.note }
457
+ >
458
+ { note }
459
+ </Text>
460
+ </Group>
461
+
462
+ { /* Render SVG children from composition API */ }
463
+ { svgChildren }
464
+ </Group>
465
+ ) }
465
466
  </svg>
466
- { withTooltips && tooltipOpen && tooltipData && (
467
- <BoundedTooltip top={ tooltipTop || 0 } left={ tooltipLeft || 0 }>
468
- <div className={ standaloneScopeClass } role="tooltip">
469
- { renderTooltip( { tooltipData } ) }
470
- </div>
471
- </BoundedTooltip>
472
- ) }
467
+ <PieSelectionOutput
468
+ { ...selectionOutputProps }
469
+ keyboardTooltipAnchor={
470
+ selectedArc &&
471
+ getKeyboardTooltipAnchor( width / 2, height, path.centroid( selectedArc ) )
472
+ }
473
+ renderTooltip={ renderTooltip }
474
+ />
473
475
  </Center>
474
476
  );
475
477
  } }
@@ -96,7 +96,7 @@ describe( 'PieSemiCircleChart', () => {
96
96
 
97
97
  await user.hover( firstSegment );
98
98
 
99
- // Wait for tooltip to be visible - it should show in the BaseTooltip component
99
+ // Wait for tooltip to be visible - it should show the default label: value content
100
100
  const tooltip = await screen.findByRole( 'tooltip' );
101
101
  expect( tooltip ).toHaveTextContent( 'MacOS' );
102
102
 
@@ -363,6 +363,60 @@ describe( 'PieSemiCircleChart', () => {
363
363
  } );
364
364
  } );
365
365
 
366
+ describe( 'PieSemiCircleChart keyboard navigation', () => {
367
+ it( 'names the focusable chart', () => {
368
+ renderPieChart( { data: mockData } );
369
+ expect( screen.getByRole( 'application', { name: 'Semi-circle chart' } ) ).toHaveAttribute(
370
+ 'tabindex',
371
+ '0'
372
+ );
373
+ } );
374
+
375
+ it( 'starts at the leftmost segment, which is the largest value', async () => {
376
+ const user = userEvent.setup();
377
+ renderPieChart( { data: mockData, withTooltips: true } );
378
+
379
+ await user.tab();
380
+ await user.keyboard( '{ArrowRight}' );
381
+
382
+ await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toHaveTextContent(
383
+ 'Category B'
384
+ );
385
+ expect( screen.getByTestId( 'pie-selected-ring' ) ).toHaveAttribute(
386
+ 'd',
387
+ screen.getAllByTestId( 'pie-segment' )[ 1 ].getAttribute( 'd' )
388
+ );
389
+ } );
390
+
391
+ it( 'clears the selection when new values reorder the segments', async () => {
392
+ const user = userEvent.setup();
393
+ const { rerender } = renderPieChart( { data: mockData, withTooltips: true } );
394
+
395
+ await user.tab();
396
+ await user.keyboard( '{ArrowRight}' );
397
+ await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toHaveTextContent(
398
+ 'Category B'
399
+ );
400
+
401
+ rerender(
402
+ <GlobalChartsProvider>
403
+ <PieSemiCircleChart
404
+ data={ [
405
+ { ...mockData[ 0 ], value: 70 },
406
+ { ...mockData[ 1 ], value: 30 },
407
+ ] }
408
+ withTooltips
409
+ />
410
+ </GlobalChartsProvider>
411
+ );
412
+
413
+ await waitFor( () =>
414
+ expect( screen.queryByTestId( 'pie-selected-ring' ) ).not.toBeInTheDocument()
415
+ );
416
+ expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
417
+ } );
418
+ } );
419
+
366
420
  // Chart container at (100, 50); the tooltip box measures 120x40. Everything
367
421
  // else the charts measure (wrapper, clipping lookups) reports the container.
368
422
  const mockRects = () =>
@@ -1,8 +1,9 @@
1
1
  import { Group } from '@visx/group';
2
2
  import { useMemo, Children, isValidElement } from 'react';
3
3
  import { Legend } from '../../../components/legend';
4
+ import type { LegendProps } from '../../../components/legend/types';
4
5
  import type { LegendPosition } from '../../../types';
5
- import type { ReactElement, ReactNode } from 'react';
6
+ import type { PropsWithChildren, ReactElement, ReactNode } from 'react';
6
7
 
7
8
  export type LegendChild = {
8
9
  element: ReactElement;
@@ -39,7 +40,7 @@ export function useChartChildren( children: ReactNode, chartType: string ): Char
39
40
  if ( isValidElement( child ) ) {
40
41
  // Extract Legend children for position-based slot rendering
41
42
  if ( child.type === Legend ) {
42
- const rawPosition = child.props?.position;
43
+ const rawPosition = ( child as ReactElement< LegendProps > ).props?.position;
43
44
  const position =
44
45
  rawPosition === 'top' || rawPosition === 'bottom' ? rawPosition : 'bottom';
45
46
 
@@ -55,15 +56,19 @@ export function useChartChildren( children: ReactNode, chartType: string ): Char
55
56
  // Handle chart-specific compound components (e.g., PieChart.SVG)
56
57
  if ( displayName === `${ chartType }.SVG` || displayName === 'Chart.SVG' ) {
57
58
  // Extract children from Chart.SVG with safety checks
58
- if ( child.props?.children ) {
59
- Children.forEach( child.props.children, svgChild => {
59
+ const { children: svgChildren } =
60
+ ( child as ReactElement< PropsWithChildren > ).props ?? {};
61
+ if ( svgChildren ) {
62
+ Children.forEach( svgChildren, svgChild => {
60
63
  svg.push( svgChild );
61
64
  } );
62
65
  }
63
66
  } else if ( displayName === `${ chartType }.HTML` || displayName === 'Chart.HTML' ) {
64
67
  // Extract children from Chart.HTML with safety checks
65
- if ( child.props?.children ) {
66
- Children.forEach( child.props.children, htmlChild => {
68
+ const { children: htmlChildren } =
69
+ ( child as ReactElement< PropsWithChildren > ).props ?? {};
70
+ if ( htmlChildren ) {
71
+ Children.forEach( htmlChildren, htmlChild => {
67
72
  html.push( htmlChild );
68
73
  } );
69
74
  }
@@ -0,0 +1,4 @@
1
+ export { orderArcsForNavigation } from './order-arcs-for-navigation';
2
+ export { PieSelectionOutput } from './pie-selection-output';
3
+ export { SelectedSegmentRing } from './selected-segment-ring';
4
+ export { usePieKeyboardNavigation } from './use-pie-keyboard-navigation';
@@ -0,0 +1,10 @@
1
+ type AngledArc = { startAngle: number; endAngle: number };
2
+
3
+ /**
4
+ * Orders arcs the way they appear: clockwise from 12 o'clock, which for a semi-circle is left to right.
5
+ *
6
+ * @param arcs - Arcs from visx's `pie` factory, in data order.
7
+ * @return A copy of `arcs` sorted by angular midpoint.
8
+ */
9
+ export const orderArcsForNavigation = < T extends AngledArc >( arcs: T[] ): T[] =>
10
+ [ ...arcs ].sort( ( a, b ) => a.startAngle + a.endAngle - ( b.startAngle + b.endAngle ) );
@@ -0,0 +1,16 @@
1
+ // The clip to the segment removes the outer half of each stroke,
2
+ // so widths are doubled.
3
+ .selected-ring__contrast,
4
+ .selected-ring__focus {
5
+ fill: none;
6
+ }
7
+
8
+ .selected-ring__contrast {
9
+ stroke: var(--a8c-charts-color-background, #fff);
10
+ stroke-width: calc(4 * var(--wpds-border-width-focus));
11
+ }
12
+
13
+ .selected-ring__focus {
14
+ stroke: var(--wpds-color-stroke-focus);
15
+ stroke-width: calc(2 * var(--wpds-border-width-focus));
16
+ }
@@ -0,0 +1,14 @@
1
+ import { VisuallyHidden } from '@wordpress/ui';
2
+ import { LabelValueContent } from '../../../components/tooltip/private/label-value-content';
3
+ import type { DataPointPercentageCalculated } from '../../../types';
4
+
5
+ interface PieSelectionAnnouncementProps {
6
+ /** The keyboard-selected segment, when no tooltip is showing to announce it. */
7
+ data?: DataPointPercentageCalculated;
8
+ }
9
+
10
+ export const PieSelectionAnnouncement = ( { data }: PieSelectionAnnouncementProps ) => (
11
+ <VisuallyHidden role="status" aria-atomic="true">
12
+ { data && <LabelValueContent data={ data } /> }
13
+ </VisuallyHidden>
14
+ );
@@ -0,0 +1,82 @@
1
+ import { useLayoutEffect, useState } from 'react';
2
+ import { PieSelectionAnnouncement } from './pie-selection-announcement';
3
+ import { PieTooltip } from './pie-tooltip';
4
+ import type { DataPointPercentageCalculated } from '../../../types';
5
+ import type { ReactNode, RefObject } from 'react';
6
+
7
+ interface Position {
8
+ top: number;
9
+ left: number;
10
+ }
11
+
12
+ interface PieSelectionOutputProps {
13
+ withTooltips: boolean;
14
+ selectedIndex?: number;
15
+ /** The keyboard-selected segment's data. */
16
+ selectedData?: DataPointPercentageCalculated;
17
+ /** Where the keyboard tooltip points, in SVG coordinates. */
18
+ keyboardTooltipAnchor?: Position;
19
+ pointerTooltip?: { data?: DataPointPercentageCalculated; top: number; left: number };
20
+ /** The element the tooltips are positioned in. */
21
+ chartRef: RefObject< HTMLDivElement >;
22
+ svgRef: RefObject< SVGSVGElement >;
23
+ tooltipRef: ( element: HTMLDivElement | null ) => void;
24
+ renderTooltip: ( params: { tooltipData: DataPointPercentageCalculated } ) => ReactNode;
25
+ }
26
+
27
+ /**
28
+ * Reports the selected segment: a focused tooltip for a keyboard selection, a pointer tooltip on hover, or a screen reader announcement without tooltips.
29
+ *
30
+ * @param {PieSelectionOutputProps} props - Component props
31
+ * @return {JSX.Element} The announcement region and the tooltip, if any
32
+ */
33
+ export const PieSelectionOutput = ( {
34
+ withTooltips,
35
+ selectedIndex,
36
+ selectedData,
37
+ keyboardTooltipAnchor,
38
+ pointerTooltip,
39
+ chartRef,
40
+ svgRef,
41
+ tooltipRef,
42
+ renderTooltip,
43
+ }: PieSelectionOutputProps ) => {
44
+ const [ svgOffset, setSvgOffset ] = useState< Position >( { top: 0, left: 0 } );
45
+ const showKeyboardTooltip = withTooltips && selectedData && keyboardTooltipAnchor;
46
+
47
+ // Measured after commit, because during render the SVG still has the previous commit's size and position.
48
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- A resize re-renders this without changing any prop, and the bail-out below ends the chain.
49
+ useLayoutEffect( () => {
50
+ const chartBounds = chartRef.current?.getBoundingClientRect();
51
+ const svgBounds = svgRef.current?.getBoundingClientRect();
52
+ if ( ! showKeyboardTooltip || ! chartBounds || ! svgBounds ) {
53
+ return;
54
+ }
55
+ const top = svgBounds.top - chartBounds.top;
56
+ const left = svgBounds.left - chartBounds.left;
57
+ setSvgOffset( current =>
58
+ current.top === top && current.left === left ? current : { top, left }
59
+ );
60
+ } );
61
+
62
+ return (
63
+ <>
64
+ <PieSelectionAnnouncement data={ withTooltips ? undefined : selectedData } />
65
+ { showKeyboardTooltip && (
66
+ <PieTooltip
67
+ top={ svgOffset.top + keyboardTooltipAnchor.top }
68
+ left={ svgOffset.left + keyboardTooltipAnchor.left }
69
+ selectedIndex={ selectedIndex }
70
+ tooltipRef={ tooltipRef }
71
+ >
72
+ { renderTooltip( { tooltipData: selectedData } ) }
73
+ </PieTooltip>
74
+ ) }
75
+ { withTooltips && ! selectedData && pointerTooltip?.data && (
76
+ <PieTooltip top={ pointerTooltip.top } left={ pointerTooltip.left }>
77
+ { renderTooltip( { tooltipData: pointerTooltip.data } ) }
78
+ </PieTooltip>
79
+ ) }
80
+ </>
81
+ );
82
+ };
@@ -0,0 +1,44 @@
1
+ import { BoundedTooltip } from '../../../components/tooltip/private/bounded-tooltip';
2
+ import { useStandaloneScopeClass } from '../../../providers/chart-scope';
3
+ import type { ReactNode } from 'react';
4
+
5
+ interface PieTooltipProps {
6
+ top: number;
7
+ left: number;
8
+ /** Set while a keyboard selection drives the tooltip, which then takes focus. */
9
+ selectedIndex?: number;
10
+ tooltipRef?: ( element: HTMLDivElement | null ) => void;
11
+ children: ReactNode;
12
+ }
13
+
14
+ export const PieTooltip = ( {
15
+ top,
16
+ left,
17
+ selectedIndex,
18
+ tooltipRef,
19
+ children,
20
+ }: PieTooltipProps ) => {
21
+ const standaloneScopeClass = useStandaloneScopeClass();
22
+
23
+ return (
24
+ <BoundedTooltip top={ top } left={ left }>
25
+ { selectedIndex === undefined ? (
26
+ <div className={ standaloneScopeClass } role="tooltip">
27
+ { children }
28
+ </div>
29
+ ) : (
30
+ <div
31
+ ref={ tooltipRef }
32
+ tabIndex={ -1 }
33
+ role="tooltip"
34
+ aria-atomic="true"
35
+ className={ standaloneScopeClass }
36
+ data-testid={ `chart-tooltip-${ selectedIndex }` }
37
+ key={ `chart-tooltip-${ selectedIndex }` }
38
+ >
39
+ { children }
40
+ </div>
41
+ ) }
42
+ </BoundedTooltip>
43
+ );
44
+ };