@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,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.
@@ -138,6 +142,9 @@ type PieSemiCircleChartResponsiveComponent = ChartComponentWithComposition<
138
142
 
139
143
  export type ArcData = PieProvidedProps< DataPointPercentageCalculated >[ 'arcs' ][ number ];
140
144
 
145
+ type PieDatum = DataPointPercentageCalculated & { index: number };
146
+ type PieArcDatum = PieProvidedProps< PieDatum >[ 'arcs' ][ number ];
147
+
141
148
  /**
142
149
  * Validates the semi-circle pie chart data
143
150
  * @param data - The data to validate
@@ -182,45 +189,13 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
182
189
  tooltipOffsetX = 0,
183
190
  tooltipOffsetY = -15,
184
191
  renderTooltip = renderDefaultPieSemiCircleTooltip,
192
+ ariaLabel,
185
193
  gap = 'md',
186
194
  } ) => {
187
195
  const legendInteractive = legend.interactive ?? false;
188
196
  const legendPosition = legend.position ?? 'bottom';
189
197
 
190
198
  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
199
 
225
200
  // Validate data first to get validation result
226
201
  const { isValid, message } = validateData( data );
@@ -285,9 +260,48 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
285
260
  } );
286
261
 
287
262
  const prefersReducedMotion = usePrefersReducedMotion();
263
+ const wipeMask = animation && ! prefersReducedMotion ? `url(#radial-wipe-${ chartId })` : null;
288
264
 
289
265
  const effectiveWidth = propWidth || DEFAULT_WIDTH;
290
266
 
267
+ // Map data with index for color assignment
268
+ // When interactive, we need to find the original index to maintain consistent colors
269
+ const dataWithIndex = visibleData.map( d => {
270
+ const originalIndex = data.findIndex( item => item.label === d.label );
271
+ return {
272
+ ...d,
273
+ index: originalIndex >= 0 ? originalIndex : 0,
274
+ };
275
+ } );
276
+
277
+ // Configure pie angles based on clockwise direction
278
+ const startAngle = clockwise ? -Math.PI / 2 : Math.PI / 2;
279
+ const endAngle = clockwise ? Math.PI / 2 : -Math.PI / 2;
280
+
281
+ const arcs = pie< PieDatum >( {
282
+ value: accessors.value,
283
+ sort: accessors.sort,
284
+ padAngle: PAD_ANGLE,
285
+ startAngle,
286
+ endAngle,
287
+ } )( dataWithIndex );
288
+ const navigationArcs = orderArcsForNavigation( arcs );
289
+
290
+ const {
291
+ chartRef,
292
+ svgRef,
293
+ selectedArc,
294
+ getSegmentHandlers,
295
+ getKeyboardTooltipAnchor,
296
+ selectionOutputProps,
297
+ chartProps,
298
+ } = usePieKeyboardNavigation( {
299
+ navigationArcs,
300
+ withTooltips,
301
+ tooltipOffsetX,
302
+ tooltipOffsetY,
303
+ } );
304
+
291
305
  if ( ! isValid ) {
292
306
  const errorWidth = propHeight
293
307
  ? Math.min( propWidth || propHeight * 2, propHeight * 2 )
@@ -307,20 +321,6 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
307
321
  );
308
322
  }
309
323
 
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
324
  const legendElement = showLegend && (
325
325
  <Legend
326
326
  orientation={ legend.orientation ?? 'horizontal' }
@@ -376,9 +376,17 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
376
376
  const radius = height; // For a semi-circle, radius equals the SVG height
377
377
  const innerRadius = radius * ( 1 - thickness );
378
378
 
379
+ const path = arc< PieArcDatum >( { innerRadius, outerRadius: radius, cornerRadius: 3 } );
380
+
379
381
  return (
380
- <Center ref={ containerRef } className={ styles[ 'pie-semi-circle-chart__plot' ] }>
382
+ <Center
383
+ ref={ chartRef }
384
+ className={ styles[ 'pie-semi-circle-chart__plot' ] }
385
+ { ...chartProps }
386
+ aria-label={ ariaLabel ?? __( 'Semi-circle chart', 'jetpack-charts' ) }
387
+ >
381
388
  <svg
389
+ ref={ svgRef }
382
390
  width={ width }
383
391
  height={ height }
384
392
  viewBox={ `0 0 ${ width } ${ height }` }
@@ -395,81 +403,70 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
395
403
  </defs>
396
404
 
397
405
  { /* 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
- >
406
+ <Group top={ height } left={ width / 2 } mask={ wipeMask }>
405
407
  { allSegmentsHidden ? (
406
408
  <SvgEmptyState x={ 0 } y={ -radius / 2 } width={ width } height={ height }>
407
409
  { getAllHiddenMessage( legendInteractive, 'segments' ) }
408
410
  </SvgEmptyState>
409
411
  ) : (
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 }
412
+ arcs.map( arcDatum => (
413
+ <g
414
+ key={ arcDatum.data.label }
415
+ { ...getSegmentHandlers(
416
+ arcDatum.data,
417
+ navigationArcs.indexOf( arcDatum )
418
+ ) }
422
419
  >
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
- </>
420
+ <path
421
+ d={ path( arcDatum ) || '' }
422
+ fill={ accessors.fill( arcDatum.data ) }
423
+ data-testid="pie-segment"
424
+ />
425
+ </g>
426
+ ) )
463
427
  ) }
464
428
  </Group>
429
+ { /* Unmasked so the entry wipe cannot hide the focus indicator. */ }
430
+ { selectedArc && (
431
+ <Group top={ height } left={ width / 2 }>
432
+ <SelectedSegmentRing chartId={ chartId } d={ path( selectedArc ) || '' } />
433
+ </Group>
434
+ ) }
435
+ { ! allSegmentsHidden && (
436
+ <Group top={ height } left={ width / 2 } mask={ wipeMask }>
437
+ { /* Label and note text */ }
438
+ <Group>
439
+ <Text
440
+ textAnchor="middle"
441
+ verticalAnchor="start"
442
+ y={ -40 } // Position above the chart with space for note
443
+ className={ styles.label }
444
+ >
445
+ { label }
446
+ </Text>
447
+ <Text
448
+ textAnchor="middle"
449
+ verticalAnchor="start"
450
+ y={ -20 } // Position between label and chart
451
+ className={ styles.note }
452
+ >
453
+ { note }
454
+ </Text>
455
+ </Group>
456
+
457
+ { /* Render SVG children from composition API */ }
458
+ { svgChildren }
459
+ </Group>
460
+ ) }
465
461
  </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
- ) }
462
+ <PieSelectionOutput
463
+ { ...selectionOutputProps }
464
+ keyboardTooltipAnchor={
465
+ selectedArc &&
466
+ getKeyboardTooltipAnchor( width / 2, height, path.centroid( selectedArc ) )
467
+ }
468
+ renderTooltip={ renderTooltip }
469
+ />
473
470
  </Center>
474
471
  );
475
472
  } }
@@ -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,49 @@
1
+ import {
2
+ BoundedTooltip,
3
+ TOOLTIP_Z_INDEX,
4
+ } from '../../../components/tooltip/private/bounded-tooltip';
5
+ import { useStandaloneScopeClass } from '../../../providers/chart-scope';
6
+ import type { CSSProperties, ReactNode } from 'react';
7
+
8
+ const TOOLTIP_BOX_STYLE: CSSProperties = { zIndex: TOOLTIP_Z_INDEX };
9
+
10
+ interface PieTooltipProps {
11
+ top: number;
12
+ left: number;
13
+ /** Set while a keyboard selection drives the tooltip, which then takes focus. */
14
+ selectedIndex?: number;
15
+ tooltipRef?: ( element: HTMLDivElement | null ) => void;
16
+ children: ReactNode;
17
+ }
18
+
19
+ export const PieTooltip = ( {
20
+ top,
21
+ left,
22
+ selectedIndex,
23
+ tooltipRef,
24
+ children,
25
+ }: PieTooltipProps ) => {
26
+ const standaloneScopeClass = useStandaloneScopeClass();
27
+
28
+ return (
29
+ <BoundedTooltip top={ top } left={ left } style={ TOOLTIP_BOX_STYLE }>
30
+ { selectedIndex === undefined ? (
31
+ <div className={ standaloneScopeClass } role="tooltip">
32
+ { children }
33
+ </div>
34
+ ) : (
35
+ <div
36
+ ref={ tooltipRef }
37
+ tabIndex={ -1 }
38
+ role="tooltip"
39
+ aria-atomic="true"
40
+ className={ standaloneScopeClass }
41
+ data-testid={ `chart-tooltip-${ selectedIndex }` }
42
+ key={ `chart-tooltip-${ selectedIndex }` }
43
+ >
44
+ { children }
45
+ </div>
46
+ ) }
47
+ </BoundedTooltip>
48
+ );
49
+ };
@@ -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
+ };