@automattic/charts 4.9.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 (50) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.cjs +159 -66
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +14 -0
  5. package/dist/index.d.cts +27 -22
  6. package/dist/index.d.ts +27 -22
  7. package/dist/index.js +159 -66
  8. package/dist/index.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/charts/area-chart/area-chart.tsx +2 -1
  11. package/src/charts/area-chart/test/area-chart.test.tsx +0 -8
  12. package/src/charts/area-chart/types.ts +0 -2
  13. package/src/charts/bar-chart/bar-chart.tsx +7 -2
  14. package/src/charts/bar-chart/test/bar-chart.test.tsx +0 -8
  15. package/src/charts/bar-chart/types.ts +0 -2
  16. package/src/charts/bar-list-chart/bar-list-chart.tsx +3 -0
  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/test/heatmap-chart.test.tsx +0 -6
  24. package/src/charts/heatmap-chart/types.ts +0 -2
  25. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  26. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +6 -0
  27. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +34 -19
  28. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +27 -1
  29. package/src/charts/leaderboard-chart/types.ts +1 -0
  30. package/src/charts/line-chart/line-chart.tsx +3 -0
  31. package/src/charts/line-chart/test/line-chart.test.tsx +0 -8
  32. package/src/charts/line-chart/types.ts +0 -2
  33. package/src/charts/pie-chart/pie-chart.module.scss +3 -0
  34. package/src/charts/pie-chart/pie-chart.tsx +0 -5
  35. package/src/charts/pie-chart/test/pie-chart.test.tsx +4 -8
  36. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +3 -0
  37. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +0 -5
  38. package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +8 -3
  39. package/src/charts/test/accessible-name.test.tsx +181 -0
  40. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +63 -3
  41. package/src/components/legend/hooks/use-chart-legend-items.ts +63 -1
  42. package/src/components/legend/private/base-legend.tsx +27 -15
  43. package/src/components/legend/test/legend.test.tsx +42 -0
  44. package/src/components/legend/types.ts +2 -0
  45. package/src/hooks/test/use-xychart-theme.test.tsx +22 -0
  46. package/src/hooks/use-xychart-theme.ts +33 -2
  47. package/src/index.ts +1 -0
  48. package/src/types.ts +19 -1
  49. package/src/utils/resolve-font-size.ts +9 -10
  50. package/src/utils/test/resolve-font-size.test.ts +11 -2
@@ -129,11 +129,6 @@ export interface PieSemiCircleChartProps extends BaseChartProps< DataPointPercen
129
129
  * When provided, replaces the default `label: value` tooltip with custom content.
130
130
  */
131
131
  renderTooltip?: ( params: PieSemiCircleChartRenderTooltipParams ) => ReactNode;
132
-
133
- /**
134
- * Accessible name of the chart. Defaults to a localized "Semi-circle chart".
135
- */
136
- ariaLabel?: string;
137
132
  }
138
133
 
139
134
  // Base props type with optional responsive properties
@@ -1,6 +1,11 @@
1
- import { BoundedTooltip } from '../../../components/tooltip/private/bounded-tooltip';
1
+ import {
2
+ BoundedTooltip,
3
+ TOOLTIP_Z_INDEX,
4
+ } from '../../../components/tooltip/private/bounded-tooltip';
2
5
  import { useStandaloneScopeClass } from '../../../providers/chart-scope';
3
- import type { ReactNode } from 'react';
6
+ import type { CSSProperties, ReactNode } from 'react';
7
+
8
+ const TOOLTIP_BOX_STYLE: CSSProperties = { zIndex: TOOLTIP_Z_INDEX };
4
9
 
5
10
  interface PieTooltipProps {
6
11
  top: number;
@@ -21,7 +26,7 @@ export const PieTooltip = ( {
21
26
  const standaloneScopeClass = useStandaloneScopeClass();
22
27
 
23
28
  return (
24
- <BoundedTooltip top={ top } left={ left }>
29
+ <BoundedTooltip top={ top } left={ left } style={ TOOLTIP_BOX_STYLE }>
25
30
  { selectedIndex === undefined ? (
26
31
  <div className={ standaloneScopeClass } role="tooltip">
27
32
  { children }
@@ -0,0 +1,181 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { GlobalChartsProvider } from '../../providers';
3
+ import { salesByProduct } from '../../stories/sample-data';
4
+ import { AreaChartUnresponsive } from '../area-chart';
5
+ import { BarChartUnresponsive } from '../bar-chart';
6
+ import { BarListChartUnresponsive } from '../bar-list-chart';
7
+ import { ConversionFunnelChart } from '../conversion-funnel-chart';
8
+ import { GeoChartUnresponsive } from '../geo-chart';
9
+ import { HeatmapChart } from '../heatmap-chart';
10
+ import { LeaderboardChartUnresponsive } from '../leaderboard-chart';
11
+ import { LineChartUnresponsive } from '../line-chart';
12
+ import { PieChartUnresponsive } from '../pie-chart';
13
+ import { PieSemiCircleChartUnresponsive } from '../pie-semi-circle-chart';
14
+ import type { GeoData } from '../../types';
15
+ import type { ReactElement } from 'react';
16
+
17
+ jest.mock( 'react-google-charts', () => ( { Chart: () => <div /> } ) );
18
+
19
+ const series = [
20
+ {
21
+ label: 'Series A',
22
+ data: [
23
+ { date: new Date( '2024-01-01' ), value: 10 },
24
+ { date: new Date( '2024-01-02' ), value: 20 },
25
+ ],
26
+ },
27
+ ];
28
+
29
+ const slices = [
30
+ { label: 'MacOS', value: 30, valueDisplay: '30', percentage: 60 },
31
+ { label: 'Linux', value: 20, valueDisplay: '20', percentage: 40 },
32
+ ];
33
+
34
+ const geoData: GeoData = [
35
+ [ 'Country', 'Value' ],
36
+ [ 'US', 100 ],
37
+ [ 'CA', 50 ],
38
+ [ 'GB', 25 ],
39
+ ];
40
+
41
+ const funnelSteps = [
42
+ { id: 'sessions', label: 'Sessions', rate: 100, count: 10000 },
43
+ { id: 'cart', label: 'Cart', rate: 71.1, count: 7110 },
44
+ { id: 'checkout', label: 'Checkout', rate: 52.5, count: 5250 },
45
+ { id: 'purchase', label: 'Purchase', rate: 10.3, count: 1030 },
46
+ ];
47
+
48
+ const heatmapData = [
49
+ { label: 'W1', data: [ { value: 1 }, { value: 2 }, { value: null } ] },
50
+ { label: 'W2', data: [ { value: 3 }, { value: 0 }, { value: 4 } ] },
51
+ ];
52
+
53
+ const leaderboardData = [
54
+ {
55
+ id: 'direct',
56
+ label: 'Direct',
57
+ currentValue: 12500,
58
+ previousValue: 10000,
59
+ currentShare: 100,
60
+ previousShare: 80,
61
+ delta: 25,
62
+ },
63
+ {
64
+ id: 'social',
65
+ label: 'Social Media',
66
+ currentValue: 8750,
67
+ previousValue: 9500,
68
+ currentShare: 70,
69
+ previousShare: 76,
70
+ delta: -8,
71
+ },
72
+ ];
73
+
74
+ const CHARTS: [ string, string, string, ( ariaLabel?: string ) => ReactElement ][] = [
75
+ [
76
+ 'line',
77
+ 'application',
78
+ 'Line chart',
79
+ ariaLabel => (
80
+ <LineChartUnresponsive
81
+ data={ series }
82
+ width={ 400 }
83
+ height={ 300 }
84
+ withGradientFill={ false }
85
+ ariaLabel={ ariaLabel }
86
+ />
87
+ ),
88
+ ],
89
+ [
90
+ 'area',
91
+ 'application',
92
+ 'Area chart',
93
+ ariaLabel => (
94
+ <AreaChartUnresponsive data={ series } width={ 400 } height={ 300 } ariaLabel={ ariaLabel } />
95
+ ),
96
+ ],
97
+ [
98
+ 'bar',
99
+ 'application',
100
+ 'Bar chart',
101
+ ariaLabel => (
102
+ <BarChartUnresponsive data={ series } width={ 400 } height={ 300 } ariaLabel={ ariaLabel } />
103
+ ),
104
+ ],
105
+ [
106
+ 'bar list',
107
+ 'application',
108
+ 'Bar list chart',
109
+ ariaLabel => (
110
+ <BarListChartUnresponsive
111
+ data={ salesByProduct }
112
+ width={ 400 }
113
+ height={ 300 }
114
+ ariaLabel={ ariaLabel }
115
+ />
116
+ ),
117
+ ],
118
+ [
119
+ 'pie',
120
+ 'application',
121
+ 'Pie chart',
122
+ ariaLabel => <PieChartUnresponsive data={ slices } size={ 300 } ariaLabel={ ariaLabel } />,
123
+ ],
124
+ [
125
+ 'semi-circle',
126
+ 'application',
127
+ 'Semi-circle chart',
128
+ ariaLabel => (
129
+ <PieSemiCircleChartUnresponsive data={ slices } width={ 300 } ariaLabel={ ariaLabel } />
130
+ ),
131
+ ],
132
+ [
133
+ 'geo',
134
+ 'group',
135
+ 'Geo chart',
136
+ ariaLabel => (
137
+ <GeoChartUnresponsive data={ geoData } width={ 800 } height={ 400 } ariaLabel={ ariaLabel } />
138
+ ),
139
+ ],
140
+ [
141
+ 'conversion funnel',
142
+ 'group',
143
+ 'Conversion funnel chart',
144
+ ariaLabel => (
145
+ <ConversionFunnelChart mainRate={ 25 } steps={ funnelSteps } ariaLabel={ ariaLabel } />
146
+ ),
147
+ ],
148
+ [
149
+ 'heatmap',
150
+ 'grid',
151
+ 'Heatmap chart',
152
+ ariaLabel => (
153
+ <HeatmapChart data={ heatmapData } width={ 500 } height={ 300 } ariaLabel={ ariaLabel } />
154
+ ),
155
+ ],
156
+ [
157
+ 'leaderboard',
158
+ 'list',
159
+ 'Leaderboard chart',
160
+ ariaLabel => (
161
+ <LeaderboardChartUnresponsive
162
+ data={ leaderboardData }
163
+ width={ 400 }
164
+ height={ 300 }
165
+ ariaLabel={ ariaLabel }
166
+ />
167
+ ),
168
+ ],
169
+ ];
170
+
171
+ it.each( CHARTS )(
172
+ 'names the %s chart from ariaLabel, or its default',
173
+ ( _chart, role, defaultName, renderChart ) => {
174
+ const { unmount } = render( <GlobalChartsProvider>{ renderChart() }</GlobalChartsProvider> );
175
+ expect( screen.getByRole( role, { name: defaultName } ) ).toBeInTheDocument();
176
+ unmount();
177
+
178
+ render( <GlobalChartsProvider>{ renderChart( 'Views by day' ) }</GlobalChartsProvider> );
179
+ expect( screen.getByRole( role, { name: 'Views by day' } ) ).toBeInTheDocument();
180
+ }
181
+ );
@@ -257,6 +257,23 @@ describe( 'useChartLegendItems', () => {
257
257
  data: [ { label: 'Mon', value: 90 } ],
258
258
  },
259
259
  ];
260
+ const twoMetricData: SeriesData[] = [
261
+ { label: 'Views', group: 'views', data: [ { label: 'Mon', value: 100 } ] },
262
+ {
263
+ label: 'Views — previous',
264
+ group: 'views',
265
+ options: { type: 'comparison' as const },
266
+ data: [ { label: 'Mon', value: 90 } ],
267
+ },
268
+ { label: 'Visitors', group: 'visitors', data: [ { label: 'Mon', value: 50 } ] },
269
+ {
270
+ label: 'Visitors — previous',
271
+ group: 'visitors',
272
+ options: { type: 'comparison' as const },
273
+ data: [ { label: 'Mon', value: 40 } ],
274
+ },
275
+ ];
276
+ const perMetricLabel = ( metric: string ) => `${ metric } (Prev. 7 days)`;
260
277
 
261
278
  test( 'is off by default', () => {
262
279
  const { result } = renderHook(
@@ -300,15 +317,58 @@ describe( 'useChartLegendItems', () => {
300
317
  expect( result.current.map( item => item.label ) ).toEqual( [ 'Views', 'Previous period' ] );
301
318
  } );
302
319
 
303
- test( 'adds nothing when the comparison series already has its own item', () => {
320
+ test( 'names one static item per compared metric with comparisonSeriesItems', () => {
304
321
  const { result } = renderHook(
305
- () => useChartLegendItems( comparisonData, { comparisonItem: true } ),
322
+ () =>
323
+ useChartLegendItems(
324
+ twoMetricData,
325
+ { collapseGroups: true, comparisonSeriesItems: perMetricLabel },
326
+ 'line'
327
+ ),
306
328
  { wrapper }
307
329
  );
308
330
 
309
- expect( result.current.map( item => item.label ) ).toEqual( [ 'Views', 'Views — previous' ] );
331
+ expect( result.current.map( item => item.label ) ).toEqual( [
332
+ 'Views',
333
+ 'Visitors',
334
+ 'Views (Prev. 7 days)',
335
+ 'Visitors (Prev. 7 days)',
336
+ ] );
337
+ expect(
338
+ result.current.slice( 2 ).map( ( { interactive, describesSeries } ) => ( {
339
+ interactive,
340
+ describesSeries,
341
+ } ) )
342
+ ).toEqual( [
343
+ { interactive: false, describesSeries: 'Views — previous' },
344
+ { interactive: false, describesSeries: 'Visitors — previous' },
345
+ ] );
346
+ expect( result.current[ 2 ].color ).toBe( result.current[ 0 ].color );
347
+ expect( result.current[ 3 ].color ).toBe( result.current[ 1 ].color );
310
348
  } );
311
349
 
350
+ test.each( [
351
+ [
352
+ 'comparisonItem',
353
+ comparisonData,
354
+ { comparisonItem: true },
355
+ [ 'Views', 'Views — previous' ],
356
+ ],
357
+ [
358
+ 'comparisonSeriesItems',
359
+ twoMetricData,
360
+ { comparisonSeriesItems: perMetricLabel },
361
+ [ 'Views', 'Views — previous', 'Visitors', 'Visitors — previous' ],
362
+ ],
363
+ ] )(
364
+ 'adds nothing for %s when the comparison series already has its own item',
365
+ ( _name, data, options, expected ) => {
366
+ const { result } = renderHook( () => useChartLegendItems( data, options ), { wrapper } );
367
+
368
+ expect( result.current.map( item => item.label ) ).toEqual( expected );
369
+ }
370
+ );
371
+
312
372
  test( 'styles a current-period series by its own line styles, not the theme index', () => {
313
373
  const theme = {
314
374
  seriesLineStyles: [ { strokeWidth: 2 } ],
@@ -8,10 +8,12 @@ import {
8
8
  } from '../../../providers';
9
9
  import { formatPercentage } from '../../../utils';
10
10
  import type {
11
+ ComparisonItemLabel,
11
12
  SeriesData,
12
13
  DataPointDate,
13
14
  DataPointPercentageCalculated,
14
15
  LegendShape,
16
+ SeriesChartLegendConfig,
15
17
  } from '../../../types';
16
18
  import type { BaseLegendItem } from '../types';
17
19
  import type { GlyphProps } from '@visx/xychart';
@@ -36,7 +38,9 @@ export interface ChartLegendOptions {
36
38
  * `options.type === 'comparison'`. Skipped when that series already has its own
37
39
  * item. Pass a string to replace the default label.
38
40
  */
39
- comparisonItem?: boolean | string;
41
+ comparisonItem?: SeriesChartLegendConfig[ 'comparisonItem' ];
42
+ /** One static item per comparison series instead; see `SeriesChartLegendConfig`. */
43
+ comparisonSeriesItems?: SeriesChartLegendConfig[ 'comparisonSeriesItems' ];
40
44
  }
41
45
 
42
46
  /**
@@ -276,6 +280,48 @@ function buildComparisonLegendItem(
276
280
  return { label, color, shapeStyle: shapeStyles, interactive: false };
277
281
  }
278
282
 
283
+ /**
284
+ * Builds one static item per comparison series folded into another series' item, named after
285
+ * that item and styled like the comparison series itself.
286
+ * @param seriesData - The series data to search for comparison series
287
+ * @param items - The legend items already built from the series
288
+ * @param formatLabel - Names an item after the label of the item it was folded into
289
+ * @param getElementStyles - Function to get element styles
290
+ * @param legendShape - The shape type for legend items (string literal or React component)
291
+ * @return The legend items, in series order
292
+ */
293
+ function buildPerSeriesComparisonItems(
294
+ seriesData: SeriesData[],
295
+ items: BaseLegendItem[],
296
+ formatLabel: ComparisonItemLabel,
297
+ getElementStyles: ( params: GetElementStylesParams ) => ElementStyles,
298
+ legendShape?: LegendShape< SeriesData[], number >
299
+ ): BaseLegendItem[] {
300
+ return seriesData.flatMap( ( series, index ) => {
301
+ const owner = items.find( item => item.seriesLabels?.includes( series.label ) );
302
+
303
+ if (
304
+ series.options?.type !== 'comparison' ||
305
+ ! owner ||
306
+ owner.seriesLabels?.[ 0 ] === series.label
307
+ ) {
308
+ return [];
309
+ }
310
+
311
+ const { color, shapeStyles } = getElementStyles( { data: series, index, legendShape } );
312
+
313
+ return [
314
+ {
315
+ label: formatLabel( owner.label ),
316
+ color,
317
+ shapeStyle: shapeStyles,
318
+ interactive: false,
319
+ describesSeries: series.label,
320
+ },
321
+ ];
322
+ } );
323
+ }
324
+
279
325
  /**
280
326
  * Processes point data into legend items
281
327
  * @param pointData - The point data to process
@@ -339,6 +385,7 @@ export function useChartLegendItems<
339
385
  glyphSize = 8,
340
386
  collapseGroups = false,
341
387
  comparisonItem = false,
388
+ comparisonSeriesItems,
342
389
  renderGlyph,
343
390
  } = options;
344
391
  const { getElementStyles } = useGlobalChartsContext();
@@ -361,6 +408,20 @@ export function useChartLegendItems<
361
408
  renderGlyph,
362
409
  legendShape
363
410
  );
411
+
412
+ if ( comparisonSeriesItems ) {
413
+ return [
414
+ ...items,
415
+ ...buildPerSeriesComparisonItems(
416
+ seriesData,
417
+ items,
418
+ comparisonSeriesItems,
419
+ getElementStyles,
420
+ legendShape
421
+ ),
422
+ ];
423
+ }
424
+
364
425
  const comparison = comparisonItem
365
426
  ? buildComparisonLegendItem(
366
427
  seriesData,
@@ -396,6 +457,7 @@ export function useChartLegendItems<
396
457
  glyphSize,
397
458
  collapseGroups,
398
459
  comparisonItem,
460
+ comparisonSeriesItems,
399
461
  renderGlyph,
400
462
  legendShape,
401
463
  ] );
@@ -11,6 +11,7 @@ import {
11
11
  forwardRef,
12
12
  useCallback,
13
13
  useContext,
14
+ useMemo,
14
15
  } from 'react';
15
16
  import { ChartInstanceContext } from '../../../charts/private/chart-instance-context';
16
17
  import { useTextTruncation } from '../../../hooks';
@@ -115,7 +116,7 @@ export const BaseLegend: ForwardRefExoticComponent<
115
116
  > = forwardRef< HTMLDivElement, BaseLegendProps >(
116
117
  (
117
118
  {
118
- items,
119
+ items: allItems,
119
120
  className,
120
121
  orientation = 'horizontal',
121
122
  alignment = 'center',
@@ -154,6 +155,31 @@ export const BaseLegend: ForwardRefExoticComponent<
154
155
  const chartInstanceContext = useContext( ChartInstanceContext );
155
156
  const standaloneScopeClass = useStandaloneScopeClass();
156
157
 
158
+ // Disabling interaction for the whole legend still preserves its series' visibility state.
159
+ const isSeriesVisible = useCallback(
160
+ ( seriesLabel: string ) => {
161
+ if ( chartInstanceContext?.isSeriesVisible ) {
162
+ return chartInstanceContext.isSeriesVisible( seriesLabel );
163
+ }
164
+ if ( ! chartId || ! context ) {
165
+ return true;
166
+ }
167
+ return context.isSeriesVisible( chartId, seriesLabel );
168
+ },
169
+ [ chartId, chartInstanceContext, context ]
170
+ );
171
+
172
+ const items = useMemo(
173
+ () =>
174
+ allItems.filter(
175
+ item =>
176
+ item.interactive !== false ||
177
+ ! item.describesSeries ||
178
+ isSeriesVisible( item.describesSeries )
179
+ ),
180
+ [ allItems, isSeriesVisible ]
181
+ );
182
+
157
183
  // Keep duplicate labels: a static comparison item can share a metric's name.
158
184
  const domain = items.map( item => item.label );
159
185
  const legendScale = scaleOrdinal( {
@@ -189,20 +215,6 @@ export const BaseLegend: ForwardRefExoticComponent<
189
215
  [ interactive, chartId, context ]
190
216
  );
191
217
 
192
- // Disabling interaction for the whole legend still preserves its series' visibility state.
193
- const isSeriesVisible = useCallback(
194
- ( seriesLabel: string ) => {
195
- if ( chartInstanceContext?.isSeriesVisible ) {
196
- return chartInstanceContext.isSeriesVisible( seriesLabel );
197
- }
198
- if ( ! chartId || ! context ) {
199
- return true;
200
- }
201
- return context.isSeriesVisible( chartId, seriesLabel );
202
- },
203
- [ chartId, chartInstanceContext, context ]
204
- );
205
-
206
218
  // Create event handlers to avoid inline arrow functions
207
219
  const createClickHandler = useCallback(
208
220
  ( seriesLabels: string[], itemInteractive: boolean ) => {
@@ -801,6 +801,48 @@ describe( 'BaseLegend', () => {
801
801
  expect( staticItem ).not.toHaveClass( 'legend-item--inactive' );
802
802
  } );
803
803
 
804
+ it( 'leaves out a static item while its series is hidden, keeping later swatches aligned', async () => {
805
+ const user = userEvent.setup();
806
+ const items = [
807
+ { label: 'Views', color: '#0000ff', seriesLabels: [ 'Views', 'Views · previous' ] },
808
+ {
809
+ label: 'Visitors',
810
+ color: '#00ff00',
811
+ seriesLabels: [ 'Visitors', 'Visitors · previous' ],
812
+ },
813
+ {
814
+ label: 'Views (Prev. 7 days)',
815
+ color: '#000088',
816
+ interactive: false,
817
+ describesSeries: 'Views · previous',
818
+ },
819
+ {
820
+ label: 'Visitors (Prev. 7 days)',
821
+ color: '#008800',
822
+ interactive: false,
823
+ describesSeries: 'Visitors · previous',
824
+ },
825
+ ];
826
+
827
+ render(
828
+ <GlobalChartsProvider>
829
+ <BaseLegend items={ items } interactive chartId="test-chart" shape={ TestShape } />
830
+ </GlobalChartsProvider>
831
+ );
832
+
833
+ await user.click( screen.getByRole( 'button', { name: /^Views/ } ) );
834
+
835
+ expect( screen.getAllByTestId( 'legend-item' ).map( item => item.textContent ) ).toEqual( [
836
+ 'Views',
837
+ 'Visitors',
838
+ 'Visitors (Prev. 7 days)',
839
+ ] );
840
+ expect( screen.getAllByTestId( 'legend-marker' )[ 2 ] ).toHaveAttribute( 'fill', '#008800' );
841
+
842
+ await user.click( screen.getByRole( 'button', { name: /^Views/ } ) );
843
+ expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 4 );
844
+ } );
845
+
804
846
  it( 'keeps a comparison item distinct when its label collides', async () => {
805
847
  const user = userEvent.setup();
806
848
  const Example = () => {
@@ -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
  };
@@ -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
@@ -394,9 +394,15 @@ export type ChartTheme = {
394
394
  *
395
395
  * `fontFamily: 'inherit'` is load-bearing here — it overrides the font stack
396
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.
397
399
  */
398
400
  svgLabelSmall?: Omit< TextProps, 'fill' >;
399
- /** 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
+ */
400
406
  svgLabelBig?: TextProps;
401
407
  annotationStyles?: ThemeAnnotationStyles;
402
408
  /** LeaderboardChart specific settings */
@@ -654,6 +660,9 @@ export type ChartLegendConfig< T = DataPoint | DataPointDate | LeaderboardEntry
654
660
  shapeStyles?: LegendShapeStyles;
655
661
  };
656
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
+
657
666
  /**
658
667
  * Legend config for charts built from `SeriesData` (line, bar, area). Adds `collapseGroups` on top
659
668
  * of the shared config. It is intentionally absent from the base `ChartLegendConfig` so point-based
@@ -674,6 +683,11 @@ export type SeriesChartLegendConfig = ChartLegendConfig< SeriesData[] > & {
674
683
  * Pass a string to replace the default label.
675
684
  */
676
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;
677
691
  };
678
692
 
679
693
  /**
@@ -704,6 +718,10 @@ export type BaseChartProps< T = DataPoint | DataPointDate | LeaderboardEntry > =
704
718
  * Additional CSS class name for the chart container
705
719
  */
706
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;
707
725
  /**
708
726
  * Width of the chart container in pixels. When omitted, the chart fills its parent's width.
709
727
  */