@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.
- package/CHANGELOG.md +28 -0
- package/dist/index.cjs +766 -277
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +72 -8
- package/dist/index.d.cts +54 -15
- package/dist/index.d.ts +54 -15
- package/dist/index.js +770 -281
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.tsx +7 -3
- package/src/charts/area-chart/test/area-chart.test.tsx +15 -14
- package/src/charts/area-chart/test/tooltip-resolution.test.tsx +2 -2
- package/src/charts/bar-chart/bar-chart.tsx +12 -4
- package/src/charts/bar-chart/test/bar-chart.test.tsx +74 -63
- package/src/charts/bar-list-chart/bar-list-chart.tsx +3 -0
- package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +3 -1
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +23 -7
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +22 -0
- package/src/charts/conversion-funnel-chart/types.ts +1 -1
- package/src/charts/geo-chart/geo-chart.tsx +17 -2
- package/src/charts/geo-chart/test/geo-chart.test.tsx +9 -4
- package/src/charts/geo-chart/types.ts +1 -1
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +10 -4
- package/src/charts/heatmap-chart/heatmap-chart.tsx +144 -37
- package/src/charts/heatmap-chart/private/column-groups.ts +79 -8
- package/src/charts/heatmap-chart/private/fit-compact-cells.ts +72 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +14 -2
- package/src/charts/heatmap-chart/private/index.ts +4 -2
- package/src/charts/heatmap-chart/test/column-groups.test.ts +41 -4
- package/src/charts/heatmap-chart/test/fit-compact-cells.test.ts +59 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +132 -7
- package/src/charts/heatmap-chart/types.ts +2 -2
- package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +52 -4
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +60 -9
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +80 -1
- package/src/charts/leaderboard-chart/types.ts +7 -1
- package/src/charts/line-chart/line-chart.tsx +8 -2
- package/src/charts/line-chart/private/line-chart-annotation-label-popover.tsx +19 -9
- package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +1 -1
- package/src/charts/line-chart/test/line-chart.test.tsx +37 -35
- package/src/charts/line-chart/test/tooltip-resolution.test.tsx +2 -2
- package/src/charts/line-chart/types.ts +3 -3
- package/src/charts/pie-chart/pie-chart.module.scss +3 -0
- package/src/charts/pie-chart/pie-chart.tsx +144 -139
- package/src/charts/pie-chart/test/pie-chart.test.tsx +167 -0
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +3 -0
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +117 -120
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +54 -0
- package/src/charts/private/chart-composition/use-chart-children.ts +11 -6
- package/src/charts/private/pie-keyboard-navigation/index.ts +4 -0
- package/src/charts/private/pie-keyboard-navigation/order-arcs-for-navigation.ts +10 -0
- package/src/charts/private/pie-keyboard-navigation/pie-keyboard-navigation.module.scss +16 -0
- package/src/charts/private/pie-keyboard-navigation/pie-selection-announcement.tsx +14 -0
- package/src/charts/private/pie-keyboard-navigation/pie-selection-output.tsx +82 -0
- package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +49 -0
- package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +47 -0
- package/src/charts/private/pie-keyboard-navigation/test/order-arcs-for-navigation.test.ts +34 -0
- package/src/charts/private/pie-keyboard-navigation/test/selected-segment-ring.test.ts +14 -0
- package/src/charts/private/pie-keyboard-navigation/use-pie-keyboard-navigation.ts +139 -0
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +3 -2
- package/src/charts/test/accessible-name.test.tsx +181 -0
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +63 -3
- package/src/components/legend/hooks/use-chart-legend-items.ts +63 -1
- package/src/components/legend/private/base-legend.tsx +27 -15
- package/src/components/legend/test/legend.test.tsx +42 -0
- package/src/components/legend/types.ts +2 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +20 -19
- package/src/components/tooltip/xy-chart-tooltip.tsx +3 -3
- package/src/hooks/test/use-xychart-theme.test.tsx +22 -0
- package/src/hooks/use-xychart-theme.ts +33 -2
- package/src/index.ts +1 -0
- package/src/types.ts +37 -3
- package/src/utils/resolve-font-size.ts +9 -10
- package/src/utils/test/resolve-font-size.test.ts +11 -2
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { Group } from '@visx/group';
|
|
2
|
-
import {
|
|
2
|
+
import { arc, pie } from '@visx/shape';
|
|
3
3
|
import { Text } from '@visx/text';
|
|
4
|
-
import {
|
|
4
|
+
import { __ } from '@wordpress/i18n';
|
|
5
5
|
import clsx from 'clsx';
|
|
6
|
-
import {
|
|
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,
|
|
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
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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,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
|
+
};
|