@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
|
@@ -63,4 +63,6 @@ export type BaseLegendItem = {
|
|
|
63
63
|
seriesLabels?: string[];
|
|
64
64
|
/** Set to false for a static item that cannot be toggled or dimmed. */
|
|
65
65
|
interactive?: boolean;
|
|
66
|
+
/** Label of the series this static item describes; the item is left out while it is hidden. */
|
|
67
|
+
describesSeries?: string;
|
|
66
68
|
};
|
|
@@ -42,7 +42,7 @@ const renderChart = ( scope?: HTMLElement, tooltipPlacement?: 'auto' | 'below-ax
|
|
|
42
42
|
const openTooltip = async () => {
|
|
43
43
|
const user = userEvent.setup();
|
|
44
44
|
|
|
45
|
-
screen.getByRole( '
|
|
45
|
+
screen.getByRole( 'application', { name: /line chart/i } ).focus();
|
|
46
46
|
await user.keyboard( '{ArrowRight}' );
|
|
47
47
|
|
|
48
48
|
return screen.getByTestId( 'xy-chart-tooltip-crosshair-vertical' );
|
|
@@ -99,7 +99,7 @@ describe( 'XYChartTooltip', () => {
|
|
|
99
99
|
const user = userEvent.setup();
|
|
100
100
|
renderChart( undefined, 'below-axis' );
|
|
101
101
|
await openTooltip();
|
|
102
|
-
const focus = jest.spyOn( screen.getByRole( '
|
|
102
|
+
const focus = jest.spyOn( screen.getByRole( 'application' ), 'focus' );
|
|
103
103
|
try {
|
|
104
104
|
await user.keyboard( keys );
|
|
105
105
|
expect( focus ).toHaveBeenCalledWith( { preventScroll: true } );
|
|
@@ -115,7 +115,7 @@ describe( 'XYChartTooltip', () => {
|
|
|
115
115
|
const user = userEvent.setup();
|
|
116
116
|
renderChart();
|
|
117
117
|
await openTooltip();
|
|
118
|
-
const focus = jest.spyOn( screen.getByRole( '
|
|
118
|
+
const focus = jest.spyOn( screen.getByRole( 'application' ), 'focus' );
|
|
119
119
|
try {
|
|
120
120
|
await user.keyboard( keys );
|
|
121
121
|
expect( focus ).toHaveBeenCalledWith();
|
|
@@ -131,7 +131,7 @@ describe( 'XYChartTooltip', () => {
|
|
|
131
131
|
} );
|
|
132
132
|
} );
|
|
133
133
|
|
|
134
|
-
// Mirrors the charts' markup: the focusable
|
|
134
|
+
// Mirrors the charts' markup: the focusable chart container wraps the element `chartRef` points at.
|
|
135
135
|
const NavigationHarness = ( {
|
|
136
136
|
totalPoints,
|
|
137
137
|
trackBlur = false,
|
|
@@ -155,8 +155,9 @@ const NavigationHarness = ( {
|
|
|
155
155
|
|
|
156
156
|
return (
|
|
157
157
|
<>
|
|
158
|
+
{ /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- mirrors the charts' container. */ }
|
|
158
159
|
<div
|
|
159
|
-
role="
|
|
160
|
+
role="application"
|
|
160
161
|
aria-label="Harness"
|
|
161
162
|
tabIndex={ 0 }
|
|
162
163
|
onKeyDown={ onChartKeyDown }
|
|
@@ -179,7 +180,7 @@ describe( 'useKeyboardNavigation', () => {
|
|
|
179
180
|
const user = userEvent.setup();
|
|
180
181
|
const view = render( <NavigationHarness totalPoints={ 6 } /> );
|
|
181
182
|
|
|
182
|
-
screen.getByRole( '
|
|
183
|
+
screen.getByRole( 'application', { name: 'Harness' } ).focus();
|
|
183
184
|
for ( let i = 0; i < presses; i++ ) {
|
|
184
185
|
await user.keyboard( '{ArrowRight}' );
|
|
185
186
|
}
|
|
@@ -188,13 +189,13 @@ describe( 'useKeyboardNavigation', () => {
|
|
|
188
189
|
return { user, view };
|
|
189
190
|
};
|
|
190
191
|
|
|
191
|
-
it( 'moves the selection to the last point when the count shrinks while the
|
|
192
|
+
it( 'moves the selection to the last point when the count shrinks while the chart has focus', async () => {
|
|
192
193
|
const { view } = await navigate( 6 );
|
|
193
194
|
|
|
194
195
|
view.rerender( <NavigationHarness totalPoints={ 3 } /> );
|
|
195
196
|
|
|
196
197
|
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '2' );
|
|
197
|
-
expect( screen.getByRole( '
|
|
198
|
+
expect( screen.getByRole( 'application', { name: 'Harness' } ) ).toHaveFocus();
|
|
198
199
|
} );
|
|
199
200
|
|
|
200
201
|
// In range rather than past the end: the effect reconciles on any count change, not only an overflow.
|
|
@@ -207,24 +208,24 @@ describe( 'useKeyboardNavigation', () => {
|
|
|
207
208
|
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
|
|
208
209
|
} );
|
|
209
210
|
|
|
210
|
-
it( 'returns focus to the
|
|
211
|
+
it( 'returns focus to the chart on Escape when there are no points', async () => {
|
|
211
212
|
const user = userEvent.setup();
|
|
212
213
|
render( <NavigationHarness totalPoints={ 0 } /> );
|
|
213
214
|
|
|
214
215
|
screen.getByRole( 'button', { name: 'Inside' } ).focus();
|
|
215
216
|
await user.keyboard( '{Escape}' );
|
|
216
217
|
|
|
217
|
-
expect( screen.getByRole( '
|
|
218
|
+
expect( screen.getByRole( 'application', { name: 'Harness' } ) ).toHaveFocus();
|
|
218
219
|
} );
|
|
219
220
|
|
|
220
|
-
it( 'returns focus to the
|
|
221
|
+
it( 'returns focus to the chart when the count drops to zero while focus is in the chart', async () => {
|
|
221
222
|
const { view } = await navigate( 2 );
|
|
222
223
|
|
|
223
224
|
screen.getByRole( 'button', { name: 'Inside' } ).focus();
|
|
224
225
|
view.rerender( <NavigationHarness totalPoints={ 0 } /> );
|
|
225
226
|
|
|
226
227
|
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
|
|
227
|
-
expect( screen.getByRole( '
|
|
228
|
+
expect( screen.getByRole( 'application', { name: 'Harness' } ) ).toHaveFocus();
|
|
228
229
|
} );
|
|
229
230
|
|
|
230
231
|
it( 'hands a keyboard selection to the pointer and continues from where the pointer is', async () => {
|
|
@@ -234,7 +235,7 @@ describe( 'useKeyboardNavigation', () => {
|
|
|
234
235
|
await user.hover( screen.getByTestId( 'point-4' ) );
|
|
235
236
|
|
|
236
237
|
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
|
|
237
|
-
expect( screen.getByRole( '
|
|
238
|
+
expect( screen.getByRole( 'application', { name: 'Harness' } ) ).toHaveFocus();
|
|
238
239
|
|
|
239
240
|
await user.keyboard( '{ArrowRight}' );
|
|
240
241
|
|
|
@@ -254,13 +255,13 @@ describe( 'useKeyboardNavigation', () => {
|
|
|
254
255
|
it( 'restarts from the first point once focus leaves after the pointer took over', async () => {
|
|
255
256
|
const user = userEvent.setup();
|
|
256
257
|
render( <NavigationHarness totalPoints={ 6 } trackBlur /> );
|
|
257
|
-
const
|
|
258
|
+
const chart = screen.getByRole( 'application', { name: 'Harness' } );
|
|
258
259
|
|
|
259
|
-
act( () =>
|
|
260
|
+
act( () => chart.focus() );
|
|
260
261
|
await user.keyboard( '{ArrowRight}{ArrowRight}' );
|
|
261
262
|
await user.hover( screen.getByTestId( 'point-4' ) );
|
|
262
263
|
act( () => screen.getByRole( 'button', { name: 'Outside' } ).focus() );
|
|
263
|
-
act( () =>
|
|
264
|
+
act( () => chart.focus() );
|
|
264
265
|
await user.keyboard( '{ArrowRight}' );
|
|
265
266
|
|
|
266
267
|
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '0' );
|
|
@@ -281,7 +282,7 @@ describe( 'useKeyboardNavigation', () => {
|
|
|
281
282
|
const user = userEvent.setup();
|
|
282
283
|
render( <NavigationHarness totalPoints={ 6 } /> );
|
|
283
284
|
|
|
284
|
-
screen.getByRole( '
|
|
285
|
+
screen.getByRole( 'application', { name: 'Harness' } ).focus();
|
|
285
286
|
await user.hover( screen.getByTestId( 'point-4' ) );
|
|
286
287
|
await user.keyboard( '{ArrowRight}' );
|
|
287
288
|
|
|
@@ -301,7 +302,7 @@ describe( 'useKeyboardNavigation', () => {
|
|
|
301
302
|
const { onKeyDown, stop } = recordPageKeyDown();
|
|
302
303
|
|
|
303
304
|
try {
|
|
304
|
-
screen.getByRole( '
|
|
305
|
+
screen.getByRole( 'application', { name: 'Harness' } ).focus();
|
|
305
306
|
await user.keyboard( '{ArrowDown}{PageDown}[Space]' );
|
|
306
307
|
|
|
307
308
|
expect( onKeyDown ).toHaveBeenCalledTimes( 3 );
|
|
@@ -320,7 +321,7 @@ describe( 'useKeyboardNavigation', () => {
|
|
|
320
321
|
const { onKeyDown, stop } = recordPageKeyDown();
|
|
321
322
|
|
|
322
323
|
try {
|
|
323
|
-
screen.getByRole( '
|
|
324
|
+
screen.getByRole( 'application', { name: 'Harness' } ).focus();
|
|
324
325
|
await user.keyboard( '{Escape}' );
|
|
325
326
|
|
|
326
327
|
expect( onKeyDown.mock.calls[ 0 ][ 0 ].defaultPrevented ).toBe( false );
|
|
@@ -261,10 +261,10 @@ export const useKeyboardNavigation = ( {
|
|
|
261
261
|
preventTooltipScroll = false,
|
|
262
262
|
visibleSeriesKey,
|
|
263
263
|
}: UseKeyboardNavigationProps ) => {
|
|
264
|
-
// `chartRef` sits inside the focusable
|
|
264
|
+
// `chartRef` sits inside the focusable chart container, so that container is what focus returns to and is measured against.
|
|
265
265
|
const getChartRoot = useCallback(
|
|
266
266
|
(): HTMLElement | null =>
|
|
267
|
-
chartRef.current?.closest< HTMLElement >( '[role="
|
|
267
|
+
chartRef.current?.closest< HTMLElement >( '[role="application"]' ) ?? chartRef.current,
|
|
268
268
|
[ chartRef ]
|
|
269
269
|
);
|
|
270
270
|
|
|
@@ -401,7 +401,7 @@ export const useKeyboardNavigation = ( {
|
|
|
401
401
|
pointerIndex.current = undefined;
|
|
402
402
|
}
|
|
403
403
|
|
|
404
|
-
//
|
|
404
|
+
// Arrows stop at the first and last point.
|
|
405
405
|
if ( event.key === 'ArrowRight' ) {
|
|
406
406
|
event.preventDefault();
|
|
407
407
|
setIsNavigating( true );
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { GlobalChartsProvider } from '../../providers';
|
|
2
3
|
import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
|
|
3
4
|
import { ChartScopeContext } from '../../providers/chart-scope';
|
|
4
5
|
import { useXYChartTheme } from '../use-xychart-theme';
|
|
@@ -68,6 +69,27 @@ describe( 'useXYChartTheme', () => {
|
|
|
68
69
|
document.body.removeChild( scope );
|
|
69
70
|
} );
|
|
70
71
|
|
|
72
|
+
it.each( [
|
|
73
|
+
[ -12, 11 ],
|
|
74
|
+
[ Infinity, 11 ],
|
|
75
|
+
[ 14, 14 ],
|
|
76
|
+
[ '-12px', 11 ],
|
|
77
|
+
[ '12.px', 11 ],
|
|
78
|
+
[ '9'.repeat( 400 ) + 'px', 11 ],
|
|
79
|
+
[ '0.875rem', '0.875rem' ],
|
|
80
|
+
[ 'var(--size)', 'var(--size)' ],
|
|
81
|
+
] )( 'renders a theme fontSize of %p as %p', ( fontSize, expected ) => {
|
|
82
|
+
const wrapper = ( { children }: { children: ReactNode } ) => (
|
|
83
|
+
<GlobalChartsProvider theme={ { svgLabelSmall: { fontSize } } }>
|
|
84
|
+
{ children }
|
|
85
|
+
</GlobalChartsProvider>
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
|
|
89
|
+
|
|
90
|
+
expect( result.current.axisStyles.x.bottom.tickLabel.fontSize ).toBe( expected );
|
|
91
|
+
} );
|
|
92
|
+
|
|
71
93
|
it( 'gives the y axis its own roles, resolving to none', () => {
|
|
72
94
|
const { result } = renderHook( () => useXYChartTheme( [] ) );
|
|
73
95
|
|
|
@@ -3,9 +3,36 @@ import { useMemo } from 'react';
|
|
|
3
3
|
import { useGlobalChartsTheme } from '../providers';
|
|
4
4
|
import { CATALOG_POINTERS } from '../providers/chart-context/private/catalog-pointers';
|
|
5
5
|
import { useChartScopeElement } from '../providers/chart-scope';
|
|
6
|
-
import { createCssVariableResolver } from '../utils';
|
|
6
|
+
import { createCssVariableResolver, resolveFontSize } from '../utils';
|
|
7
7
|
import type { SeriesData } from '../types';
|
|
8
8
|
|
|
9
|
+
type LabelStyle = { fontSize?: number | string };
|
|
10
|
+
|
|
11
|
+
const STARTS_LIKE_A_NUMBER = /^[+-]?[\d.]/;
|
|
12
|
+
const NON_NEGATIVE_CSS_NUMBER = /^\+?(?:\d*\.)?\d+(?:e[+-]?\d+)?(?:[a-z]+|%)?$/i;
|
|
13
|
+
|
|
14
|
+
const isRenderableFontSize = ( fontSize: number | string ): boolean => {
|
|
15
|
+
if ( typeof fontSize === 'number' ) {
|
|
16
|
+
return resolveFontSize( fontSize ) !== undefined;
|
|
17
|
+
}
|
|
18
|
+
// A pattern, not `CSS.supports`, so SSR and the browser agree; `resolveFontSize` would drop renderable `rem`. Keywords, `var()` and `calc()` pass through.
|
|
19
|
+
const value = fontSize.trim();
|
|
20
|
+
if ( ! STARTS_LIKE_A_NUMBER.test( value ) ) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
return NON_NEGATIVE_CSS_NUMBER.test( value ) && Number.isFinite( parseFloat( value ) );
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// visx writes `fontSize` straight onto `<text>`, where an invalid one renders at the browser's 16px default; dropping it lets visx use its own default.
|
|
27
|
+
const withRenderableFontSize = < T extends LabelStyle >( style?: T ): T | undefined => {
|
|
28
|
+
if ( style?.fontSize === undefined || isRenderableFontSize( style.fontSize ) ) {
|
|
29
|
+
return style;
|
|
30
|
+
}
|
|
31
|
+
const rest = { ...style };
|
|
32
|
+
delete rest.fontSize;
|
|
33
|
+
return rest;
|
|
34
|
+
};
|
|
35
|
+
|
|
9
36
|
export const useXYChartTheme = ( data: SeriesData[] ) => {
|
|
10
37
|
const theme = useGlobalChartsTheme();
|
|
11
38
|
const scopeElement = useChartScopeElement();
|
|
@@ -47,7 +74,11 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
|
|
|
47
74
|
xTickLineStyles: { ...theme.xTickLineStyles, stroke: CATALOG_POINTERS.tickX },
|
|
48
75
|
yAxisLineStyles: { stroke: CATALOG_POINTERS.axisY },
|
|
49
76
|
yTickLineStyles: { stroke: CATALOG_POINTERS.tickY },
|
|
50
|
-
svgLabelSmall: {
|
|
77
|
+
svgLabelSmall: {
|
|
78
|
+
...withRenderableFontSize( theme.svgLabelSmall ),
|
|
79
|
+
fill: CATALOG_POINTERS.labelAxis,
|
|
80
|
+
},
|
|
81
|
+
svgLabelBig: withRenderableFontSize( theme.svgLabelBig ),
|
|
51
82
|
} );
|
|
52
83
|
}, [ theme, seriesColorKey, scopeElement ] );
|
|
53
84
|
};
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -191,7 +191,7 @@ export type DataPointDate = {
|
|
|
191
191
|
color?: string;
|
|
192
192
|
};
|
|
193
193
|
|
|
194
|
-
|
|
194
|
+
type LeaderboardEntryFields = {
|
|
195
195
|
/**
|
|
196
196
|
* Unique internal key (e.g., 'key-direct')
|
|
197
197
|
*/
|
|
@@ -245,7 +245,8 @@ export type LeaderboardEntry = {
|
|
|
245
245
|
* For links or other interactive affordances (external-link icons, info
|
|
246
246
|
* tooltips), put them in the `label` render prop instead of using onClick —
|
|
247
247
|
* a row is either a button (onClick) or carries interactive label content,
|
|
248
|
-
* never both, since interactive elements cannot be nested in HTML.
|
|
248
|
+
* never both, since interactive elements cannot be nested in HTML. The
|
|
249
|
+
* exception is a row that sets `hasInteractiveLabel`.
|
|
249
250
|
*/
|
|
250
251
|
onClick?: ( event: MouseEvent< HTMLButtonElement > ) => void;
|
|
251
252
|
|
|
@@ -261,6 +262,21 @@ export type LeaderboardEntry = {
|
|
|
261
262
|
ariaLabel?: string;
|
|
262
263
|
};
|
|
263
264
|
|
|
265
|
+
export type LeaderboardEntry = LeaderboardEntryFields &
|
|
266
|
+
(
|
|
267
|
+
| { hasInteractiveLabel?: false }
|
|
268
|
+
| {
|
|
269
|
+
/**
|
|
270
|
+
* Set when the `label` holds its own link or button. The row's button
|
|
271
|
+
* then sits behind the row instead of around it, so both stay operable.
|
|
272
|
+
* Requires `onClick`, and `ariaLabel` since the button wraps no text.
|
|
273
|
+
*/
|
|
274
|
+
hasInteractiveLabel: true;
|
|
275
|
+
onClick: ( event: MouseEvent< HTMLButtonElement > ) => void;
|
|
276
|
+
ariaLabel: string;
|
|
277
|
+
}
|
|
278
|
+
);
|
|
279
|
+
|
|
264
280
|
export type GradientStop = {
|
|
265
281
|
offset: string;
|
|
266
282
|
color?: string;
|
|
@@ -378,9 +394,15 @@ export type ChartTheme = {
|
|
|
378
394
|
*
|
|
379
395
|
* `fontFamily: 'inherit'` is load-bearing here — it overrides the font stack
|
|
380
396
|
* `buildChartTheme` injects inline on SVG `<text>`.
|
|
397
|
+
*
|
|
398
|
+
* Give `fontSize` in pixels (`12` or `'12px'`). Margins and pie labels cannot measure `rem`, `em`, `%` or `var()`, so they use the default size and labels can clip (CHARTS-303). An invalid size is dropped.
|
|
381
399
|
*/
|
|
382
400
|
svgLabelSmall?: Omit< TextProps, 'fill' >;
|
|
383
|
-
/**
|
|
401
|
+
/**
|
|
402
|
+
* Styles for large SVG text (eg. axis titles), passed through to the XYChart theme.
|
|
403
|
+
*
|
|
404
|
+
* Give `fontSize` in pixels, as for `svgLabelSmall`.
|
|
405
|
+
*/
|
|
384
406
|
svgLabelBig?: TextProps;
|
|
385
407
|
annotationStyles?: ThemeAnnotationStyles;
|
|
386
408
|
/** LeaderboardChart specific settings */
|
|
@@ -638,6 +660,9 @@ export type ChartLegendConfig< T = DataPoint | DataPointDate | LeaderboardEntry
|
|
|
638
660
|
shapeStyles?: LegendShapeStyles;
|
|
639
661
|
};
|
|
640
662
|
|
|
663
|
+
/** Names a comparison series' legend item after the label of the series it is compared with. */
|
|
664
|
+
export type ComparisonItemLabel = ( seriesLabel: string ) => string;
|
|
665
|
+
|
|
641
666
|
/**
|
|
642
667
|
* Legend config for charts built from `SeriesData` (line, bar, area). Adds `collapseGroups` on top
|
|
643
668
|
* of the shared config. It is intentionally absent from the base `ChartLegendConfig` so point-based
|
|
@@ -658,6 +683,11 @@ export type SeriesChartLegendConfig = ChartLegendConfig< SeriesData[] > & {
|
|
|
658
683
|
* Pass a string to replace the default label.
|
|
659
684
|
*/
|
|
660
685
|
comparisonItem?: boolean | string;
|
|
686
|
+
/**
|
|
687
|
+
* Append one static item per comparison series instead, named by calling this with the label
|
|
688
|
+
* of the series it is compared with, and left out while that series is hidden.
|
|
689
|
+
*/
|
|
690
|
+
comparisonSeriesItems?: ComparisonItemLabel;
|
|
661
691
|
};
|
|
662
692
|
|
|
663
693
|
/**
|
|
@@ -688,6 +718,10 @@ export type BaseChartProps< T = DataPoint | DataPointDate | LeaderboardEntry > =
|
|
|
688
718
|
* Additional CSS class name for the chart container
|
|
689
719
|
*/
|
|
690
720
|
className?: string;
|
|
721
|
+
/**
|
|
722
|
+
* Accessible name of the chart. Name it after what it shows, and give each chart on a page a different name. Each chart type has a localized default, such as "Line chart".
|
|
723
|
+
*/
|
|
724
|
+
ariaLabel?: string;
|
|
691
725
|
/**
|
|
692
726
|
* Width of the chart container in pixels. When omitted, the chart fills its parent's width.
|
|
693
727
|
*/
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
const toFontSize = ( size: number ): number | undefined =>
|
|
2
|
+
Number.isFinite( size ) && size >= 0 ? size : undefined;
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Resolve a theme `fontSize` value into a plain number suitable for
|
|
3
6
|
* canvas-based measurement (e.g. `getStringWidth`).
|
|
4
7
|
*
|
|
5
8
|
* Accepts:
|
|
6
|
-
* - A number — returned as-is
|
|
9
|
+
* - A finite, non-negative number — returned as-is
|
|
7
10
|
* - A pixel string like `"12px"` — parsed and returned as a number
|
|
8
11
|
*
|
|
9
|
-
* Returns `undefined` for any other input (missing value,
|
|
10
|
-
* relative units like `rem`/`em`/`%`/`vh`) so callers can fall back to
|
|
12
|
+
* Returns `undefined` for any other input (missing value, a non-finite or
|
|
13
|
+
* negative size, or relative units like `rem`/`em`/`%`/`vh`) so callers can fall back to
|
|
11
14
|
* their own default. Relative units are intentionally rejected because
|
|
12
15
|
* we cannot resolve them to absolute pixels here without a parent
|
|
13
16
|
* computed style, and silently returning the unitless prefix
|
|
@@ -18,19 +21,15 @@
|
|
|
18
21
|
*/
|
|
19
22
|
export const resolveFontSize = ( val?: number | string ): number | undefined => {
|
|
20
23
|
if ( typeof val === 'number' ) {
|
|
21
|
-
return
|
|
24
|
+
return toFontSize( val );
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
if ( typeof val === 'string' ) {
|
|
25
28
|
// Only accept plain numeric strings or pixel values.
|
|
26
29
|
// Reject rem, em, %, vh, vw, etc. — they cannot be resolved to
|
|
27
30
|
// absolute pixels without a computed style context.
|
|
28
|
-
const match = val.trim().match(
|
|
29
|
-
|
|
30
|
-
return undefined;
|
|
31
|
-
}
|
|
32
|
-
const parsed = parseFloat( match[ 1 ] );
|
|
33
|
-
return isNaN( parsed ) ? undefined : parsed;
|
|
31
|
+
const match = val.trim().match( /^\+?((?:\d*\.)?\d+)(px)?$/ );
|
|
32
|
+
return match ? toFontSize( parseFloat( match[ 1 ] ) ) : undefined;
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
return undefined;
|
|
@@ -11,8 +11,8 @@ describe( 'resolveFontSize', () => {
|
|
|
11
11
|
expect( resolveFontSize( 13.5 ) ).toBe( 13.5 );
|
|
12
12
|
} );
|
|
13
13
|
|
|
14
|
-
it( 'returns undefined for
|
|
15
|
-
expect( resolveFontSize(
|
|
14
|
+
it.each( [ NaN, Infinity, -12 ] )( 'returns undefined for %p', val => {
|
|
15
|
+
expect( resolveFontSize( val ) ).toBeUndefined();
|
|
16
16
|
} );
|
|
17
17
|
} );
|
|
18
18
|
|
|
@@ -25,8 +25,17 @@ describe( 'resolveFontSize', () => {
|
|
|
25
25
|
it( 'parses pixel strings', () => {
|
|
26
26
|
expect( resolveFontSize( '12px' ) ).toBe( 12 );
|
|
27
27
|
expect( resolveFontSize( '13.5px' ) ).toBe( 13.5 );
|
|
28
|
+
expect( resolveFontSize( '.5px' ) ).toBe( 0.5 );
|
|
29
|
+
expect( resolveFontSize( '+12px' ) ).toBe( 12 );
|
|
28
30
|
} );
|
|
29
31
|
|
|
32
|
+
it.each( [ '12.px', '12.', '-12px', '-.5', '9'.repeat( 400 ) + 'px' ] )(
|
|
33
|
+
'rejects %p, which is not a valid font size',
|
|
34
|
+
val => {
|
|
35
|
+
expect( resolveFontSize( val ) ).toBeUndefined();
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
|
|
30
39
|
it( 'trims whitespace before parsing', () => {
|
|
31
40
|
expect( resolveFontSize( ' 14px ' ) ).toBe( 14 );
|
|
32
41
|
} );
|