@automattic/charts 4.9.0 → 4.11.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/TOKENS.md +7 -3
- package/dist/index.cjs +496 -774
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +66 -3
- package/dist/index.d.cts +65 -60
- package/dist/index.d.ts +65 -60
- package/dist/index.js +467 -726
- package/dist/index.js.map +1 -1
- package/dist/visx/group/index.js +1 -2
- package/dist/visx/legend/index.js +1 -2
- package/dist/visx/text/index.js +1 -2
- package/package.json +13 -12
- package/src/charts/area-chart/area-chart.tsx +2 -1
- package/src/charts/area-chart/test/area-chart.test.tsx +0 -8
- package/src/charts/area-chart/types.ts +0 -2
- package/src/charts/bar-chart/bar-chart.tsx +7 -2
- package/src/charts/bar-chart/test/bar-chart.test.tsx +0 -8
- package/src/charts/bar-chart/types.ts +0 -2
- package/src/charts/bar-list-chart/bar-list-chart.tsx +3 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +5 -0
- 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.module.scss +27 -0
- package/src/charts/geo-chart/geo-chart.tsx +85 -45
- package/src/charts/geo-chart/private/use-tooltip-position.ts +106 -0
- package/src/charts/geo-chart/test/geo-chart.test.tsx +77 -46
- package/src/charts/geo-chart/test/use-tooltip-position.test.ts +43 -0
- package/src/charts/geo-chart/types.ts +1 -1
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +3 -3
- package/src/charts/heatmap-chart/private/heatmap-scale.ts +48 -43
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +5 -11
- package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +55 -27
- package/src/charts/heatmap-chart/types.ts +1 -3
- package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +6 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +34 -19
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +35 -6
- package/src/charts/leaderboard-chart/types.ts +1 -0
- package/src/charts/line-chart/line-chart.tsx +3 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +0 -8
- package/src/charts/line-chart/types.ts +0 -2
- package/src/charts/pie-chart/pie-chart.module.scss +3 -0
- package/src/charts/pie-chart/pie-chart.tsx +0 -5
- package/src/charts/pie-chart/test/pie-chart.test.tsx +4 -8
- 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 +0 -5
- package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +8 -3
- 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.module.scss +5 -0
- package/src/components/legend/private/base-legend.tsx +35 -17
- package/src/components/legend/test/legend.test.tsx +42 -0
- package/src/components/legend/types.ts +2 -0
- package/src/components/tooltip/_surface.scss +19 -0
- package/src/components/tooltip/private/bounded-tooltip.tsx +2 -2
- package/src/components/tooltip/tooltip-box.module.scss +3 -17
- 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/styles/chart-scope.scss +6 -4
- package/src/styles/test/chart-scope.test.ts +6 -2
- package/src/types.ts +19 -1
- package/src/utils/resolve-font-size.ts +9 -10
- package/src/utils/test/resolve-font-size.test.ts +11 -2
package/dist/visx/group/index.js
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export { Group };
|
|
1
|
+
export { Group } from "@visx/group";
|
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export { CircleShape, LineShape, RectShape };
|
|
1
|
+
export { CircleShape, LineShape, RectShape } from "@visx/legend";
|
package/dist/visx/text/index.js
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export { Text, getStringWidth, useText };
|
|
1
|
+
export { Text, getStringWidth, useText } from "@visx/text";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.11.0",
|
|
4
4
|
"description": "Display charts within Automattic products.",
|
|
5
5
|
"homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"typecheck": "tsgo --noEmit"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@automattic/number-formatters": "^1.2.
|
|
66
|
+
"@automattic/number-formatters": "^1.2.11",
|
|
67
67
|
"@babel/runtime": "7.29.7",
|
|
68
68
|
"@date-fns/tz": "1.5.0",
|
|
69
69
|
"@react-spring/web": "10.1.1",
|
|
@@ -84,9 +84,9 @@
|
|
|
84
84
|
"@visx/vendor": "^4.0.0",
|
|
85
85
|
"@visx/xychart": "^4.0.0",
|
|
86
86
|
"@wordpress/i18n": "^6.0.0",
|
|
87
|
-
"@wordpress/icons": "^
|
|
88
|
-
"@wordpress/theme": "2.
|
|
89
|
-
"@wordpress/ui": "0.
|
|
87
|
+
"@wordpress/icons": "^18.0.0",
|
|
88
|
+
"@wordpress/theme": "2.3.0",
|
|
89
|
+
"@wordpress/ui": "0.24.0",
|
|
90
90
|
"clsx": "2.1.1",
|
|
91
91
|
"date-fns": "^4.1.0",
|
|
92
92
|
"deepmerge": "4.3.1",
|
|
@@ -104,26 +104,27 @@
|
|
|
104
104
|
"@testing-library/jest-dom": "^6.0.0",
|
|
105
105
|
"@testing-library/react": "^16.0.0",
|
|
106
106
|
"@testing-library/user-event": "^14.4.3",
|
|
107
|
-
"@tsdown/css": "0.
|
|
107
|
+
"@tsdown/css": "0.23.1",
|
|
108
108
|
"@types/jest": "^30.0.0",
|
|
109
109
|
"@types/node": "^24.12.0",
|
|
110
110
|
"@types/react": "18.3.31",
|
|
111
111
|
"@types/react-dom": "18.3.7",
|
|
112
112
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
113
113
|
"@visx/glyph": "4.0.0",
|
|
114
|
-
"@wordpress/components": "
|
|
115
|
-
"@wordpress/element": "
|
|
116
|
-
"@wordpress/private-apis": "
|
|
117
|
-
"babel-jest": "30.5.
|
|
114
|
+
"@wordpress/components": "42.0.0",
|
|
115
|
+
"@wordpress/element": "9.0.0",
|
|
116
|
+
"@wordpress/private-apis": "2.0.0",
|
|
117
|
+
"babel-jest": "30.5.2",
|
|
118
118
|
"identity-obj-proxy": "^3.0.0",
|
|
119
|
-
"jest": "30.5.
|
|
119
|
+
"jest": "30.5.2",
|
|
120
120
|
"jest-extended": "7.0.0",
|
|
121
|
+
"lightningcss": "^1.33.0",
|
|
121
122
|
"magic-string": "0.30.21",
|
|
122
123
|
"react": "18.3.1",
|
|
123
124
|
"react-dom": "18.3.1",
|
|
124
125
|
"sass-embedded": "1.97.3",
|
|
125
126
|
"storybook": "10.4.6",
|
|
126
|
-
"tsdown": "0.
|
|
127
|
+
"tsdown": "0.23.1",
|
|
127
128
|
"typescript": "6.0.3"
|
|
128
129
|
},
|
|
129
130
|
"peerDependencies": {
|
|
@@ -262,8 +262,9 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
|
|
|
262
262
|
glyphSize: 0,
|
|
263
263
|
collapseGroups: legend.collapseGroups ?? false,
|
|
264
264
|
comparisonItem: legend.comparisonItem ?? false,
|
|
265
|
+
comparisonSeriesItems: legend.comparisonSeriesItems,
|
|
265
266
|
} ),
|
|
266
|
-
[ legend.collapseGroups, legend.comparisonItem ]
|
|
267
|
+
[ legend.collapseGroups, legend.comparisonItem, legend.comparisonSeriesItems ]
|
|
267
268
|
);
|
|
268
269
|
const legendItems = useChartLegendItems( dataSorted, legendOptions, legendShape );
|
|
269
270
|
|
|
@@ -785,14 +785,6 @@ describe( 'AreaChart', () => {
|
|
|
785
785
|
const chart = screen.getByRole( 'application', { name: /area chart/i } );
|
|
786
786
|
expect( chart ).toHaveAttribute( 'tabIndex', '0' );
|
|
787
787
|
expect( chart ).toHaveAttribute( 'aria-label', 'Area chart' );
|
|
788
|
-
} );
|
|
789
|
-
|
|
790
|
-
test.each( [
|
|
791
|
-
[ undefined, 'Area chart' ],
|
|
792
|
-
[ 'Views over time', 'Views over time' ],
|
|
793
|
-
] )( 'names the chart container from ariaLabel %p', ( ariaLabel, name ) => {
|
|
794
|
-
renderWithProvider( { ariaLabel } );
|
|
795
|
-
expect( screen.getByRole( 'application', { name } ) ).toBeInTheDocument();
|
|
796
788
|
expect( screen.queryByLabelText( 'XYChart' ) ).not.toBeInTheDocument();
|
|
797
789
|
} );
|
|
798
790
|
} );
|
|
@@ -11,8 +11,6 @@ import type { CurveType } from '../line-chart/types';
|
|
|
11
11
|
import type { ReactNode } from 'react';
|
|
12
12
|
|
|
13
13
|
export interface AreaChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
|
|
14
|
-
/** Accessible name of the chart. Defaults to a localized "Area chart". */
|
|
15
|
-
ariaLabel?: string;
|
|
16
14
|
/**
|
|
17
15
|
* Legend configuration. Supports `collapseGroups` on top of the shared options.
|
|
18
16
|
*/
|
|
@@ -139,6 +139,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
139
139
|
const legendInteractive = legend.interactive ?? false;
|
|
140
140
|
const legendCollapseGroups = legend.collapseGroups ?? false;
|
|
141
141
|
const legendComparisonItem = legend.comparisonItem ?? false;
|
|
142
|
+
const legendComparisonSeriesItems = legend.comparisonSeriesItems;
|
|
142
143
|
const horizontal = orientation === 'horizontal';
|
|
143
144
|
const chartId = useChartId( providedChartId );
|
|
144
145
|
const hiddenSeries = useDefaultHiddenSeries( chartId, defaultHiddenSeries );
|
|
@@ -159,8 +160,12 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
159
160
|
|
|
160
161
|
// Create legend items using the reusable hook
|
|
161
162
|
const legendOptions = useMemo(
|
|
162
|
-
() => ( {
|
|
163
|
-
|
|
163
|
+
() => ( {
|
|
164
|
+
collapseGroups: legendCollapseGroups,
|
|
165
|
+
comparisonItem: legendComparisonItem,
|
|
166
|
+
comparisonSeriesItems: legendComparisonSeriesItems,
|
|
167
|
+
} ),
|
|
168
|
+
[ legendCollapseGroups, legendComparisonItem, legendComparisonSeriesItems ]
|
|
164
169
|
);
|
|
165
170
|
const legendItems = useChartLegendItems( dataSorted, legendOptions );
|
|
166
171
|
|
|
@@ -1685,14 +1685,6 @@ describe( 'BarChart', () => {
|
|
|
1685
1685
|
expect( chart ).toHaveAttribute( 'tabIndex', '0' );
|
|
1686
1686
|
expect( chart ).toHaveAttribute( 'role', 'application' );
|
|
1687
1687
|
expect( chart ).toHaveAttribute( 'aria-label', 'Bar chart' );
|
|
1688
|
-
} );
|
|
1689
|
-
|
|
1690
|
-
test.each( [
|
|
1691
|
-
[ undefined, 'Bar chart' ],
|
|
1692
|
-
[ 'Views over time', 'Views over time' ],
|
|
1693
|
-
] )( 'names the chart container from ariaLabel %p', ( ariaLabel, name ) => {
|
|
1694
|
-
renderWithTheme( { ariaLabel } );
|
|
1695
|
-
expect( screen.getByRole( 'application', { name } ) ).toBeInTheDocument();
|
|
1696
1688
|
expect( screen.queryByLabelText( 'XYChart' ) ).not.toBeInTheDocument();
|
|
1697
1689
|
} );
|
|
1698
1690
|
|
|
@@ -19,8 +19,6 @@ export type BandHighlightSelection = {
|
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
export interface BarChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
|
|
22
|
-
/** Accessible name of the chart. Defaults to a localized "Bar chart". */
|
|
23
|
-
ariaLabel?: string;
|
|
24
22
|
/**
|
|
25
23
|
* Legend configuration. Supports `collapseGroups` on top of the shared options.
|
|
26
24
|
*/
|
|
@@ -2,6 +2,7 @@ import { formatNumberCompact } from '@automattic/number-formatters';
|
|
|
2
2
|
import { Group } from '@visx/group';
|
|
3
3
|
import { createScale, scaleBand } from '@visx/scale';
|
|
4
4
|
import { Text, type TextProps } from '@visx/text';
|
|
5
|
+
import { __ } from '@wordpress/i18n';
|
|
5
6
|
import clsx from 'clsx';
|
|
6
7
|
import { useContext, useMemo } from 'react';
|
|
7
8
|
import { GlobalChartsContext, GlobalChartsProvider, useGlobalChartsContext } from '../../providers';
|
|
@@ -235,6 +236,7 @@ const BarListChartInternal: FC< BarListChartProps > = ( {
|
|
|
235
236
|
top: 0,
|
|
236
237
|
},
|
|
237
238
|
className,
|
|
239
|
+
ariaLabel,
|
|
238
240
|
...rest
|
|
239
241
|
} ) => {
|
|
240
242
|
const { getElementStyles } = useGlobalChartsContext();
|
|
@@ -333,6 +335,7 @@ const BarListChartInternal: FC< BarListChartProps > = ( {
|
|
|
333
335
|
xScale: chartOptions.xScale,
|
|
334
336
|
yScale: chartOptions.yScale,
|
|
335
337
|
} }
|
|
338
|
+
ariaLabel={ ariaLabel ?? __( 'Bar list chart', 'jetpack-charts' ) }
|
|
336
339
|
{ ...rest }
|
|
337
340
|
/>
|
|
338
341
|
);
|
|
@@ -85,6 +85,11 @@
|
|
|
85
85
|
background-color: var(--a8c-charts-color-surface-secondary);
|
|
86
86
|
position: relative;
|
|
87
87
|
cursor: pointer;
|
|
88
|
+
|
|
89
|
+
&:focus-visible {
|
|
90
|
+
outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus);
|
|
91
|
+
outline-offset: var(--wpds-border-width-focus);
|
|
92
|
+
}
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
.funnel-bar {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { useTooltip } from '@visx/tooltip';
|
|
2
|
+
import { __ } from '@wordpress/i18n';
|
|
2
3
|
import { Stack } from '@wordpress/ui';
|
|
3
4
|
import clsx from 'clsx';
|
|
4
5
|
import { type FC, useRef, useMemo, useEffect, useState, useCallback, useContext } from 'react';
|
|
5
6
|
import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
|
|
6
|
-
import { usePrefersReducedMotion } from '../../hooks';
|
|
7
|
+
import { useElementSize, usePrefersReducedMotion } from '../../hooks';
|
|
7
8
|
import {
|
|
8
9
|
GlobalChartsProvider,
|
|
9
10
|
GlobalChartsContext,
|
|
@@ -19,6 +20,9 @@ import styles from './conversion-funnel-chart.module.scss';
|
|
|
19
20
|
import { useFunnelSelection } from './private';
|
|
20
21
|
import type { FunnelStep, ConversionFunnelChartProps } from './types';
|
|
21
22
|
|
|
23
|
+
/** Chart height (px) below which the funnel tightens its gaps so the bars keep their room. */
|
|
24
|
+
const SHORT_CHART_HEIGHT = 180;
|
|
25
|
+
|
|
22
26
|
/**
|
|
23
27
|
* Internal ConversionFunnelChart component with chart registration
|
|
24
28
|
*
|
|
@@ -30,6 +34,7 @@ import type { FunnelStep, ConversionFunnelChartProps } from './types';
|
|
|
30
34
|
* @param props.loading - Whether the chart is in loading state
|
|
31
35
|
* @param props.animation - Whether to show chart animation on initial render or not
|
|
32
36
|
* @param props.className - Additional CSS class name
|
|
37
|
+
* @param props.ariaLabel - Accessible name for the chart group
|
|
33
38
|
* @param props.height - Height of the chart container. Falls back to style.height if set, otherwise defaults to "100%".
|
|
34
39
|
* @param props.style - Custom styling
|
|
35
40
|
* @param props.renderStepLabel - Custom render function for step labels
|
|
@@ -46,6 +51,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
|
|
|
46
51
|
loading = false,
|
|
47
52
|
animation,
|
|
48
53
|
className,
|
|
54
|
+
ariaLabel,
|
|
49
55
|
chartId: providedChartId,
|
|
50
56
|
height,
|
|
51
57
|
style,
|
|
@@ -69,11 +75,19 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
|
|
|
69
75
|
const { handleBarClick, handleBarKeyDown, clearSelection, getStepState } =
|
|
70
76
|
useFunnelSelection( hideTooltip );
|
|
71
77
|
const standaloneScopeClass = useStandaloneScopeClass();
|
|
78
|
+
const [ measureChartRef, , chartHeight ] = useElementSize< HTMLDivElement >();
|
|
79
|
+
// A zero height is an unmeasured or hidden chart, not a short one.
|
|
80
|
+
const isShort = chartHeight > 0 && chartHeight < SHORT_CHART_HEIGHT;
|
|
81
|
+
const gap = isShort ? 'sm' : 'xl';
|
|
72
82
|
// Stable identity so React doesn't detach/reattach (and re-render the scope context) on every commit. Keep it firing on unmount (node === null).
|
|
73
|
-
const setChartRef = useCallback(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
83
|
+
const setChartRef = useCallback(
|
|
84
|
+
( node: HTMLDivElement | null ) => {
|
|
85
|
+
chartRef.current = node;
|
|
86
|
+
setScopeNode( node );
|
|
87
|
+
measureChartRef( node );
|
|
88
|
+
},
|
|
89
|
+
[ measureChartRef ]
|
|
90
|
+
);
|
|
77
91
|
|
|
78
92
|
// Wrapper to clear selectedBarRef after clearing selection
|
|
79
93
|
const clearSelectionAndRef = useCallback( () => {
|
|
@@ -309,8 +323,10 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
|
|
|
309
323
|
return (
|
|
310
324
|
<Stack
|
|
311
325
|
direction="column"
|
|
312
|
-
gap=
|
|
326
|
+
gap={ gap }
|
|
313
327
|
data-testid="conversion-funnel-chart"
|
|
328
|
+
role="group"
|
|
329
|
+
aria-label={ ariaLabel ?? __( 'Conversion funnel chart', 'jetpack-charts' ) }
|
|
314
330
|
ref={ setChartRef }
|
|
315
331
|
className={ clsx(
|
|
316
332
|
'conversion-funnel-chart',
|
|
@@ -351,7 +367,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
|
|
|
351
367
|
isColorPaletteResolved && styles[ 'funnel-step--animated' ],
|
|
352
368
|
isBlurred && styles[ 'funnel-step--blurred' ]
|
|
353
369
|
) }
|
|
354
|
-
gap=
|
|
370
|
+
gap={ gap }
|
|
355
371
|
>
|
|
356
372
|
{ /* Step Label and Rate */ }
|
|
357
373
|
<Stack direction="column" gap="xs">
|
|
@@ -3,6 +3,11 @@ import userEvent from '@testing-library/user-event';
|
|
|
3
3
|
import ConversionFunnelChart from '../conversion-funnel-chart';
|
|
4
4
|
import type { FunnelStep } from '../types';
|
|
5
5
|
|
|
6
|
+
let mockChartHeight = 0;
|
|
7
|
+
jest.mock( '../../../hooks/use-element-size', () => ( {
|
|
8
|
+
useElementSize: () => [ jest.fn(), 500, mockChartHeight ],
|
|
9
|
+
} ) );
|
|
10
|
+
|
|
6
11
|
// Mock data for testing
|
|
7
12
|
const mockSteps: FunnelStep[] = [
|
|
8
13
|
{
|
|
@@ -42,6 +47,10 @@ const renderWithoutTheme = ( component: React.ReactElement ) => {
|
|
|
42
47
|
};
|
|
43
48
|
|
|
44
49
|
describe( 'ConversionFunnelChart', () => {
|
|
50
|
+
afterEach( () => {
|
|
51
|
+
mockChartHeight = 0;
|
|
52
|
+
} );
|
|
53
|
+
|
|
45
54
|
describe( 'Basic Rendering', () => {
|
|
46
55
|
it( 'renders the main conversion rate', () => {
|
|
47
56
|
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
@@ -116,6 +125,19 @@ describe( 'ConversionFunnelChart', () => {
|
|
|
116
125
|
height: '250px',
|
|
117
126
|
} );
|
|
118
127
|
} );
|
|
128
|
+
|
|
129
|
+
it.each( [
|
|
130
|
+
[ 'unmeasured', 0, 'xl' ],
|
|
131
|
+
[ 'short', 120, 'sm' ],
|
|
132
|
+
[ 'tall', 320, 'xl' ],
|
|
133
|
+
] )( 'spaces a %s (%ipx) chart with the %s gap', ( _, height, gap ) => {
|
|
134
|
+
mockChartHeight = height;
|
|
135
|
+
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
136
|
+
|
|
137
|
+
expect( screen.getByTestId( 'conversion-funnel-chart' ).style.gap ).toContain(
|
|
138
|
+
`gap-${ gap }`
|
|
139
|
+
);
|
|
140
|
+
} );
|
|
119
141
|
} );
|
|
120
142
|
|
|
121
143
|
describe( 'Empty State', () => {
|
|
@@ -59,7 +59,7 @@ export interface TooltipRenderProps {
|
|
|
59
59
|
*/
|
|
60
60
|
export interface ConversionFunnelChartProps extends Pick<
|
|
61
61
|
BaseChartProps,
|
|
62
|
-
'className' | 'chartId' | 'animation'
|
|
62
|
+
'className' | 'chartId' | 'animation' | 'ariaLabel'
|
|
63
63
|
> {
|
|
64
64
|
/** Main conversion rate to highlight */
|
|
65
65
|
mainRate: number;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@use "../../components/tooltip/surface" as tooltip;
|
|
2
|
+
|
|
1
3
|
.container {
|
|
2
4
|
position: relative;
|
|
3
5
|
background-color: var(--a8c-charts-color-background, #fff);
|
|
@@ -8,5 +10,30 @@
|
|
|
8
10
|
// whole and grows the box instead.
|
|
9
11
|
:global(.google-visualization-tooltip) {
|
|
10
12
|
white-space: nowrap;
|
|
13
|
+
border: 0;
|
|
14
|
+
|
|
15
|
+
@include tooltip.surface;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
:global(.google-visualization-tooltip-item-list) {
|
|
19
|
+
margin: 0;
|
|
20
|
+
padding: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The long chain outranks Google's `:first-child` item rule.
|
|
24
|
+
:global(.google-visualization-tooltip .google-visualization-tooltip-item-list .google-visualization-tooltip-item) {
|
|
25
|
+
margin: 0;
|
|
26
|
+
padding: 0;
|
|
27
|
+
|
|
28
|
+
&:first-child {
|
|
29
|
+
padding-block-end: var(--wpds-dimension-padding-md);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Google writes these inline on every span.
|
|
34
|
+
:global(.google-visualization-tooltip-item) span {
|
|
35
|
+
color: inherit !important;
|
|
36
|
+
font-family: inherit !important;
|
|
37
|
+
font-size: inherit !important;
|
|
11
38
|
}
|
|
12
39
|
}
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { __ } from '@wordpress/i18n';
|
|
5
5
|
import clsx from 'clsx';
|
|
6
|
-
import { FC, useContext, useEffect, useMemo, useRef } from 'react';
|
|
6
|
+
import { FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
7
7
|
import { Chart, type GoogleChartPackages, type ReactGoogleChartEvent } from 'react-google-charts';
|
|
8
8
|
/**
|
|
9
9
|
* Internal dependencies
|
|
10
10
|
*/
|
|
11
|
+
import { TooltipTheme } from '../../components/tooltip/private/tooltip-theme';
|
|
11
12
|
import {
|
|
12
13
|
GlobalChartsContext,
|
|
13
14
|
GlobalChartsProvider,
|
|
@@ -21,6 +22,7 @@ import { sanitizeHtml } from '../../utils/sanitize-html';
|
|
|
21
22
|
import { Center } from '../private/center';
|
|
22
23
|
import { withResponsive } from '../private/with-responsive';
|
|
23
24
|
import styles from './geo-chart.module.scss';
|
|
25
|
+
import { useTooltipPosition } from './private/use-tooltip-position';
|
|
24
26
|
import type { GeoChartError, GeoChartProps } from './types';
|
|
25
27
|
|
|
26
28
|
const DEFAULT_FEATURE_FILL_COLOR = '#ffffff';
|
|
@@ -90,6 +92,40 @@ function involvesGeoChartErrorElement( node: Node ): boolean {
|
|
|
90
92
|
);
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
const isTooltipColumn = ( column: unknown ): boolean =>
|
|
96
|
+
typeof column === 'object' && column !== null && 'role' in column && column.role === 'tooltip';
|
|
97
|
+
|
|
98
|
+
// Google renders a column or cell as markup when its `p.html` is truthy, even the string 'false'.
|
|
99
|
+
const hasHtmlFlag = ( columnOrCell: unknown ): boolean =>
|
|
100
|
+
typeof columnOrCell === 'object' &&
|
|
101
|
+
columnOrCell !== null &&
|
|
102
|
+
'p' in columnOrCell &&
|
|
103
|
+
typeof columnOrCell.p === 'object' &&
|
|
104
|
+
columnOrCell.p !== null &&
|
|
105
|
+
'html' in columnOrCell.p &&
|
|
106
|
+
!! columnOrCell.p.html;
|
|
107
|
+
|
|
108
|
+
const sanitizeIfString = ( value: unknown ) =>
|
|
109
|
+
typeof value === 'string' ? sanitizeHtml( value ) : value;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Sanitizes a tooltip cell Google will render as markup, whether a string or a `{ v, f, p }` object.
|
|
113
|
+
*
|
|
114
|
+
* @param cell - The tooltip cell.
|
|
115
|
+
* @return The cell with its markup sanitized.
|
|
116
|
+
*/
|
|
117
|
+
function sanitizeTooltipCell( cell: unknown ): unknown {
|
|
118
|
+
if ( typeof cell !== 'object' || cell === null ) {
|
|
119
|
+
return sanitizeIfString( cell );
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
...cell,
|
|
124
|
+
...( 'v' in cell && { v: sanitizeIfString( cell.v ) } ),
|
|
125
|
+
...( 'f' in cell && { f: sanitizeIfString( cell.f ) } ),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
93
129
|
/**
|
|
94
130
|
* Normalizes the raw Google Charts error event into the GeoChart error shape.
|
|
95
131
|
*
|
|
@@ -126,6 +162,7 @@ function normalizeGeoChartError( eventArgs: unknown ): GeoChartError {
|
|
|
126
162
|
*
|
|
127
163
|
* @param props - The props for the GeoChart component
|
|
128
164
|
* @param props.data - Data in Google Charts format (array of arrays with headers)
|
|
165
|
+
* @param props.ariaLabel - Accessible name of the chart
|
|
129
166
|
* @param props.width - Width of the chart in pixels
|
|
130
167
|
* @param props.height - Height of the chart in pixels
|
|
131
168
|
* @param props.region - Region to display ('world', 'US', or ISO 3166-1 alpha-2 code)
|
|
@@ -140,6 +177,7 @@ function normalizeGeoChartError( eventArgs: unknown ): GeoChartError {
|
|
|
140
177
|
const GeoChartInternal: FC< GeoChartProps > = ( {
|
|
141
178
|
className,
|
|
142
179
|
data,
|
|
180
|
+
ariaLabel,
|
|
143
181
|
width,
|
|
144
182
|
height,
|
|
145
183
|
region = 'world',
|
|
@@ -153,6 +191,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
|
|
|
153
191
|
const scopeElement = useChartScopeElement();
|
|
154
192
|
const containerRef = useRef< HTMLDivElement >( null );
|
|
155
193
|
const reportedErrorIdsRef = useRef< Set< string > >( new Set() );
|
|
194
|
+
const [ isLoaded, setIsLoaded ] = useState( false );
|
|
156
195
|
|
|
157
196
|
// The ChartWrapper `error` event does not fire for every draw failure —
|
|
158
197
|
// notably not when GeoChart's asynchronous map-file load fails (e.g.
|
|
@@ -195,7 +234,10 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
|
|
|
195
234
|
return () => observer.disconnect();
|
|
196
235
|
}, [ onError ] );
|
|
197
236
|
|
|
198
|
-
|
|
237
|
+
useTooltipPosition( containerRef );
|
|
238
|
+
|
|
239
|
+
const handleLoad = useCallback( () => setIsLoaded( true ), [] );
|
|
240
|
+
|
|
199
241
|
const loadingPlaceholder = (
|
|
200
242
|
<Center
|
|
201
243
|
className={ clsx( 'geo-chart', styles.container, className ) }
|
|
@@ -217,49 +259,30 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
|
|
|
217
259
|
normalizeColorToHex( CATALOG_POINTERS.surfaceSecondary, scopeElement, resolveCssVariable ) ||
|
|
218
260
|
DEFAULT_FEATURE_FILL_COLOR;
|
|
219
261
|
|
|
220
|
-
// Identify HTML tooltip column indices and sanitize their content to prevent XSS.
|
|
221
262
|
const sanitizedData = useMemo( () => {
|
|
222
263
|
if ( data.length === 0 ) {
|
|
223
|
-
return
|
|
264
|
+
return data;
|
|
224
265
|
}
|
|
225
266
|
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (
|
|
230
|
-
typeof col === 'object' &&
|
|
231
|
-
col !== null &&
|
|
232
|
-
'role' in col &&
|
|
233
|
-
col.role === 'tooltip' &&
|
|
234
|
-
'p' in col &&
|
|
235
|
-
typeof col.p === 'object' &&
|
|
236
|
-
col.p !== null &&
|
|
237
|
-
'html' in col.p &&
|
|
238
|
-
col.p.html === true
|
|
239
|
-
) {
|
|
240
|
-
htmlTooltipIndices.push( i );
|
|
241
|
-
}
|
|
242
|
-
}
|
|
267
|
+
const tooltipColumns = data[ 0 ].flatMap( ( column, index ) =>
|
|
268
|
+
isTooltipColumn( column ) ? [ { index, isHtml: hasHtmlFlag( column ) } ] : []
|
|
269
|
+
);
|
|
243
270
|
|
|
244
|
-
if (
|
|
245
|
-
return
|
|
271
|
+
if ( tooltipColumns.length === 0 ) {
|
|
272
|
+
return data;
|
|
246
273
|
}
|
|
247
274
|
|
|
248
|
-
// Sanitize HTML content in tooltip columns for data rows (skip header row)
|
|
249
275
|
const sanitizedRows = data.slice( 1 ).map( row => {
|
|
250
276
|
const newRow = [ ...row ];
|
|
251
|
-
for ( const
|
|
252
|
-
if (
|
|
253
|
-
newRow[
|
|
277
|
+
for ( const { index, isHtml } of tooltipColumns ) {
|
|
278
|
+
if ( isHtml || hasHtmlFlag( newRow[ index ] ) ) {
|
|
279
|
+
newRow[ index ] = sanitizeTooltipCell( newRow[ index ] ) as ( typeof newRow )[ number ];
|
|
254
280
|
}
|
|
255
281
|
}
|
|
256
282
|
return newRow;
|
|
257
283
|
} );
|
|
258
284
|
|
|
259
|
-
return
|
|
260
|
-
data: [ data[ 0 ], ...sanitizedRows ] as typeof data,
|
|
261
|
-
hasHtmlTooltips: true,
|
|
262
|
-
};
|
|
285
|
+
return [ data[ 0 ], ...sanitizedRows ] as typeof data;
|
|
263
286
|
}, [ data ] );
|
|
264
287
|
|
|
265
288
|
const options: GoogleChartOptions = useMemo(
|
|
@@ -272,7 +295,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
|
|
|
272
295
|
backgroundColor: backgroundColorHex,
|
|
273
296
|
datalessRegionColor: defaultFillColorHex,
|
|
274
297
|
defaultColor: defaultFillColorHex,
|
|
275
|
-
tooltip: { trigger: 'focus', isHtml:
|
|
298
|
+
tooltip: { trigger: 'focus', isHtml: true },
|
|
276
299
|
legend: 'none',
|
|
277
300
|
keepAspectRatio: true,
|
|
278
301
|
} ),
|
|
@@ -285,16 +308,26 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
|
|
|
285
308
|
fullColorHex,
|
|
286
309
|
backgroundColorHex,
|
|
287
310
|
defaultFillColorHex,
|
|
288
|
-
sanitizedData.hasHtmlTooltips,
|
|
289
311
|
]
|
|
290
312
|
);
|
|
291
313
|
|
|
292
|
-
const chartEvents = useMemo< ReactGoogleChartEvent[]
|
|
314
|
+
const chartEvents = useMemo< ReactGoogleChartEvent[] >( () => {
|
|
315
|
+
const readyEvent: ReactGoogleChartEvent = {
|
|
316
|
+
eventName: 'ready',
|
|
317
|
+
// Google hardcodes aria-label="A chart." on its SVG, which repeats the chart's own name.
|
|
318
|
+
callback: () => {
|
|
319
|
+
containerRef.current
|
|
320
|
+
?.querySelectorAll( 'svg' )
|
|
321
|
+
.forEach( svg => svg.setAttribute( 'aria-hidden', 'true' ) );
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
|
|
293
325
|
if ( ! onError ) {
|
|
294
|
-
return
|
|
326
|
+
return [ readyEvent ];
|
|
295
327
|
}
|
|
296
328
|
|
|
297
329
|
return [
|
|
330
|
+
readyEvent,
|
|
298
331
|
{
|
|
299
332
|
eventName: 'error',
|
|
300
333
|
callback: ( { eventArgs } ) => {
|
|
@@ -307,20 +340,27 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
|
|
|
307
340
|
return (
|
|
308
341
|
<Center
|
|
309
342
|
ref={ containerRef }
|
|
343
|
+
role="group"
|
|
344
|
+
aria-label={ ariaLabel ?? __( 'Geo chart', 'jetpack-charts' ) }
|
|
310
345
|
className={ clsx( 'geo-chart', styles.container, className ) }
|
|
311
346
|
data-testid="geo-chart"
|
|
312
347
|
style={ { width, height } }
|
|
313
348
|
>
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
349
|
+
{ /* Outside TooltipTheme, so a custom placeholder keeps the host theme; `loader` would put it inside. */ }
|
|
350
|
+
{ ! isLoaded && loadingPlaceholder }
|
|
351
|
+
{ /* Every `--wpds-*` read inside resolves for the dark tooltip; map colors must keep coming from scopeElement. */ }
|
|
352
|
+
<TooltipTheme>
|
|
353
|
+
<Chart
|
|
354
|
+
chartType="GeoChart"
|
|
355
|
+
chartPackages={ GEO_CHART_PACKAGES }
|
|
356
|
+
width={ width }
|
|
357
|
+
height={ height }
|
|
358
|
+
data={ sanitizedData }
|
|
359
|
+
options={ options }
|
|
360
|
+
chartEvents={ chartEvents }
|
|
361
|
+
onLoad={ handleLoad }
|
|
362
|
+
/>
|
|
363
|
+
</TooltipTheme>
|
|
324
364
|
</Center>
|
|
325
365
|
);
|
|
326
366
|
};
|