@automattic/charts 4.7.0 → 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/TOKENS.md +6 -3
- package/dist/index.cjs +834 -321
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +68 -17
- package/dist/index.d.cts +102 -28
- package/dist/index.d.ts +102 -28
- package/dist/index.js +838 -327
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.tsx +7 -4
- package/src/charts/area-chart/test/area-chart.test.tsx +24 -15
- package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +1 -1
- 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.module.scss +7 -0
- package/src/charts/bar-chart/bar-chart.tsx +25 -16
- package/src/charts/bar-chart/private/comparison-bars.tsx +1 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +106 -62
- 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/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +9 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +16 -8
- package/src/charts/heatmap-chart/heatmap-chart.tsx +183 -66
- 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-context.ts +2 -2
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +15 -3
- package/src/charts/heatmap-chart/private/heatmap-scale.ts +74 -0
- package/src/charts/heatmap-chart/private/index.ts +6 -3
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +24 -6
- 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 +231 -8
- package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +78 -0
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +18 -6
- package/src/charts/heatmap-chart/types.ts +6 -3
- 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 +7 -4
- 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 +153 -143
- package/src/charts/pie-chart/test/pie-chart.test.tsx +171 -0
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +126 -124
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +55 -1
- 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/test/label-text-color.test.ts +6 -8
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +3 -2
- package/src/components/tooltip/base-tooltip.tsx +31 -32
- package/src/components/tooltip/index.ts +3 -1
- package/src/components/tooltip/private/bounded-tooltip.tsx +15 -15
- package/src/components/tooltip/private/label-value-content.tsx +8 -0
- package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +336 -0
- package/src/components/tooltip/test/tool-tip.test.tsx +8 -6
- package/src/components/tooltip/test/tooltip-box.test.tsx +53 -0
- package/src/components/tooltip/test/xy-chart-tooltip-overlay.test.tsx +445 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +283 -365
- package/src/components/tooltip/{base-tooltip.module.scss → tooltip-box.module.scss} +5 -8
- package/src/components/tooltip/tooltip-box.tsx +43 -0
- package/src/components/tooltip/types.ts +7 -1
- package/src/components/tooltip/xy-chart-tooltip.tsx +410 -300
- package/src/hooks/test/use-chart-margin.test.tsx +8 -4
- package/src/hooks/use-chart-margin.tsx +21 -18
- package/src/index.ts +8 -3
- package/src/providers/chart-context/global-charts-provider.tsx +5 -14
- package/src/providers/chart-context/hooks/use-default-hidden-series.ts +1 -1
- package/src/providers/chart-context/private/catalog-pointers.ts +1 -0
- package/src/providers/chart-context/private/resolve-opaque-hex.ts +16 -0
- package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +1 -1
- package/src/styles/chart-scope.scss +1 -0
- package/src/test-utils/zoned-parsing-suite.ts +0 -10
- package/src/types.ts +18 -2
- package/src/utils/test/date-formatting.test.ts +0 -10
- package/src/components/tooltip/accessible-tooltip.tsx +0 -433
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +0 -331
|
@@ -1,12 +1,11 @@
|
|
|
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 {
|
|
9
|
-
import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
|
|
8
|
+
import { LabelValueContent } from '../../components/tooltip/private/label-value-content';
|
|
10
9
|
import {
|
|
11
10
|
useDataWithPercentages,
|
|
12
11
|
useLegendVisibilityData,
|
|
@@ -19,12 +18,17 @@ import {
|
|
|
19
18
|
useGlobalChartsContext,
|
|
20
19
|
GlobalChartsContext,
|
|
21
20
|
} from '../../providers';
|
|
22
|
-
import { useStandaloneScopeClass } from '../../providers/chart-scope';
|
|
23
21
|
import { attachSubComponents } from '../../utils';
|
|
24
22
|
import { Center } from '../private/center';
|
|
25
23
|
import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition';
|
|
26
24
|
import { ChartInstanceContext } from '../private/chart-instance-context';
|
|
27
25
|
import { ChartLayout } from '../private/chart-layout';
|
|
26
|
+
import {
|
|
27
|
+
orderArcsForNavigation,
|
|
28
|
+
PieSelectionOutput,
|
|
29
|
+
SelectedSegmentRing,
|
|
30
|
+
usePieKeyboardNavigation,
|
|
31
|
+
} from '../private/pie-keyboard-navigation';
|
|
28
32
|
import { RadialWipeAnimation } from '../private/radial-wipe-animation';
|
|
29
33
|
import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
|
|
30
34
|
import { withResponsive } from '../private/with-responsive';
|
|
@@ -39,7 +43,7 @@ import type {
|
|
|
39
43
|
import type { ChartComponentWithComposition } from '../private/chart-composition';
|
|
40
44
|
import type { ResponsiveConfig } from '../private/with-responsive';
|
|
41
45
|
import type { PieProvidedProps } from '@visx/shape';
|
|
42
|
-
import type { FC,
|
|
46
|
+
import type { FC, ReactNode } from 'react';
|
|
43
47
|
|
|
44
48
|
/**
|
|
45
49
|
* Parameters passed to the renderTooltip function for semi-circle charts.
|
|
@@ -53,7 +57,7 @@ export type PieSemiCircleChartRenderTooltipParams = {
|
|
|
53
57
|
|
|
54
58
|
/**
|
|
55
59
|
* Default tooltip renderer for semi-circle pie charts.
|
|
56
|
-
* Renders
|
|
60
|
+
* Renders the default `label: value` tooltip content for the hovered segment.
|
|
57
61
|
*
|
|
58
62
|
* @param {PieSemiCircleChartRenderTooltipParams} params - The tooltip parameters containing the hovered data point
|
|
59
63
|
* @return {ReactNode} The rendered tooltip content
|
|
@@ -61,7 +65,7 @@ export type PieSemiCircleChartRenderTooltipParams = {
|
|
|
61
65
|
const renderDefaultPieSemiCircleTooltip = ( {
|
|
62
66
|
tooltipData,
|
|
63
67
|
}: PieSemiCircleChartRenderTooltipParams ): ReactNode => {
|
|
64
|
-
return <
|
|
68
|
+
return <LabelValueContent data={ tooltipData } />;
|
|
65
69
|
};
|
|
66
70
|
|
|
67
71
|
const PAD_ANGLE = 0.03; // Padding between segments
|
|
@@ -122,9 +126,14 @@ export interface PieSemiCircleChartProps extends BaseChartProps< DataPointPercen
|
|
|
122
126
|
|
|
123
127
|
/**
|
|
124
128
|
* Custom render function for tooltip content.
|
|
125
|
-
* When provided, replaces the default
|
|
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
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
</>
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
467
|
+
<PieSelectionOutput
|
|
468
|
+
{ ...selectionOutputProps }
|
|
469
|
+
keyboardTooltipAnchor={
|
|
470
|
+
selectedArc &&
|
|
471
|
+
getKeyboardTooltipAnchor( width / 2, height, path.centroid( selectedArc ) )
|
|
472
|
+
}
|
|
473
|
+
renderTooltip={ renderTooltip }
|
|
474
|
+
/>
|
|
473
475
|
</Center>
|
|
474
476
|
);
|
|
475
477
|
} }
|
|
@@ -96,7 +96,7 @@ describe( 'PieSemiCircleChart', () => {
|
|
|
96
96
|
|
|
97
97
|
await user.hover( firstSegment );
|
|
98
98
|
|
|
99
|
-
// Wait for tooltip to be visible - it should show
|
|
99
|
+
// Wait for tooltip to be visible - it should show the default label: value content
|
|
100
100
|
const tooltip = await screen.findByRole( 'tooltip' );
|
|
101
101
|
expect( tooltip ).toHaveTextContent( 'MacOS' );
|
|
102
102
|
|
|
@@ -363,6 +363,60 @@ describe( 'PieSemiCircleChart', () => {
|
|
|
363
363
|
} );
|
|
364
364
|
} );
|
|
365
365
|
|
|
366
|
+
describe( 'PieSemiCircleChart keyboard navigation', () => {
|
|
367
|
+
it( 'names the focusable chart', () => {
|
|
368
|
+
renderPieChart( { data: mockData } );
|
|
369
|
+
expect( screen.getByRole( 'application', { name: 'Semi-circle chart' } ) ).toHaveAttribute(
|
|
370
|
+
'tabindex',
|
|
371
|
+
'0'
|
|
372
|
+
);
|
|
373
|
+
} );
|
|
374
|
+
|
|
375
|
+
it( 'starts at the leftmost segment, which is the largest value', async () => {
|
|
376
|
+
const user = userEvent.setup();
|
|
377
|
+
renderPieChart( { data: mockData, withTooltips: true } );
|
|
378
|
+
|
|
379
|
+
await user.tab();
|
|
380
|
+
await user.keyboard( '{ArrowRight}' );
|
|
381
|
+
|
|
382
|
+
await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toHaveTextContent(
|
|
383
|
+
'Category B'
|
|
384
|
+
);
|
|
385
|
+
expect( screen.getByTestId( 'pie-selected-ring' ) ).toHaveAttribute(
|
|
386
|
+
'd',
|
|
387
|
+
screen.getAllByTestId( 'pie-segment' )[ 1 ].getAttribute( 'd' )
|
|
388
|
+
);
|
|
389
|
+
} );
|
|
390
|
+
|
|
391
|
+
it( 'clears the selection when new values reorder the segments', async () => {
|
|
392
|
+
const user = userEvent.setup();
|
|
393
|
+
const { rerender } = renderPieChart( { data: mockData, withTooltips: true } );
|
|
394
|
+
|
|
395
|
+
await user.tab();
|
|
396
|
+
await user.keyboard( '{ArrowRight}' );
|
|
397
|
+
await expect( screen.findByTestId( 'chart-tooltip-0' ) ).resolves.toHaveTextContent(
|
|
398
|
+
'Category B'
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
rerender(
|
|
402
|
+
<GlobalChartsProvider>
|
|
403
|
+
<PieSemiCircleChart
|
|
404
|
+
data={ [
|
|
405
|
+
{ ...mockData[ 0 ], value: 70 },
|
|
406
|
+
{ ...mockData[ 1 ], value: 30 },
|
|
407
|
+
] }
|
|
408
|
+
withTooltips
|
|
409
|
+
/>
|
|
410
|
+
</GlobalChartsProvider>
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
await waitFor( () =>
|
|
414
|
+
expect( screen.queryByTestId( 'pie-selected-ring' ) ).not.toBeInTheDocument()
|
|
415
|
+
);
|
|
416
|
+
expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
|
|
417
|
+
} );
|
|
418
|
+
} );
|
|
419
|
+
|
|
366
420
|
// Chart container at (100, 50); the tooltip box measures 120x40. Everything
|
|
367
421
|
// else the charts measure (wrapper, clipping lookups) reports the container.
|
|
368
422
|
const mockRects = () =>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Group } from '@visx/group';
|
|
2
2
|
import { useMemo, Children, isValidElement } from 'react';
|
|
3
3
|
import { Legend } from '../../../components/legend';
|
|
4
|
+
import type { LegendProps } from '../../../components/legend/types';
|
|
4
5
|
import type { LegendPosition } from '../../../types';
|
|
5
|
-
import type { ReactElement, ReactNode } from 'react';
|
|
6
|
+
import type { PropsWithChildren, ReactElement, ReactNode } from 'react';
|
|
6
7
|
|
|
7
8
|
export type LegendChild = {
|
|
8
9
|
element: ReactElement;
|
|
@@ -39,7 +40,7 @@ export function useChartChildren( children: ReactNode, chartType: string ): Char
|
|
|
39
40
|
if ( isValidElement( child ) ) {
|
|
40
41
|
// Extract Legend children for position-based slot rendering
|
|
41
42
|
if ( child.type === Legend ) {
|
|
42
|
-
const rawPosition = child.props?.position;
|
|
43
|
+
const rawPosition = ( child as ReactElement< LegendProps > ).props?.position;
|
|
43
44
|
const position =
|
|
44
45
|
rawPosition === 'top' || rawPosition === 'bottom' ? rawPosition : 'bottom';
|
|
45
46
|
|
|
@@ -55,15 +56,19 @@ export function useChartChildren( children: ReactNode, chartType: string ): Char
|
|
|
55
56
|
// Handle chart-specific compound components (e.g., PieChart.SVG)
|
|
56
57
|
if ( displayName === `${ chartType }.SVG` || displayName === 'Chart.SVG' ) {
|
|
57
58
|
// Extract children from Chart.SVG with safety checks
|
|
58
|
-
|
|
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,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
|
+
};
|