@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,11 +1,10 @@
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 { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
9
8
  import { LabelValueContent } from '../../components/tooltip/private/label-value-content';
10
9
  import {
11
10
  useDataWithPercentages,
@@ -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.
@@ -125,6 +129,11 @@ export interface PieSemiCircleChartProps extends BaseChartProps< DataPointPercen
125
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
  } }
@@ -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
+ };
@@ -0,0 +1,47 @@
1
+ import styles from './pie-keyboard-navigation.module.scss';
2
+
3
+ interface SelectedSegmentRingProps {
4
+ /** The chart's id, which keeps the clip path unique on a page with several charts. */
5
+ chartId: string;
6
+ /** The selected segment's path, from the same arc generator that draws the segment. */
7
+ d: string;
8
+ }
9
+
10
+ /**
11
+ * Encodes `value` for an SVG id, losslessly so distinct chart ids never share a clip path.
12
+ *
13
+ * @param value - Any string.
14
+ * @return `value` with each character outside `[A-Za-z0-9-]` written as `_<hex code point>_`.
15
+ */
16
+ export const toSvgId = ( value: string ) =>
17
+ Array.from( value, character =>
18
+ /[A-Za-z0-9-]/.test( character )
19
+ ? character
20
+ : `_${ character.codePointAt( 0 )?.toString( 16 ) }_`
21
+ ).join( '' );
22
+
23
+ /**
24
+ * Marks the selected segment with a two-tone ring drawn inside its edge, so the SVG edge cannot cut it off.
25
+ *
26
+ * @param {SelectedSegmentRingProps} props - Component props
27
+ * @return {JSX.Element} The ring
28
+ */
29
+ export const SelectedSegmentRing = ( { chartId, d }: SelectedSegmentRingProps ) => {
30
+ const clipId = `pie-selected-ring-clip-${ toSvgId( chartId ) }`;
31
+ const clipPath = `url(#${ clipId })`;
32
+
33
+ return (
34
+ <g pointerEvents="none" aria-hidden="true">
35
+ <clipPath id={ clipId }>
36
+ <path d={ d } />
37
+ </clipPath>
38
+ <path d={ d } clipPath={ clipPath } className={ styles[ 'selected-ring__contrast' ] } />
39
+ <path
40
+ d={ d }
41
+ clipPath={ clipPath }
42
+ className={ styles[ 'selected-ring__focus' ] }
43
+ data-testid="pie-selected-ring"
44
+ />
45
+ </g>
46
+ );
47
+ };