@automattic/charts 4.12.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +35 -29
  2. package/TOKENS.md +3 -0
  3. package/dist/index.cjs +733 -2203
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +42 -13
  6. package/dist/index.d.cts +41 -125
  7. package/dist/index.d.ts +41 -125
  8. package/dist/index.js +756 -1881
  9. package/dist/index.js.map +1 -1
  10. package/package.json +9 -9
  11. package/src/charts/area-chart/area-chart.tsx +16 -6
  12. package/src/charts/area-chart/test/area-chart.test.tsx +42 -9
  13. package/src/charts/bar-chart/bar-chart.tsx +17 -12
  14. package/src/charts/bar-chart/private/index.ts +1 -1
  15. package/src/charts/bar-chart/private/slot-tooltip-members.tsx +4 -5
  16. package/src/charts/bar-chart/private/use-bar-stops.ts +1 -3
  17. package/src/charts/bar-chart/private/use-overlap-slots.ts +35 -16
  18. package/src/charts/bar-chart/test/bar-chart.test.tsx +115 -0
  19. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +24 -11
  20. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +235 -182
  21. package/src/charts/conversion-funnel-chart/index.ts +2 -0
  22. package/src/charts/conversion-funnel-chart/private/index.ts +1 -0
  23. package/src/charts/conversion-funnel-chart/private/use-funnel-selection.ts +9 -47
  24. package/src/charts/conversion-funnel-chart/private/use-roving-step-focus.ts +83 -0
  25. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +312 -92
  26. package/src/charts/conversion-funnel-chart/test/use-funnel-selection.test.ts +23 -188
  27. package/src/charts/conversion-funnel-chart/types.ts +22 -0
  28. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +42 -2
  29. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +124 -36
  30. package/src/charts/leaderboard-chart/private/resolve-leaderboard-entries.ts +53 -0
  31. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +88 -0
  32. package/src/charts/leaderboard-chart/test/resolve-leaderboard-entries.test.ts +146 -0
  33. package/src/charts/leaderboard-chart/types.ts +6 -0
  34. package/src/charts/line-chart/line-chart.tsx +10 -8
  35. package/src/charts/line-chart/test/line-chart.test.tsx +75 -0
  36. package/src/components/legend/private/base-legend.tsx +13 -8
  37. package/src/components/legend/test/legend.test.tsx +125 -0
  38. package/src/components/legend/types.ts +9 -1
  39. package/src/components/tooltip/private/at-nearest-date.ts +49 -0
  40. package/src/components/tooltip/private/keyboard-stops.ts +67 -0
  41. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +10 -2
  42. package/src/components/tooltip/test/keyboard-stops.test.ts +36 -0
  43. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +135 -1
  44. package/src/components/tooltip/xy-chart-tooltip.tsx +8 -12
  45. package/src/index.ts +2 -0
  46. package/src/styles/chart-scope.scss +1 -0
  47. package/src/types.ts +5 -6
@@ -2,205 +2,40 @@ import { act, renderHook } from '@testing-library/react';
2
2
  import { useFunnelSelection } from '../private/use-funnel-selection';
3
3
 
4
4
  describe( 'useFunnelSelection', () => {
5
- describe( 'Initial State', () => {
6
- it( 'starts with no step selected', () => {
7
- const { result } = renderHook( () => useFunnelSelection() );
5
+ it( 'starts with no step selected', () => {
6
+ const { result } = renderHook( () => useFunnelSelection() );
8
7
 
9
- const stepState = result.current.getStepState( 'test-step' );
10
- expect( stepState.isClicked ).toBe( false );
11
- expect( stepState.isBlurred ).toBe( false );
8
+ expect( result.current.getStepState( 'cart' ) ).toEqual( {
9
+ isSelected: false,
10
+ isBlurred: false,
12
11
  } );
13
12
  } );
14
13
 
15
- describe( 'Bar Click Handling', () => {
16
- it( 'selects a step when bar is clicked', () => {
17
- const { result } = renderHook( () => useFunnelSelection() );
14
+ it( 'selects one step and blurs the others', () => {
15
+ const { result } = renderHook( () => useFunnelSelection() );
18
16
 
19
- act( () => {
20
- result.current.handleBarClick( 'test-step' );
21
- } );
17
+ act( () => result.current.selectStep( 'sessions' ) );
18
+ act( () => result.current.selectStep( 'cart' ) );
22
19
 
23
- const stepState = result.current.getStepState( 'test-step' );
24
- expect( stepState.isClicked ).toBe( true );
25
- expect( stepState.isBlurred ).toBe( false );
20
+ expect( result.current.getStepState( 'cart' ) ).toEqual( {
21
+ isSelected: true,
22
+ isBlurred: false,
26
23
  } );
27
-
28
- it( 'blurs other steps when one is selected', () => {
29
- const { result } = renderHook( () => useFunnelSelection() );
30
-
31
- act( () => {
32
- result.current.handleBarClick( 'selected-step' );
33
- } );
34
-
35
- const selectedState = result.current.getStepState( 'selected-step' );
36
- const otherState = result.current.getStepState( 'other-step' );
37
-
38
- expect( selectedState.isClicked ).toBe( true );
39
- expect( selectedState.isBlurred ).toBe( false );
40
- expect( otherState.isClicked ).toBe( false );
41
- expect( otherState.isBlurred ).toBe( true );
42
- } );
43
-
44
- it( 'deselects step when clicking the same bar again', () => {
45
- const { result } = renderHook( () => useFunnelSelection() );
46
-
47
- // First click to select
48
- act( () => {
49
- result.current.handleBarClick( 'test-step' );
50
- } );
51
-
52
- let stepState = result.current.getStepState( 'test-step' );
53
- expect( stepState.isClicked ).toBe( true );
54
-
55
- // Second click to deselect
56
- act( () => {
57
- result.current.handleBarClick( 'test-step' );
58
- } );
59
-
60
- stepState = result.current.getStepState( 'test-step' );
61
- expect( stepState.isClicked ).toBe( false );
62
- expect( stepState.isBlurred ).toBe( false );
63
- } );
64
-
65
- it( 'switches selection when clicking different bar', () => {
66
- const { result } = renderHook( () => useFunnelSelection() );
67
-
68
- // Select first step
69
- act( () => {
70
- result.current.handleBarClick( 'step1' );
71
- } );
72
-
73
- // Select second step
74
- act( () => {
75
- result.current.handleBarClick( 'step2' );
76
- } );
77
-
78
- const step1State = result.current.getStepState( 'step1' );
79
- const step2State = result.current.getStepState( 'step2' );
80
-
81
- expect( step1State.isClicked ).toBe( false );
82
- expect( step1State.isBlurred ).toBe( true );
83
- expect( step2State.isClicked ).toBe( true );
84
- expect( step2State.isBlurred ).toBe( false );
85
- } );
86
- } );
87
-
88
- describe( 'Keyboard Handling', () => {
89
- it( 'selects step on Enter key', () => {
90
- const { result } = renderHook( () => useFunnelSelection() );
91
- const mockEvent = {
92
- key: 'Enter',
93
- preventDefault: jest.fn(),
94
- stopPropagation: jest.fn(),
95
- } as unknown as React.KeyboardEvent;
96
-
97
- act( () => {
98
- result.current.handleBarKeyDown( 'test-step', mockEvent );
99
- } );
100
-
101
- const stepState = result.current.getStepState( 'test-step' );
102
- expect( stepState.isClicked ).toBe( true );
103
- expect( mockEvent.preventDefault ).toHaveBeenCalled();
104
- // Note: stopPropagation is not called in keyboard handler
105
- } );
106
-
107
- it( 'selects step on Space key', () => {
108
- const { result } = renderHook( () => useFunnelSelection() );
109
- const mockEvent = {
110
- key: ' ',
111
- preventDefault: jest.fn(),
112
- stopPropagation: jest.fn(),
113
- } as unknown as React.KeyboardEvent;
114
-
115
- act( () => {
116
- result.current.handleBarKeyDown( 'test-step', mockEvent );
117
- } );
118
-
119
- const stepState = result.current.getStepState( 'test-step' );
120
- expect( stepState.isClicked ).toBe( true );
121
- expect( mockEvent.preventDefault ).toHaveBeenCalled();
122
- // Note: stopPropagation is not called in keyboard handler
123
- } );
124
-
125
- it( 'ignores other keys', () => {
126
- const { result } = renderHook( () => useFunnelSelection() );
127
- const mockEvent = {
128
- key: 'Tab',
129
- preventDefault: jest.fn(),
130
- stopPropagation: jest.fn(),
131
- } as unknown as React.KeyboardEvent;
132
-
133
- act( () => {
134
- result.current.handleBarKeyDown( 'test-step', mockEvent );
135
- } );
136
-
137
- const stepState = result.current.getStepState( 'test-step' );
138
- expect( stepState.isClicked ).toBe( false );
139
- expect( mockEvent.preventDefault ).not.toHaveBeenCalled();
140
- expect( mockEvent.stopPropagation ).not.toHaveBeenCalled();
141
- } );
142
- } );
143
-
144
- describe( 'Clear Selection', () => {
145
- it( 'clears selection when clearSelection is called', () => {
146
- const { result } = renderHook( () => useFunnelSelection() );
147
-
148
- // First select a step
149
- act( () => {
150
- result.current.handleBarClick( 'test-step' );
151
- } );
152
-
153
- let stepState = result.current.getStepState( 'test-step' );
154
- expect( stepState.isClicked ).toBe( true );
155
-
156
- // Clear selection
157
- act( () => {
158
- result.current.clearSelection();
159
- } );
160
-
161
- stepState = result.current.getStepState( 'test-step' );
162
- expect( stepState.isClicked ).toBe( false );
163
- expect( stepState.isBlurred ).toBe( false );
24
+ expect( result.current.getStepState( 'sessions' ) ).toEqual( {
25
+ isSelected: false,
26
+ isBlurred: true,
164
27
  } );
165
28
  } );
166
29
 
167
- describe( 'getStepState', () => {
168
- it( 'returns correct state for unselected steps', () => {
169
- const { result } = renderHook( () => useFunnelSelection() );
170
-
171
- const stepState = result.current.getStepState( 'test-step' );
172
- expect( stepState ).toEqual( {
173
- isClicked: false,
174
- isBlurred: false,
175
- } );
176
- } );
177
-
178
- it( 'returns correct state for selected step', () => {
179
- const { result } = renderHook( () => useFunnelSelection() );
30
+ it( 'clears the selection and hides the tooltip', () => {
31
+ const hideTooltip = jest.fn();
32
+ const { result } = renderHook( () => useFunnelSelection( hideTooltip ) );
180
33
 
181
- act( () => {
182
- result.current.handleBarClick( 'selected-step' );
183
- } );
34
+ act( () => result.current.selectStep( 'cart' ) );
35
+ act( () => result.current.clearSelection() );
184
36
 
185
- const stepState = result.current.getStepState( 'selected-step' );
186
- expect( stepState ).toEqual( {
187
- isClicked: true,
188
- isBlurred: false,
189
- } );
190
- } );
191
-
192
- it( 'returns correct state for blurred steps', () => {
193
- const { result } = renderHook( () => useFunnelSelection() );
194
-
195
- act( () => {
196
- result.current.handleBarClick( 'selected-step' );
197
- } );
198
-
199
- const blurredStepState = result.current.getStepState( 'other-step' );
200
- expect( blurredStepState ).toEqual( {
201
- isClicked: false,
202
- isBlurred: true,
203
- } );
204
- } );
37
+ expect( result.current.selectedStepId ).toBeNull();
38
+ expect( result.current.getStepState( 'sessions' ).isBlurred ).toBe( false );
39
+ expect( hideTooltip ).toHaveBeenCalled();
205
40
  } );
206
41
  } );
@@ -42,12 +42,32 @@ export interface MainMetricRenderProps {
42
42
  changeColor?: string;
43
43
  }
44
44
 
45
+ /**
46
+ * A labeled line in a funnel step's tooltip
47
+ */
48
+ export interface FunnelTooltipRow {
49
+ label: string;
50
+ value: string;
51
+ }
52
+
53
+ /**
54
+ * What a funnel step's tooltip shows below or beside the step's name. Screen readers read the same content after the name.
55
+ */
56
+ export interface FunnelTooltipContent {
57
+ /** Shown on the name's line, e.g. the step's count */
58
+ value?: string;
59
+ /** Labeled lines below the name */
60
+ rows?: FunnelTooltipRow[];
61
+ }
62
+
45
63
  /**
46
64
  * Render prop for customizing tooltip content
47
65
  */
48
66
  export interface TooltipRenderProps {
49
67
  step: FunnelStep;
50
68
  index: number;
69
+ /** The step's tooltip content. Screen readers read the whole rendered tooltip as the step's description. */
70
+ content: FunnelTooltipContent;
51
71
  top: number;
52
72
  left: number;
53
73
  /** @deprecated The tooltip box draws the package surface; this is no longer set. */
@@ -81,6 +101,8 @@ export interface ConversionFunnelChartProps extends Pick<
81
101
  renderMainMetric?: ( props: MainMetricRenderProps ) => React.ReactNode;
82
102
  /** Custom render function for tooltip content */
83
103
  renderTooltip?: ( props: TooltipRenderProps ) => React.ReactNode;
104
+ /** What each step's tooltip shows, which screen readers also read after the step's name. Defaults to the count and the conversion rate. */
105
+ getTooltipContent?: ( step: FunnelStep, index: number ) => FunnelTooltipContent;
84
106
  /** Inline styles merged over the tooltip box defaults. */
85
107
  tooltipStyle?: React.CSSProperties;
86
108
  }
@@ -49,7 +49,13 @@
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
+ }
54
+
55
+ // The bar's 100% height needs a track that fills the row,
56
+ // not one sized to its content.
57
+ .barTrack {
58
+ align-self: stretch;
53
59
  }
54
60
  }
55
61
 
@@ -74,6 +80,20 @@
74
80
  var(--a8c-charts-motion-easing-entrance) both;
75
81
  }
76
82
 
83
+ // Grows from zero, which is its inline-end edge.
84
+ &--negative {
85
+ transform-origin: right;
86
+ }
87
+
88
+ // transform-origin has no logical keywords, so mirror both origins in RTL.
89
+ [dir="rtl"] &--animated {
90
+ transform-origin: right;
91
+ }
92
+
93
+ [dir="rtl"] &--negative {
94
+ transform-origin: left;
95
+ }
96
+
77
97
  @keyframes stretch {
78
98
 
79
99
  from {
@@ -85,11 +105,31 @@
85
105
  }
86
106
  }
87
107
  }
108
+
109
+ .barTrack {
110
+ display: grid;
111
+
112
+ .bar {
113
+ grid-column: 2;
114
+ justify-self: start;
115
+ }
116
+
117
+ .bar--negative {
118
+ grid-column: 1;
119
+ justify-self: end;
120
+ }
121
+ }
88
122
  }
89
123
 
124
+ // Spans the row's value and delta tracks and lays its cells out on them, so
125
+ // the values and the deltas each end on their own column edge whatever width
126
+ // the other takes.
90
127
  .valueContainer {
128
+ display: grid;
129
+ grid-column: 2 / -1;
130
+ grid-template-columns: subgrid;
91
131
  align-items: center;
92
- justify-content: flex-end;
132
+ justify-items: end;
93
133
  }
94
134
 
95
135
  .deltaValue {
@@ -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;
@@ -107,6 +111,56 @@ const BarLabel = ( { label }: { label: LeaderboardEntry[ 'label' ] } ) => (
107
111
  <>{ typeof label === 'string' ? <Text className={ styles.label }>{ label }</Text> : label }</>
108
112
  );
109
113
 
114
+ /** The largest negative and positive share magnitudes, which size each side of zero. */
115
+ type DivergingScale = { negative: number; positive: number };
116
+
117
+ const Bar = ( {
118
+ share,
119
+ color,
120
+ animation,
121
+ divergingScale,
122
+ }: {
123
+ share: number;
124
+ color: string;
125
+ animation?: boolean;
126
+ divergingScale?: DivergingScale;
127
+ } ) => {
128
+ const isNegative = share < 0;
129
+ const sideMax = divergingScale?.[ isNegative ? 'negative' : 'positive' ];
130
+ // A diverging bar is measured against the largest share on its own side of zero.
131
+ const width = sideMax ? ( Math.abs( share ) / sideMax ) * 100 : Math.abs( share );
132
+
133
+ const bar = (
134
+ <div
135
+ data-testid="leaderboard-chart-bar"
136
+ className={ clsx( styles.bar, {
137
+ [ styles[ 'bar--animated' ] ]: animation,
138
+ [ styles[ 'bar--negative' ] ]: divergingScale && isNegative,
139
+ } ) }
140
+ style={ {
141
+ width: getBarWidth( width ),
142
+ backgroundColor: color,
143
+ } }
144
+ ></div>
145
+ );
146
+
147
+ if ( ! divergingScale ) {
148
+ return bar;
149
+ }
150
+
151
+ // Each side is as wide as its share of the full range, so zero sits where the range crosses it.
152
+ return (
153
+ <div
154
+ className={ styles.barTrack }
155
+ style={ {
156
+ gridTemplateColumns: `${ divergingScale.negative }fr ${ divergingScale.positive }fr`,
157
+ } }
158
+ >
159
+ { bar }
160
+ </div>
161
+ );
162
+ };
163
+
110
164
  const BarWithLabel = ( {
111
165
  entry,
112
166
  withComparison,
@@ -114,51 +168,53 @@ const BarWithLabel = ( {
114
168
  primaryColor,
115
169
  secondaryColor,
116
170
  animation,
171
+ divergingScale,
117
172
  isPrimaryVisible = true,
118
173
  isComparisonVisible = true,
119
174
  }: {
120
- entry: LeaderboardEntry;
175
+ entry: ResolvedLeaderboardEntry;
121
176
  withComparison?: boolean;
122
177
  withOverlayLabel?: boolean;
123
178
  primaryColor: string;
124
179
  secondaryColor: string;
180
+ divergingScale?: DivergingScale;
125
181
  isPrimaryVisible?: boolean;
126
182
  isComparisonVisible?: boolean;
127
183
  animation?: boolean;
128
184
  } ) => {
129
185
  const showComparisonBar = withComparison && ! withOverlayLabel && isComparisonVisible;
130
186
 
187
+ const bars = (
188
+ <>
189
+ { isPrimaryVisible && (
190
+ <Bar
191
+ share={ entry.currentShare }
192
+ color={ primaryColor }
193
+ animation={ animation }
194
+ divergingScale={ divergingScale }
195
+ />
196
+ ) }
197
+
198
+ { showComparisonBar && hasPreviousValue( entry ) && (
199
+ <Bar
200
+ share={ entry.previousShare }
201
+ color={ secondaryColor }
202
+ animation={ animation }
203
+ divergingScale={ divergingScale }
204
+ />
205
+ ) }
206
+ </>
207
+ );
208
+
131
209
  return (
132
210
  <div
133
211
  className={ clsx( styles.barWithLabelContainer, {
134
212
  [ styles[ 'is-overlay' ] ]: withOverlayLabel,
135
213
  } ) }
214
+ data-testid={ divergingScale ? 'leaderboard-chart-diverging-row' : undefined }
136
215
  >
137
216
  <BarLabel label={ entry.label } />
138
-
139
- { isPrimaryVisible && (
140
- <div
141
- className={ clsx( styles.bar, {
142
- [ styles[ 'bar--animated' ] ]: animation,
143
- } ) }
144
- style={ {
145
- width: getBarWidth( entry.currentShare ),
146
- backgroundColor: primaryColor,
147
- } }
148
- ></div>
149
- ) }
150
-
151
- { showComparisonBar && hasPreviousValue( entry ) && (
152
- <div
153
- className={ clsx( styles.bar, {
154
- [ styles[ 'bar--animated' ] ]: animation,
155
- } ) }
156
- style={ {
157
- width: getBarWidth( entry.previousShare ),
158
- backgroundColor: secondaryColor,
159
- } }
160
- ></div>
161
- ) }
217
+ { bars }
162
218
  </div>
163
219
  );
164
220
  };
@@ -174,6 +230,7 @@ const BarWithLabel = ( {
174
230
  * @param props.height - Optional height of the chart container in pixels
175
231
  * @param props.withComparison - Whether to show comparison data
176
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
177
234
  * @param props.primaryColor - Primary color for current period bars
178
235
  * @param props.secondaryColor - Secondary color for comparison period bars
179
236
  * @param props.valueFormatter - Custom formatter for values
@@ -198,6 +255,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
198
255
  height: propHeight,
199
256
  withComparison = false,
200
257
  withOverlayLabel = false,
258
+ negativeBars = 'diverge',
201
259
  primaryColor,
202
260
  secondaryColor,
203
261
  valueFormatter = defaultValueFormatter,
@@ -267,6 +325,33 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
267
325
  return ! isPrimaryVisible;
268
326
  }, [ isPrimaryVisible, isComparisonVisible, withComparison, withOverlayLabel ] );
269
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
+
339
+ // One negative share puts every row on a shared zero point, so the bars stay comparable.
340
+ const divergingScale = useMemo( (): DivergingScale | undefined => {
341
+ const scale = { negative: 0, positive: 0 };
342
+ for ( const entry of resolvedData ) {
343
+ const shares = [ entry.currentShare ];
344
+ if ( withComparisonBars && hasPreviousValue( entry ) ) {
345
+ shares.push( entry.previousShare );
346
+ }
347
+ for ( const share of shares ) {
348
+ const side = share < 0 ? 'negative' : 'positive';
349
+ scale[ side ] = Math.max( scale[ side ], Math.abs( share ) );
350
+ }
351
+ }
352
+ return scale.negative > 0 ? scale : undefined;
353
+ }, [ resolvedData, withComparisonBars ] );
354
+
270
355
  // Validate data
271
356
  const isDataValid = Boolean( data && data.length > 0 );
272
357
 
@@ -300,6 +385,11 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
300
385
  data
301
386
  );
302
387
  const shouldFitRows = fitRows && isMeasurable;
388
+ // Comparing adds a delta track so the values and the deltas each line up in a column
389
+ // of their own; with either series hidden the grid keeps its two tracks.
390
+ const showComparisonColumn = withComparison && isComparisonVisible;
391
+ const templateColumns =
392
+ showComparisonColumn && isPrimaryVisible ? 'minmax(0, 1fr) auto auto' : 'minmax(0, 1fr) auto';
303
393
 
304
394
  // Handle empty or undefined data
305
395
  if ( ! data || data.length === 0 ) {
@@ -393,12 +483,12 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
393
483
  <Grid
394
484
  role="list"
395
485
  aria-label={ ariaLabel ?? __( 'Leaderboard chart', 'jetpack-charts' ) }
396
- templateColumns="minmax(0, 1fr) auto"
486
+ templateColumns={ templateColumns }
397
487
  rowGap={ ROW_GAP }
398
488
  columnGap={ COLUMN_GAP }
399
489
  data-leaderboard-grid
400
490
  >
401
- { data.map( ( entry, rowIndex ) => {
491
+ { resolvedData.map( ( entry, rowIndex ) => {
402
492
  // visibility, not clipping: hidden rows keep their geometry (so a
403
493
  // taller container can reveal them) but leave hit testing, focus,
404
494
  // and the accessibility tree.
@@ -406,7 +496,6 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
406
496
  shouldFitRows && rowIndex >= fittedCount
407
497
  ? ( { visibility: 'hidden' } as const )
408
498
  : undefined;
409
- const showComparisonColumn = withComparison && isComparisonVisible;
410
499
  const hasPreviousPeriodValue = hasPreviousValue( entry );
411
500
  const hasDelta = hasDeltaValue( entry );
412
501
  const showComparisonValue = showComparisonColumn && hasDelta;
@@ -426,15 +515,14 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
426
515
  withOverlayLabel={ withOverlayLabel }
427
516
  primaryColor={ resolvedPrimaryColor }
428
517
  secondaryColor={ resolvedSecondaryColor }
518
+ divergingScale={ divergingScale }
429
519
  isPrimaryVisible={ isPrimaryVisible }
430
520
  isComparisonVisible={ isComparisonVisible }
431
521
  animation={ animation && ! loading && ! prefersReducedMotion }
432
522
  />
433
523
  </Stack>
434
524
 
435
- <Stack
436
- direction="row"
437
- gap="xs"
525
+ <div
438
526
  className={ clsx( styles.valueContainer, {
439
527
  [ styles.overlayLabel ]: withOverlayLabel,
440
528
  } ) }
@@ -463,7 +551,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
463
551
  </VisuallyHidden>
464
552
  </Text>
465
553
  ) }
466
- </Stack>
554
+ </div>
467
555
  </>
468
556
  );
469
557
 
@@ -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
+ }