@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
@@ -900,6 +900,131 @@ describe( 'BaseLegend', () => {
900
900
  } );
901
901
  } );
902
902
 
903
+ describe( 'Controlled items', () => {
904
+ const VisibilityProbe = () => {
905
+ const { isSeriesVisible } = useGlobalChartsContext();
906
+ return (
907
+ <output data-testid="views-visible">
908
+ { String( isSeriesVisible( 'test-chart', 'Views' ) ) }
909
+ </output>
910
+ );
911
+ };
912
+
913
+ it.each( [ false, true ] )( 'takes the pressed state from the item (%s)', pressed => {
914
+ render(
915
+ <GlobalChartsProvider>
916
+ <BaseLegend
917
+ items={ [ { label: 'Previous period', color: '#000', pressed, onToggle: jest.fn() } ] }
918
+ interactive
919
+ chartId="test-chart"
920
+ />
921
+ </GlobalChartsProvider>
922
+ );
923
+
924
+ expect( screen.getByRole( 'button', { name: /Previous period/ } ) ).toHaveAttribute(
925
+ 'aria-pressed',
926
+ String( pressed )
927
+ );
928
+ } );
929
+
930
+ it.each( [ 'click', 'Enter', ' ' ] )(
931
+ 'calls onToggle on %s and leaves series visibility alone',
932
+ async interaction => {
933
+ const user = userEvent.setup();
934
+ const onToggle = jest.fn();
935
+ render(
936
+ <GlobalChartsProvider>
937
+ <BaseLegend
938
+ items={ [
939
+ {
940
+ label: 'Previous period',
941
+ color: '#000',
942
+ seriesLabels: [ 'Views' ],
943
+ pressed: false,
944
+ onToggle,
945
+ },
946
+ ] }
947
+ interactive
948
+ chartId="test-chart"
949
+ />
950
+ <VisibilityProbe />
951
+ </GlobalChartsProvider>
952
+ );
953
+
954
+ const button = screen.getByRole( 'button', { name: /Previous period/ } );
955
+ if ( interaction === 'click' ) {
956
+ await user.click( button );
957
+ } else {
958
+ button.focus();
959
+ await user.keyboard( interaction === ' ' ? ' ' : '{Enter}' );
960
+ }
961
+
962
+ expect( onToggle ).toHaveBeenCalledTimes( 1 );
963
+ expect( onToggle ).toHaveBeenCalledWith();
964
+ expect( screen.getByTestId( 'views-visible' ) ).toHaveTextContent( 'true' );
965
+ }
966
+ );
967
+
968
+ it( 'ignores key repeats while Enter is held', async () => {
969
+ const user = userEvent.setup();
970
+ const onToggle = jest.fn();
971
+ render(
972
+ <GlobalChartsProvider>
973
+ <BaseLegend
974
+ items={ [ { label: 'Previous period', color: '#000', pressed: false, onToggle } ] }
975
+ interactive
976
+ chartId="test-chart"
977
+ />
978
+ </GlobalChartsProvider>
979
+ );
980
+
981
+ screen.getByRole( 'button', { name: /Previous period/ } ).focus();
982
+ await user.keyboard( '{Enter>3/}' );
983
+
984
+ expect( onToggle ).toHaveBeenCalledTimes( 1 );
985
+ } );
986
+
987
+ const renderStaticControlledItem = ( pressed: boolean ) => {
988
+ const onToggle = jest.fn();
989
+ render(
990
+ <GlobalChartsProvider>
991
+ <BaseLegend
992
+ items={ [
993
+ {
994
+ label: 'Previous period',
995
+ color: '#000',
996
+ seriesLabels: [ 'Views' ],
997
+ pressed,
998
+ onToggle,
999
+ },
1000
+ ] }
1001
+ interactive={ false }
1002
+ chartId="test-chart"
1003
+ />
1004
+ </GlobalChartsProvider>
1005
+ );
1006
+ return { item: screen.getByTestId( 'legend-item' ), onToggle };
1007
+ };
1008
+
1009
+ it( 'stays static in a legend that is not interactive', async () => {
1010
+ const user = userEvent.setup();
1011
+ const { item, onToggle } = renderStaticControlledItem( true );
1012
+
1013
+ expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument();
1014
+ expect( item ).not.toHaveClass( 'legend-item--inactive' );
1015
+ expect( item ).not.toHaveAttribute( 'aria-label' );
1016
+ await user.click( item );
1017
+ expect( onToggle ).not.toHaveBeenCalled();
1018
+ } );
1019
+
1020
+ it( 'dims an item that is not pressed in a legend that is not interactive', () => {
1021
+ const { item } = renderStaticControlledItem( false );
1022
+
1023
+ expect( item ).toHaveClass( 'legend-item--inactive' );
1024
+ expect( item ).toHaveAttribute( 'aria-label', 'Previous period: hidden' );
1025
+ } );
1026
+ } );
1027
+
903
1028
  describe( 'Non-interactive legend reflects hidden state', () => {
904
1029
  const HiddenSeriesHarness = ( { interactive }: { interactive: boolean } ) => {
905
1030
  const context = useGlobalChartsContext();
@@ -65,4 +65,12 @@ export type BaseLegendItem = {
65
65
  interactive?: boolean;
66
66
  /** Label of the series this static item describes; the item is left out while it is hidden. */
67
67
  describesSeries?: string;
68
- };
68
+ } & (
69
+ | {
70
+ /** Pressed state of the toggle. */
71
+ pressed: boolean;
72
+ /** Makes an interactive legend item a toggle the consumer controls, instead of a series toggle. */
73
+ onToggle: () => void;
74
+ }
75
+ | { pressed?: never; onToggle?: never }
76
+ );
@@ -56,6 +56,10 @@ const defaultTheme: CompleteChartTheme = {
56
56
  margin: { top: 2, right: 2, bottom: 2, left: 2 },
57
57
  strokeWidth: 1.5,
58
58
  },
59
+ compactWhenShort: {
60
+ below: 140,
61
+ margin: { top: 2, right: 2, bottom: 2, left: 2 },
62
+ },
59
63
  // The compact 11px square / 2px gap is the contribution-graph rhythm, which has no WPDS dimension.
60
64
  heatmapChart: {
61
65
  compactCellGap: 2,
@@ -87,6 +87,7 @@
87
87
 
88
88
  --a8c-charts-dimension-leaderboard-row-gap: var(--wpds-dimension-gap-md);
89
89
  --a8c-charts-dimension-leaderboard-column-gap: var(--wpds-dimension-gap-xs);
90
+ --a8c-charts-dimension-leaderboard-label-inset: var(--wpds-dimension-padding-sm);
90
91
  --a8c-charts-dimension-legend-column-gap: var(--wpds-dimension-gap-lg);
91
92
  --a8c-charts-dimension-legend-row-gap: var(--wpds-dimension-gap-sm);
92
93
  }
package/src/types.ts CHANGED
@@ -214,20 +214,18 @@ type LeaderboardEntryFields = {
214
214
  previousValue?: number;
215
215
 
216
216
  /**
217
- * Width of current bar, as % of the largest absolute value across rows. A negative share
218
- * extends the bar from a shared zero point toward the inline start, for every row.
217
+ * Width of current bar, as %. Derived from the values when omitted; a passed value wins.
218
+ * A negative share extends the bar from a shared zero point toward the inline start, for every row.
219
219
  */
220
- currentShare: number;
220
+ currentShare?: number;
221
221
 
222
222
  /**
223
- * Width of previous bar, as % of the largest absolute value across rows. Omit when this
224
- * row has no matching comparison-period value.
223
+ * Width of previous bar, as %. Derived from `previousValue` when omitted; a passed value wins.
225
224
  */
226
225
  previousShare?: number;
227
226
 
228
227
  /**
229
- * Delta of the entry. Omit when the percentage change is unavailable, such
230
- * as when the row has no comparison value or its previous value is zero.
228
+ * Percentage change. Derived from the values when omitted; a passed value wins.
231
229
  */
232
230
  delta?: number;
233
231
 
@@ -319,9 +317,9 @@ export type BarStyles = {
319
317
  };
320
318
 
321
319
  /**
322
- * How a bar chart places its series in a category. `grouped` puts each primary series in a slot of its own; `overlap` puts every primary in one slot and every comparison in a second, and overlaps the `group`s within each slot.
320
+ * How a bar chart places its series in a category. `grouped` puts each primary series in a slot of its own; `overlap` puts every primary in one slot and every comparison in a second, and overlaps the `group`s within each slot; `stacked` stacks every primary in one bar, the first series at the bottom.
323
321
  */
324
- export type BarChartLayout = 'grouped' | 'overlap';
322
+ export type BarChartLayout = 'grouped' | 'overlap' | 'stacked';
325
323
 
326
324
  export type MultipleDataPointsDate = {
327
325
  label: string;
@@ -439,6 +437,18 @@ export type ChartTheme = {
439
437
  /** Stroke width for the sparkline line */
440
438
  strokeWidth?: number;
441
439
  };
440
+ /** Compact mode for `compactWhenShort` line, area and bar charts */
441
+ compactWhenShort?: {
442
+ /** Height (px) below which the chart turns compact */
443
+ below?: number;
444
+ /** Margin the chart defaults to while compact */
445
+ margin?: {
446
+ top?: number;
447
+ right?: number;
448
+ bottom?: number;
449
+ left?: number;
450
+ };
451
+ };
442
452
  /**
443
453
  * HeatmapChart settings. Cell gap, radius, value size and the selection ring come from
444
454
  * WPDS tokens in CSS, so only the compact sizing lives here.
@@ -469,6 +479,9 @@ export type CompleteChartTheme = Required< ChartTheme > & {
469
479
  sparkline: Required< NonNullable< ChartTheme[ 'sparkline' ] > > & {
470
480
  margin: Required< NonNullable< ChartTheme[ 'sparkline' ] >[ 'margin' ] >;
471
481
  };
482
+ compactWhenShort: Required< NonNullable< ChartTheme[ 'compactWhenShort' ] > > & {
483
+ margin: Required< NonNullable< ChartTheme[ 'compactWhenShort' ] >[ 'margin' ] >;
484
+ };
472
485
  heatmapChart: Required< NonNullable< ChartTheme[ 'heatmapChart' ] > >;
473
486
  };
474
487
 
@@ -522,6 +535,12 @@ export type AxisOptions = {
522
535
  * so callers can force a specific axis (e.g. integer-only steps on a sparse chart).
523
536
  */
524
537
  tickValues?: ScaleInput< AxisScale >[];
538
+ /**
539
+ * Most decimal places a value-axis tick may carry, so labels rounded to that precision never repeat;
540
+ * an axis holding fewer than two steps widens to the nearest ones. Whole-number data always uses 0.
541
+ * Ignored on a log scale, or when `tickValues` or the value scale's `domain` is set. Line and bar charts only.
542
+ */
543
+ tickDecimals?: number;
525
544
  axisClassName?: string;
526
545
  axisLineClassName?: string;
527
546
  labelClassName?: string;
@@ -711,6 +730,18 @@ export interface SeriesVisibilityProps {
711
730
  defaultHiddenSeries?: readonly string[];
712
731
  }
713
732
 
733
+ /**
734
+ * Compact mode for cartesian charts placed in short containers.
735
+ */
736
+ export interface CompactWhenShortProps {
737
+ /**
738
+ * Drop the axes, grid and legend, and default to `theme.compactWhenShort.margin`, while the
739
+ * chart is shorter than `theme.compactWhenShort.below`. Series hidden through the legend stay
740
+ * hidden until the chart is tall again. Horizontal bar charts ignore it.
741
+ */
742
+ compactWhenShort?: boolean;
743
+ }
744
+
714
745
  /**
715
746
  * Base properties shared across all chart components
716
747
  */
@@ -1,57 +0,0 @@
1
- import { createScale } from '@visx/scale';
2
- import { getWholeNumberTickValues, hasOnlyWholeNumbers } from '../whole-number-ticks';
3
- import type { SeriesData } from '../../../types';
4
-
5
- const linear = ( domain: [ number, number ] ) =>
6
- createScale( { type: 'linear', domain, range: [ 100, 0 ] } );
7
-
8
- const series = ( ...values: Array< number | null > ): SeriesData => ( {
9
- label: 'A',
10
- data: values.map( ( value, i ) => ( { date: new Date( 2024, i, 1 ), value } ) ),
11
- } );
12
-
13
- describe( 'getWholeNumberTickValues', () => {
14
- it( 'keeps only the whole ticks of a range smaller than the tick count', () => {
15
- expect( getWholeNumberTickValues( linear( [ 0, 1 ] ), 4 ) ).toEqual( [ 0, 1 ] );
16
- expect( getWholeNumberTickValues( linear( [ 0, 2 ] ), 4 ) ).toEqual( [ 0, 1, 2 ] );
17
- } );
18
-
19
- it( 'leaves a scale whose ticks are already whole to visx', () => {
20
- expect( getWholeNumberTickValues( linear( [ 0, 1000 ] ), 4 ) ).toBeUndefined();
21
- } );
22
-
23
- it( 'leaves non-numeric scales to visx', () => {
24
- const band = createScale( { type: 'band', domain: [ 'a', 'b' ], range: [ 0, 100 ] } );
25
- const time = createScale( {
26
- type: 'time',
27
- domain: [ new Date( 2024, 0, 1 ), new Date( 2024, 0, 2 ) ],
28
- range: [ 0, 100 ],
29
- } );
30
- expect( getWholeNumberTickValues( band, 4 ) ).toBeUndefined();
31
- expect( getWholeNumberTickValues( time, 4 ) ).toBeUndefined();
32
- expect( getWholeNumberTickValues( undefined, 4 ) ).toBeUndefined();
33
- } );
34
-
35
- it( 'leaves a range with no whole tick to visx', () => {
36
- expect( getWholeNumberTickValues( linear( [ 0.2, 0.8 ] ), 4 ) ).toBeUndefined();
37
- } );
38
-
39
- it( 'leaves a range with only one whole tick to visx', () => {
40
- expect( getWholeNumberTickValues( linear( [ 0, 0.8 ] ), 4 ) ).toBeUndefined();
41
- } );
42
- } );
43
-
44
- describe( 'hasOnlyWholeNumbers', () => {
45
- it( 'is true when every reading is whole, skipping missing ones', () => {
46
- expect( hasOnlyWholeNumbers( [ series( 0, 1, null, 3 ) ] ) ).toBe( true );
47
- } );
48
-
49
- it( 'is false when any reading has a fraction', () => {
50
- expect( hasOnlyWholeNumbers( [ series( 0, 1 ), series( 0.5 ) ] ) ).toBe( false );
51
- } );
52
-
53
- it( 'is false when there is no reading at all', () => {
54
- expect( hasOnlyWholeNumbers( [ series( null, null ) ] ) ).toBe( false );
55
- expect( hasOnlyWholeNumbers( [] ) ).toBe( false );
56
- } );
57
- } );
@@ -1,79 +0,0 @@
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
- /**
31
- * The scale's ticks with the fractional ones dropped.
32
- *
33
- * d3 steps by 1, 2 or 5 times a power of ten, so every whole number inside the domain is one of its ticks.
34
- *
35
- * @param scale - The value scale visx built.
36
- * @param count - Ticks to ask the scale for; undefined uses the scale's own default, as visx does.
37
- * @return Whole-number ticks, or undefined when visx's own ticks need no change.
38
- */
39
- export const getWholeNumberTickValues = (
40
- scale: unknown,
41
- count?: number
42
- ): number[] | undefined => {
43
- const ticks = ( scale as { ticks?: ( count?: number ) => unknown[] } )?.ticks?.( count );
44
- if ( ! ticks?.every( ( tick ): tick is number => typeof tick === 'number' ) ) {
45
- return undefined;
46
- }
47
- const whole = ticks.filter( tick => Number.isInteger( tick ) );
48
- return whole.length > 1 && whole.length < ticks.length ? whole : undefined;
49
- };
50
-
51
- type WholeNumberTicksProps = {
52
- axis: 'x' | 'y';
53
- numTicks?: number;
54
- enabled: boolean;
55
- children: ( tickValues: number[] | undefined ) => ReactNode;
56
- };
57
-
58
- /**
59
- * Hands its children the whole-number ticks of the chart's value scale.
60
- *
61
- * @param props - Component props.
62
- * @param props.axis - Which scale carries the values.
63
- * @param props.numTicks - Ticks to ask the scale for.
64
- * @param props.enabled - Whether the plotted values are all whole numbers and the caller pinned neither the ticks nor the value domain.
65
- * @param props.children - Renders the axis and grid with the tick values.
66
- * @return The rendered children.
67
- */
68
- export const WholeNumberTicks = ( {
69
- axis,
70
- numTicks,
71
- enabled,
72
- children,
73
- }: WholeNumberTicksProps ) => {
74
- const { xScale, yScale } = useContext( DataContext );
75
- const tickValues = enabled
76
- ? getWholeNumberTickValues( axis === 'x' ? xScale : yScale, numTicks )
77
- : undefined;
78
- return <>{ children( tickValues ) }</>;
79
- };