@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/index.cjs +766 -277
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +72 -8
  5. package/dist/index.d.cts +54 -15
  6. package/dist/index.d.ts +54 -15
  7. package/dist/index.js +770 -281
  8. package/dist/index.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/charts/area-chart/area-chart.tsx +7 -3
  11. package/src/charts/area-chart/test/area-chart.test.tsx +15 -14
  12. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +2 -2
  13. package/src/charts/bar-chart/bar-chart.tsx +12 -4
  14. package/src/charts/bar-chart/test/bar-chart.test.tsx +74 -63
  15. package/src/charts/bar-list-chart/bar-list-chart.tsx +3 -0
  16. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +3 -1
  17. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +23 -7
  18. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +22 -0
  19. package/src/charts/conversion-funnel-chart/types.ts +1 -1
  20. package/src/charts/geo-chart/geo-chart.tsx +17 -2
  21. package/src/charts/geo-chart/test/geo-chart.test.tsx +9 -4
  22. package/src/charts/geo-chart/types.ts +1 -1
  23. package/src/charts/heatmap-chart/heatmap-chart.module.scss +10 -4
  24. package/src/charts/heatmap-chart/heatmap-chart.tsx +144 -37
  25. package/src/charts/heatmap-chart/private/column-groups.ts +79 -8
  26. package/src/charts/heatmap-chart/private/fit-compact-cells.ts +72 -0
  27. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +14 -2
  28. package/src/charts/heatmap-chart/private/index.ts +4 -2
  29. package/src/charts/heatmap-chart/test/column-groups.test.ts +41 -4
  30. package/src/charts/heatmap-chart/test/fit-compact-cells.test.ts +59 -0
  31. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +132 -7
  32. package/src/charts/heatmap-chart/types.ts +2 -2
  33. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  34. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +52 -4
  35. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +60 -9
  36. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +80 -1
  37. package/src/charts/leaderboard-chart/types.ts +7 -1
  38. package/src/charts/line-chart/line-chart.tsx +8 -2
  39. package/src/charts/line-chart/private/line-chart-annotation-label-popover.tsx +19 -9
  40. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +1 -1
  41. package/src/charts/line-chart/test/line-chart.test.tsx +37 -35
  42. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +2 -2
  43. package/src/charts/line-chart/types.ts +3 -3
  44. package/src/charts/pie-chart/pie-chart.module.scss +3 -0
  45. package/src/charts/pie-chart/pie-chart.tsx +144 -139
  46. package/src/charts/pie-chart/test/pie-chart.test.tsx +167 -0
  47. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +3 -0
  48. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +117 -120
  49. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +54 -0
  50. package/src/charts/private/chart-composition/use-chart-children.ts +11 -6
  51. package/src/charts/private/pie-keyboard-navigation/index.ts +4 -0
  52. package/src/charts/private/pie-keyboard-navigation/order-arcs-for-navigation.ts +10 -0
  53. package/src/charts/private/pie-keyboard-navigation/pie-keyboard-navigation.module.scss +16 -0
  54. package/src/charts/private/pie-keyboard-navigation/pie-selection-announcement.tsx +14 -0
  55. package/src/charts/private/pie-keyboard-navigation/pie-selection-output.tsx +82 -0
  56. package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +49 -0
  57. package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +47 -0
  58. package/src/charts/private/pie-keyboard-navigation/test/order-arcs-for-navigation.test.ts +34 -0
  59. package/src/charts/private/pie-keyboard-navigation/test/selected-segment-ring.test.ts +14 -0
  60. package/src/charts/private/pie-keyboard-navigation/use-pie-keyboard-navigation.ts +139 -0
  61. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +3 -2
  62. package/src/charts/test/accessible-name.test.tsx +181 -0
  63. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +63 -3
  64. package/src/components/legend/hooks/use-chart-legend-items.ts +63 -1
  65. package/src/components/legend/private/base-legend.tsx +27 -15
  66. package/src/components/legend/test/legend.test.tsx +42 -0
  67. package/src/components/legend/types.ts +2 -0
  68. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +20 -19
  69. package/src/components/tooltip/xy-chart-tooltip.tsx +3 -3
  70. package/src/hooks/test/use-xychart-theme.test.tsx +22 -0
  71. package/src/hooks/use-xychart-theme.ts +33 -2
  72. package/src/index.ts +1 -0
  73. package/src/types.ts +37 -3
  74. package/src/utils/resolve-font-size.ts +9 -10
  75. 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( 'grid', { name: /line chart/i } ).focus();
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( 'grid' ), 'focus' );
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( 'grid' ), 'focus' );
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 grid wraps the element `chartRef` points at.
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="grid"
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( 'grid', { name: 'Harness' } ).focus();
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 grid has focus', async () => {
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( 'grid', { name: 'Harness' } ) ).toHaveFocus();
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 grid on Escape when there are no points', async () => {
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( 'grid', { name: 'Harness' } ) ).toHaveFocus();
218
+ expect( screen.getByRole( 'application', { name: 'Harness' } ) ).toHaveFocus();
218
219
  } );
219
220
 
220
- it( 'returns focus to the grid when the count drops to zero while focus is in the chart', async () => {
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( 'grid', { name: 'Harness' } ) ).toHaveFocus();
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( 'grid', { name: 'Harness' } ) ).toHaveFocus();
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 grid = screen.getByRole( 'grid', { name: 'Harness' } );
258
+ const chart = screen.getByRole( 'application', { name: 'Harness' } );
258
259
 
259
- act( () => grid.focus() );
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( () => grid.focus() );
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( 'grid', { name: 'Harness' } ).focus();
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( 'grid', { name: 'Harness' } ).focus();
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( 'grid', { name: 'Harness' } ).focus();
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 grid, so the grid is what focus returns to and is measured against.
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="grid"]' ) ?? chartRef.current,
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
- // WAI-ARIA grid: arrows stop at the first and last cell.
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: { ...theme.svgLabelSmall, fill: CATALOG_POINTERS.labelAxis },
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
@@ -68,6 +68,7 @@ export type {
68
68
  LegendPosition,
69
69
  ChartLegendConfig,
70
70
  SeriesChartLegendConfig,
71
+ ComparisonItemLabel,
71
72
  SeriesVisibilityProps,
72
73
  BaseChartProps,
73
74
  GoogleDataTableColumn,
package/src/types.ts CHANGED
@@ -191,7 +191,7 @@ export type DataPointDate = {
191
191
  color?: string;
192
192
  };
193
193
 
194
- export type LeaderboardEntry = {
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
- /** Styles for large SVG text (eg. axis titles), passed through to the XYChart theme. */
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, NaN, or
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 isNaN( val ) ? undefined : val;
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( /^(-?\d+\.?\d*|-?\.\d+)(px)?$/ );
29
- if ( ! match ) {
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 NaN', () => {
15
- expect( resolveFontSize( NaN ) ).toBeUndefined();
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
  } );