@automattic/charts 1.11.0 → 2.0.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 +24 -2
  2. package/TOKENS.md +19 -1
  3. package/dist/index.cjs +2196 -2711
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +23 -43
  6. package/dist/index.d.cts +72 -6
  7. package/dist/index.d.ts +72 -6
  8. package/dist/index.js +278 -792
  9. package/dist/index.js.map +1 -1
  10. package/package.json +5 -4
  11. package/src/charts/area-chart/area-chart.tsx +31 -10
  12. package/src/charts/area-chart/test/area-chart.test.tsx +102 -0
  13. package/src/charts/area-chart/types.ts +20 -6
  14. package/src/charts/bar-chart/bar-chart.tsx +33 -9
  15. package/src/charts/bar-chart/test/bar-chart.test.tsx +117 -0
  16. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +7 -7
  17. package/src/charts/line-chart/line-chart.module.scss +3 -3
  18. package/src/charts/line-chart/line-chart.tsx +43 -7
  19. package/src/charts/line-chart/test/line-chart.test.tsx +234 -5
  20. package/src/charts/line-chart/types.ts +14 -0
  21. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +1 -1
  22. package/src/charts/private/test/time-axis.test.ts +182 -0
  23. package/src/charts/private/time-axis.ts +84 -5
  24. package/src/charts/private/x-zoom/index.ts +1 -0
  25. package/src/charts/private/{test → x-zoom/test}/x-zoom.test.tsx +92 -4
  26. package/src/charts/private/x-zoom/x-zoom.module.scss +35 -0
  27. package/src/charts/private/{x-zoom.tsx → x-zoom/x-zoom.tsx} +44 -27
  28. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +152 -0
  29. package/src/components/legend/hooks/use-chart-legend-items.ts +118 -16
  30. package/src/components/legend/private/base-legend.module.scss +1 -1
  31. package/src/components/legend/private/base-legend.tsx +21 -9
  32. package/src/components/legend/test/legend-config-types.test.tsx +32 -0
  33. package/src/components/legend/test/legend.test.tsx +121 -1
  34. package/src/components/legend/types.ts +6 -0
  35. package/src/components/tooltip/base-tooltip.module.scss +1 -1
  36. package/src/components/trend-indicator/trend-indicator.module.scss +1 -1
  37. package/src/index.ts +2 -0
  38. package/src/types.ts +31 -0
  39. package/src/charts/private/x-zoom.module.scss +0 -50
@@ -2,6 +2,7 @@ import { curveCatmullRom, curveLinear, curveMonotoneX } from '@visx/curve';
2
2
  import { scaleTime } from '@visx/scale';
3
3
  import { differenceInHours, differenceInYears } from 'date-fns';
4
4
  import type { useChartDataTransform } from '../../hooks';
5
+ import type { TickResolution } from '../../types';
5
6
  import type { CurveType } from '../line-chart/types';
6
7
 
7
8
  // Approximate min pixel width for an x-axis tick label.
@@ -47,20 +48,98 @@ const formatHourTick = ( timestamp: number ) => {
47
48
  return date.toLocaleTimeString( undefined, { hour: 'numeric', hour12: true } );
48
49
  };
49
50
 
50
- // Pick the most informative tick formatter for the data's time span: hours
51
- // within a day, calendar dates within a year, otherwise just years.
52
- export const getFormatter = ( sortedData: ReturnType< typeof useChartDataTransform > ) => {
51
+ // Hour ticks with the date at midnight boundaries, so multi-day spans of
52
+ // sub-daily data keep their days identifiable.
53
+ const formatDateOrHourTick = ( timestamp: number ) => {
54
+ const date = new Date( timestamp );
55
+ return date.getHours() === 0 && date.getMinutes() === 0
56
+ ? formatDateTick( timestamp )
57
+ : formatHourTick( timestamp );
58
+ };
59
+
60
+ // Month ticks with the year at January boundaries, for month-or-coarser
61
+ // buckets where a full "Sep 1" date would misread as a daily point.
62
+ const formatMonthOrYearTick = ( timestamp: number ) => {
63
+ const date = new Date( timestamp );
64
+ return date.getMonth() === 0
65
+ ? formatYearTick( timestamp )
66
+ : date.toLocaleDateString( undefined, { month: 'short' } );
67
+ };
68
+
69
+ // Smallest interval between consecutive points across all series, in hours.
70
+ // Infinity when no series has two points.
71
+ const getPointSpacingInHours = ( sortedData: ReturnType< typeof useChartDataTransform > ) => {
72
+ return sortedData.reduce(
73
+ ( spacing, datom ) =>
74
+ datom.data.reduce( ( seriesSpacing, point, index ) => {
75
+ const previous = datom.data[ index - 1 ];
76
+ if ( previous?.date === undefined || point?.date === undefined ) {
77
+ return seriesSpacing;
78
+ }
79
+ return Math.min(
80
+ seriesSpacing,
81
+ Math.abs( differenceInHours( point.date, previous.date ) )
82
+ );
83
+ }, spacing ),
84
+ Number.POSITIVE_INFINITY
85
+ );
86
+ };
87
+
88
+ // Nominal point spacing for a caller-declared bucket resolution. A month maps
89
+ // to the shortest month so the month-or-coarser regime engages (see
90
+ // getFormatter). Year is absent: it short-circuits to year ticks before the
91
+ // spacing regimes run.
92
+ const SPACING_BY_RESOLUTION: Record< Exclude< TickResolution, 'year' >, number > = {
93
+ hour: 1,
94
+ day: 24,
95
+ week: 24 * 7,
96
+ month: 28 * 24,
97
+ };
98
+
99
+ // Pick the most informative tick formatter for the data's resolution and time
100
+ // span: hours within a day, hours with day boundaries for sub-daily data
101
+ // spanning up to a week, calendar dates for daily-or-finer buckets within a
102
+ // year, months (with the year at January) for month-or-coarser buckets,
103
+ // otherwise just years. The resolution comes from `tickResolution` when the
104
+ // caller knows it, and is inferred from point spacing otherwise.
105
+ export const getFormatter = (
106
+ sortedData: ReturnType< typeof useChartDataTransform >,
107
+ tickResolution?: TickResolution
108
+ ) => {
109
+ // The month regime only prints the year at January boundaries, so yearly
110
+ // buckets starting mid-year would render as month names; year ticks are
111
+ // correct for yearly buckets at any span.
112
+ if ( tickResolution === 'year' ) {
113
+ return formatYearTick;
114
+ }
115
+
53
116
  const minX = Math.min( ...sortedData.map( datom => datom.data.at( 0 )?.date ) );
54
117
  const maxX = Math.max( ...sortedData.map( datom => datom.data.at( -1 )?.date ) );
55
118
 
119
+ const spacingInHours = tickResolution
120
+ ? SPACING_BY_RESOLUTION[ tickResolution ]
121
+ : getPointSpacingInHours( sortedData );
122
+ // 23, not 24: a daily gap shrinks to 23 wall-clock hours across a
123
+ // spring-forward DST transition.
124
+ const isSubDaily = spacingInHours < 23;
125
+
56
126
  const diffInHours = Math.abs( differenceInHours( maxX, minX ) );
57
- if ( diffInHours <= 24 ) {
127
+ if ( diffInHours <= 24 && isSubDaily ) {
58
128
  return formatHourTick;
59
129
  }
60
130
 
131
+ if ( diffInHours <= 24 * 7 && isSubDaily ) {
132
+ return formatDateOrHourTick;
133
+ }
134
+
61
135
  const diffInYears = Math.abs( differenceInYears( maxX, minX ) );
62
136
  if ( diffInYears <= 1 ) {
63
- return formatDateTick;
137
+ // 28 days: the shortest month, so monthly buckets qualify but weekly
138
+ // don't. The finiteness check keeps all-single-point series — whose
139
+ // resolution is unknowable — on date ticks.
140
+ return Number.isFinite( spacingInHours ) && spacingInHours >= 28 * 24
141
+ ? formatMonthOrYearTick
142
+ : formatDateTick;
64
143
  }
65
144
 
66
145
  return formatYearTick;
@@ -0,0 +1 @@
1
+ export { useXZoom, ZoomClip, ZoomResetButton, ZoomSelectionRect } from './x-zoom';
@@ -1,8 +1,10 @@
1
- import { act, renderHook } from '@testing-library/react';
2
- import { useRef } from 'react';
3
- import { useXZoom } from '../x-zoom';
4
- import type { SingleChartRef } from '../single-chart-context';
1
+ import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { useCallback, useRef, useState } from 'react';
4
+ import { useXZoom, ZoomResetButton } from '../index';
5
+ import type { SingleChartRef } from '../../single-chart-context';
5
6
  import type { EventHandlerParams } from '@visx/xychart';
7
+ import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
6
8
 
7
9
  // A fake scale that lets the hook convert pixel positions to data values.
8
10
  // The hook only uses `.invert()`; everything else can be stubbed.
@@ -140,3 +142,89 @@ describe( 'useXZoom', () => {
140
142
  expect( result.current.domain ).toBeNull();
141
143
  } );
142
144
  } );
145
+
146
+ const preventDefaultKeydown = ( event: ReactKeyboardEvent< HTMLDivElement > ) =>
147
+ event.preventDefault();
148
+
149
+ describe( 'ZoomResetButton', () => {
150
+ test( 'renders a labelled button without a native title tooltip', () => {
151
+ const noop = jest.fn();
152
+ render( <ZoomResetButton onClick={ noop } /> );
153
+ const button = screen.getByTestId( 'chart-zoom-reset' );
154
+ expect( button.tagName ).toBe( 'BUTTON' );
155
+ expect( button ).toHaveClass( 'x-zoom__reset' );
156
+ expect( button ).toHaveAccessibleName( 'Reset zoom' );
157
+ // IconButton renders a real tooltip, so the `title` fallback is gone —
158
+ // `title` is invisible to keyboard users and cannot be dismissed.
159
+ expect( button ).not.toHaveAttribute( 'title' );
160
+ } );
161
+
162
+ test( 'shows a tooltip on keyboard focus', async () => {
163
+ const noop = jest.fn();
164
+ render( <ZoomResetButton onClick={ noop } /> );
165
+ await userEvent.tab();
166
+ expect( screen.getByTestId( 'chart-zoom-reset' ) ).toHaveFocus();
167
+ // The button's only text is the tooltip's — its own label is an
168
+ // `aria-label` attribute, so this cannot match the trigger.
169
+ await expect( screen.findByText( 'Reset zoom' ) ).resolves.toBeVisible();
170
+ } );
171
+
172
+ test( 'dismisses the tooltip on Escape', async () => {
173
+ const noop = jest.fn();
174
+ render( <ZoomResetButton onClick={ noop } /> );
175
+ await userEvent.tab();
176
+ await expect( screen.findByText( 'Reset zoom' ) ).resolves.toBeVisible();
177
+ await userEvent.keyboard( '{Escape}' );
178
+ await waitFor( () => expect( screen.queryByText( 'Reset zoom' ) ).not.toBeInTheDocument() );
179
+ } );
180
+
181
+ test( 'fires onClick when activated', async () => {
182
+ const onClick = jest.fn();
183
+ render( <ZoomResetButton onClick={ onClick } /> );
184
+ await userEvent.click( screen.getByTestId( 'chart-zoom-reset' ) );
185
+ expect( onClick ).toHaveBeenCalledTimes( 1 );
186
+ } );
187
+
188
+ test( 'leaves no orphaned tooltip when activation unmounts the button', async () => {
189
+ // Resetting the zoom unmounts this control while its tooltip is open.
190
+ // The tooltip renders in a portal outside the container, so a missed
191
+ // cleanup would strand it on the page rather than remove it with the
192
+ // button.
193
+ /**
194
+ * Mirrors the host charts: the reset control exists only while zoomed.
195
+ *
196
+ * @return JSX element or null.
197
+ */
198
+ function Host() {
199
+ const [ zoomed, setZoomed ] = useState( true );
200
+ const unzoom = useCallback( () => setZoomed( false ), [] );
201
+ return zoomed ? <ZoomResetButton onClick={ unzoom } /> : null;
202
+ }
203
+ render( <Host /> );
204
+ await userEvent.tab();
205
+ await expect( screen.findByText( 'Reset zoom' ) ).resolves.toBeVisible();
206
+
207
+ await userEvent.keyboard( '{Enter}' );
208
+
209
+ await waitFor( () =>
210
+ expect( screen.queryByTestId( 'chart-zoom-reset' ) ).not.toBeInTheDocument()
211
+ );
212
+ expect( document.body ).not.toHaveTextContent( 'Reset zoom' );
213
+ } );
214
+
215
+ test( 'keyboard activation survives the chart wrapper keydown handler', async () => {
216
+ const onClick = jest.fn();
217
+ // Mirrors the chart's grid wrapper, whose keyboard-navigation handler
218
+ // calls preventDefault() on bubbled keydowns — which would cancel the
219
+ // native Enter/Space button activation.
220
+ render(
221
+ <div role="grid" tabIndex={ 0 } onKeyDown={ preventDefaultKeydown }>
222
+ <ZoomResetButton onClick={ onClick } />
223
+ </div>
224
+ );
225
+ act( () => screen.getByTestId( 'chart-zoom-reset' ).focus() );
226
+ await userEvent.keyboard( '{Enter}' );
227
+ await userEvent.keyboard( ' ' );
228
+ expect( onClick ).toHaveBeenCalledTimes( 2 );
229
+ } );
230
+ } );
@@ -0,0 +1,35 @@
1
+ .x-zoom {
2
+
3
+ // The selection's translucency lives in the SVG opacity properties so
4
+ // the colors themselves can come straight from WPDS tokens.
5
+ &__selection {
6
+ fill: var(--a8c-charts-color-zoom-selection, var(--wpds-color-background-interactive-brand-strong, #3858e9));
7
+ fill-opacity: 0.16;
8
+ stroke: var(--a8c-charts-color-zoom-selection-stroke, var(--wpds-color-stroke-interactive-brand, #3858e9));
9
+ stroke-opacity: 0.65;
10
+ stroke-width: var(--wpds-border-width-xs, 1px);
11
+ pointer-events: none;
12
+ }
13
+
14
+ // The elevation shadow is deliberately kept for separation
15
+ // from chart content (CHARTS-237 design call, carrying
16
+ // over CHARTS-200's tokenization). `@wordpress/theme` 1.0.0
17
+ // dropped the `--wpds-elevation-*` group, so this role carries
18
+ // the former token's spec value directly rather than nesting
19
+ // a `--wpds-*` var.
20
+ &__reset {
21
+ position: absolute;
22
+ top: var(--wpds-dimension-gap-sm, 8px);
23
+ right: var(--wpds-dimension-gap-sm, 8px);
24
+ z-index: 2;
25
+ box-shadow: var(--a8c-charts-elevation-xs, 0 1px 1px 0 #00000008, 0 1px 2px 0 #00000005, 0 3px 3px 0 #00000005, 0 4px 4px 0 #00000003);
26
+
27
+ // WPDS outline-neutral is transparent-bodied at rest; give this
28
+ // floating control an opaque body so chart content doesn't show
29
+ // through it. Scoped to the rest state so the WPDS
30
+ // hover/active/focus backgrounds still apply.
31
+ &:not(:hover, :active, :focus) {
32
+ background-color: var(--a8c-charts-color-background, var(--wpds-color-background-surface-neutral-strong, #fff));
33
+ }
34
+ }
35
+ }
@@ -1,11 +1,12 @@
1
1
  import { DataContext } from '@visx/xychart';
2
2
  import { __ } from '@wordpress/i18n';
3
+ import { IconButton } from '@wordpress/ui';
3
4
  import { useCallback, useContext, useMemo, useState } from 'react';
4
5
  import styles from './x-zoom.module.scss';
5
- import type { SingleChartRef } from './single-chart-context';
6
+ import type { SingleChartRef } from '../single-chart-context';
6
7
  import type { AxisScale } from '@visx/axis';
7
8
  import type { EventHandlerParams } from '@visx/xychart';
8
- import type { ReactNode, RefObject } from 'react';
9
+ import type { KeyboardEvent, ReactNode, RefObject } from 'react';
9
10
 
10
11
  const MIN_DRAG_PIXELS = 6;
11
12
 
@@ -170,40 +171,56 @@ export function ZoomClip( {
170
171
  }
171
172
 
172
173
  /**
173
- * Visible icon-only reset button rendered as an HTML overlay on top of
174
- * the chart container. The host should wrap its SVG in a `position: relative`
175
- * container so the button anchors correctly.
174
+ * Visible icon-only reset control rendered as an HTML overlay on top of the
175
+ * chart container, using the WPDS `IconButton`. The host should wrap its SVG in
176
+ * a `position: relative` container so the button anchors correctly.
176
177
  *
177
178
  * @param props - Props.
178
179
  * @param props.onClick - Click handler. Typically the `reset` from `useXZoom`.
179
180
  * @return JSX element.
180
181
  */
181
182
  export function ZoomResetButton( { onClick }: { onClick: () => void } ) {
182
- const label = __( 'Reset zoom', 'jetpack-charts' );
183
+ // The chart's keyboard-navigation wrapper calls preventDefault() on
184
+ // bubbled keydowns, which would cancel the native Enter/Space button
185
+ // activation. Keep activation keys out of its reach.
186
+ const stopActivationKeys = useCallback( ( event: KeyboardEvent< HTMLButtonElement > ) => {
187
+ if ( event.key === 'Enter' || event.key === ' ' ) {
188
+ event.stopPropagation();
189
+ }
190
+ }, [] );
183
191
  return (
184
- <button
185
- type="button"
192
+ <IconButton
186
193
  className={ styles[ 'x-zoom__reset' ] }
194
+ onKeyDown={ stopActivationKeys }
195
+ label={ __( 'Reset zoom', 'jetpack-charts' ) }
196
+ variant="outline"
197
+ tone="neutral"
198
+ size="small"
187
199
  onClick={ onClick }
188
- aria-label={ label }
189
- title={ label }
190
200
  data-testid="chart-zoom-reset"
191
- >
192
- <svg
193
- className={ styles[ 'x-zoom__reset-icon' ] }
194
- viewBox="0 0 24 24"
195
- fill="none"
196
- stroke="currentColor"
197
- strokeWidth="2"
198
- strokeLinecap="round"
199
- strokeLinejoin="round"
200
- aria-hidden="true"
201
- focusable="false"
202
- >
203
- <circle cx="10" cy="10" r="6" />
204
- <line x1="15" y1="15" x2="20" y2="20" />
205
- <line x1="7" y1="10" x2="13" y2="10" />
206
- </svg>
207
- </button>
201
+ icon={
202
+ <svg
203
+ viewBox="0 0 24 24"
204
+ fill="none"
205
+ stroke="currentColor"
206
+ strokeWidth="2"
207
+ strokeLinecap="round"
208
+ strokeLinejoin="round"
209
+ aria-hidden="true"
210
+ focusable="false"
211
+ >
212
+ { /*
213
+ Icons render edge-to-edge at 24px, so inset the glyph the
214
+ way @wordpress/icons glyphs do (drawn within roughly 4-20
215
+ of the viewBox, ~1.5px effective stroke).
216
+ */ }
217
+ <g transform="translate(2.4 2.4) scale(0.8)">
218
+ <circle cx="10" cy="10" r="6" />
219
+ <line x1="15" y1="15" x2="20" y2="20" />
220
+ <line x1="7" y1="10" x2="13" y2="10" />
221
+ </g>
222
+ </svg>
223
+ }
224
+ />
208
225
  );
209
226
  }
@@ -265,4 +265,156 @@ describe( 'useChartLegendItems', () => {
265
265
  expect( result.current[ 0 ].color ).toBeDefined();
266
266
  } );
267
267
  } );
268
+
269
+ describe( 'Group collapsing (SeriesData)', () => {
270
+ const groupedComparisonData: SeriesData[] = [
271
+ { label: 'Views', group: 'views', data: [ { label: 'Mon', value: 100 } ] },
272
+ {
273
+ label: 'Views — previous',
274
+ group: 'views',
275
+ options: { type: 'comparison' as const },
276
+ data: [ { label: 'Mon', value: 90 } ],
277
+ },
278
+ { label: 'Visitors', group: 'visitors', data: [ { label: 'Mon', value: 50 } ] },
279
+ {
280
+ label: 'Visitors — previous',
281
+ group: 'visitors',
282
+ options: { type: 'comparison' as const },
283
+ data: [ { label: 'Mon', value: 45 } ],
284
+ },
285
+ ];
286
+
287
+ describe( 'by default', () => {
288
+ test( 'keeps one item per series for a comparison group', () => {
289
+ const { result } = renderHook( () => useChartLegendItems( groupedComparisonData ), {
290
+ wrapper,
291
+ } );
292
+
293
+ expect( result.current ).toHaveLength( 4 );
294
+ expect( result.current.map( item => item.label ) ).toEqual( [
295
+ 'Views',
296
+ 'Views — previous',
297
+ 'Visitors',
298
+ 'Visitors — previous',
299
+ ] );
300
+ } );
301
+
302
+ test( 'gives each item only its own series label', () => {
303
+ const { result } = renderHook( () => useChartLegendItems( groupedComparisonData ), {
304
+ wrapper,
305
+ } );
306
+
307
+ expect( result.current.map( item => item.seriesLabels ) ).toEqual( [
308
+ [ 'Views' ],
309
+ [ 'Views — previous' ],
310
+ [ 'Visitors' ],
311
+ [ 'Visitors — previous' ],
312
+ ] );
313
+ } );
314
+
315
+ test( 'preserves the original series order for interleaved groups', () => {
316
+ // `group` predates collapsing and was used for colour coordination, so existing
317
+ // charts may interleave grouped series. The default legend must not reorder them.
318
+ const interleaved: SeriesData[] = [
319
+ { label: 'Views', group: 'views', data: [ { label: 'Mon', value: 100 } ] },
320
+ { label: 'Visitors', group: 'visitors', data: [ { label: 'Mon', value: 50 } ] },
321
+ {
322
+ label: 'Views — previous',
323
+ group: 'views',
324
+ options: { type: 'comparison' as const },
325
+ data: [ { label: 'Mon', value: 90 } ],
326
+ },
327
+ {
328
+ label: 'Visitors — previous',
329
+ group: 'visitors',
330
+ options: { type: 'comparison' as const },
331
+ data: [ { label: 'Mon', value: 45 } ],
332
+ },
333
+ ];
334
+
335
+ const { result } = renderHook( () => useChartLegendItems( interleaved ), { wrapper } );
336
+
337
+ expect( result.current.map( item => item.label ) ).toEqual( [
338
+ 'Views',
339
+ 'Visitors',
340
+ 'Views — previous',
341
+ 'Visitors — previous',
342
+ ] );
343
+ } );
344
+ } );
345
+
346
+ describe( 'with collapseGroups enabled', () => {
347
+ const collapsed = () =>
348
+ renderHook( () => useChartLegendItems( groupedComparisonData, { collapseGroups: true } ), {
349
+ wrapper,
350
+ } );
351
+
352
+ test( 'collapses series that share a group into one item per group', () => {
353
+ const { result } = collapsed();
354
+
355
+ expect( result.current ).toHaveLength( 2 );
356
+ } );
357
+
358
+ test( 'uses the primary (non-comparison) series label for the grouped item', () => {
359
+ const { result } = collapsed();
360
+
361
+ expect( result.current[ 0 ].label ).toBe( 'Views' );
362
+ expect( result.current[ 1 ].label ).toBe( 'Visitors' );
363
+ } );
364
+
365
+ test( 'exposes all member series labels via seriesLabels, primary first', () => {
366
+ const { result } = collapsed();
367
+
368
+ expect( result.current[ 0 ].seriesLabels ).toEqual( [ 'Views', 'Views — previous' ] );
369
+ expect( result.current[ 1 ].seriesLabels ).toEqual( [ 'Visitors', 'Visitors — previous' ] );
370
+ } );
371
+
372
+ test( 'collapses a shared group that has no comparison series', () => {
373
+ const sharedGroupNoComparison: SeriesData[] = [
374
+ { label: 'A', group: 'metrics', data: [ { label: 'x', value: 1 } ] },
375
+ { label: 'B', group: 'metrics', data: [ { label: 'x', value: 2 } ] },
376
+ ];
377
+
378
+ const { result } = renderHook(
379
+ () => useChartLegendItems( sharedGroupNoComparison, { collapseGroups: true } ),
380
+ { wrapper }
381
+ );
382
+
383
+ expect( result.current ).toHaveLength( 1 );
384
+ expect( result.current[ 0 ].label ).toBe( 'A' );
385
+ expect( result.current[ 0 ].seriesLabels ).toEqual( [ 'A', 'B' ] );
386
+ } );
387
+
388
+ test( 'does not collapse series with distinct group values', () => {
389
+ const distinctGroups: SeriesData[] = [
390
+ { label: 'A', group: 'a', data: [ { label: 'x', value: 1 } ] },
391
+ { label: 'B', group: 'b', data: [ { label: 'x', value: 2 } ] },
392
+ ];
393
+
394
+ const { result } = renderHook(
395
+ () => useChartLegendItems( distinctGroups, { collapseGroups: true } ),
396
+ { wrapper }
397
+ );
398
+
399
+ expect( result.current ).toHaveLength( 2 );
400
+ expect( result.current.map( item => item.label ) ).toEqual( [ 'A', 'B' ] );
401
+ } );
402
+
403
+ test( 'leaves ungrouped series as individual items with their own seriesLabels', () => {
404
+ const ungrouped: SeriesData[] = [
405
+ { label: 'One', data: [ { label: 'x', value: 1 } ] },
406
+ { label: 'Two', data: [ { label: 'x', value: 2 } ] },
407
+ ];
408
+
409
+ const { result } = renderHook(
410
+ () => useChartLegendItems( ungrouped, { collapseGroups: true } ),
411
+ { wrapper }
412
+ );
413
+
414
+ expect( result.current ).toHaveLength( 2 );
415
+ expect( result.current[ 0 ].seriesLabels ).toEqual( [ 'One' ] );
416
+ expect( result.current[ 1 ].seriesLabels ).toEqual( [ 'Two' ] );
417
+ } );
418
+ } );
419
+ } );
268
420
  } );