@automattic/charts 4.13.0 → 4.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "4.13.0",
3
+ "version": "4.14.0",
4
4
  "description": "Display charts within Automattic products.",
5
5
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
6
6
  "bugs": {
@@ -63,7 +63,7 @@
63
63
  "typecheck": "tsgo --noEmit"
64
64
  },
65
65
  "dependencies": {
66
- "@automattic/number-formatters": "^1.2.12",
66
+ "@automattic/number-formatters": "^1.3.0",
67
67
  "@babel/runtime": "7.29.7",
68
68
  "@date-fns/tz": "1.5.0",
69
69
  "@react-spring/web": "10.1.2",
@@ -83,10 +83,10 @@
83
83
  "@visx/tooltip": "^4.0.0",
84
84
  "@visx/vendor": "^4.0.0",
85
85
  "@visx/xychart": "^4.0.0",
86
- "@wordpress/i18n": "^6.0.0",
87
- "@wordpress/icons": "^18.0.0",
88
- "@wordpress/theme": "2.3.0",
89
- "@wordpress/ui": "0.24.0",
86
+ "@wordpress/i18n": "^7.0.0",
87
+ "@wordpress/icons": "^19.0.0",
88
+ "@wordpress/theme": "3.0.0",
89
+ "@wordpress/ui": "0.25.0",
90
90
  "clsx": "2.1.1",
91
91
  "date-fns": "^4.1.0",
92
92
  "deepmerge": "4.3.1",
@@ -111,9 +111,9 @@
111
111
  "@types/react-dom": "19.3.0",
112
112
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
113
113
  "@visx/glyph": "4.0.0",
114
- "@wordpress/components": "42.0.0",
115
- "@wordpress/element": "9.0.0",
116
- "@wordpress/private-apis": "2.0.0",
114
+ "@wordpress/components": "43.0.0",
115
+ "@wordpress/element": "10.0.0",
116
+ "@wordpress/private-apis": "3.0.0",
117
117
  "babel-jest": "30.5.2",
118
118
  "identity-obj-proxy": "^3.0.0",
119
119
  "jest": "30.5.2",
@@ -49,7 +49,7 @@
49
49
  }
50
50
 
51
51
  .label {
52
- padding-left: var(--wpds-dimension-padding-sm);
52
+ padding-inline-start: var(--a8c-charts-dimension-leaderboard-label-inset);
53
53
  }
54
54
 
55
55
  // The bar's 100% height needs a track that fills the row,
@@ -23,6 +23,10 @@ import { getAllHiddenMessage } from '../private/svg-empty-state';
23
23
  import { withResponsive } from '../private/with-responsive';
24
24
  import { useFittedRowCount, useLeaderboardLegendItems } from './hooks';
25
25
  import styles from './leaderboard-chart.module.scss';
26
+ import {
27
+ resolveLeaderboardEntries,
28
+ type ResolvedLeaderboardEntry,
29
+ } from './private/resolve-leaderboard-entries';
26
30
  import type { LeaderboardChartProps } from './types';
27
31
  import type { LeaderboardEntry } from '../../types';
28
32
 
@@ -75,15 +79,15 @@ const getBarWidth = ( share: number ): string =>
75
79
  `calc(${ share }% - var(--a8c-charts-dimension-leaderboard-bar-hover-inset, 0px) * ${ share } / 100)`;
76
80
 
77
81
  const hasPreviousValue = (
78
- entry: LeaderboardEntry
79
- ): entry is LeaderboardEntry & {
82
+ entry: ResolvedLeaderboardEntry
83
+ ): entry is ResolvedLeaderboardEntry & {
80
84
  previousValue: number;
81
85
  previousShare: number;
82
86
  } => entry.previousValue != null && entry.previousShare != null;
83
87
 
84
88
  const hasDeltaValue = (
85
- entry: LeaderboardEntry
86
- ): entry is LeaderboardEntry & {
89
+ entry: ResolvedLeaderboardEntry
90
+ ): entry is ResolvedLeaderboardEntry & {
87
91
  previousValue: number;
88
92
  previousShare: number;
89
93
  delta: number;
@@ -128,6 +132,7 @@ const Bar = ( {
128
132
 
129
133
  const bar = (
130
134
  <div
135
+ data-testid="leaderboard-chart-bar"
131
136
  className={ clsx( styles.bar, {
132
137
  [ styles[ 'bar--animated' ] ]: animation,
133
138
  [ styles[ 'bar--negative' ] ]: divergingScale && isNegative,
@@ -167,7 +172,7 @@ const BarWithLabel = ( {
167
172
  isPrimaryVisible = true,
168
173
  isComparisonVisible = true,
169
174
  }: {
170
- entry: LeaderboardEntry;
175
+ entry: ResolvedLeaderboardEntry;
171
176
  withComparison?: boolean;
172
177
  withOverlayLabel?: boolean;
173
178
  primaryColor: string;
@@ -225,6 +230,7 @@ const BarWithLabel = ( {
225
230
  * @param props.height - Optional height of the chart container in pixels
226
231
  * @param props.withComparison - Whether to show comparison data
227
232
  * @param props.withOverlayLabel - Whether to overlay the label on top of the bar
233
+ * @param props.negativeBars - How a negative value draws: from a shared zero point, or as an empty bar
228
234
  * @param props.primaryColor - Primary color for current period bars
229
235
  * @param props.secondaryColor - Secondary color for comparison period bars
230
236
  * @param props.valueFormatter - Custom formatter for values
@@ -249,6 +255,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
249
255
  height: propHeight,
250
256
  withComparison = false,
251
257
  withOverlayLabel = false,
258
+ negativeBars = 'diverge',
252
259
  primaryColor,
253
260
  secondaryColor,
254
261
  valueFormatter = defaultValueFormatter,
@@ -318,11 +325,21 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
318
325
  return ! isPrimaryVisible;
319
326
  }, [ isPrimaryVisible, isComparisonVisible, withComparison, withOverlayLabel ] );
320
327
 
328
+ const withComparisonBars = withComparison && ! withOverlayLabel;
329
+
330
+ const resolvedData = useMemo(
331
+ () =>
332
+ resolveLeaderboardEntries( data ?? [], {
333
+ includePrevious: withComparisonBars,
334
+ clampNegative: negativeBars === 'clamp',
335
+ } ),
336
+ [ data, withComparisonBars, negativeBars ]
337
+ );
338
+
321
339
  // One negative share puts every row on a shared zero point, so the bars stay comparable.
322
340
  const divergingScale = useMemo( (): DivergingScale | undefined => {
323
- const withComparisonBars = withComparison && ! withOverlayLabel;
324
341
  const scale = { negative: 0, positive: 0 };
325
- for ( const entry of data ?? [] ) {
342
+ for ( const entry of resolvedData ) {
326
343
  const shares = [ entry.currentShare ];
327
344
  if ( withComparisonBars && hasPreviousValue( entry ) ) {
328
345
  shares.push( entry.previousShare );
@@ -333,7 +350,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
333
350
  }
334
351
  }
335
352
  return scale.negative > 0 ? scale : undefined;
336
- }, [ data, withComparison, withOverlayLabel ] );
353
+ }, [ resolvedData, withComparisonBars ] );
337
354
 
338
355
  // Validate data
339
356
  const isDataValid = Boolean( data && data.length > 0 );
@@ -471,7 +488,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
471
488
  columnGap={ COLUMN_GAP }
472
489
  data-leaderboard-grid
473
490
  >
474
- { data.map( ( entry, rowIndex ) => {
491
+ { resolvedData.map( ( entry, rowIndex ) => {
475
492
  // visibility, not clipping: hidden rows keep their geometry (so a
476
493
  // taller container can reveal them) but leave hit testing, focus,
477
494
  // and the accessibility tree.
@@ -0,0 +1,53 @@
1
+ import type { LeaderboardEntry } from '../../../types';
2
+
3
+ export type ResolvedLeaderboardEntry = LeaderboardEntry & { currentShare: number };
4
+
5
+ const toShare = ( value: number, max: number ) => ( max === 0 ? 0 : ( value / max ) * 100 );
6
+
7
+ const toDelta = ( currentValue: number, previousValue: number | undefined ) => {
8
+ if ( previousValue == null ) {
9
+ return undefined;
10
+ }
11
+ if ( previousValue === 0 ) {
12
+ return currentValue === 0 ? 0 : undefined;
13
+ }
14
+ return ( ( currentValue - previousValue ) / Math.abs( previousValue ) ) * 100;
15
+ };
16
+
17
+ /**
18
+ * Fills in the shares and delta an entry omits, derived from its values.
19
+ *
20
+ * @param data - Leaderboard entries; passed shares and delta win over derived ones.
21
+ * @param options - Resolver options.
22
+ * @param options.includePrevious - Whether previous values count toward the shared max.
23
+ * @param options.clampNegative - Whether a negative share draws as an empty bar instead of below zero.
24
+ * @return Entries with `currentShare` always set.
25
+ */
26
+ export function resolveLeaderboardEntries(
27
+ data: LeaderboardEntry[],
28
+ { includePrevious, clampNegative = false }: { includePrevious: boolean; clampNegative?: boolean }
29
+ ): ResolvedLeaderboardEntry[] {
30
+ const magnitude = clampNegative ? ( value: number ) => Math.max( value, 0 ) : Math.abs;
31
+ const clamp = ( share: number ) => ( clampNegative ? Math.max( share, 0 ) : share );
32
+
33
+ const max = data.reduce( ( largest, { currentValue, previousValue } ) => {
34
+ const previous = includePrevious && previousValue != null ? magnitude( previousValue ) : 0;
35
+ return Math.max( largest, magnitude( currentValue ), previous );
36
+ }, 0 );
37
+
38
+ return data.map( entry => {
39
+ const passedOrDerivedPrevious =
40
+ entry.previousShare ??
41
+ ( entry.previousValue != null ? toShare( entry.previousValue, max ) : undefined );
42
+ const previousShare =
43
+ passedOrDerivedPrevious !== undefined ? clamp( passedOrDerivedPrevious ) : undefined;
44
+ const delta = entry.delta ?? toDelta( entry.currentValue, entry.previousValue );
45
+
46
+ return {
47
+ ...entry,
48
+ currentShare: clamp( entry.currentShare ?? toShare( entry.currentValue, max ) ),
49
+ ...( previousShare !== undefined && { previousShare } ),
50
+ ...( delta !== undefined && { delta } ),
51
+ };
52
+ } );
53
+ }
@@ -81,6 +81,22 @@ describe( 'LeaderboardChart', () => {
81
81
  expect( screen.getByText( '-8%' ) ).toBeInTheDocument();
82
82
  } );
83
83
 
84
+ it( 'derives the delta of entries that pass only values', () => {
85
+ render(
86
+ <LeaderboardChart
87
+ data={ [
88
+ { id: 'up', label: 'Up', currentValue: 100, previousValue: 80 },
89
+ { id: 'down', label: 'Down', currentValue: 50, previousValue: 100 },
90
+ ] }
91
+ withComparison={ true }
92
+ deltaFormatter={ testDeltaFormatter }
93
+ />
94
+ );
95
+
96
+ expect( screen.getByText( '25delta' ) ).toBeInTheDocument();
97
+ expect( screen.getByText( '-50delta' ) ).toBeInTheDocument();
98
+ } );
99
+
84
100
  it( 'shows a placeholder when a row has no previous value', () => {
85
101
  render(
86
102
  <LeaderboardChart
@@ -170,6 +186,31 @@ describe( 'LeaderboardChart', () => {
170
186
  expect( screen.queryAllByTestId( 'leaderboard-chart-diverging-row' ) ).toHaveLength( expected );
171
187
  } );
172
188
 
189
+ it( 'keeps every bar on one side when negative bars clamp', () => {
190
+ const data = [
191
+ { ...mockData[ 0 ], currentShare: undefined, currentValue: -40 },
192
+ mockData[ 1 ],
193
+ ];
194
+ render( <LeaderboardChart data={ data } withComparison negativeBars="clamp" /> );
195
+
196
+ expect( screen.queryAllByTestId( 'leaderboard-chart-diverging-row' ) ).toHaveLength( 0 );
197
+ } );
198
+
199
+ it.each( [
200
+ [ 'comparison bars draw', false, /^calc\(50%/ ],
201
+ [ 'the overlay label hides comparison bars', true, /^calc\(100%/ ],
202
+ ] )( 'sizes the current bar against the max when %s', ( _, withOverlayLabel, expected ) => {
203
+ const data = [
204
+ { id: 'a', label: 'A', currentValue: 50, previousValue: 100 },
205
+ { id: 'b', label: 'B', currentValue: 25, previousValue: 10 },
206
+ ] as LeaderboardEntry[];
207
+ render(
208
+ <LeaderboardChart data={ data } withComparison withOverlayLabel={ withOverlayLabel } />
209
+ );
210
+
211
+ expect( screen.getAllByTestId( 'leaderboard-chart-bar' )[ 0 ].style.width ).toMatch( expected );
212
+ } );
213
+
173
214
  it( 'shows custom label when provided', () => {
174
215
  render(
175
216
  <LeaderboardChart
@@ -0,0 +1,146 @@
1
+ import { resolveLeaderboardEntries } from '../private/resolve-leaderboard-entries';
2
+ import type { LeaderboardEntry } from '../../../types';
3
+
4
+ type EntryInput = Partial< LeaderboardEntry > & { currentValue: number };
5
+
6
+ const toEntries = ( rows: EntryInput[] ) =>
7
+ rows.map( ( row, index ) => ( {
8
+ id: `row-${ index }`,
9
+ label: `Row ${ index }`,
10
+ ...row,
11
+ } ) ) as LeaderboardEntry[];
12
+
13
+ describe( 'resolveLeaderboardEntries', () => {
14
+ const comparisonRows = [
15
+ { currentValue: 50, previousValue: 100 },
16
+ { currentValue: 25, previousValue: 40 },
17
+ ];
18
+
19
+ it( 'derives both shares and delta against one max across both periods', () => {
20
+ const result = resolveLeaderboardEntries( toEntries( comparisonRows ), {
21
+ includePrevious: true,
22
+ } );
23
+
24
+ expect(
25
+ result.map( ( { currentShare, previousShare, delta } ) => ( {
26
+ currentShare,
27
+ previousShare,
28
+ delta,
29
+ } ) )
30
+ ).toEqual( [
31
+ { currentShare: 50, previousShare: 100, delta: -50 },
32
+ { currentShare: 25, previousShare: 40, delta: -37.5 },
33
+ ] );
34
+ } );
35
+
36
+ it( 'lets a negative previous value set the max', () => {
37
+ const [ entry ] = resolveLeaderboardEntries(
38
+ toEntries( [ { currentValue: 10, previousValue: -40 } ] ),
39
+ { includePrevious: true }
40
+ );
41
+
42
+ expect( entry ).toMatchObject( { currentShare: 25, previousShare: -100 } );
43
+ } );
44
+
45
+ it( 'ignores previous values for the max when comparison bars are not drawn', () => {
46
+ const result = resolveLeaderboardEntries( toEntries( comparisonRows ), {
47
+ includePrevious: false,
48
+ } );
49
+
50
+ expect( result.map( ( { currentShare } ) => currentShare ) ).toEqual( [ 100, 50 ] );
51
+ } );
52
+
53
+ it( 'keeps passed fields and derives the rest', () => {
54
+ const [ entry ] = resolveLeaderboardEntries(
55
+ toEntries( [ { currentValue: 50, currentShare: 10, previousValue: 100, delta: 7 } ] ),
56
+ { includePrevious: true }
57
+ );
58
+
59
+ expect( entry ).toMatchObject( { currentShare: 10, delta: 7, previousShare: 100 } );
60
+ } );
61
+
62
+ it( 'derives a missing share from the max over every row, passed or not', () => {
63
+ const result = resolveLeaderboardEntries(
64
+ toEntries( [ { currentValue: 10, currentShare: 100 }, { currentValue: 20 } ] ),
65
+ { includePrevious: false }
66
+ );
67
+
68
+ expect( result.map( ( { currentShare } ) => currentShare ) ).toEqual( [ 100, 100 ] );
69
+ } );
70
+
71
+ it( 'keeps the sign of negative values', () => {
72
+ const result = resolveLeaderboardEntries(
73
+ toEntries( [ { currentValue: -30 }, { currentValue: 60 } ] ),
74
+ { includePrevious: false }
75
+ );
76
+
77
+ expect( result.map( ( { currentShare } ) => currentShare ) ).toEqual( [ -50, 100 ] );
78
+ } );
79
+
80
+ it( 'clamps negative shares to 0 and sizes the rest against the largest positive value', () => {
81
+ const result = resolveLeaderboardEntries(
82
+ toEntries( [
83
+ { currentValue: -90, previousValue: 30 },
84
+ { currentValue: 60, previousValue: -10, previousShare: -20 },
85
+ ] ),
86
+ { includePrevious: true, clampNegative: true }
87
+ );
88
+
89
+ expect(
90
+ result.map( ( { currentShare, previousShare, delta } ) => ( {
91
+ currentShare,
92
+ previousShare,
93
+ delta,
94
+ } ) )
95
+ ).toEqual( [
96
+ { currentShare: 0, previousShare: 50, delta: -400 },
97
+ { currentShare: 100, previousShare: 0, delta: 700 },
98
+ ] );
99
+ } );
100
+
101
+ it.each( [
102
+ { currentValue: 5, previousValue: -10, expected: 150 },
103
+ { currentValue: 0, previousValue: 0, expected: 0 },
104
+ { currentValue: 5, previousValue: 0, expected: undefined },
105
+ { currentValue: 5, previousValue: undefined, expected: undefined },
106
+ ] )(
107
+ 'derives delta $expected for current $currentValue and previous $previousValue',
108
+ ( { currentValue, previousValue, expected } ) => {
109
+ const [ entry ] = resolveLeaderboardEntries(
110
+ toEntries( [ { currentValue, previousValue } ] ),
111
+ { includePrevious: true }
112
+ );
113
+
114
+ expect( entry.delta ).toBe( expected );
115
+ }
116
+ );
117
+
118
+ it( 'sizes rows without a previous value against the shared max', () => {
119
+ const result = resolveLeaderboardEntries(
120
+ toEntries( [ { currentValue: 50, previousValue: 100 }, { currentValue: 25 } ] ),
121
+ { includePrevious: true }
122
+ );
123
+
124
+ expect( result.map( ( { currentShare } ) => currentShare ) ).toEqual( [ 50, 25 ] );
125
+ expect( result[ 1 ] ).not.toHaveProperty( 'previousShare' );
126
+ } );
127
+
128
+ it( 'gives shares of 0 when every value is 0', () => {
129
+ const [ entry ] = resolveLeaderboardEntries(
130
+ toEntries( [ { currentValue: 0, previousValue: 0 } ] ),
131
+ { includePrevious: true }
132
+ );
133
+
134
+ expect( entry ).toMatchObject( { currentShare: 0, previousShare: 0, delta: 0 } );
135
+ } );
136
+
137
+ it( 'leaves other fields untouched', () => {
138
+ const onClick = jest.fn();
139
+ const [ entry ] = resolveLeaderboardEntries(
140
+ toEntries( [ { id: 'a', label: 'A', currentValue: 1, onClick } ] ),
141
+ { includePrevious: false }
142
+ );
143
+
144
+ expect( entry ).toMatchObject( { id: 'a', label: 'A', onClick } );
145
+ } );
146
+ } );
@@ -30,6 +30,12 @@ export interface LeaderboardChartProps extends Pick<
30
30
  */
31
31
  withOverlayLabel?: boolean;
32
32
 
33
+ /**
34
+ * How a negative value draws: `diverge` puts every row on a shared zero point, `clamp` draws it as an empty bar.
35
+ * @default 'diverge'
36
+ */
37
+ negativeBars?: 'diverge' | 'clamp';
38
+
33
39
  /**
34
40
  * Primary color for current period bars
35
41
  */
@@ -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
@@ -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
+ );
@@ -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