@automattic/charts 4.13.0 → 4.15.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 (56) hide show
  1. package/CHANGELOG.md +34 -29
  2. package/TOKENS.md +4 -1
  3. package/dist/index.cjs +1069 -1620
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +33 -1
  6. package/dist/index.d.cts +60 -15
  7. package/dist/index.d.ts +60 -15
  8. package/dist/index.js +1072 -1575
  9. package/dist/index.js.map +1 -1
  10. package/package.json +11 -10
  11. package/src/charts/area-chart/area-chart.tsx +29 -20
  12. package/src/charts/area-chart/types.ts +3 -1
  13. package/src/charts/bar-chart/bar-chart.module.scss +38 -0
  14. package/src/charts/bar-chart/bar-chart.tsx +159 -56
  15. package/src/charts/bar-chart/private/band-tooltip.tsx +40 -5
  16. package/src/charts/bar-chart/private/bar-layer.tsx +66 -13
  17. package/src/charts/bar-chart/private/category.ts +25 -0
  18. package/src/charts/bar-chart/private/comparison-bars-geometry.ts +13 -1
  19. package/src/charts/bar-chart/private/index.ts +3 -1
  20. package/src/charts/bar-chart/private/selected-bar-ring.tsx +17 -6
  21. package/src/charts/bar-chart/private/stack-layout.ts +99 -0
  22. package/src/charts/bar-chart/private/stacked-bar-series.tsx +145 -0
  23. package/src/charts/bar-chart/private/test/stack-layout.test.ts +81 -0
  24. package/src/charts/bar-chart/private/test/value-domain.test.ts +10 -0
  25. package/src/charts/bar-chart/private/use-bar-chart-options.ts +35 -11
  26. package/src/charts/bar-chart/private/{use-overlap-slots.ts → use-bar-slots.ts} +36 -38
  27. package/src/charts/bar-chart/private/use-bar-stops.ts +19 -19
  28. package/src/charts/bar-chart/private/value-domain.ts +10 -8
  29. package/src/charts/bar-chart/test/bar-chart-stacked.test.tsx +546 -0
  30. package/src/charts/bar-chart/test/bar-chart.test.tsx +75 -0
  31. package/src/charts/bar-chart/test/use-bar-legend-items.test.tsx +18 -0
  32. package/src/charts/bar-chart/types.ts +3 -1
  33. package/src/charts/bar-chart/use-bar-colors.ts +12 -0
  34. package/src/charts/bar-chart/use-bar-legend-items.ts +4 -2
  35. package/src/charts/bar-list-chart/bar-list-chart.tsx +2 -0
  36. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +1 -1
  37. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +26 -9
  38. package/src/charts/leaderboard-chart/private/resolve-leaderboard-entries.ts +53 -0
  39. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +41 -0
  40. package/src/charts/leaderboard-chart/test/resolve-leaderboard-entries.test.ts +146 -0
  41. package/src/charts/leaderboard-chart/types.ts +6 -0
  42. package/src/charts/line-chart/line-chart.tsx +55 -26
  43. package/src/charts/line-chart/test/line-chart.test.tsx +86 -0
  44. package/src/charts/line-chart/types.ts +3 -1
  45. package/src/charts/private/test/compact-when-short.test.tsx +170 -0
  46. package/src/charts/private/test/tick-decimals.test.tsx +95 -0
  47. package/src/charts/private/tick-decimals.tsx +132 -0
  48. package/src/charts/private/use-compact-when-short.ts +18 -0
  49. package/src/components/legend/private/base-legend.tsx +13 -8
  50. package/src/components/legend/test/legend.test.tsx +125 -0
  51. package/src/components/legend/types.ts +9 -1
  52. package/src/providers/chart-context/themes.ts +4 -0
  53. package/src/styles/chart-scope.scss +1 -0
  54. package/src/types.ts +40 -9
  55. package/src/charts/private/test/whole-number-ticks.test.tsx +0 -57
  56. package/src/charts/private/whole-number-ticks.tsx +0 -79
@@ -313,6 +313,92 @@ describe( 'LineChart', () => {
313
313
  expect( ticks.sort() ).toEqual( [ '0', '1' ] );
314
314
  } );
315
315
 
316
+ test.each( [
317
+ [ 'small amounts', 0, [ 0.4, 1.2, 2.1, 2.6 ], [ '0', '1', '2' ] ],
318
+ [ 'amounts under one step', 0, [ 0.31, 0.55, 0.73 ], [ '0', '1' ] ],
319
+ [ 'cents', 2, [ 0.4, 1.2, 1.6 ], [ '0.00', '0.50', '1.00', '1.50' ] ],
320
+ ] )( 'holds ticks to tickDecimals for %s', ( _, tickDecimals, values, expected ) => {
321
+ renderWithTheme( {
322
+ data: [
323
+ {
324
+ label: 'Series A',
325
+ data: values.map( ( value, i ) => ( { date: new Date( 2024, i + 2, 1 ), value } ) ),
326
+ },
327
+ ],
328
+ options: {
329
+ yScale: { zero: true },
330
+ axis: {
331
+ y: { tickDecimals, tickFormat: ( value: number ) => value.toFixed( tickDecimals ) },
332
+ },
333
+ },
334
+ } );
335
+
336
+ const chart = screen.getByRole( 'application', { name: /line chart/i } );
337
+ const ticks = within( chart )
338
+ .getAllByText( /^-?[\d.,]+$/ )
339
+ .map( el => el.textContent );
340
+ expect( ticks.sort() ).toEqual( expected );
341
+ } );
342
+
343
+ test( "leaves a caller's tickValues axis unwidened by tickDecimals", () => {
344
+ const linePath = ( y: { tickValues: number[]; tickDecimals?: number } ) => {
345
+ const { unmount } = renderWithTheme( {
346
+ data: [
347
+ {
348
+ label: 'Series A',
349
+ data: [ 0.31, 0.55, 0.73 ].map( ( value, i ) => ( {
350
+ date: new Date( 2024, i + 2, 1 ),
351
+ value,
352
+ } ) ),
353
+ },
354
+ ],
355
+ options: { axis: { y } },
356
+ } );
357
+ const chart = screen.getByRole( 'application', { name: /line chart/i } );
358
+ // eslint-disable-next-line testing-library/no-node-access -- visx's stroke is a <path class="visx-line">.
359
+ const d = chart.querySelector( 'path.visx-line' )?.getAttribute( 'd' );
360
+ unmount();
361
+ return d;
362
+ };
363
+
364
+ expect( linePath( { tickValues: [ 0.4, 0.6 ], tickDecimals: 0 } ) ).toBe(
365
+ linePath( { tickValues: [ 0.4, 0.6 ] } )
366
+ );
367
+ } );
368
+
369
+ test( 'widens with the decimals it ticks at when a hidden series keeps its place in the domain', () => {
370
+ renderWithTheme( {
371
+ rescaleYOnVisibilityChange: false,
372
+ defaultHiddenSeries: [ 'Series B' ],
373
+ data: [
374
+ {
375
+ label: 'Series A',
376
+ data: [ 0, 0, 0 ].map( ( value, i ) => ( {
377
+ date: new Date( 2024, i + 2, 1 ),
378
+ value,
379
+ } ) ),
380
+ },
381
+ {
382
+ label: 'Series B',
383
+ data: [ 0.001, 0.003, 0.004 ].map( ( value, i ) => ( {
384
+ date: new Date( 2024, i + 2, 1 ),
385
+ value,
386
+ } ) ),
387
+ },
388
+ ],
389
+ options: {
390
+ yScale: { zero: true },
391
+ axis: { y: { tickDecimals: 2, tickFormat: ( value: number ) => value.toFixed( 2 ) } },
392
+ },
393
+ } );
394
+
395
+ const chart = screen.getByRole( 'application', { name: /line chart/i } );
396
+ const ticks = within( chart )
397
+ .getAllByText( /^-?[\d.,]+$/ )
398
+ .map( el => el.textContent );
399
+ expect( ticks.sort() ).toEqual( [ '0.00', '1.00' ] );
400
+ } );
401
+
316
402
  test( 'ignores a hidden fractional series when checking for whole numbers', () => {
317
403
  renderWithTheme( {
318
404
  defaultHiddenSeries: [ 'Series B' ],
@@ -5,6 +5,7 @@ import type {
5
5
  SeriesData,
6
6
  SeriesChartLegendConfig,
7
7
  SeriesVisibilityProps,
8
+ CompactWhenShortProps,
8
9
  AnnotationStyles,
9
10
  DataPoint,
10
11
  } from '../../types';
@@ -30,7 +31,8 @@ export type RenderLineGlyphProps< Datum extends object > = GlyphProps< Datum > &
30
31
  position?: 'start' | 'end';
31
32
  };
32
33
 
33
- export interface LineChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
34
+ export interface LineChartProps
35
+ extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps, CompactWhenShortProps {
34
36
  /**
35
37
  * Legend configuration. Supports `collapseGroups` on top of the shared options.
36
38
  */
@@ -0,0 +1,170 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { createRef } from 'react';
3
+ import { GlobalChartsProvider, defaultTheme } from '../../../providers';
4
+ import { AreaChartUnresponsive } from '../../area-chart/area-chart';
5
+ import { BarChartUnresponsive } from '../../bar-chart/bar-chart';
6
+ import { LineChartUnresponsive } from '../../line-chart/line-chart';
7
+ import type { ChartInstanceRef } from '../chart-instance-context';
8
+ import type { ComponentProps, ComponentType, ReactNode, Ref } from 'react';
9
+
10
+ const mockRefCallback = jest.fn();
11
+ jest.mock( '../../../hooks/use-element-size', () => ( {
12
+ useElementSize: () => [ mockRefCallback, 500, 300 ],
13
+ } ) );
14
+
15
+ type CompactChartProps = {
16
+ data: typeof data;
17
+ width: number;
18
+ height: number;
19
+ compactWhenShort?: boolean;
20
+ showLegend?: boolean;
21
+ orientation?: 'horizontal' | 'vertical';
22
+ margin?: { left?: number };
23
+ withGradientFill?: boolean;
24
+ chartId?: string;
25
+ legend?: { interactive?: boolean };
26
+ defaultHiddenSeries?: string[];
27
+ children?: ReactNode;
28
+ ref?: Ref< ChartInstanceRef >;
29
+ };
30
+ type ScaleRange = { range: () => number[] };
31
+ type CompactChart = ComponentType< CompactChartProps > & {
32
+ Legend: ComponentType;
33
+ };
34
+
35
+ const data = [ 'A', 'B' ].map( ( label, offset ) => ( {
36
+ label,
37
+ data: [ 1, 2, 3 ].map( day => ( {
38
+ date: new Date( 2024, 0, day ),
39
+ value: day * 10 + offset,
40
+ } ) ),
41
+ } ) );
42
+
43
+ const CHARTS = [
44
+ [ 'LineChart', LineChartUnresponsive ],
45
+ [ 'AreaChart', AreaChartUnresponsive ],
46
+ [ 'BarChart', BarChartUnresponsive ],
47
+ ] as unknown as [ string, CompactChart ][];
48
+
49
+ const chartTree = (
50
+ Chart: CompactChart,
51
+ props: Partial< CompactChartProps > = {},
52
+ theme?: ComponentProps< typeof GlobalChartsProvider >[ 'theme' ]
53
+ ) => (
54
+ <GlobalChartsProvider theme={ theme }>
55
+ <Chart
56
+ data={ data }
57
+ width={ 500 }
58
+ height={ 300 }
59
+ withGradientFill={ false }
60
+ showLegend
61
+ { ...props }
62
+ >
63
+ <Chart.Legend />
64
+ </Chart>
65
+ </GlobalChartsProvider>
66
+ );
67
+
68
+ const renderChart = ( ...args: Parameters< typeof chartTree > ) => render( chartTree( ...args ) );
69
+
70
+ /* eslint-disable testing-library/no-node-access -- visx renders axes and grid lines without roles or test IDs. */
71
+ const chrome = () => ( {
72
+ axes: document.querySelectorAll( '.visx-axis' ).length,
73
+ grid: document.querySelectorAll( '.visx-rows, .visx-columns' ).length > 0,
74
+ legends: screen.queryAllByTestId( 'legend-horizontal' ).length,
75
+ } );
76
+ /* eslint-enable testing-library/no-node-access */
77
+
78
+ const FULL = { axes: 2, grid: true, legends: 2 };
79
+ const COMPACT = { axes: 0, grid: false, legends: 0 };
80
+
81
+ describe( 'compactWhenShort', () => {
82
+ describe.each( CHARTS )( '%s', ( _name, Chart ) => {
83
+ it.each( [
84
+ [ 'an unmeasured chart', 0, true, FULL ],
85
+ [ 'a short chart', 100, true, COMPACT ],
86
+ [ 'a tall chart', 300, true, FULL ],
87
+ [ 'a short chart that did not opt in', 100, false, FULL ],
88
+ ] )( 'draws %s with the expected chrome', ( _label, height, compactWhenShort, expected ) => {
89
+ renderChart( Chart, { height, compactWhenShort } );
90
+
91
+ expect( chrome() ).toEqual( expected );
92
+ } );
93
+
94
+ it( 'restores the chrome when a short chart grows back', () => {
95
+ const { rerender } = renderChart( Chart, { height: 100, compactWhenShort: true } );
96
+ rerender( chartTree( Chart, { height: 300, compactWhenShort: true } ) );
97
+
98
+ expect( chrome() ).toEqual( FULL );
99
+ } );
100
+
101
+ it( 'does not point at the hidden legend when every series is hidden', () => {
102
+ renderChart( Chart, {
103
+ height: 100,
104
+ compactWhenShort: true,
105
+ chartId: 'all-hidden',
106
+ legend: { interactive: true },
107
+ defaultHiddenSeries: [ 'A', 'B' ],
108
+ } );
109
+
110
+ expect( screen.getByText( 'All series are hidden.' ) ).toBeInTheDocument();
111
+ } );
112
+ } );
113
+
114
+ it( 'reads the threshold from the theme', () => {
115
+ renderChart(
116
+ LineChartUnresponsive as unknown as CompactChart,
117
+ { height: 160, compactWhenShort: true },
118
+ { compactWhenShort: { below: 200 } }
119
+ );
120
+
121
+ expect( chrome() ).toEqual( COMPACT );
122
+ } );
123
+
124
+ it( 'leaves horizontal bars alone', () => {
125
+ renderChart( BarChartUnresponsive as unknown as CompactChart, {
126
+ height: 100,
127
+ compactWhenShort: true,
128
+ orientation: 'horizontal',
129
+ } );
130
+
131
+ expect( chrome() ).toEqual( FULL );
132
+ } );
133
+
134
+ describe.each( CHARTS.slice( 0, 2 ) )( '%s margin', ( _name, Chart ) => {
135
+ it.each( [
136
+ [ 'defaults to the compact margin', undefined, 2 ],
137
+ [ 'keeps a side the caller set', { left: 40 }, 40 ],
138
+ ] )( '%s', ( _label, margin, left ) => {
139
+ const ref = createRef< ChartInstanceRef >();
140
+ renderChart( Chart, { height: 100, compactWhenShort: true, margin, ref } );
141
+
142
+ const scales = ref.current?.getScales() as Record< 'xScale' | 'yScale', ScaleRange >;
143
+ expect( scales.xScale.range()[ 0 ] ).toBe( left );
144
+ expect( scales.yScale.range()[ 1 ] ).toBe( 2 );
145
+ } );
146
+ } );
147
+
148
+ describe( 'BarChart margin', () => {
149
+ const leftmostBarX = ( margin?: { left?: number } ) => {
150
+ const { unmount } = renderChart( BarChartUnresponsive as unknown as CompactChart, {
151
+ height: 100,
152
+ compactWhenShort: true,
153
+ margin,
154
+ } );
155
+ // eslint-disable-next-line testing-library/no-node-access -- visx bars take no role or test ID.
156
+ const bars = [ ...document.querySelectorAll( '.visx-bar' ) ];
157
+ const x = Math.min( ...bars.map( bar => Number( bar.getAttribute( 'x' ) ) ) );
158
+ unmount();
159
+ return x;
160
+ };
161
+
162
+ it( 'defaults to the compact margin', () => {
163
+ expect( leftmostBarX() ).toBe( leftmostBarX( defaultTheme.compactWhenShort.margin ) );
164
+ } );
165
+
166
+ it( 'keeps a side the caller set', () => {
167
+ expect( leftmostBarX( { left: 40 } ) ).toBeGreaterThan( leftmostBarX() );
168
+ } );
169
+ } );
170
+ } );
@@ -0,0 +1,95 @@
1
+ import { createScale } from '@visx/scale';
2
+ import {
3
+ getTickDecimals,
4
+ getTickDecimalsDomain,
5
+ getTickDecimalValues,
6
+ hasOnlyWholeNumbers,
7
+ } from '../tick-decimals';
8
+ import type { SeriesData } from '../../../types';
9
+
10
+ const linear = ( domain: [ number, number ] ) =>
11
+ createScale( { type: 'linear', domain, range: [ 100, 0 ] } );
12
+
13
+ const series = ( ...values: Array< number | null > ): SeriesData => ( {
14
+ label: 'A',
15
+ data: values.map( ( value, i ) => ( { date: new Date( 2024, i, 1 ), value } ) ),
16
+ } );
17
+
18
+ describe( 'getTickDecimalValues', () => {
19
+ it( 'keeps only the whole ticks of a range smaller than the tick count', () => {
20
+ expect( getTickDecimalValues( linear( [ 0, 1 ] ), 4 ) ).toEqual( [ 0, 1 ] );
21
+ expect( getTickDecimalValues( linear( [ 0, 2 ] ), 4 ) ).toEqual( [ 0, 1, 2 ] );
22
+ } );
23
+
24
+ it( 'leaves a scale whose ticks are already whole to visx', () => {
25
+ expect( getTickDecimalValues( linear( [ 0, 1000 ] ), 4 ) ).toBeUndefined();
26
+ } );
27
+
28
+ it( 'leaves non-numeric scales to visx', () => {
29
+ const band = createScale( { type: 'band', domain: [ 'a', 'b' ], range: [ 0, 100 ] } );
30
+ const time = createScale( {
31
+ type: 'time',
32
+ domain: [ new Date( 2024, 0, 1 ), new Date( 2024, 0, 2 ) ],
33
+ range: [ 0, 100 ],
34
+ } );
35
+ expect( getTickDecimalValues( band, 4 ) ).toBeUndefined();
36
+ expect( getTickDecimalValues( time, 4 ) ).toBeUndefined();
37
+ expect( getTickDecimalValues( undefined, 4 ) ).toBeUndefined();
38
+ } );
39
+
40
+ it( 'leaves a range with no whole tick to visx', () => {
41
+ expect( getTickDecimalValues( linear( [ 0.2, 0.8 ] ), 4 ) ).toBeUndefined();
42
+ } );
43
+
44
+ it( 'leaves a range with only one whole tick to visx', () => {
45
+ expect( getTickDecimalValues( linear( [ 0, 0.8 ] ), 4 ) ).toBeUndefined();
46
+ } );
47
+
48
+ it( 'keeps ticks at the given decimal places, through float error on small and large values', () => {
49
+ expect( getTickDecimalValues( linear( [ 0, 0.07 ] ), 10, 2 ) ).toEqual( [
50
+ 0, 0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07,
51
+ ] );
52
+ expect( getTickDecimalValues( linear( [ 131072.01, 131072.02 ] ), 4, 2 ) ).toEqual( [
53
+ 131072.01, 131072.02,
54
+ ] );
55
+ } );
56
+ } );
57
+
58
+ describe( 'getTickDecimalsDomain', () => {
59
+ it.each( [
60
+ [ 'widens a range inside one step', [ 0.3, 0.73 ], 0, [ 0, 1 ] ],
61
+ [ 'widens a range between two steps', [ 1.2, 1.8 ], 0, [ 1, 2 ] ],
62
+ [ 'widens at two decimals', [ 0.031, 0.034 ], 2, [ 0.03, 0.04 ] ],
63
+ [ 'widens from a step float error puts just below', [ 0.29, 0.295 ], 2, [ 0.29, 0.3 ] ],
64
+ [ 'widens ends float noise rounds to one step', [ 0.3, 0.1 + 0.2 ], 2, [ 0.3, 0.31 ] ],
65
+ [ 'keeps a range holding two steps', [ 0, 1.2 ], 0, undefined ],
66
+ [ 'keeps a range without tickDecimals', [ 0.3, 0.73 ], undefined, undefined ],
67
+ ] )( '%s', ( _, domain, decimals, expected ) => {
68
+ expect( getTickDecimalsDomain( domain as [ number, number ], decimals ) ).toEqual( expected );
69
+ } );
70
+ } );
71
+
72
+ describe( 'getTickDecimals', () => {
73
+ it.each( [
74
+ [ 'whole-number data over a caller decimals', true, 2, 0 ],
75
+ [ 'fractional data at the caller decimals', false, 2, 2 ],
76
+ [ 'fractional data without tickDecimals', false, undefined, undefined ],
77
+ ] )( 'holds %s', ( _, whole, decimals, expected ) => {
78
+ expect( getTickDecimals( whole, decimals ) ).toBe( expected );
79
+ } );
80
+ } );
81
+
82
+ describe( 'hasOnlyWholeNumbers', () => {
83
+ it( 'is true when every reading is whole, skipping missing ones', () => {
84
+ expect( hasOnlyWholeNumbers( [ series( 0, 1, null, 3 ) ] ) ).toBe( true );
85
+ } );
86
+
87
+ it( 'is false when any reading has a fraction', () => {
88
+ expect( hasOnlyWholeNumbers( [ series( 0, 1 ), series( 0.5 ) ] ) ).toBe( false );
89
+ } );
90
+
91
+ it( 'is false when there is no reading at all', () => {
92
+ expect( hasOnlyWholeNumbers( [ series( null, null ) ] ) ).toBe( false );
93
+ expect( hasOnlyWholeNumbers( [] ) ).toBe( false );
94
+ } );
95
+ } );
@@ -0,0 +1,132 @@
1
+ import { DataContext } from '@visx/xychart';
2
+ import { useContext } from 'react';
3
+ import { isReading } from './readings';
4
+ import type { SeriesData } from '../../types';
5
+ import type { ReactNode } from 'react';
6
+
7
+ /**
8
+ * Whether every reading in the series is a whole number.
9
+ *
10
+ * @param series - The series visx renders.
11
+ * @return True when at least one reading exists and none has a fraction.
12
+ */
13
+ export const hasOnlyWholeNumbers = ( series: SeriesData[] ): boolean => {
14
+ let sawReading = false;
15
+ for ( const { data } of series ) {
16
+ for ( const point of data ) {
17
+ const value = point?.value;
18
+ if ( ! isReading( value ) ) {
19
+ continue;
20
+ }
21
+ if ( ! Number.isInteger( value ) ) {
22
+ return false;
23
+ }
24
+ sawReading = true;
25
+ }
26
+ }
27
+ return sawReading;
28
+ };
29
+
30
+ // Float error in tick * 10^decimals grows with the value, e.g. 0.07 * 100 = 7.000000000000001.
31
+ const EPSILON = 1e-12;
32
+ const tolerance = ( scaled: number ): number => EPSILON * Math.max( 1, Math.abs( scaled ) );
33
+
34
+ const isValidDecimals = ( decimals: number | undefined ): decimals is number =>
35
+ Number.isInteger( decimals ) && ( decimals as number ) >= 0;
36
+
37
+ const isOnDecimals = ( value: number, decimals: number ): boolean => {
38
+ const scaled = value * 10 ** decimals;
39
+ return Math.abs( scaled - Math.round( scaled ) ) < tolerance( scaled );
40
+ };
41
+
42
+ /**
43
+ * The scale's ticks with those finer than `decimals` places dropped.
44
+ *
45
+ * d3 steps by 1, 2 or 5 times a power of ten, so every multiple of 10^-decimals inside the domain is one of its ticks.
46
+ *
47
+ * @param scale - The value scale visx built.
48
+ * @param count - Ticks to ask the scale for; undefined uses the scale's own default, as visx does.
49
+ * @param decimals - Decimal places a tick may carry; 0 keeps whole numbers.
50
+ * @return The kept ticks, or undefined when visx's own ticks need no change.
51
+ */
52
+ export const getTickDecimalValues = (
53
+ scale: unknown,
54
+ count?: number,
55
+ decimals = 0
56
+ ): number[] | undefined => {
57
+ const ticks = ( scale as { ticks?: ( count?: number ) => unknown[] } )?.ticks?.( count );
58
+ if ( ! ticks?.every( ( tick ): tick is number => typeof tick === 'number' ) ) {
59
+ return undefined;
60
+ }
61
+ const kept = ticks.filter( tick => isOnDecimals( tick, decimals ) );
62
+ return kept.length > 1 && kept.length < ticks.length ? kept : undefined;
63
+ };
64
+
65
+ /**
66
+ * A value domain widened to the nearest `decimals` steps when fewer than two fit inside it, so the axis shows at least two ticks.
67
+ *
68
+ * @param domain - The domain the chart would scale to.
69
+ * @param decimals - The axis's `tickDecimals`.
70
+ * @return The widened domain, or undefined when `domain` needs no change.
71
+ */
72
+ export const getTickDecimalsDomain = (
73
+ domain: [ number, number ] | null | undefined,
74
+ decimals: number | undefined
75
+ ): [ number, number ] | undefined => {
76
+ if ( ! domain || ! isValidDecimals( decimals ) ) {
77
+ return undefined;
78
+ }
79
+ const scale = 10 ** decimals;
80
+ const low = domain[ 0 ] * scale;
81
+ const high = domain[ 1 ] * scale;
82
+ if ( Math.floor( high + tolerance( high ) ) - Math.ceil( low - tolerance( low ) ) >= 1 ) {
83
+ return undefined;
84
+ }
85
+ const floor = Math.floor( low + tolerance( low ) );
86
+ // Keeps one step when float noise rounds both ends to the same value.
87
+ const ceil = Math.max( Math.ceil( high - tolerance( high ) ), floor + 1 );
88
+ return [ floor / scale, ceil / scale ];
89
+ };
90
+
91
+ /**
92
+ * The decimals a value axis's ticks are held to: 0 for whole-number data, else the caller's `tickDecimals`.
93
+ *
94
+ * @param hasWholeNumberValues - Whether every plotted value is a whole number.
95
+ * @param tickDecimals - The axis's `tickDecimals`.
96
+ * @return Decimal places, or undefined to keep visx's ticks.
97
+ */
98
+ export const getTickDecimals = (
99
+ hasWholeNumberValues: boolean,
100
+ tickDecimals: number | undefined
101
+ ): number | undefined => {
102
+ if ( hasWholeNumberValues ) {
103
+ return 0;
104
+ }
105
+ return isValidDecimals( tickDecimals ) ? tickDecimals : undefined;
106
+ };
107
+
108
+ type TickDecimalsProps = {
109
+ axis: 'x' | 'y';
110
+ numTicks?: number;
111
+ decimals: number | undefined;
112
+ children: ( tickValues: number[] | undefined ) => ReactNode;
113
+ };
114
+
115
+ /**
116
+ * Hands its children the chart's value-scale ticks, held to `decimals` places.
117
+ *
118
+ * @param props - Component props.
119
+ * @param props.axis - Which scale carries the values.
120
+ * @param props.numTicks - Ticks to ask the scale for.
121
+ * @param props.decimals - Decimal places a tick may carry, or undefined to keep visx's ticks.
122
+ * @param props.children - Renders the axis and grid with the tick values.
123
+ * @return The rendered children.
124
+ */
125
+ export const TickDecimals = ( { axis, numTicks, decimals, children }: TickDecimalsProps ) => {
126
+ const { xScale, yScale } = useContext( DataContext );
127
+ const tickValues =
128
+ decimals === undefined
129
+ ? undefined
130
+ : getTickDecimalValues( axis === 'x' ? xScale : yScale, numTicks, decimals );
131
+ return <>{ children( tickValues ) }</>;
132
+ };
@@ -0,0 +1,18 @@
1
+ import { useGlobalChartsTheme } from '../../providers';
2
+
3
+ /**
4
+ * Whether a `compactWhenShort` chart is compact, and the margin it then defaults to.
5
+ *
6
+ * Reads the height the chart was given, not the measured plot area: that grows once the legend
7
+ * is hidden and would flip the chart straight back. A zero height is unmeasured, not short.
8
+ *
9
+ * @param enabled - The chart's `compactWhenShort` prop.
10
+ * @param height - The height the chart was given.
11
+ * @return Whether the chart is compact, and the compact margin when it is.
12
+ */
13
+ export const useCompactWhenShort = ( enabled: boolean, height: number | undefined ) => {
14
+ const { compactWhenShort } = useGlobalChartsTheme();
15
+ const isCompact = enabled && ( height ?? 0 ) > 0 && height < compactWhenShort.below;
16
+
17
+ return { isCompact, compactMargin: isCompact ? compactWhenShort.margin : undefined };
18
+ };
@@ -217,24 +217,27 @@ export const BaseLegend: ForwardRefExoticComponent<
217
217
 
218
218
  // Create event handlers to avoid inline arrow functions
219
219
  const createClickHandler = useCallback(
220
- ( seriesLabels: string[], itemInteractive: boolean ) => {
220
+ ( seriesLabels: string[], itemInteractive: boolean, onToggle?: () => void ) => {
221
221
  if ( ! itemInteractive ) {
222
222
  return undefined;
223
223
  }
224
- return () => handleLegendClick( seriesLabels );
224
+ return onToggle ? () => onToggle() : () => handleLegendClick( seriesLabels );
225
225
  },
226
226
  [ handleLegendClick ]
227
227
  );
228
228
 
229
229
  const createKeyDownHandler = useCallback(
230
- ( seriesLabels: string[], itemInteractive: boolean ) => {
230
+ ( seriesLabels: string[], itemInteractive: boolean, onToggle?: () => void ) => {
231
231
  if ( ! itemInteractive ) {
232
232
  return undefined;
233
233
  }
234
+ const toggle = onToggle ?? ( () => handleLegendClick( seriesLabels ) );
234
235
  return ( event: KeyboardEvent ) => {
235
236
  if ( event.key === 'Enter' || event.key === ' ' ) {
236
237
  event.preventDefault();
237
- handleLegendClick( seriesLabels );
238
+ if ( ! event.repeat ) {
239
+ toggle();
240
+ }
238
241
  }
239
242
  };
240
243
  },
@@ -278,11 +281,13 @@ export const BaseLegend: ForwardRefExoticComponent<
278
281
  const seriesLabels = matchedItem?.seriesLabels?.length
279
282
  ? matchedItem.seriesLabels
280
283
  : [ label.text ];
281
- const visible =
282
- matchedItem?.interactive === false || isSeriesVisible( seriesLabels[ 0 ] );
283
284
  const itemInteractive = interactive && matchedItem?.interactive !== false;
284
- const handleClick = createClickHandler( seriesLabels, itemInteractive );
285
- const handleKeyDown = createKeyDownHandler( seriesLabels, itemInteractive );
285
+ const onToggle = itemInteractive ? matchedItem?.onToggle : undefined;
286
+ const visible = matchedItem?.onToggle
287
+ ? matchedItem.pressed
288
+ : matchedItem?.interactive === false || isSeriesVisible( seriesLabels[ 0 ] );
289
+ const handleClick = createClickHandler( seriesLabels, itemInteractive, onToggle );
290
+ const handleKeyDown = createKeyDownHandler( seriesLabels, itemInteractive, onToggle );
286
291
 
287
292
  return (
288
293
  <LegendItem