@automattic/charts 4.12.0 → 4.13.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 (39) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/index.cjs +641 -636
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +40 -12
  5. package/dist/index.d.cts +26 -121
  6. package/dist/index.d.ts +26 -121
  7. package/dist/index.js +630 -613
  8. package/dist/index.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/charts/area-chart/area-chart.tsx +16 -6
  11. package/src/charts/area-chart/test/area-chart.test.tsx +42 -9
  12. package/src/charts/bar-chart/bar-chart.tsx +17 -12
  13. package/src/charts/bar-chart/private/index.ts +1 -1
  14. package/src/charts/bar-chart/private/slot-tooltip-members.tsx +4 -5
  15. package/src/charts/bar-chart/private/use-bar-stops.ts +1 -3
  16. package/src/charts/bar-chart/private/use-overlap-slots.ts +35 -16
  17. package/src/charts/bar-chart/test/bar-chart.test.tsx +115 -0
  18. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +24 -11
  19. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +235 -182
  20. package/src/charts/conversion-funnel-chart/index.ts +2 -0
  21. package/src/charts/conversion-funnel-chart/private/index.ts +1 -0
  22. package/src/charts/conversion-funnel-chart/private/use-funnel-selection.ts +9 -47
  23. package/src/charts/conversion-funnel-chart/private/use-roving-step-focus.ts +83 -0
  24. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +312 -92
  25. package/src/charts/conversion-funnel-chart/test/use-funnel-selection.test.ts +23 -188
  26. package/src/charts/conversion-funnel-chart/types.ts +22 -0
  27. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +41 -1
  28. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +101 -30
  29. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +47 -0
  30. package/src/charts/line-chart/line-chart.tsx +10 -8
  31. package/src/charts/line-chart/test/line-chart.test.tsx +75 -0
  32. package/src/components/tooltip/private/at-nearest-date.ts +49 -0
  33. package/src/components/tooltip/private/keyboard-stops.ts +67 -0
  34. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +10 -2
  35. package/src/components/tooltip/test/keyboard-stops.test.ts +36 -0
  36. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +135 -1
  37. package/src/components/tooltip/xy-chart-tooltip.tsx +8 -12
  38. package/src/index.ts +2 -0
  39. package/src/types.ts +4 -3
@@ -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
  }
@@ -51,6 +51,12 @@
51
51
  .label {
52
52
  padding-left: var(--wpds-dimension-padding-sm);
53
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;
59
+ }
54
60
  }
55
61
 
56
62
  .bar {
@@ -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 {
@@ -107,6 +107,55 @@ const BarLabel = ( { label }: { label: LeaderboardEntry[ 'label' ] } ) => (
107
107
  <>{ typeof label === 'string' ? <Text className={ styles.label }>{ label }</Text> : label }</>
108
108
  );
109
109
 
110
+ /** The largest negative and positive share magnitudes, which size each side of zero. */
111
+ type DivergingScale = { negative: number; positive: number };
112
+
113
+ const Bar = ( {
114
+ share,
115
+ color,
116
+ animation,
117
+ divergingScale,
118
+ }: {
119
+ share: number;
120
+ color: string;
121
+ animation?: boolean;
122
+ divergingScale?: DivergingScale;
123
+ } ) => {
124
+ const isNegative = share < 0;
125
+ const sideMax = divergingScale?.[ isNegative ? 'negative' : 'positive' ];
126
+ // A diverging bar is measured against the largest share on its own side of zero.
127
+ const width = sideMax ? ( Math.abs( share ) / sideMax ) * 100 : Math.abs( share );
128
+
129
+ const bar = (
130
+ <div
131
+ className={ clsx( styles.bar, {
132
+ [ styles[ 'bar--animated' ] ]: animation,
133
+ [ styles[ 'bar--negative' ] ]: divergingScale && isNegative,
134
+ } ) }
135
+ style={ {
136
+ width: getBarWidth( width ),
137
+ backgroundColor: color,
138
+ } }
139
+ ></div>
140
+ );
141
+
142
+ if ( ! divergingScale ) {
143
+ return bar;
144
+ }
145
+
146
+ // Each side is as wide as its share of the full range, so zero sits where the range crosses it.
147
+ return (
148
+ <div
149
+ className={ styles.barTrack }
150
+ style={ {
151
+ gridTemplateColumns: `${ divergingScale.negative }fr ${ divergingScale.positive }fr`,
152
+ } }
153
+ >
154
+ { bar }
155
+ </div>
156
+ );
157
+ };
158
+
110
159
  const BarWithLabel = ( {
111
160
  entry,
112
161
  withComparison,
@@ -114,6 +163,7 @@ const BarWithLabel = ( {
114
163
  primaryColor,
115
164
  secondaryColor,
116
165
  animation,
166
+ divergingScale,
117
167
  isPrimaryVisible = true,
118
168
  isComparisonVisible = true,
119
169
  }: {
@@ -122,43 +172,44 @@ const BarWithLabel = ( {
122
172
  withOverlayLabel?: boolean;
123
173
  primaryColor: string;
124
174
  secondaryColor: string;
175
+ divergingScale?: DivergingScale;
125
176
  isPrimaryVisible?: boolean;
126
177
  isComparisonVisible?: boolean;
127
178
  animation?: boolean;
128
179
  } ) => {
129
180
  const showComparisonBar = withComparison && ! withOverlayLabel && isComparisonVisible;
130
181
 
182
+ const bars = (
183
+ <>
184
+ { isPrimaryVisible && (
185
+ <Bar
186
+ share={ entry.currentShare }
187
+ color={ primaryColor }
188
+ animation={ animation }
189
+ divergingScale={ divergingScale }
190
+ />
191
+ ) }
192
+
193
+ { showComparisonBar && hasPreviousValue( entry ) && (
194
+ <Bar
195
+ share={ entry.previousShare }
196
+ color={ secondaryColor }
197
+ animation={ animation }
198
+ divergingScale={ divergingScale }
199
+ />
200
+ ) }
201
+ </>
202
+ );
203
+
131
204
  return (
132
205
  <div
133
206
  className={ clsx( styles.barWithLabelContainer, {
134
207
  [ styles[ 'is-overlay' ] ]: withOverlayLabel,
135
208
  } ) }
209
+ data-testid={ divergingScale ? 'leaderboard-chart-diverging-row' : undefined }
136
210
  >
137
211
  <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
- ) }
212
+ { bars }
162
213
  </div>
163
214
  );
164
215
  };
@@ -267,6 +318,23 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
267
318
  return ! isPrimaryVisible;
268
319
  }, [ isPrimaryVisible, isComparisonVisible, withComparison, withOverlayLabel ] );
269
320
 
321
+ // One negative share puts every row on a shared zero point, so the bars stay comparable.
322
+ const divergingScale = useMemo( (): DivergingScale | undefined => {
323
+ const withComparisonBars = withComparison && ! withOverlayLabel;
324
+ const scale = { negative: 0, positive: 0 };
325
+ for ( const entry of data ?? [] ) {
326
+ const shares = [ entry.currentShare ];
327
+ if ( withComparisonBars && hasPreviousValue( entry ) ) {
328
+ shares.push( entry.previousShare );
329
+ }
330
+ for ( const share of shares ) {
331
+ const side = share < 0 ? 'negative' : 'positive';
332
+ scale[ side ] = Math.max( scale[ side ], Math.abs( share ) );
333
+ }
334
+ }
335
+ return scale.negative > 0 ? scale : undefined;
336
+ }, [ data, withComparison, withOverlayLabel ] );
337
+
270
338
  // Validate data
271
339
  const isDataValid = Boolean( data && data.length > 0 );
272
340
 
@@ -300,6 +368,11 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
300
368
  data
301
369
  );
302
370
  const shouldFitRows = fitRows && isMeasurable;
371
+ // Comparing adds a delta track so the values and the deltas each line up in a column
372
+ // of their own; with either series hidden the grid keeps its two tracks.
373
+ const showComparisonColumn = withComparison && isComparisonVisible;
374
+ const templateColumns =
375
+ showComparisonColumn && isPrimaryVisible ? 'minmax(0, 1fr) auto auto' : 'minmax(0, 1fr) auto';
303
376
 
304
377
  // Handle empty or undefined data
305
378
  if ( ! data || data.length === 0 ) {
@@ -393,7 +466,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
393
466
  <Grid
394
467
  role="list"
395
468
  aria-label={ ariaLabel ?? __( 'Leaderboard chart', 'jetpack-charts' ) }
396
- templateColumns="minmax(0, 1fr) auto"
469
+ templateColumns={ templateColumns }
397
470
  rowGap={ ROW_GAP }
398
471
  columnGap={ COLUMN_GAP }
399
472
  data-leaderboard-grid
@@ -406,7 +479,6 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
406
479
  shouldFitRows && rowIndex >= fittedCount
407
480
  ? ( { visibility: 'hidden' } as const )
408
481
  : undefined;
409
- const showComparisonColumn = withComparison && isComparisonVisible;
410
482
  const hasPreviousPeriodValue = hasPreviousValue( entry );
411
483
  const hasDelta = hasDeltaValue( entry );
412
484
  const showComparisonValue = showComparisonColumn && hasDelta;
@@ -426,15 +498,14 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
426
498
  withOverlayLabel={ withOverlayLabel }
427
499
  primaryColor={ resolvedPrimaryColor }
428
500
  secondaryColor={ resolvedSecondaryColor }
501
+ divergingScale={ divergingScale }
429
502
  isPrimaryVisible={ isPrimaryVisible }
430
503
  isComparisonVisible={ isComparisonVisible }
431
504
  animation={ animation && ! loading && ! prefersReducedMotion }
432
505
  />
433
506
  </Stack>
434
507
 
435
- <Stack
436
- direction="row"
437
- gap="xs"
508
+ <div
438
509
  className={ clsx( styles.valueContainer, {
439
510
  [ styles.overlayLabel ]: withOverlayLabel,
440
511
  } ) }
@@ -463,7 +534,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
463
534
  </VisuallyHidden>
464
535
  </Text>
465
536
  ) }
466
- </Stack>
537
+ </div>
467
538
  </>
468
539
  );
469
540
 
@@ -152,6 +152,24 @@ describe( 'LeaderboardChart', () => {
152
152
  expect( container.querySelectorAll( '.bar' ) ).toHaveLength( 2 );
153
153
  } );
154
154
 
155
+ it.each( [
156
+ [ 'a current share is negative', { currentShare: -40 }, true, 2 ],
157
+ [ 'a comparison share is negative', { previousShare: -40 }, true, 2 ],
158
+ [ 'only a hidden comparison share is negative', { previousShare: -40 }, false, 0 ],
159
+ [
160
+ 'only a share without a comparison value is negative',
161
+ { previousShare: -40, previousValue: undefined },
162
+ true,
163
+ 0,
164
+ ],
165
+ [ 'every share is positive', {}, true, 0 ],
166
+ ] )( 'diverges the bars from zero when %s', ( _, override, withComparison, expected ) => {
167
+ const data = [ { ...mockData[ 0 ], ...override }, mockData[ 1 ] ];
168
+ render( <LeaderboardChart data={ data } withComparison={ withComparison } /> );
169
+
170
+ expect( screen.queryAllByTestId( 'leaderboard-chart-diverging-row' ) ).toHaveLength( expected );
171
+ } );
172
+
155
173
  it( 'shows custom label when provided', () => {
156
174
  render(
157
175
  <LeaderboardChart
@@ -748,6 +766,35 @@ describe( 'LeaderboardChart', () => {
748
766
  describe( 'grid gaps', () => {
749
767
  // eslint-disable-next-line testing-library/no-node-access -- The grid is rendered by `Grid` from @wordpress/components, which takes no test id.
750
768
  const grid = () => document.querySelector< HTMLElement >( '[data-leaderboard-grid]' );
769
+ const templateColumns = () =>
770
+ grid().style.getPropertyValue( '--wp-components-grid-template-columns' );
771
+
772
+ it( 'lays the rows out without a delta track when not comparing', () => {
773
+ render( <LeaderboardChart data={ mockData } withComparison={ false } /> );
774
+
775
+ expect( templateColumns() ).toBe( 'minmax(0, 1fr) auto' );
776
+ } );
777
+
778
+ it.each( [ [ 'Previous period' ], [ 'Current period' ] ] )(
779
+ 'adds a delta track when comparing and drops it while "%s" is hidden',
780
+ async legendItem => {
781
+ const user = userEvent.setup();
782
+ render(
783
+ <LeaderboardChart
784
+ data={ mockData }
785
+ withComparison={ true }
786
+ showLegend={ true }
787
+ legend={ { interactive: true } }
788
+ />
789
+ );
790
+
791
+ expect( templateColumns() ).toBe( 'minmax(0, 1fr) auto auto' );
792
+
793
+ await user.click( screen.getByRole( 'button', { name: new RegExp( legendItem ) } ) );
794
+
795
+ expect( templateColumns() ).toBe( 'minmax(0, 1fr) auto' );
796
+ }
797
+ );
751
798
 
752
799
  it( 'hands the catalog role to the grid', () => {
753
800
  render( <LeaderboardChart data={ mockData } /> );
@@ -16,6 +16,7 @@ import {
16
16
  } from 'react';
17
17
  import { Legend, useChartLegendItems } from '../../components/legend';
18
18
  import { XYChartTooltip, useKeyboardNavigation } from '../../components/tooltip';
19
+ import { getKeyboardStops } from '../../components/tooltip/private/keyboard-stops';
19
20
  import {
20
21
  useXYChartTheme,
21
22
  useChartDataTransform,
@@ -387,17 +388,18 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
387
388
  return min < max ? [ min, max ] : undefined;
388
389
  }, [ rescaleYOnVisibilityChange, dataSorted, isSeriesVisible, options?.yScale ] );
389
390
 
390
- // Keyboard navigation steps through x positions, and the grouped tooltip reads every
391
- // visible series at that position; the first visible series with a point there names it.
391
+ const stops = useMemo( () => getKeyboardStops( visibleSeries ), [ visibleSeries ] );
392
+
393
+ // The grouped tooltip reads every visible series at a stop; the first visible series with a point there names it.
392
394
  const activateSelectedPoint = useCallback(
393
- ( index: number ) => {
394
- const series = visibleSeries.find( candidate => candidate.data[ index ] );
395
+ ( stop: number ) => {
396
+ const match = stops.membersAt( stop )[ 0 ];
395
397
 
396
- if ( series ) {
397
- onDatumActivate?.( { datum: series.data[ index ], index, key: series.label } );
398
+ if ( match ) {
399
+ onDatumActivate?.( { datum: match.datum, index: match.index, key: match.series.label } );
398
400
  }
399
401
  },
400
- [ visibleSeries, onDatumActivate ]
402
+ [ stops, onDatumActivate ]
401
403
  );
402
404
 
403
405
  const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
@@ -406,7 +408,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
406
408
  isNavigating,
407
409
  setIsNavigating,
408
410
  chartRef,
409
- totalPoints: Math.max( 0, ...visibleSeries.map( series => series.data.length ) ),
411
+ totalPoints: stops.count,
410
412
  visibleSeriesKey,
411
413
  onActivate: activateSelectedPoint,
412
414
  preventTooltipScroll: tooltipPlacement === 'below-axis',