@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.
- package/CHANGELOG.md +14 -0
- package/dist/index.cjs +624 -228
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +58 -8
- package/dist/index.d.cts +42 -8
- package/dist/index.d.ts +42 -8
- package/dist/index.js +628 -232
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.tsx +5 -2
- package/src/charts/area-chart/test/area-chart.test.tsx +23 -14
- package/src/charts/area-chart/test/tooltip-resolution.test.tsx +2 -2
- package/src/charts/area-chart/types.ts +2 -0
- package/src/charts/bar-chart/bar-chart.tsx +5 -2
- package/src/charts/bar-chart/test/bar-chart.test.tsx +82 -63
- package/src/charts/bar-chart/types.ts +2 -0
- package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +3 -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 -1
- package/src/charts/heatmap-chart/types.ts +2 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +46 -4
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +37 -1
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +53 -0
- package/src/charts/leaderboard-chart/types.ts +6 -1
- package/src/charts/line-chart/line-chart.tsx +5 -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 +45 -35
- package/src/charts/line-chart/test/tooltip-resolution.test.tsx +2 -2
- package/src/charts/line-chart/types.ts +5 -3
- package/src/charts/pie-chart/pie-chart.tsx +149 -139
- package/src/charts/pie-chart/test/pie-chart.test.tsx +171 -0
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +122 -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 +44 -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/components/tooltip/test/xy-chart-tooltip.test.tsx +20 -19
- package/src/components/tooltip/xy-chart-tooltip.tsx +3 -3
- package/src/types.ts +18 -2
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { Group } from '@visx/group';
|
|
2
|
-
import {
|
|
3
|
-
import { useTooltip } from '@visx/tooltip';
|
|
2
|
+
import { arc, pie } from '@visx/shape';
|
|
4
3
|
import { color as d3Color } from '@visx/vendor/d3-color';
|
|
4
|
+
import { __ } from '@wordpress/i18n';
|
|
5
5
|
import clsx from 'clsx';
|
|
6
6
|
import isEqual from 'fast-deep-equal';
|
|
7
|
-
import {
|
|
7
|
+
import { useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
8
8
|
import { Legend, useChartLegendItems } from '../../components/legend';
|
|
9
|
-
import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
|
|
10
9
|
import { LabelValueContent } from '../../components/tooltip/private/label-value-content';
|
|
11
10
|
import {
|
|
12
11
|
useDataWithPercentages,
|
|
@@ -22,7 +21,6 @@ import {
|
|
|
22
21
|
GlobalChartsContext,
|
|
23
22
|
} from '../../providers';
|
|
24
23
|
import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
|
|
25
|
-
import { useStandaloneScopeClass } from '../../providers/chart-scope';
|
|
26
24
|
import { attachSubComponents, createCssVariableResolver, resolveFontSize } from '../../utils';
|
|
27
25
|
import { getStringWidth } from '../../visx/text';
|
|
28
26
|
import { Center } from '../private/center';
|
|
@@ -30,6 +28,12 @@ import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composit
|
|
|
30
28
|
import { ChartInstanceContext } from '../private/chart-instance-context';
|
|
31
29
|
import { ChartLayout } from '../private/chart-layout';
|
|
32
30
|
import { pickLabelTextColor, resolveLabelRoles } from '../private/label-text-color';
|
|
31
|
+
import {
|
|
32
|
+
orderArcsForNavigation,
|
|
33
|
+
PieSelectionOutput,
|
|
34
|
+
SelectedSegmentRing,
|
|
35
|
+
usePieKeyboardNavigation,
|
|
36
|
+
} from '../private/pie-keyboard-navigation';
|
|
33
37
|
import { RadialWipeAnimation } from '../private/radial-wipe-animation/';
|
|
34
38
|
import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
|
|
35
39
|
import { withResponsive, ResponsiveConfig } from '../private/with-responsive';
|
|
@@ -43,7 +47,11 @@ import type {
|
|
|
43
47
|
} from '../../types';
|
|
44
48
|
import type { ChartComponentWithComposition } from '../private/chart-composition';
|
|
45
49
|
import type { LabelRoles, LabelTextColor } from '../private/label-text-color';
|
|
46
|
-
import type {
|
|
50
|
+
import type { PieProvidedProps } from '@visx/shape';
|
|
51
|
+
import type { JSX, ReactNode, FC } from 'react';
|
|
52
|
+
|
|
53
|
+
type PieDatum = DataPointPercentageCalculated & { index: number };
|
|
54
|
+
type PieArcDatum = PieProvidedProps< PieDatum >[ 'arcs' ][ number ];
|
|
47
55
|
|
|
48
56
|
/**
|
|
49
57
|
* Parameters passed to the renderTooltip function for pie charts.
|
|
@@ -130,6 +138,11 @@ export interface PieChartProps extends BaseChartProps< DataPointPercentage[] > {
|
|
|
130
138
|
* When provided, replaces the default `label: value` tooltip with custom content.
|
|
131
139
|
*/
|
|
132
140
|
renderTooltip?: ( params: PieChartRenderTooltipParams ) => ReactNode;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Accessible name of the chart. Defaults to a localized "Pie chart".
|
|
144
|
+
*/
|
|
145
|
+
ariaLabel?: string;
|
|
133
146
|
}
|
|
134
147
|
|
|
135
148
|
// Base props type with optional responsive properties
|
|
@@ -201,6 +214,7 @@ const PieChartInternal = ( {
|
|
|
201
214
|
tooltipOffsetX = 0,
|
|
202
215
|
tooltipOffsetY = -15,
|
|
203
216
|
renderTooltip = renderDefaultPieTooltip,
|
|
217
|
+
ariaLabel,
|
|
204
218
|
gap = 'md',
|
|
205
219
|
}: PieChartProps ) => {
|
|
206
220
|
const legendInteractive = legend.interactive ?? false;
|
|
@@ -208,12 +222,6 @@ const PieChartInternal = ( {
|
|
|
208
222
|
|
|
209
223
|
const providerTheme = useGlobalChartsTheme();
|
|
210
224
|
const chartId = useChartId( providedChartId );
|
|
211
|
-
const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, hideTooltip, showTooltip } =
|
|
212
|
-
useTooltip< DataPointPercentageCalculated >();
|
|
213
|
-
const standaloneScopeClass = useStandaloneScopeClass();
|
|
214
|
-
|
|
215
|
-
// The tooltip renders inside this element, so pointer coordinates are taken relative to it.
|
|
216
|
-
const containerRef = useRef< HTMLDivElement >( null );
|
|
217
225
|
|
|
218
226
|
// The element the chart's own `className` lands on, so an override set there reaches this
|
|
219
227
|
// decision the same way it reaches CSS.
|
|
@@ -221,13 +229,6 @@ const PieChartInternal = ( {
|
|
|
221
229
|
// Null until read, and while a label plate is set: text on the plate keeps the inverse role.
|
|
222
230
|
const [ labelRoles, setLabelRoles ] = useState< LabelRoles | null >( null );
|
|
223
231
|
|
|
224
|
-
const onMouseLeave = useCallback( () => {
|
|
225
|
-
if ( ! withTooltips ) {
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
hideTooltip();
|
|
229
|
-
}, [ withTooltips, hideTooltip ] );
|
|
230
|
-
|
|
231
232
|
const { getElementStyles, isSeriesVisible, isColorPaletteResolved } = useGlobalChartsContext();
|
|
232
233
|
const { isValid, message } = validateData( data );
|
|
233
234
|
|
|
@@ -292,14 +293,7 @@ const PieChartInternal = ( {
|
|
|
292
293
|
} );
|
|
293
294
|
|
|
294
295
|
const prefersReducedMotion = usePrefersReducedMotion();
|
|
295
|
-
|
|
296
|
-
if ( ! isValid ) {
|
|
297
|
-
return (
|
|
298
|
-
<div className={ clsx( 'pie-chart', styles[ 'pie-chart' ], className ) }>
|
|
299
|
-
<div className={ styles[ 'error-message' ] }>{ message }</div>
|
|
300
|
-
</div>
|
|
301
|
-
);
|
|
302
|
-
}
|
|
296
|
+
const wipeMask = animation && ! prefersReducedMotion ? `url(#radial-wipe-${ chartId })` : null;
|
|
303
297
|
|
|
304
298
|
// Calculate the angle between each (use original data length for consistent spacing)
|
|
305
299
|
const padAngle = gapScale * ( ( 2 * Math.PI ) / data.length );
|
|
@@ -321,6 +315,32 @@ const PieChartInternal = ( {
|
|
|
321
315
|
},
|
|
322
316
|
};
|
|
323
317
|
|
|
318
|
+
const arcs = pie< PieDatum >( { value: accessors.value, padAngle } )( dataWithIndex );
|
|
319
|
+
const navigationArcs = orderArcsForNavigation( arcs );
|
|
320
|
+
|
|
321
|
+
const {
|
|
322
|
+
chartRef,
|
|
323
|
+
svgRef,
|
|
324
|
+
selectedArc,
|
|
325
|
+
getSegmentHandlers,
|
|
326
|
+
getKeyboardTooltipAnchor,
|
|
327
|
+
selectionOutputProps,
|
|
328
|
+
chartProps,
|
|
329
|
+
} = usePieKeyboardNavigation( {
|
|
330
|
+
navigationArcs,
|
|
331
|
+
withTooltips,
|
|
332
|
+
tooltipOffsetX,
|
|
333
|
+
tooltipOffsetY,
|
|
334
|
+
} );
|
|
335
|
+
|
|
336
|
+
if ( ! isValid ) {
|
|
337
|
+
return (
|
|
338
|
+
<div className={ clsx( 'pie-chart', styles[ 'pie-chart' ], className ) }>
|
|
339
|
+
<div className={ styles[ 'error-message' ] }>{ message }</div>
|
|
340
|
+
</div>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
324
344
|
const legendElement = showLegend && (
|
|
325
345
|
<Legend
|
|
326
346
|
orientation={ legend.orientation ?? 'horizontal' }
|
|
@@ -383,9 +403,72 @@ const PieChartInternal = ( {
|
|
|
383
403
|
? Math.min( cornerScale * outerRadius, maxCornerRadius )
|
|
384
404
|
: 0;
|
|
385
405
|
|
|
406
|
+
const path = arc< PieArcDatum >( { innerRadius, outerRadius, cornerRadius } );
|
|
407
|
+
|
|
408
|
+
const svgLabelSmall = providerTheme.svgLabelSmall;
|
|
409
|
+
const fontSize = resolveFontSize( svgLabelSmall?.fontSize ) ?? 12;
|
|
410
|
+
|
|
411
|
+
const renderLabel = ( arcDatum: PieArcDatum, index: number ) => {
|
|
412
|
+
if ( arcDatum.endAngle - arcDatum.startAngle < 0.25 ) {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const [ centroidX, centroidY ] = path.centroid( arcDatum );
|
|
417
|
+
const estimatedTextWidth = getStringWidth( arcDatum.data.label, {
|
|
418
|
+
fontSize,
|
|
419
|
+
fontFamily: svgLabelSmall?.fontFamily,
|
|
420
|
+
fontWeight: svgLabelSmall?.fontWeight,
|
|
421
|
+
} );
|
|
422
|
+
const labelPadding = 6;
|
|
423
|
+
const backgroundWidth = estimatedTextWidth + labelPadding * 2;
|
|
424
|
+
const backgroundHeight = fontSize + labelPadding * 2;
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
<g key={ `label-${ index }` }>
|
|
428
|
+
<rect
|
|
429
|
+
className={ styles[ 'pie-chart__label-plate' ] }
|
|
430
|
+
x={ centroidX - backgroundWidth / 2 }
|
|
431
|
+
y={ centroidY - backgroundHeight / 2 }
|
|
432
|
+
width={ backgroundWidth }
|
|
433
|
+
height={ backgroundHeight }
|
|
434
|
+
rx={ 4 }
|
|
435
|
+
ry={ 4 }
|
|
436
|
+
pointerEvents="none"
|
|
437
|
+
/>
|
|
438
|
+
<text
|
|
439
|
+
className={ clsx(
|
|
440
|
+
styles[ 'pie-chart__label-text' ],
|
|
441
|
+
LABEL_TEXT_MODIFIER[
|
|
442
|
+
pickLabelTextColor(
|
|
443
|
+
accessors.fill( arcDatum.data ),
|
|
444
|
+
labelRoles,
|
|
445
|
+
'label-inverse'
|
|
446
|
+
)
|
|
447
|
+
]
|
|
448
|
+
) }
|
|
449
|
+
data-testid="pie-label"
|
|
450
|
+
x={ centroidX }
|
|
451
|
+
y={ centroidY }
|
|
452
|
+
dy=".33em"
|
|
453
|
+
fontSize={ fontSize }
|
|
454
|
+
textAnchor="middle"
|
|
455
|
+
pointerEvents="none"
|
|
456
|
+
>
|
|
457
|
+
{ arcDatum.data.label }
|
|
458
|
+
</text>
|
|
459
|
+
</g>
|
|
460
|
+
);
|
|
461
|
+
};
|
|
462
|
+
|
|
386
463
|
return (
|
|
387
|
-
<Center
|
|
464
|
+
<Center
|
|
465
|
+
ref={ chartRef }
|
|
466
|
+
className={ styles[ 'pie-chart__plot' ] }
|
|
467
|
+
{ ...chartProps }
|
|
468
|
+
aria-label={ ariaLabel ?? __( 'Pie chart', 'jetpack-charts' ) }
|
|
469
|
+
>
|
|
388
470
|
<svg
|
|
471
|
+
ref={ svgRef }
|
|
389
472
|
viewBox={ `0 0 ${ width } ${ height }` }
|
|
390
473
|
preserveAspectRatio="xMidYMid meet"
|
|
391
474
|
width={ width }
|
|
@@ -399,125 +482,52 @@ const PieChartInternal = ( {
|
|
|
399
482
|
/>
|
|
400
483
|
</defs>
|
|
401
484
|
|
|
402
|
-
<Group
|
|
403
|
-
top={ centerY }
|
|
404
|
-
left={ centerX }
|
|
405
|
-
mask={
|
|
406
|
-
animation && ! prefersReducedMotion ? `url(#radial-wipe-${ chartId })` : null
|
|
407
|
-
}
|
|
408
|
-
>
|
|
485
|
+
<Group top={ centerY } left={ centerX } mask={ wipeMask }>
|
|
409
486
|
{ allSegmentsHidden ? (
|
|
410
487
|
<SvgEmptyState x={ 0 } y={ 0 } width={ width } height={ height }>
|
|
411
488
|
{ getAllHiddenMessage( legendInteractive, 'segments' ) }
|
|
412
489
|
</SvgEmptyState>
|
|
413
490
|
) : (
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const bounds = containerRef.current?.getBoundingClientRect();
|
|
432
|
-
if ( ! bounds ) {
|
|
433
|
-
return;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
showTooltip( {
|
|
437
|
-
tooltipData: arc.data,
|
|
438
|
-
tooltipLeft: event.clientX - bounds.left + tooltipOffsetX,
|
|
439
|
-
tooltipTop: event.clientY - bounds.top + tooltipOffsetY,
|
|
440
|
-
} );
|
|
441
|
-
};
|
|
442
|
-
|
|
443
|
-
const fill = accessors.fill( arc.data );
|
|
444
|
-
const pathProps: SVGProps< SVGPathElement > & {
|
|
445
|
-
'data-testid'?: string;
|
|
446
|
-
} = {
|
|
447
|
-
d: pie.path( arc ) || '',
|
|
448
|
-
fill,
|
|
449
|
-
'data-testid': 'pie-segment',
|
|
450
|
-
};
|
|
451
|
-
|
|
452
|
-
const groupProps: SVGProps< SVGGElement > = {};
|
|
453
|
-
if ( withTooltips ) {
|
|
454
|
-
groupProps.onMouseMove = handleMouseMove;
|
|
455
|
-
groupProps.onMouseLeave = onMouseLeave;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
const svgLabelSmall = providerTheme.svgLabelSmall;
|
|
459
|
-
const fontSize = resolveFontSize( svgLabelSmall?.fontSize ) ?? 12;
|
|
460
|
-
const estimatedTextWidth = getStringWidth( arc.data.label, {
|
|
461
|
-
fontSize,
|
|
462
|
-
fontFamily: svgLabelSmall?.fontFamily,
|
|
463
|
-
fontWeight: svgLabelSmall?.fontWeight,
|
|
464
|
-
} );
|
|
465
|
-
const labelPadding = 6;
|
|
466
|
-
const backgroundWidth = estimatedTextWidth + labelPadding * 2;
|
|
467
|
-
const backgroundHeight = fontSize + labelPadding * 2;
|
|
468
|
-
|
|
469
|
-
return (
|
|
470
|
-
<g key={ `arc-${ index }` } { ...groupProps }>
|
|
471
|
-
<path { ...pathProps } />
|
|
472
|
-
{ showLabels && hasSpaceForLabel && (
|
|
473
|
-
<g>
|
|
474
|
-
<rect
|
|
475
|
-
className={ styles[ 'pie-chart__label-plate' ] }
|
|
476
|
-
x={ centroidX - backgroundWidth / 2 }
|
|
477
|
-
y={ centroidY - backgroundHeight / 2 }
|
|
478
|
-
width={ backgroundWidth }
|
|
479
|
-
height={ backgroundHeight }
|
|
480
|
-
rx={ 4 }
|
|
481
|
-
ry={ 4 }
|
|
482
|
-
pointerEvents="none"
|
|
483
|
-
/>
|
|
484
|
-
<text
|
|
485
|
-
className={ clsx(
|
|
486
|
-
styles[ 'pie-chart__label-text' ],
|
|
487
|
-
LABEL_TEXT_MODIFIER[
|
|
488
|
-
pickLabelTextColor( fill, labelRoles, 'label-inverse' )
|
|
489
|
-
]
|
|
490
|
-
) }
|
|
491
|
-
data-testid="pie-label"
|
|
492
|
-
x={ centroidX }
|
|
493
|
-
y={ centroidY }
|
|
494
|
-
dy=".33em"
|
|
495
|
-
fontSize={ fontSize }
|
|
496
|
-
textAnchor="middle"
|
|
497
|
-
pointerEvents="none"
|
|
498
|
-
>
|
|
499
|
-
{ arc.data.label }
|
|
500
|
-
</text>
|
|
501
|
-
</g>
|
|
502
|
-
) }
|
|
503
|
-
</g>
|
|
504
|
-
);
|
|
505
|
-
} );
|
|
506
|
-
} }
|
|
507
|
-
</Pie>
|
|
491
|
+
arcs.map( ( arcDatum, index ) => (
|
|
492
|
+
<g
|
|
493
|
+
key={ `arc-${ index }` }
|
|
494
|
+
{ ...getSegmentHandlers(
|
|
495
|
+
arcDatum.data,
|
|
496
|
+
navigationArcs.indexOf( arcDatum )
|
|
497
|
+
) }
|
|
498
|
+
>
|
|
499
|
+
<path
|
|
500
|
+
d={ path( arcDatum ) || '' }
|
|
501
|
+
fill={ accessors.fill( arcDatum.data ) }
|
|
502
|
+
data-testid="pie-segment"
|
|
503
|
+
/>
|
|
504
|
+
</g>
|
|
505
|
+
) )
|
|
508
506
|
) }
|
|
509
|
-
|
|
510
|
-
{ /* Render SVG children (like Group, Text) inside the SVG */ }
|
|
511
|
-
{ ! allSegmentsHidden && svgChildren }
|
|
512
507
|
</Group>
|
|
508
|
+
{ /* Unmasked so the entry wipe cannot hide the focus indicator. */ }
|
|
509
|
+
{ selectedArc && (
|
|
510
|
+
<Group top={ centerY } left={ centerX }>
|
|
511
|
+
<SelectedSegmentRing chartId={ chartId } d={ path( selectedArc ) || '' } />
|
|
512
|
+
</Group>
|
|
513
|
+
) }
|
|
514
|
+
{ ! allSegmentsHidden && (
|
|
515
|
+
<Group top={ centerY } left={ centerX } mask={ wipeMask }>
|
|
516
|
+
{ showLabels && arcs.map( renderLabel ) }
|
|
517
|
+
|
|
518
|
+
{ /* Render SVG children (like Group, Text) inside the SVG */ }
|
|
519
|
+
{ svgChildren }
|
|
520
|
+
</Group>
|
|
521
|
+
) }
|
|
513
522
|
</svg>
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
523
|
+
<PieSelectionOutput
|
|
524
|
+
{ ...selectionOutputProps }
|
|
525
|
+
keyboardTooltipAnchor={
|
|
526
|
+
selectedArc &&
|
|
527
|
+
getKeyboardTooltipAnchor( centerX, centerY, path.centroid( selectedArc ) )
|
|
528
|
+
}
|
|
529
|
+
renderTooltip={ renderTooltip }
|
|
530
|
+
/>
|
|
521
531
|
</Center>
|
|
522
532
|
);
|
|
523
533
|
} }
|
|
@@ -773,6 +773,121 @@ describe( 'PieChart', () => {
|
|
|
773
773
|
expect( screen.queryByText( /Click legend items to show data/i ) ).not.toBeInTheDocument();
|
|
774
774
|
} );
|
|
775
775
|
} );
|
|
776
|
+
|
|
777
|
+
describe( 'Keyboard navigation', () => {
|
|
778
|
+
const data = [
|
|
779
|
+
{ label: 'MacOS', value: 30000, valueDisplay: '30K' },
|
|
780
|
+
{ label: 'Linux', value: 22000, valueDisplay: '22K' },
|
|
781
|
+
];
|
|
782
|
+
|
|
783
|
+
test.each( [
|
|
784
|
+
[ undefined, 'Pie chart' ],
|
|
785
|
+
[ 'Devices', 'Devices' ],
|
|
786
|
+
] )( 'names the focusable chart from ariaLabel %p', ( ariaLabel, name ) => {
|
|
787
|
+
render( <PieChart data={ data } ariaLabel={ ariaLabel } /> );
|
|
788
|
+
expect( screen.getByRole( 'application', { name } ) ).toHaveAttribute( 'tabindex', '0' );
|
|
789
|
+
} );
|
|
790
|
+
|
|
791
|
+
const expectRingOn = ( segment: HTMLElement ) =>
|
|
792
|
+
expect( screen.getByTestId( 'pie-selected-ring' ) ).toHaveAttribute(
|
|
793
|
+
'd',
|
|
794
|
+
segment.getAttribute( 'd' )
|
|
795
|
+
);
|
|
796
|
+
|
|
797
|
+
test( 'arrow keys move the focused tooltip and highlight across segments', async () => {
|
|
798
|
+
const user = userEvent.setup();
|
|
799
|
+
render( <PieChart data={ data } withTooltips /> );
|
|
800
|
+
const [ macos, linux ] = screen.getAllByTestId( 'pie-segment' );
|
|
801
|
+
expect( screen.queryByTestId( 'pie-selected-ring' ) ).not.toBeInTheDocument();
|
|
802
|
+
|
|
803
|
+
await user.tab();
|
|
804
|
+
await user.keyboard( '{ArrowRight}' );
|
|
805
|
+
await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toHaveTextContent(
|
|
806
|
+
'MacOS'
|
|
807
|
+
);
|
|
808
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
|
|
809
|
+
expectRingOn( macos );
|
|
810
|
+
|
|
811
|
+
await user.keyboard( '{ArrowRight}' );
|
|
812
|
+
await expect( screen.findByTestId( 'chart-tooltip-1' ) ).resolves.toHaveTextContent(
|
|
813
|
+
'Linux'
|
|
814
|
+
);
|
|
815
|
+
expectRingOn( linux );
|
|
816
|
+
} );
|
|
817
|
+
|
|
818
|
+
test( 'announces and highlights a segment without a tooltip when withTooltips is false', async () => {
|
|
819
|
+
const user = userEvent.setup();
|
|
820
|
+
render( <PieChart data={ data } /> );
|
|
821
|
+
const status = screen.getByRole( 'status' );
|
|
822
|
+
expect( status ).toBeEmptyDOMElement();
|
|
823
|
+
|
|
824
|
+
await user.tab();
|
|
825
|
+
await user.keyboard( '{ArrowRight}' );
|
|
826
|
+
expectRingOn( screen.getAllByTestId( 'pie-segment' )[ 0 ] );
|
|
827
|
+
expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
|
|
828
|
+
expect( status ).toHaveTextContent( 'MacOS: 30K' );
|
|
829
|
+
} );
|
|
830
|
+
|
|
831
|
+
test( 'leaves the announcement to the focused tooltip when withTooltips is true', async () => {
|
|
832
|
+
const user = userEvent.setup();
|
|
833
|
+
render( <PieChart data={ data } withTooltips /> );
|
|
834
|
+
|
|
835
|
+
await user.tab();
|
|
836
|
+
await user.keyboard( '{ArrowRight}' );
|
|
837
|
+
await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toBeInTheDocument();
|
|
838
|
+
expect( screen.getByRole( 'status' ) ).toBeEmptyDOMElement();
|
|
839
|
+
} );
|
|
840
|
+
|
|
841
|
+
test.each( [
|
|
842
|
+
[ true, [ 'Linux: 22K' ] ],
|
|
843
|
+
[ false, [] ],
|
|
844
|
+
] )(
|
|
845
|
+
'hovering a segment ends the keyboard selection (withTooltips %p)',
|
|
846
|
+
async ( withTooltips, tooltips ) => {
|
|
847
|
+
const user = userEvent.setup();
|
|
848
|
+
render( <PieChart data={ data } withTooltips={ withTooltips } /> );
|
|
849
|
+
|
|
850
|
+
await user.tab();
|
|
851
|
+
await user.keyboard( '{ArrowRight}' );
|
|
852
|
+
expect( screen.getByTestId( 'pie-selected-ring' ) ).toBeInTheDocument();
|
|
853
|
+
|
|
854
|
+
await user.hover( screen.getAllByTestId( 'pie-segment' )[ 1 ] );
|
|
855
|
+
|
|
856
|
+
expect( screen.queryByTestId( 'pie-selected-ring' ) ).not.toBeInTheDocument();
|
|
857
|
+
expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
|
|
858
|
+
expect( screen.queryAllByRole( 'tooltip' ).map( tooltip => tooltip.textContent ) ).toEqual(
|
|
859
|
+
tooltips
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
);
|
|
863
|
+
|
|
864
|
+
test( 'keeps focus in the chart when tooltips are turned off under a keyboard selection', async () => {
|
|
865
|
+
const user = userEvent.setup();
|
|
866
|
+
const { rerender } = render( <PieChart data={ data } withTooltips /> );
|
|
867
|
+
|
|
868
|
+
await user.tab();
|
|
869
|
+
await user.keyboard( '{ArrowRight}{ArrowRight}' );
|
|
870
|
+
await expect( screen.findByTestId( 'chart-tooltip-1' ) ).resolves.toHaveFocus();
|
|
871
|
+
|
|
872
|
+
rerender( <PieChart data={ data } withTooltips={ false } /> );
|
|
873
|
+
|
|
874
|
+
expect( screen.getByRole( 'application' ) ).toHaveFocus();
|
|
875
|
+
expectRingOn( screen.getAllByTestId( 'pie-segment' )[ 1 ] );
|
|
876
|
+
} );
|
|
877
|
+
|
|
878
|
+
test( 'keeps focus in the chart when an update removes the selected segment', async () => {
|
|
879
|
+
const user = userEvent.setup();
|
|
880
|
+
const { rerender } = render( <PieChart data={ data } withTooltips /> );
|
|
881
|
+
|
|
882
|
+
await user.tab();
|
|
883
|
+
await user.keyboard( '{ArrowRight}{ArrowRight}' );
|
|
884
|
+
await expect( screen.findByTestId( 'chart-tooltip-1' ) ).resolves.toHaveFocus();
|
|
885
|
+
|
|
886
|
+
rerender( <PieChart data={ data.slice( 0, 1 ) } withTooltips /> );
|
|
887
|
+
|
|
888
|
+
await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toHaveFocus();
|
|
889
|
+
} );
|
|
890
|
+
} );
|
|
776
891
|
} );
|
|
777
892
|
|
|
778
893
|
// Chart container at (100, 50); the tooltip box measures 120x40. Everything
|
|
@@ -829,4 +944,60 @@ describe( 'PieChart tooltip position', () => {
|
|
|
829
944
|
transform: 'translate(60px, 65px)',
|
|
830
945
|
} );
|
|
831
946
|
} );
|
|
947
|
+
|
|
948
|
+
test( 'moves the keyboard tooltip with the segment when the chart resizes', async () => {
|
|
949
|
+
const rects = mockRects();
|
|
950
|
+
const rectOf = rects.getMockImplementation();
|
|
951
|
+
let svgShift = 0;
|
|
952
|
+
// The plot is centered in the 400x300 container, so its offset follows its committed size.
|
|
953
|
+
rects.mockImplementation( function ( this: Element ) {
|
|
954
|
+
const rect = rectOf.call( this );
|
|
955
|
+
if ( ! ( this instanceof SVGSVGElement && this.hasAttribute( 'viewBox' ) ) ) {
|
|
956
|
+
return rect;
|
|
957
|
+
}
|
|
958
|
+
const size = Number( this.getAttribute( 'width' ) );
|
|
959
|
+
return {
|
|
960
|
+
...rect,
|
|
961
|
+
left: 100 + ( 400 - size ) / 2 + svgShift,
|
|
962
|
+
top: 50 + ( 300 - size ) / 2 + svgShift,
|
|
963
|
+
};
|
|
964
|
+
} );
|
|
965
|
+
const user = userEvent.setup();
|
|
966
|
+
const data = [
|
|
967
|
+
{ label: 'A', value: 60, valueDisplay: '60' },
|
|
968
|
+
{ label: 'B', value: 40, valueDisplay: '40' },
|
|
969
|
+
];
|
|
970
|
+
const renderAtSize = ( size: number ) => (
|
|
971
|
+
<GlobalChartsProvider>
|
|
972
|
+
<PieChart data={ data } width={ 400 } height={ 300 } size={ size } withTooltips />
|
|
973
|
+
</GlobalChartsProvider>
|
|
974
|
+
);
|
|
975
|
+
const selectFirstSegment = async () => {
|
|
976
|
+
await user.tab();
|
|
977
|
+
await user.keyboard( '{ArrowRight}' );
|
|
978
|
+
await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toHaveFocus();
|
|
979
|
+
return screen.getByTestId( 'bounded-tooltip' ).style.transform;
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
const translation = ( transform: string ) => transform.match( /-?[\d.]+/g )?.map( Number );
|
|
983
|
+
|
|
984
|
+
const view = render( renderAtSize( 100 ) );
|
|
985
|
+
const expected = await selectFirstSegment();
|
|
986
|
+
view.unmount();
|
|
987
|
+
|
|
988
|
+
svgShift = 7;
|
|
989
|
+
const { unmount } = render( renderAtSize( 100 ) );
|
|
990
|
+
expect( translation( await selectFirstSegment() ) ).toEqual(
|
|
991
|
+
translation( expected )?.map( value => value + 7 )
|
|
992
|
+
);
|
|
993
|
+
unmount();
|
|
994
|
+
svgShift = 0;
|
|
995
|
+
|
|
996
|
+
const { rerender } = render( renderAtSize( 200 ) );
|
|
997
|
+
await expect( selectFirstSegment() ).resolves.not.toBe( expected );
|
|
998
|
+
rerender( renderAtSize( 100 ) );
|
|
999
|
+
|
|
1000
|
+
await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toHaveFocus();
|
|
1001
|
+
expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( { transform: expected } );
|
|
1002
|
+
} );
|
|
832
1003
|
} );
|